diff --git a/README.md b/README.md index f2b611d..4024c4d 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,19 @@ import hbs from 'ember-cli-htmlbars-inline-precompile'; import hbs from 'htmlbars-inline-precompile'; ``` +It also understands the `template()` API from [RFC 931](https://rfcs.emberjs.com/id/0931-template-compiler-api/): + +```js +import { template } from '@ember/template-compiler'; +``` + +By default, `template()` is **polyfilled**: it is lowered to the older +`precompileTemplate` / `setComponentTemplate` / `templateOnly` primitives so the +output runs on Ember versions that don't natively understand `template()`. If +you are targeting a runtime that implements `@ember/template-compiler` natively +(Ember 6+), pass `rfc931: 'native'` to keep the `template()` call in the output +instead. See the `rfc931` option below. + ## Common Options This package has both a Node implementation and a portable implementation that works in browsers. @@ -86,6 +99,23 @@ interface Options { // Optional list of custom transforms to apply to the handlebars AST before // compilation. See `type Transform` below. transforms?: Transform[]; + + // Controls how the RFC 931 `template()` API (imported from + // `@ember/template-compiler`) is emitted. + // + // "polyfilled": The default. `template()` calls are lowered to the older, + // widely-supported primitives (`precompileTemplate` / `createTemplateFactory` + // plus `setComponentTemplate` and `templateOnly`). Use this to target Ember + // versions that do not natively understand `template()`. + // + // "native": `template()` calls are preserved in the output for runtimes that + // implement `@ember/template-compiler` natively (Ember 6+). AST transforms + // still run, and the implicit `eval` form is normalized into the explicit + // `scope` form. This only affects `targetFormat: 'hbs'`; with + // `targetFormat: 'wire'` the template is fully compiled to the standard wire + // format (which already runs on native runtimes), so this option has no + // effect there. + rfc931?: 'polyfilled' | 'native'; } // The legal legacy module names. These are the only ones that are supported, diff --git a/__tests__/all.test.ts b/__tests__/all.test.ts index 4e507fb..7d12d00 100644 --- a/__tests__/all.test.ts +++ b/__tests__/all.test.ts @@ -1554,12 +1554,356 @@ describe('htmlbars-inline-precompile', function () { export default class MyComponent { } setComponentTemplate( - precompileTemplate('', { scope: () => ({ HelloWorld }), strictMode: true }), + precompileTemplate('', { scope: () => ({ HelloWorld }), strictMode: true }), MyComponent ); `); }); + describe('native rfc931', function () { + it('preserves template() for a 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 }) });` + ); + + // the template() call and its import are preserved; only the template + // body is transformed. + expect(transformed).equalCode(` + import { template } from '@ember/template-compiler'; + import HelloWorld from "somewhere"; + export default template('', { scope: () => ({ HelloWorld }) }); + `); + }); + + it('preserves template() for a 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('preserves template() for a class-backed component declared outside the 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 the implicit eval form into the explicit 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 the implicit eval form into the explicit scope form for a 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 the 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('handles multiple templates in a module 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('preserves a template-only component with no options 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

"); + `); + }); + + it('falls back to the standard wire format for a template-only component in wire format', async function () { + // In wire format the template is fully compiled, which already runs on + // native runtimes, so rfc931: 'native' produces the same output as the + // default (polyfilled) wire output. + 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 HelloWorld from "somewhere"; + import { setComponentTemplate } from "@ember/component"; + import { createTemplateFactory } from "@ember/template-factory"; + import templateOnly from "@ember/component/template-only"; + export default setComponentTemplate(createTemplateFactory( + /* + + */ + { + id: "", + block: "", + moduleName: "", + scope: () => [HelloWorld], + isStrictMode: true, + } + ), templateOnly()); + `); + }); + + it('falls back to the standard wire format for a 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 HelloWorld from "somewhere"; + import { setComponentTemplate } from "@ember/component"; + import { createTemplateFactory } from "@ember/template-factory"; + export default class { + static { + setComponentTemplate( + createTemplateFactory( + /* + + */ + { + id: "", + block: "", + moduleName: "", + scope: () => [HelloWorld], + isStrictMode: true, + } + ), + this + ); + } + } + `); + }); + }); + it('cleans up leftover imports when there is more than one template', async function () { plugins = [ [ @@ -2517,9 +2861,125 @@ describe('htmlbars-inline-precompile', function () { } `); }); + + it('preserves template() for expression form with rfc931: native and hbs target', async function () { + plugins = [ + [ + HTMLBarsInlinePrecompile, + { + targetFormat: 'hbs', + rfc931: 'native', + }, + ], + ]; + + let p = new Preprocessor(); + + const { code: preTransformed } = p.process( + `import HelloWorld from 'somewhere'; + const MyComponent = ; + ` + ); + + let transformed = await transform(preTransformed); + + // the gjs is preprocessed by content-tag into the implicit eval form, + // then this plugin keeps template() and converts eval -> scope. content-tag + // aliases the import to a unique name (`template as template_`) which + // we preserve; normalize it back to `template` for a stable assertion. + expect(normalizeContentTagImport(transformed)).equalCode(` + import { template } from "@ember/template-compiler"; + import HelloWorld from "somewhere"; + const MyComponent = template('', { scope: () => ({ HelloWorld }) }); + `); + }); + + it('preserves template() for class member form with rfc931: native and hbs target', async function () { + plugins = [ + [ + HTMLBarsInlinePrecompile, + { + targetFormat: 'hbs', + rfc931: 'native', + }, + ], + ]; + + let p = new Preprocessor(); + + const { code: preTransformed } = p.process( + `import HelloWorld from 'somewhere'; + export default class { + + } + ` + ); + + let transformed = await transform(preTransformed); + + expect(normalizeContentTagImport(transformed)).equalCode(` + import { template } from "@ember/template-compiler"; + import HelloWorld from "somewhere"; + export default class { + static { + template('', { component: this, scope: () => ({ HelloWorld }) }); + } + } + `); + }); + + it('compiles to wire format with targetFormat: wire', async function () { + plugins = [ + [ + HTMLBarsInlinePrecompile, + { + targetFormat: 'wire', + }, + ], + ]; + + let p = new Preprocessor(); + + const { code: preTransformed } = p.process( + `import HelloWorld from 'somewhere'; + const MyComponent = ; + ` + ); + + let transformed = await transform(preTransformed); + + // content-tag emits the implicit eval form; the default (polyfilled) wire + // path fully compiles it and drops the @ember/template-compiler import. + expect(normalizeWireFormat(transformed)).equalCode(` + import HelloWorld from "somewhere"; + import { setComponentTemplate } from "@ember/component"; + import { createTemplateFactory } from "@ember/template-factory"; + import templateOnly from "@ember/component/template-only"; + const MyComponent = setComponentTemplate(createTemplateFactory( + /* + + */ + { + id: "", + block: "", + moduleName: "", + scope: () => [HelloWorld], + isStrictMode: true, + } + ), templateOnly()); + `); + }); }); }); +// content-tag aliases its `template` import to a unique, hashed local name +// (e.g. `template as template_abc123`). When we preserve the template() call +// (rfc931: 'native'), that alias survives into the output. Normalize it back to +// `template` so assertions don't depend on the specific hash. +function normalizeContentTagImport(src: string): string { + return src.replace(/template_[0-9a-f]+/g, 'template'); +} + // This takes out parts of ember's wire format that aren't our job and shouldn't // break our tests if they change. function normalizeWireFormat(src: string): string { diff --git a/src/plugin.ts b/src/plugin.ts index 809c9f1..841d152 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,23 @@ export interface Options { // Optional list of custom transforms to apply to the handlebars AST before // compilation. transforms?: ExtendedPluginBuilder[]; + + // Controls how the RFC 931 `template()` API (imported from + // `@ember/template-compiler`) is emitted. + // + // "polyfilled": The default. `template()` calls are lowered to the older, + // widely-supported primitives (`precompileTemplate` / `createTemplateFactory` + // plus `setComponentTemplate` and `templateOnly`). Use this to target Ember + // versions that do not natively understand `template()`. + // + // "native": `template()` calls are preserved in the output for runtimes that + // implement `@ember/template-compiler` natively (Ember 6+). AST transforms + // still run, and the implicit `eval` form is normalized into the explicit + // `scope` form. This only affects `targetFormat: 'hbs'`; with + // `targetFormat: 'wire'` the template is fully compiled to the standard wire + // format (which already runs on native runtimes), so this option has no + // effect there. + rfc931?: 'polyfilled' | 'native'; } interface WireOpts { @@ -100,6 +117,7 @@ interface WireOpts { outputModuleOverrides: Record>; enableLegacyModules: LegacyModuleName[]; transforms: ExtendedPluginBuilder[]; + rfc931: 'polyfilled' | 'native'; } interface HbsOpts { @@ -107,6 +125,7 @@ interface HbsOpts { outputModuleOverrides: Record>; enableLegacyModules: LegacyModuleName[]; transforms: ExtendedPluginBuilder[]; + rfc931: 'polyfilled' | 'native'; } type NormalizedOpts = WireOpts | HbsOpts; @@ -124,6 +143,7 @@ function normalizeOpts(options: Options): NormalizedOpts { ...options, targetFormat: 'wire', compiler, + rfc931: options.rfc931 ?? 'polyfilled', }; } else { return { @@ -132,6 +152,7 @@ function normalizeOpts(options: Options): NormalizedOpts { transforms: [], ...options, targetFormat: 'hbs', + rfc931: options.rfc931 ?? 'polyfilled', }; } } @@ -347,7 +368,14 @@ function* configuredModules(normalizedOpts: NormalizedOpts) { ) { continue; } - yield moduleConfig; + if (moduleConfig.rfc931Support) { + // The `@ember/template-compiler` `template()` config defaults to + // 'polyfilled'; the user's `rfc931` option decides whether we lower it to + // the legacy primitives or keep it native. + yield { ...moduleConfig, rfc931Support: normalizedOpts.rfc931 }; + } else { + yield moduleConfig; + } } } @@ -631,6 +659,15 @@ function updateCallForm( // precompileTemplate call for the final updateScope below. // target = target.get('arguments.0') as NodePath; + } else if (formatOptions.rfc931Support === 'native') { + // Native mode: the runtime understands `template()` directly, so we keep + // the call (and its callee import) as-is. We only drop the implicit `eval` + // option, because its role is taken over by the explicit `scope` that + // updateScope appends below. The user's `component` and `strict` options + // are preserved untouched. + 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 @@ -725,6 +762,23 @@ function removeEvalAndScope(target: NodePath) { } } +// Removes only the `eval` option from a template() call, leaving `component`, +// `strict`, and any other options in place. Used in native rfc931 mode, where +// the template() call is preserved and the implicit eval form is converted into +// the explicit scope form (the scope is re-added by updateScope). +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.