diff --git a/__tests__/all.test.ts b/__tests__/all.test.ts index 4e507fb..1483fa9 100644 --- a/__tests__/all.test.ts +++ b/__tests__/all.test.ts @@ -1766,6 +1766,358 @@ describe('htmlbars-inline-precompile', function () { `); }); + describe('native rfc931', function () { + const color: ExtendedPluginBuilder = (env) => { + return { + name: 'simple-transform', + visitor: { + PathExpression(node) { + if (node.original === 'red') { + return env.syntax.builders.string('#ff0000'); + } + return undefined; + }, + }, + }; + }; + + it('emits template() for template-only component in hbs format', async function () { + plugins = [ + [ + HTMLBarsInlinePrecompile, + { + targetFormat: 'hbs', + transforms: [color], + rfc931: 'native', + }, + ], + ]; + + let transformed = await transform( + `import { template } from '@ember/template-compiler'; + import HelloWorld from 'somewhere'; + export default template('', { scope: () => ({ HelloWorld }) });` + ); + + expect(transformed).equalCode(` + import { template } from '@ember/template-compiler'; + import HelloWorld from "somewhere"; + export default template('', { scope: () => ({ HelloWorld }) }); + `); + }); + + it('emits template() for class-backed component in hbs format', async function () { + plugins = [ + [ + HTMLBarsInlinePrecompile, + { + targetFormat: 'hbs', + transforms: [color], + rfc931: 'native', + }, + ], + ]; + + let transformed = await transform( + ` + import { template } from '@ember/template-compiler'; + import HelloWorld from 'somewhere'; + export default class MyComponent { + static { + template('', { component: this, scope: () => ({ HelloWorld }) }); + } + } + ` + ); + + expect(transformed).equalCode(` + import { template } from '@ember/template-compiler'; + import HelloWorld from "somewhere"; + export default class MyComponent { + static { + template('', { component: this, scope: () => ({ HelloWorld }) }); + } + } + `); + }); + + it('emits template() for class-backed component outside class in hbs format', async function () { + plugins = [ + [ + HTMLBarsInlinePrecompile, + { + targetFormat: 'hbs', + transforms: [color], + rfc931: 'native', + }, + ], + ]; + + let transformed = await transform( + ` + import { template } from '@ember/template-compiler'; + import HelloWorld from 'somewhere'; + export default class MyComponent { + } + template('', { component: MyComponent, scope: () => ({ HelloWorld }) }); + ` + ); + + expect(transformed).equalCode(` + import { template } from '@ember/template-compiler'; + import HelloWorld from "somewhere"; + export default class MyComponent { + } + template('', { component: MyComponent, scope: () => ({ HelloWorld }) }); + `); + }); + + it('converts eval form to scope form in hbs format', async function () { + plugins = [ + [ + HTMLBarsInlinePrecompile, + { + targetFormat: 'hbs', + rfc931: 'native', + }, + ], + ]; + + let transformed = await transform( + `import { template } from '@ember/template-compiler'; + import HelloWorld from 'somewhere'; + export default template('', { eval() { return eval(arguments[0]); } });` + ); + + expect(transformed).equalCode(` + import { template } from '@ember/template-compiler'; + import HelloWorld from "somewhere"; + export default template("", { scope: () => ({ HelloWorld }) }); + `); + }); + + it('converts eval form to scope form for class-backed component in hbs format', async function () { + plugins = [ + [ + HTMLBarsInlinePrecompile, + { + targetFormat: 'hbs', + rfc931: 'native', + }, + ], + ]; + + let transformed = await transform( + ` + import { template } from '@ember/template-compiler'; + import Component from '@glimmer/component'; + import HelloWorld from 'somewhere'; + export default class MyComponent extends Component { + static { + template('', { component: this, eval() { return eval(arguments[0]); } }); + } + } + ` + ); + + expect(transformed).equalCode(` + import { template } from '@ember/template-compiler'; + import Component from "@glimmer/component"; + import HelloWorld from "somewhere"; + export default class MyComponent extends Component { + static { + template("", { component: this, scope: () => ({ HelloWorld }) }); + } + } + `); + }); + + it("preserves user's strict option on template() in hbs format", async function () { + plugins = [ + [ + HTMLBarsInlinePrecompile, + { + targetFormat: 'hbs', + rfc931: 'native', + }, + ], + ]; + + let transformed = await transform( + `import { template } from '@ember/template-compiler'; + import HelloWorld from 'somewhere'; + export default template('', { strict: false, scope: () => ({ HelloWorld }) });` + ); + + expect(transformed).equalCode(` + import { template } from '@ember/template-compiler'; + import HelloWorld from "somewhere"; + export default template('', { strict: false, scope: () => ({ HelloWorld }) }); + `); + }); + + it('emits template() for template-only component in wire format', async function () { + plugins = [ + [ + HTMLBarsInlinePrecompile, + { + targetFormat: 'wire', + transforms: [], + rfc931: 'native', + }, + ], + ]; + + let transformed = await transform( + `import { template } from '@ember/template-compiler'; + import HelloWorld from 'somewhere'; + export default template('', { scope: () => ({ HelloWorld }) });` + ); + + expect(normalizeWireFormat(transformed)).equalCode(` + import { template } from '@ember/template-compiler'; + import HelloWorld from "somewhere"; + import { createTemplateFactory } from "@ember/template-factory"; + export default template(createTemplateFactory( + /* + + */ + { + id: "", + block: "", + moduleName: "", + scope: () => [HelloWorld], + isStrictMode: true, + } + )); + `); + }); + + it('emits template() for class-backed component in wire format', async function () { + plugins = [ + [ + HTMLBarsInlinePrecompile, + { + targetFormat: 'wire', + transforms: [], + rfc931: 'native', + }, + ], + ]; + + let transformed = await transform( + ` + import { template } from '@ember/template-compiler'; + import HelloWorld from 'somewhere'; + export default class { + static { + template('', { component: this, scope: () => ({ HelloWorld }) }); + } + } + ` + ); + + expect(normalizeWireFormat(transformed)).equalCode(` + import { template } from '@ember/template-compiler'; + import HelloWorld from "somewhere"; + import { createTemplateFactory } from "@ember/template-factory"; + export default class { + static { + template( + createTemplateFactory( + /* + + */ + { + id: "", + block: "", + moduleName: "", + scope: () => [HelloWorld], + isStrictMode: true, + } + ), + { component: this } + ); + } + } + `); + }); + + it('handles multiple templates with native rfc931 in hbs format', async function () { + plugins = [ + [ + HTMLBarsInlinePrecompile, + { + targetFormat: 'hbs', + rfc931: 'native', + }, + ], + ]; + + let transformed = await transform( + ` + import { template } from "@ember/template-compiler"; + import Component from '@glimmer/component'; + export default class Test extends Component { + foo = 1; + static{ + template("", { + component: this, + eval () { + return eval(arguments[0]); + } + }); + } + } + const Icon = template("Icon", { + eval () { + return eval(arguments[0]); + } + }); + ` + ); + + expect(transformed).equalCode(` + import { template } from "@ember/template-compiler"; + import Component from "@glimmer/component"; + export default class Test extends Component { + foo = 1; + static { + template("", { + component: this, + scope: () => ({ + Icon, + }), + }); + } + } + const Icon = template("Icon", {}); + `); + }); + + it('handles template-only component with no scope in hbs format', async function () { + plugins = [ + [ + HTMLBarsInlinePrecompile, + { + targetFormat: 'hbs', + rfc931: 'native', + }, + ], + ]; + + let transformed = await transform( + `import { template } from '@ember/template-compiler'; + export default template('

Hello World

');` + ); + + expect(transformed).equalCode(` + import { template } from '@ember/template-compiler'; + export default template("

Hello World

"); + `); + }); + }); + describe('scope', function () { it('correctly handles scope function (non-block arrow function)', async function () { let source = ''; diff --git a/src/plugin.ts b/src/plugin.ts index 809c9f1..b419dd2 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -18,7 +18,7 @@ interface ModuleConfig { export: string; allowTemplateLiteral?: true; enableScope?: true; - rfc931Support?: 'polyfilled'; + rfc931Support?: 'polyfilled' | 'native'; } const INLINE_PRECOMPILE_MODULES: ModuleConfig[] = [ @@ -92,6 +92,17 @@ export interface Options { // Optional list of custom transforms to apply to the handlebars AST before // compilation. transforms?: ExtendedPluginBuilder[]; + + // Controls how RFC 931's template() API is handled. + // + // "polyfilled": The default. template() calls are converted to older APIs + // (precompileTemplate, setComponentTemplate, templateOnly) for runtimes + // that don't natively support template(). + // + // "native": template() calls are kept as-is (with scope and eval + // normalization applied) for runtimes that natively support the + // template() API from @ember/template-compiler. + rfc931?: 'polyfilled' | 'native'; } interface WireOpts { @@ -100,6 +111,7 @@ interface WireOpts { outputModuleOverrides: Record>; enableLegacyModules: LegacyModuleName[]; transforms: ExtendedPluginBuilder[]; + rfc931: 'polyfilled' | 'native'; } interface HbsOpts { @@ -107,6 +119,7 @@ interface HbsOpts { outputModuleOverrides: Record>; enableLegacyModules: LegacyModuleName[]; transforms: ExtendedPluginBuilder[]; + rfc931: 'polyfilled' | 'native'; } type NormalizedOpts = WireOpts | HbsOpts; @@ -124,6 +137,7 @@ function normalizeOpts(options: Options): NormalizedOpts { ...options, targetFormat: 'wire', compiler, + rfc931: options.rfc931 ?? 'polyfilled', }; } else { return { @@ -132,6 +146,7 @@ function normalizeOpts(options: Options): NormalizedOpts { transforms: [], ...options, targetFormat: 'hbs', + rfc931: options.rfc931 ?? 'polyfilled', }; } } @@ -168,7 +183,10 @@ export function makePlugin( }, exit(_path: NodePath, state: State) { if (normalizedOpts.targetFormat === 'wire') { - for (let { moduleName, export: exportName } of configuredModules(normalizedOpts)) { + for (let { moduleName, export: exportName, rfc931Support } of configuredModules(normalizedOpts)) { + if (rfc931Support === 'native') { + continue; + } state.util.removeImport(moduleName, exportName); } } @@ -347,7 +365,12 @@ function* configuredModules(normalizedOpts: NormalizedOpts) { ) { continue; } - yield moduleConfig; + // Override rfc931Support mode based on user option + if (moduleConfig.rfc931Support) { + yield { ...moduleConfig, rfc931Support: normalizedOpts.rfc931 }; + } else { + yield moduleConfig; + } } } @@ -503,7 +526,20 @@ function insertCompiledTemplate( let expression = t.callExpression(templateFactoryIdentifier, [templateExpression]); - if (config.rfc931Support) { + if (config.rfc931Support === 'native') { + let templateCallArgs: t.Expression[] = [expression]; + if (backingClass) { + templateCallArgs.push( + t.objectExpression([ + t.objectProperty(t.identifier('component'), backingClass.node as t.Expression), + ]) + ); + } + expression = t.callExpression( + i.import('@ember/template-compiler', 'template'), + templateCallArgs + ); + } else if (config.rfc931Support === 'polyfilled') { expression = t.callExpression(i.import('@ember/component', 'setComponentTemplate'), [ expression, backingClass?.node ?? @@ -516,6 +552,12 @@ function insertCompiledTemplate( return expression; }); + // In native mode, the output contains a template() call which the visitor + // would try to re-process. Mark it as already handled. + if (config.rfc931Support === 'native') { + state.recursionGuard.add(target.node); + } + remapAndBindIdentifiers(target, babel, scopeLocals); } @@ -632,6 +674,19 @@ function updateCallForm( // target = target.get('arguments.0') as NodePath; } + + if (formatOptions.rfc931Support === 'native') { + // In native mode, keep the template() call form but normalize: + // - Remove eval property (eval form has been converted to scope via buildScopeLocals) + // - Keep component property as-is + // - Keep strict property as-is (don't rename to strictMode) + // - Don't replace callee with precompileTemplate + // - Don't wrap in setComponentTemplate + removeEval(target); + target.node.arguments = target.node.arguments.slice(0, 2); + state.recursionGuard.add(target.node); + } + // We deliberately do updateScope at the end so that when it updates // references, those references will point to the accurate paths in the // final AST. @@ -725,6 +780,22 @@ function removeEvalAndScope(target: NodePath) { } } +// Removes only the eval property from template() options, keeping component +// and other properties. Used in native rfc931 mode where the template() call +// is preserved. +function removeEval(target: NodePath) { + let secondArg = target.get('arguments.1') as NodePath | undefined; + if (secondArg) { + let evalProp = secondArg.get('properties').find((p) => { + let key = p.get('key') as NodePath; + return key.isIdentifier() && key.node.name === 'eval'; + }); + if (evalProp) { + evalProp.remove(); + } + } +} + // Given a call to template(), convert its "strict" argument into // precompileTemplate's "strictMode" argument. They differ in name and default // value.