From aec31f391c61ccaad4825b7cea4bd9dd669314d4 Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Wed, 29 Jun 2022 22:34:21 -0400 Subject: [PATCH 01/31] Allow AST plugins to manipulate Javascript scope This spikes out the ability for AST plugins to access Javascript imports and expression. Still needs more practical testing. Does not support Ember < 3.28 because that's where we gained the option to use lexically scoped values in non-strict templates. --- README.md | 1 + __tests__/tests.ts | 186 +++++++++++++++++++++++++++++++++++++++++++-- package.json | 3 +- src/js-utils.ts | 95 +++++++++++++++++++++++ src/plugin.ts | 18 ++++- yarn.lock | 68 ++++++++++++++--- 6 files changed, 350 insertions(+), 21 deletions(-) create mode 100644 src/js-utils.ts diff --git a/README.md b/README.md index 6b9de7d..9195d47 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ interface NodeOptions extends Options { // Options handling rules: // // - we add `content`, which is the original string form of the template + // - we add `meta.jsutils: JSUtils`, which gives AST transform plugins access to methods for manipulating the outer Javascript scope. This only works in non-strict-mode templates on Ember 3.28+ because prior to that only strict-mode templates could use lexically scoped values. // - we have special parsing for `scope` which becomes `locals` when passed // to your precompile // - anything else the user passes to `precompileTemplate` will be passed diff --git a/__tests__/tests.ts b/__tests__/tests.ts index 43f80c3..992a666 100644 --- a/__tests__/tests.ts +++ b/__tests__/tests.ts @@ -5,6 +5,8 @@ import TransformTemplateLiterals from '@babel/plugin-transform-template-literals import TransformModules from '@babel/plugin-transform-modules-amd'; import TransformUnicodeEscapes from '@babel/plugin-transform-unicode-escapes'; import { stripIndent } from 'common-tags'; +import { WithJSUtils } from '../src/js-utils'; +import type { ASTPluginBuilder, ASTPluginEnvironment } from '@glimmer/syntax'; describe('htmlbars-inline-precompile', function () { let precompile: NonNullable; @@ -25,7 +27,8 @@ describe('htmlbars-inline-precompile', function () { beforeEach(function () { optionsReceived = undefined; precompile = (template, options) => { - optionsReceived = options; + optionsReceived = { ...options }; + delete optionsReceived.meta; return `"precompiled(${template})"`; }; @@ -93,6 +96,7 @@ describe('htmlbars-inline-precompile', function () { expect(optionsReceived).toEqual({ contents: source, + locals: [], }); }); @@ -106,6 +110,7 @@ describe('htmlbars-inline-precompile', function () { expect(optionsReceived).toEqual({ contents: source, isProduction: true, + locals: [], }); }); @@ -117,6 +122,7 @@ describe('htmlbars-inline-precompile', function () { expect(optionsReceived).toEqual({ contents: source, + locals: [], }); }); @@ -159,6 +165,7 @@ describe('htmlbars-inline-precompile', function () { stringifiedThing: { foo: 'baz', }, + locals: [], }); }); @@ -244,6 +251,7 @@ describe('htmlbars-inline-precompile', function () { expect(optionsReceived).toEqual({ contents: source, + locals: [], }); }); @@ -570,13 +578,9 @@ describe('htmlbars-inline-precompile', function () { // eslint-disable-next-line @typescript-eslint/no-var-requires const compiler = require('ember-source/dist/ember-template-compiler'); - beforeEach(function () { - precompile = (template, options) => { - return compiler.precompile(template, options); - }; - }); - it('includes the original template content', function () { + precompile = (template, options) => compiler.precompile(template, options); + let transformed = transform(stripIndent` import { precompileTemplate } from '@ember/template-compilation'; @@ -585,6 +589,143 @@ describe('htmlbars-inline-precompile', function () { expect(transformed).toContain(`hello {{firstName}}`); }); + + it('allows AST transform to bind a JS expression', function () { + precompile = runASTTransform(compiler, function (env) { + return { + name: 'sample-transform', + visitor: { + PathExpression(node) { + if (node.original === 'onePlusOne') { + let name = env.meta.jsutils.bindValue('1+1', { nameHint: 'two' }); + return env.syntax.builders.path(name); + } + return undefined; + }, + }, + }; + }); + + let transformed = transform(stripIndent` + import { precompileTemplate } from '@ember/template-compilation'; + const template = precompileTemplate(''); + `); + + expect(transformed).toContain(`@text={{two}}`); + expect(transformed).toContain(`locals: [two]`); + expect(transformed).toContain(`let two = 1 + 1`); + }); + + it('adds locals to the compiled output', function () { + precompile = compileASTTransform(compiler, function (env) { + return { + name: 'sample-transform', + visitor: { + PathExpression(node) { + if (node.original === 'onePlusOne') { + let name = env.meta.jsutils.bindValue('1+1', { nameHint: 'two' }); + return env.syntax.builders.path(name); + } + return undefined; + }, + }, + }; + }); + + let transformed = transform(stripIndent` + import { precompileTemplate } from '@ember/template-compilation'; + const template = precompileTemplate(''); + `); + expect(transformed).toContain(`"scope": () => [two]`); + }); + + it('allows AST transform to bind a JS import', function () { + precompile = runASTTransform(compiler, function (env) { + return { + name: 'sample-transform', + visitor: { + PathExpression(node) { + if (node.original === 'onePlusOne') { + let name = env.meta.jsutils.bindImport('my-library', 'default', { + nameHint: 'two', + }); + return env.syntax.builders.path(name); + } + return undefined; + }, + }, + }; + }); + + let transformed = transform(stripIndent` + import { precompileTemplate } from '@ember/template-compilation'; + const template = precompileTemplate(''); + `); + + expect(transformed).toContain(`@text={{two}}`); + expect(transformed).toContain(`locals: [two]`); + expect(transformed).toContain(`import two from "my-library"`); + }); + + it('does not smash existing binding for import', function () { + precompile = runASTTransform(compiler, function (env) { + return { + name: 'sample-transform', + visitor: { + PathExpression(node) { + if (node.original === 'onePlusOne') { + let name = env.meta.jsutils.bindImport('my-library', 'default', { + nameHint: 'two', + }); + return env.syntax.builders.path(name); + } + return undefined; + }, + }, + }; + }); + + let transformed = transform(stripIndent` + import { precompileTemplate } from '@ember/template-compilation'; + export function inner() { + let two = 'twice'; + const template = precompileTemplate(''); + } + `); + + expect(transformed).toContain(`@text={{two0}}`); + expect(transformed).toContain(`locals: [two0]`); + expect(transformed).toContain(`import two0 from "my-library"`); + }); + + it('does not smash existing binding for expression', function () { + precompile = runASTTransform(compiler, function (env) { + return { + name: 'sample-transform', + visitor: { + PathExpression(node) { + if (node.original === 'onePlusOne') { + let name = env.meta.jsutils.bindValue('1+1', { nameHint: 'two' }); + return env.syntax.builders.path(name); + } + return undefined; + }, + }, + }; + }); + + let transformed = transform(stripIndent` + import { precompileTemplate } from '@ember/template-compilation'; + export default function() { + let two = 'twice'; + const template = precompileTemplate(''); + } + `); + + expect(transformed).toContain(`@text={{two0}}`); + expect(transformed).toContain(`locals: [two0]`); + expect(transformed).toContain(`let two0 = 1 + 1`); + }); }); describe('scope', function () { @@ -663,3 +804,34 @@ describe('htmlbars-inline-precompile', function () { }); }); }); + +function runASTTransform( + compiler: any, + customTransform: ASTPluginBuilder> +) { + return (template: string, options: Record) => { + let ast = compiler._preprocess(template, { + ...options, + plugins: { ast: [customTransform] }, + }); + return ( + '{ transformedHBS: `' + + compiler._print(ast) + + '`, locals: [' + + ((options.locals as string[]) ?? []).join(',') + + ']}' + ); + }; +} + +function compileASTTransform( + compiler: any, + customTransform: ASTPluginBuilder> +) { + return (template: string, options: Record) => { + return compiler.precompile(template, { + ...options, + plugins: { ast: [customTransform] }, + }); + }; +} diff --git a/package.json b/package.json index 2f4421f..080725b 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@babel/plugin-transform-template-literals": "^7.14.5", "@babel/plugin-transform-unicode-escapes": "^7.14.5", "@babel/traverse": "^7.14.5", + "@glimmer/syntax": "^0.84.2", "@types/babel__traverse": "^7.11.1", "@types/jest": "^26.0.23", "@types/line-column": "^1.0.0", @@ -51,7 +52,7 @@ "@typescript-eslint/eslint-plugin": "^4.28.4", "@typescript-eslint/parser": "^4.28.4", "common-tags": "^1.8.0", - "ember-source": "^3.27.5", + "ember-source": "^3.28.9", "eslint": "^6.8.0", "eslint-config-prettier": "^6.15.0", "eslint-plugin-node": "^11.1.0", diff --git a/src/js-utils.ts b/src/js-utils.ts new file mode 100644 index 0000000..ddefc62 --- /dev/null +++ b/src/js-utils.ts @@ -0,0 +1,95 @@ +import type { types as t } from '@babel/core'; +import type * as Babel from '@babel/core'; +import type { NodePath } from '@babel/traverse'; +import type { ImportUtil } from 'babel-import-util'; + +// This exists to give AST plugins a controlled interface for influencing the +// surrounding Javascript scope +export class JSUtils { + #babel: typeof Babel; + #program: NodePath; + #template: NodePath; + #locals: string[]; + #importer: ImportUtil; + + constructor( + babel: typeof Babel, + program: NodePath, + template: NodePath, + locals: string[], + importer: ImportUtil + ) { + this.#babel = babel; + this.#program = program; + this.#template = template; + this.#locals = locals; + this.#importer = importer; + } + + bindValue(expression: string, opts?: { nameHint?: string }): string { + let name = this.#unusedNameLike(opts?.nameHint ?? 'a'); + let t = this.#babel.types; + this.#program.unshiftContainer( + 'body', + t.variableDeclaration('let', [ + t.variableDeclarator(t.identifier(name), this.#parseExpression(expression)), + ]) + ); + this.#locals.push(name); + return name; + } + + bindImport(moduleSpecifier: string, exportedName: string, opts?: { nameHint?: string }): string { + let identifier = this.#importer.import( + this.#template, + moduleSpecifier, + exportedName, + opts?.nameHint + ); + this.#locals.push(identifier.name); + return identifier.name; + } + + #parseExpression(expressionString: string): t.Expression { + let parsed = this.#babel.parse(expressionString); + if (!parsed) { + throw new Error(`JSUtils.bindValue could not understand the expression: ${expressionString}`); + } + let statements = body(parsed); + if (statements.length !== 1) { + throw new Error( + `JSUtils.bindValue expected to find exactly one expression but found ${statements.length} in: ${expressionString}` + ); + } + let statement = statements[0]; + if (statement.type !== 'ExpressionStatement') { + throw new Error( + `JSUtils.bindValue expected to find an expression but found ${statement.type} in: ${expressionString}` + ); + } + return statement.expression; + } + + #unusedNameLike(desiredName: string): string { + let candidate = desiredName; + let counter = 0; + while (this.#template.scope.hasBinding(candidate)) { + candidate = `${desiredName}${counter++}`; + } + return candidate; + } +} + +// This extends Glimmer's ASTPluginEnvironment type to put our jsutils into +// meta. +export type WithJSUtils = { + meta: T['meta'] & { jsutils: JSUtils }; +} & T; + +function body(node: t.Program | t.File) { + if (node.type === 'File') { + return node.program.body; + } else { + return node.body; + } +} diff --git a/src/plugin.ts b/src/plugin.ts index 3f2c3bc..2a07eff 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -3,6 +3,7 @@ import type * as Babel from '@babel/core'; import type { types as t } from '@babel/core'; import { ImportUtil } from 'babel-import-util'; import { ExpressionParser } from './expression-parser'; +import { JSUtils } from './js-utils'; export type EmberPrecompile = (templateString: string, options: Record) => string; @@ -77,6 +78,7 @@ interface State { util: ImportUtil; precompile: EmberPrecompile; templateFactory: { moduleName: string; exportName: string }; + program: NodePath; } export default function makePlugin( @@ -87,12 +89,23 @@ export default function makePlugin( let t = babel.types; function insertCompiledTemplate( - target: NodePath, + target: NodePath, state: State, template: string, userTypedOptions: Record ): void { - let options = Object.assign({ contents: template }, userTypedOptions); + if (!userTypedOptions.locals) { + userTypedOptions.locals = []; + } + let jsutils = new JSUtils( + babel, + state.program, + target, + userTypedOptions.locals as string[], + state.util + ); + let meta = Object.assign({ jsutils }, userTypedOptions?.meta); + let options = Object.assign({}, userTypedOptions, { contents: template, meta }); let precompile = state.precompile; let precompileResultString: string; @@ -142,6 +155,7 @@ export default function makePlugin( : { exportName, moduleName }; state.util = new ImportUtil(t, path); state.precompile = loadPrecompiler(state.opts as O); + state.program = path; }, exit(_path: NodePath, state: State) { for (let { moduleName, export: exportName } of configuredModules(state)) { diff --git a/yarn.lock b/yarn.lock index 3f284ab..da8dbd3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1026,13 +1026,49 @@ resolved "https://registry.yarnpkg.com/@ember/edition-utils/-/edition-utils-1.2.0.tgz#a039f542dc14c8e8299c81cd5abba95e2459cfa6" integrity sha512-VmVq/8saCaPdesQmftPqbFtxJWrzxNGSQ+e8x8LLe3Hjm36pJ04Q8LeORGZkAeOhldoUX9seLGmSaHeXkIqoog== -"@glimmer/vm-babel-plugins@0.78.2": - version "0.78.2" - resolved "https://registry.yarnpkg.com/@glimmer/vm-babel-plugins/-/vm-babel-plugins-0.78.2.tgz#b530a19f54da385c7099a22cf348e9062d186838" - integrity sha512-GSEf16h6OCtKx7PsSvD21cLXZuVc6swW2rSOAvfLeZco1DEWMRgYTwkCkColydKZcQ3gvwbPBeYwTC2K6tlnjg== +"@glimmer/env@0.1.7": + version "0.1.7" + resolved "https://registry.yarnpkg.com/@glimmer/env/-/env-0.1.7.tgz#fd2d2b55a9029c6b37a6c935e8c8871ae70dfa07" + integrity sha512-JKF/a9I9jw6fGoz8kA7LEQslrwJ5jms5CXhu/aqkBWk+PmZ6pTl8mlb/eJ/5ujBGTiQzBhy5AIWF712iA+4/mw== + +"@glimmer/interfaces@0.84.2": + version "0.84.2" + resolved "https://registry.yarnpkg.com/@glimmer/interfaces/-/interfaces-0.84.2.tgz#764cf92c954adcd1a851e5dc68ec1f6b654dc3bd" + integrity sha512-tMZxQpOddUVmHEOuripkNqVR7ba0K4doiYnFd4WyswqoHPlxqpBujbIamQ+bWCWEF0U4yxsXKa31ekS/JHkiBQ== + dependencies: + "@simple-dom/interface" "^1.4.0" + +"@glimmer/syntax@^0.84.2": + version "0.84.2" + resolved "https://registry.yarnpkg.com/@glimmer/syntax/-/syntax-0.84.2.tgz#a3f65e51eec20f6adb79c6159d1ad1166fa5bccd" + integrity sha512-SPBd1tpIR9XeaXsXsMRCnKz63eLnIZ0d5G9QC4zIBFBC3pQdtG0F5kWeuRVCdfTIFuR+5WBMfk5jvg+3gbQhjg== + dependencies: + "@glimmer/interfaces" "0.84.2" + "@glimmer/util" "0.84.2" + "@handlebars/parser" "~2.0.0" + simple-html-tokenizer "^0.5.11" + +"@glimmer/util@0.84.2": + version "0.84.2" + resolved "https://registry.yarnpkg.com/@glimmer/util/-/util-0.84.2.tgz#2711ba40f25f44b2ea309cad49f5c2622c6211bc" + integrity sha512-VbhzE2s4rmU+qJF3gGBTL1IDjq+/G2Th51XErS8MQVMCmE4CU2pdwSzec8PyOowqCGUOrVIWuMzEI6VoPM4L4w== + dependencies: + "@glimmer/env" "0.1.7" + "@glimmer/interfaces" "0.84.2" + "@simple-dom/interface" "^1.4.0" + +"@glimmer/vm-babel-plugins@0.80.3": + version "0.80.3" + resolved "https://registry.yarnpkg.com/@glimmer/vm-babel-plugins/-/vm-babel-plugins-0.80.3.tgz#434b62172318cac43830d3ac29818cf2c5f111c1" + integrity sha512-9ej6xlm5MzHBJ5am2l0dbbn8Z0wJoYoMpM8FcrGMlUP6SPMLWxvxpMsApgQo8u6dvZRCjR3/bw3fdf7GOy0AFw== dependencies: babel-plugin-debug-macros "^0.3.4" +"@handlebars/parser@~2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@handlebars/parser/-/parser-2.0.0.tgz#5e8b7298f31ff8f7b260e6b7363c7e9ceed7d9c5" + integrity sha512-EP9uEDZv/L5Qh9IWuMUGJRfwhXJ4h1dqKTT4/3+tY0eu7sPis7xh23j61SYUnNF4vqCQvvUXpDo9Bh/+q1zASA== + "@iarna/toml@2.2.5": version "2.2.5" resolved "https://registry.yarnpkg.com/@iarna/toml/-/toml-2.2.5.tgz#b32366c89b43c6f8cefbdefac778b9c828e3ba8c" @@ -1359,6 +1395,11 @@ dependencies: "@octokit/openapi-types" "^7.3.2" +"@simple-dom/interface@^1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@simple-dom/interface/-/interface-1.4.0.tgz#e8feea579232017f89b0138e2726facda6fbb71f" + integrity sha512-l5qumKFWU0S+4ZzMaLXFU8tQZsicHEMEyAxI5kDFGhJsRqDwe0a7/iPA/GdxlGyDKseQQAgIz5kzU7eXTrlSpA== + "@sindresorhus/is@^0.14.0": version "0.14.0" resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea" @@ -1925,7 +1966,7 @@ babel-jest@^26.6.3: graceful-fs "^4.2.4" slash "^3.0.0" -babel-plugin-debug-macros@^0.3.3, babel-plugin-debug-macros@^0.3.4: +babel-plugin-debug-macros@^0.3.4: version "0.3.4" resolved "https://registry.yarnpkg.com/babel-plugin-debug-macros/-/babel-plugin-debug-macros-0.3.4.tgz#22961d0cb851a80654cece807a8b4b73d85c6075" integrity sha512-wfel/vb3pXfwIDZUrkoDrn5FHmlWI96PCJ3UCDv2a86poJ3EQrnArNW5KfHSVJ9IOgxHbo748cQt7sDU+0KCEw== @@ -3076,17 +3117,17 @@ ember-router-generator@^2.0.0: "@babel/traverse" "^7.4.5" recast "^0.18.1" -ember-source@^3.27.5: - version "3.27.5" - resolved "https://registry.yarnpkg.com/ember-source/-/ember-source-3.27.5.tgz#8e9ce24c17e7a16dc3c2b128d3d3e24ea79e6726" - integrity sha512-oSGM9mD6BuOcGilYqU+F2MtCferQhKWO3REX1P9qgN1Wzfa5kXjbjBBdPNWfBtg7bZLGM27H8JgiV6+t3uGegA== +ember-source@^3.28.9: + version "3.28.9" + resolved "https://registry.yarnpkg.com/ember-source/-/ember-source-3.28.9.tgz#804c56b2d71d3cc3decff15a3273bb35d668300a" + integrity sha512-Fy7V3yvj+3oyo2+ke52aaihKMcFnnF7Oj9ixj547yzh2faqRfqouB5ZSiwXFH8rxw22rKaM8DiuQO4JN2Ay6xQ== dependencies: "@babel/helper-module-imports" "^7.8.3" "@babel/plugin-transform-block-scoping" "^7.8.3" "@babel/plugin-transform-object-assign" "^7.8.3" "@ember/edition-utils" "^1.2.0" - "@glimmer/vm-babel-plugins" "0.78.2" - babel-plugin-debug-macros "^0.3.3" + "@glimmer/vm-babel-plugins" "0.80.3" + babel-plugin-debug-macros "^0.3.4" babel-plugin-filter-imports "^4.0.0" broccoli-concat "^4.2.4" broccoli-debug "^0.6.4" @@ -6929,6 +6970,11 @@ silent-error@^1.0.0, silent-error@^1.1.1: dependencies: debug "^2.2.0" +simple-html-tokenizer@^0.5.11: + version "0.5.11" + resolved "https://registry.yarnpkg.com/simple-html-tokenizer/-/simple-html-tokenizer-0.5.11.tgz#4c5186083c164ba22a7b477b7687ac056ad6b1d9" + integrity sha512-C2WEK/Z3HoSFbYq8tI7ni3eOo/NneSPRoPpcM7WdLjFOArFuyXEjAoCdOC3DgMfRyziZQ1hCNR4mrNdWEvD0og== + sisteransi@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.0.tgz#77d9622ff909080f1c19e5f4a1df0c1b0a27b88c" From d140e538d77a983fcb3eac678b4ee84251c89cc8 Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Thu, 30 Jun 2022 07:25:47 -0400 Subject: [PATCH 02/31] add failing tests for hbs scope collision --- __tests__/tests.ts | 62 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/__tests__/tests.ts b/__tests__/tests.ts index 992a666..daeb62f 100644 --- a/__tests__/tests.ts +++ b/__tests__/tests.ts @@ -667,7 +667,7 @@ describe('htmlbars-inline-precompile', function () { expect(transformed).toContain(`import two from "my-library"`); }); - it('does not smash existing binding for import', function () { + it('does not smash existing js binding for import', function () { precompile = runASTTransform(compiler, function (env) { return { name: 'sample-transform', @@ -698,7 +698,37 @@ describe('htmlbars-inline-precompile', function () { expect(transformed).toContain(`import two0 from "my-library"`); }); - it('does not smash existing binding for expression', function () { + it('does not smash existing hbs binding for import', function () { + precompile = runASTTransform(compiler, function (env) { + return { + name: 'sample-transform', + visitor: { + PathExpression(node) { + if (node.original === 'onePlusOne') { + let name = env.meta.jsutils.bindImport('my-library', 'default', { + nameHint: 'two', + }); + return env.syntax.builders.path(name); + } + return undefined; + }, + }, + }; + }); + + let transformed = transform(stripIndent` + import { precompileTemplate } from '@ember/template-compilation'; + export function inner() { + const template = precompileTemplate('{{#let "twice" as |two|}}{{/let}}'); + } + `); + + expect(transformed).toContain(`@text={{two0}}`); + expect(transformed).toContain(`locals: [two0]`); + expect(transformed).toContain(`import two0 from "my-library"`); + }); + + it('does not smash existing js binding for expression', function () { precompile = runASTTransform(compiler, function (env) { return { name: 'sample-transform', @@ -726,6 +756,34 @@ describe('htmlbars-inline-precompile', function () { expect(transformed).toContain(`locals: [two0]`); expect(transformed).toContain(`let two0 = 1 + 1`); }); + + it('does not smash existing hbs binding for expression', function () { + precompile = runASTTransform(compiler, function (env) { + return { + name: 'sample-transform', + visitor: { + PathExpression(node) { + if (node.original === 'onePlusOne') { + let name = env.meta.jsutils.bindValue('1+1', { nameHint: 'two' }); + return env.syntax.builders.path(name); + } + return undefined; + }, + }, + }; + }); + + let transformed = transform(stripIndent` + import { precompileTemplate } from '@ember/template-compilation'; + export default function() { + const template = precompileTemplate('{{#let "twice" as |two|}}{{/let}}'); + } + `); + + expect(transformed).toContain(`@text={{two0}}`); + expect(transformed).toContain(`locals: [two0]`); + expect(transformed).toContain(`let two0 = 1 + 1`); + }); }); describe('scope', function () { From ac773923aaaf104b0b009c592008c954ea3daada Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Thu, 30 Jun 2022 14:03:15 -0400 Subject: [PATCH 03/31] handle HBS scope collision --- __tests__/tests.ts | 178 ++++++++++++++++++--------------------------- src/js-utils.ts | 80 +++++++++++++++++--- 2 files changed, 140 insertions(+), 118 deletions(-) diff --git a/__tests__/tests.ts b/__tests__/tests.ts index daeb62f..20cd39c 100644 --- a/__tests__/tests.ts +++ b/__tests__/tests.ts @@ -578,6 +578,38 @@ describe('htmlbars-inline-precompile', function () { // eslint-disable-next-line @typescript-eslint/no-var-requires const compiler = require('ember-source/dist/ember-template-compiler'); + let expressionTransform: ASTPluginBuilder> = (env) => { + return { + name: 'expression-transform', + visitor: { + PathExpression(node, path) { + if (node.original === 'onePlusOne') { + let name = env.meta.jsutils.bindValue('1+1', path, { nameHint: 'two' }); + return env.syntax.builders.path(name); + } + return undefined; + }, + }, + }; + }; + + let importTransform: ASTPluginBuilder> = (env) => { + return { + name: 'import-transform', + visitor: { + PathExpression(node, path) { + if (node.original === 'onePlusOne') { + let name = env.meta.jsutils.bindImport('my-library', 'default', path, { + nameHint: 'two', + }); + return env.syntax.builders.path(name); + } + return undefined; + }, + }, + }; + }; + it('includes the original template content', function () { precompile = (template, options) => compiler.precompile(template, options); @@ -591,20 +623,7 @@ describe('htmlbars-inline-precompile', function () { }); it('allows AST transform to bind a JS expression', function () { - precompile = runASTTransform(compiler, function (env) { - return { - name: 'sample-transform', - visitor: { - PathExpression(node) { - if (node.original === 'onePlusOne') { - let name = env.meta.jsutils.bindValue('1+1', { nameHint: 'two' }); - return env.syntax.builders.path(name); - } - return undefined; - }, - }, - }; - }); + precompile = runASTTransform(compiler, expressionTransform); let transformed = transform(stripIndent` import { precompileTemplate } from '@ember/template-compilation'; @@ -617,20 +636,7 @@ describe('htmlbars-inline-precompile', function () { }); it('adds locals to the compiled output', function () { - precompile = compileASTTransform(compiler, function (env) { - return { - name: 'sample-transform', - visitor: { - PathExpression(node) { - if (node.original === 'onePlusOne') { - let name = env.meta.jsutils.bindValue('1+1', { nameHint: 'two' }); - return env.syntax.builders.path(name); - } - return undefined; - }, - }, - }; - }); + precompile = compileASTTransform(compiler, expressionTransform); let transformed = transform(stripIndent` import { precompileTemplate } from '@ember/template-compilation'; @@ -640,22 +646,7 @@ describe('htmlbars-inline-precompile', function () { }); it('allows AST transform to bind a JS import', function () { - precompile = runASTTransform(compiler, function (env) { - return { - name: 'sample-transform', - visitor: { - PathExpression(node) { - if (node.original === 'onePlusOne') { - let name = env.meta.jsutils.bindImport('my-library', 'default', { - nameHint: 'two', - }); - return env.syntax.builders.path(name); - } - return undefined; - }, - }, - }; - }); + precompile = runASTTransform(compiler, importTransform); let transformed = transform(stripIndent` import { precompileTemplate } from '@ember/template-compilation'; @@ -668,22 +659,7 @@ describe('htmlbars-inline-precompile', function () { }); it('does not smash existing js binding for import', function () { - precompile = runASTTransform(compiler, function (env) { - return { - name: 'sample-transform', - visitor: { - PathExpression(node) { - if (node.original === 'onePlusOne') { - let name = env.meta.jsutils.bindImport('my-library', 'default', { - nameHint: 'two', - }); - return env.syntax.builders.path(name); - } - return undefined; - }, - }, - }; - }); + precompile = runASTTransform(compiler, importTransform); let transformed = transform(stripIndent` import { precompileTemplate } from '@ember/template-compilation'; @@ -699,22 +675,7 @@ describe('htmlbars-inline-precompile', function () { }); it('does not smash existing hbs binding for import', function () { - precompile = runASTTransform(compiler, function (env) { - return { - name: 'sample-transform', - visitor: { - PathExpression(node) { - if (node.original === 'onePlusOne') { - let name = env.meta.jsutils.bindImport('my-library', 'default', { - nameHint: 'two', - }); - return env.syntax.builders.path(name); - } - return undefined; - }, - }, - }; - }); + precompile = runASTTransform(compiler, importTransform); let transformed = transform(stripIndent` import { precompileTemplate } from '@ember/template-compilation'; @@ -724,25 +685,13 @@ describe('htmlbars-inline-precompile', function () { `); expect(transformed).toContain(`@text={{two0}}`); + expect(transformed).toContain(`let two0 = two`); expect(transformed).toContain(`locals: [two0]`); - expect(transformed).toContain(`import two0 from "my-library"`); + expect(transformed).toContain(`import two from "my-library"`); }); it('does not smash existing js binding for expression', function () { - precompile = runASTTransform(compiler, function (env) { - return { - name: 'sample-transform', - visitor: { - PathExpression(node) { - if (node.original === 'onePlusOne') { - let name = env.meta.jsutils.bindValue('1+1', { nameHint: 'two' }); - return env.syntax.builders.path(name); - } - return undefined; - }, - }, - }; - }); + precompile = runASTTransform(compiler, expressionTransform); let transformed = transform(stripIndent` import { precompileTemplate } from '@ember/template-compilation'; @@ -757,21 +706,8 @@ describe('htmlbars-inline-precompile', function () { expect(transformed).toContain(`let two0 = 1 + 1`); }); - it('does not smash existing hbs binding for expression', function () { - precompile = runASTTransform(compiler, function (env) { - return { - name: 'sample-transform', - visitor: { - PathExpression(node) { - if (node.original === 'onePlusOne') { - let name = env.meta.jsutils.bindValue('1+1', { nameHint: 'two' }); - return env.syntax.builders.path(name); - } - return undefined; - }, - }, - }; - }); + it('does not smash existing hbs block binding for expression', function () { + precompile = runASTTransform(compiler, expressionTransform); let transformed = transform(stripIndent` import { precompileTemplate } from '@ember/template-compilation'; @@ -784,6 +720,36 @@ describe('htmlbars-inline-precompile', function () { expect(transformed).toContain(`locals: [two0]`); expect(transformed).toContain(`let two0 = 1 + 1`); }); + + it('does not smash existing hbs element binding for expression', function () { + precompile = runASTTransform(compiler, expressionTransform); + + let transformed = transform(stripIndent` + import { precompileTemplate } from '@ember/template-compilation'; + export default function() { + const template = precompileTemplate(''); + } + `); + + expect(transformed).toContain(`@text={{two0}}`); + expect(transformed).toContain(`locals: [two0]`); + expect(transformed).toContain(`let two0 = 1 + 1`); + }); + + it('understands that block params are only defined in the body, not the arguments, of an element', function () { + precompile = runASTTransform(compiler, expressionTransform); + + let transformed = transform(stripIndent` + import { precompileTemplate } from '@ember/template-compilation'; + export default function() { + const template = precompileTemplate('{{two}}'); + } + `); + + expect(transformed).toContain(`@text={{two}}`); + expect(transformed).toContain(`locals: [two]`); + expect(transformed).toContain(`let two = 1 + 1`); + }); }); describe('scope', function () { diff --git a/src/js-utils.ts b/src/js-utils.ts index ddefc62..8f48868 100644 --- a/src/js-utils.ts +++ b/src/js-utils.ts @@ -1,6 +1,7 @@ import type { types as t } from '@babel/core'; import type * as Babel from '@babel/core'; import type { NodePath } from '@babel/traverse'; +import type { ASTv1, WalkerPath } from '@glimmer/syntax'; import type { ImportUtil } from 'babel-import-util'; // This exists to give AST plugins a controlled interface for influencing the @@ -26,8 +27,16 @@ export class JSUtils { this.#importer = importer; } - bindValue(expression: string, opts?: { nameHint?: string }): string { - let name = this.#unusedNameLike(opts?.nameHint ?? 'a'); + bindValue( + expression: string, + target: WalkerPath, + opts?: { nameHint?: string } + ): string { + let name = unusedNameLike( + opts?.nameHint ?? 'a', + (candidate) => + this.#template.scope.hasBinding(candidate) || astNodeHasBinding(target, candidate) + ); let t = this.#babel.types; this.#program.unshiftContainer( 'body', @@ -39,15 +48,35 @@ export class JSUtils { return name; } - bindImport(moduleSpecifier: string, exportedName: string, opts?: { nameHint?: string }): string { - let identifier = this.#importer.import( + bindImport( + moduleSpecifier: string, + exportedName: string, + target: WalkerPath, + opts?: { nameHint?: string } + ): string { + let importedIdentifier = this.#importer.import( this.#template, moduleSpecifier, exportedName, opts?.nameHint ); - this.#locals.push(identifier.name); - return identifier.name; + + // only need to check for collisions with HBS here, the JS collisions were + // handled for us by ImportUtil + let identifier = unusedNameLike(importedIdentifier.name, (candidate) => + astNodeHasBinding(target, candidate) + ); + if (identifier !== importedIdentifier.name) { + let t = this.#babel.types; + this.#program.unshiftContainer( + 'body', + t.variableDeclaration('let', [ + t.variableDeclarator(t.identifier(identifier), importedIdentifier), + ]) + ); + } + this.#locals.push(identifier); + return identifier; } #parseExpression(expressionString: string): t.Expression { @@ -69,15 +98,42 @@ export class JSUtils { } return statement.expression; } +} + +function unusedNameLike(desiredName: string, isUsed: (name: string) => boolean): string { + let candidate = desiredName; + let counter = 0; + while (isUsed(candidate)) { + candidate = `${desiredName}${counter++}`; + } + return candidate; +} - #unusedNameLike(desiredName: string): string { - let candidate = desiredName; - let counter = 0; - while (this.#template.scope.hasBinding(candidate)) { - candidate = `${desiredName}${counter++}`; +function astNodeHasBinding(target: WalkerPath, name: string): boolean { + let cursor: WalkerPath | null = target; + while (cursor) { + let parentNode = cursor.parent?.node; + if ( + parentNode?.type === 'ElementNode' && + parentNode.blockParams.includes(name) && + // an ElementNode's block params are valid only within its children + parentNode.children.includes(cursor.node as ASTv1.Statement) + ) { + return true; } - return candidate; + + if ( + parentNode?.type === 'Block' && + parentNode.blockParams.includes(name) && + // a Block's blockParams are valid only within its body + parentNode.body.includes(cursor.node as ASTv1.Statement) + ) { + return true; + } + + cursor = cursor.parent; } + return false; } // This extends Glimmer's ASTPluginEnvironment type to put our jsutils into From c219c93ea93c2e50c9ab8f098499e9aedf8cf0d2 Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Thu, 30 Jun 2022 16:30:14 -0400 Subject: [PATCH 04/31] renaming bindValue->bindExpression I think that's less ambiguous. --- __tests__/tests.ts | 2 +- src/js-utils.ts | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/__tests__/tests.ts b/__tests__/tests.ts index 20cd39c..7f68bf1 100644 --- a/__tests__/tests.ts +++ b/__tests__/tests.ts @@ -584,7 +584,7 @@ describe('htmlbars-inline-precompile', function () { visitor: { PathExpression(node, path) { if (node.original === 'onePlusOne') { - let name = env.meta.jsutils.bindValue('1+1', path, { nameHint: 'two' }); + let name = env.meta.jsutils.bindExpression('1+1', path, { nameHint: 'two' }); return env.syntax.builders.path(name); } return undefined; diff --git a/src/js-utils.ts b/src/js-utils.ts index 8f48868..993dfd0 100644 --- a/src/js-utils.ts +++ b/src/js-utils.ts @@ -27,7 +27,7 @@ export class JSUtils { this.#importer = importer; } - bindValue( + bindExpression( expression: string, target: WalkerPath, opts?: { nameHint?: string } @@ -67,6 +67,7 @@ export class JSUtils { astNodeHasBinding(target, candidate) ); if (identifier !== importedIdentifier.name) { + // The importedIdentifier let t = this.#babel.types; this.#program.unshiftContainer( 'body', @@ -82,18 +83,20 @@ export class JSUtils { #parseExpression(expressionString: string): t.Expression { let parsed = this.#babel.parse(expressionString); if (!parsed) { - throw new Error(`JSUtils.bindValue could not understand the expression: ${expressionString}`); + throw new Error( + `JSUtils.bindExpression could not understand the expression: ${expressionString}` + ); } let statements = body(parsed); if (statements.length !== 1) { throw new Error( - `JSUtils.bindValue expected to find exactly one expression but found ${statements.length} in: ${expressionString}` + `JSUtils.bindExpression expected to find exactly one expression but found ${statements.length} in: ${expressionString}` ); } let statement = statements[0]; if (statement.type !== 'ExpressionStatement') { throw new Error( - `JSUtils.bindValue expected to find an expression but found ${statement.type} in: ${expressionString}` + `JSUtils.bindExpression expected to find an expression but found ${statement.type} in: ${expressionString}` ); } return statement.expression; From 7f4eb944db94711cf3a20c395f95915655ac64cd Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Thu, 30 Jun 2022 17:15:39 -0400 Subject: [PATCH 05/31] documenting some gnarlier bits --- src/js-utils.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/js-utils.ts b/src/js-utils.ts index 993dfd0..af13ace 100644 --- a/src/js-utils.ts +++ b/src/js-utils.ts @@ -54,6 +54,7 @@ export class JSUtils { target: WalkerPath, opts?: { nameHint?: string } ): string { + // This will discover or create the local name for accessing the given import. let importedIdentifier = this.#importer.import( this.#template, moduleSpecifier, @@ -61,13 +62,17 @@ export class JSUtils { opts?.nameHint ); - // only need to check for collisions with HBS here, the JS collisions were - // handled for us by ImportUtil let identifier = unusedNameLike(importedIdentifier.name, (candidate) => astNodeHasBinding(target, candidate) ); if (identifier !== importedIdentifier.name) { - // The importedIdentifier + // The importedIdentifier that we have in Javascript is not usable within + // our HBS because it's shadowed by a block param. So we will introduce a + // second name via a variable declaration. + // + // The reason we don't force the import itself to have this name is that + // we might be re-using an existing import, and we don't want to go + // rewriting all of its callsites that are unrelated to us. let t = this.#babel.types; this.#program.unshiftContainer( 'body', From bfd5900d92a0778bff2af48ec08c483701c8cf36 Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Wed, 27 Jul 2022 17:34:14 -0400 Subject: [PATCH 06/31] update readme --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9195d47..a76d6fc 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ interface NodeOptions extends Options { // Options handling rules: // // - we add `content`, which is the original string form of the template - // - we add `meta.jsutils: JSUtils`, which gives AST transform plugins access to methods for manipulating the outer Javascript scope. This only works in non-strict-mode templates on Ember 3.28+ because prior to that only strict-mode templates could use lexically scoped values. + // - we add `meta.installJSUtils`, which gives AST transform plugins access to methods for manipulating the surrounding javascript scope. // - we have special parsing for `scope` which becomes `locals` when passed // to your precompile // - anything else the user passes to `precompileTemplate` will be passed @@ -99,6 +99,10 @@ import * as babel from '@babel/core'; babel.transform(someCode, { plugins: [makePlugin(loadTemplateCompiler)] }); ``` +# JSUtils: Manipulating Javascript from within AST transforms + +AST transforms are plugins for modifying HBS templates at build time. Because those templates are embedded in Javascript and can access the Javascript scope, an AST plugin may want to introduce some new things into Javascript scope. That is why babel-plugin-ember-template-compilation offers the `JSUtiles` API. + # Acknowledgement / History This repo derives from https://github.com/ember-cli/babel-plugin-htmlbars-inline-precompile From 01e628acc5f03463224d6261cbc6f2deac04dc4b Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Thu, 28 Jul 2022 16:35:21 -0400 Subject: [PATCH 07/31] adding docs --- README.md | 39 +++++++++++++++++++++++++++++++++++++-- src/js-utils.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a76d6fc..5446d0d 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ interface NodeOptions extends Options { // Options handling rules: // // - we add `content`, which is the original string form of the template - // - we add `meta.installJSUtils`, which gives AST transform plugins access to methods for manipulating the surrounding javascript scope. + // - we add `meta.jsutils`, which gives AST transform plugins access to methods for manipulating the surrounding javascript scope. See "JSUtils" below. // - we have special parsing for `scope` which becomes `locals` when passed // to your precompile // - anything else the user passes to `precompileTemplate` will be passed @@ -101,7 +101,42 @@ babel.transform(someCode, { plugins: [makePlugin(loadTemplateCompiler)] }); # JSUtils: Manipulating Javascript from within AST transforms -AST transforms are plugins for modifying HBS templates at build time. Because those templates are embedded in Javascript and can access the Javascript scope, an AST plugin may want to introduce some new things into Javascript scope. That is why babel-plugin-ember-template-compilation offers the `JSUtiles` API. +AST transforms are plugins for modifying HBS templates at build time. Because those templates are embedded in Javascript and can access the Javascript scope, an AST plugin may want to introduce some new things into Javascript scope. + +Your AST transform can access the JSUtils API via `env.meta.jsutils`. Here's an example transform. + +```js +function myAstTransform(env) { + return { + name: 'my-ast-transform', + visitor: { + PathExpression(node, path) { + if (node.original === 'onePlusOne') { + let name = env.meta.jsutils.bindExpression('1+1', path, { nameHint: 'two' }); + return env.syntax.builders.path(name); + } + }, + }, + }; +} +``` + +The example transform above would rewrite: + +```js +import { precompileTemplate } from '@ember/template-compilation'; +precompileTemplate('>'); +``` + +To: + +```js +import { precompileTemplate } from '@ember/template-compilation'; +let two = 1 + 1; +precompileTemplate('', { scope: () => ({ two }) }); +``` + +See the inline docs in js-utils.js for details on the methods available. # Acknowledgement / History diff --git a/src/js-utils.ts b/src/js-utils.ts index af13ace..d34c8ce 100644 --- a/src/js-utils.ts +++ b/src/js-utils.ts @@ -27,6 +27,20 @@ export class JSUtils { this.#importer = importer; } + /** + * Create a new binding that you can use in your template, initialized with + * the given Javascript expression. + * + * @param expression A javascript expression whose value will initialize your + * new binding. + * @param target The location within your template where the binding will be + * used. This matters so we can avoid naming collisions. + * @param opts.nameHint Optionally, provide a descriptive name for your new + * binding. We will mangle this name as needed to avoid collisions, but + * picking a good name here can aid in debugging. + * + * @return The name you can use in your template to access the binding. + */ bindExpression( expression: string, target: WalkerPath, @@ -48,6 +62,20 @@ export class JSUtils { return name; } + /** + * Gain access to an imported value within your template. + * + * @param moduleSpecifier The path to import from. + * @param exportedName The named export you wish to access, or "default" for + * the default export. + * @param target The location within your template where the binding will be + * used. This matters so we can avoid naming collisions. + * @param opts.nameHint Optionally, provide a descriptive name for your new + * binding. We will mangle this name as needed to avoid collisions, but + * picking a good name here can aid in debugging. + * + * @return The name you can use in your template to access the imported value. + */ bindImport( moduleSpecifier: string, exportedName: string, From 3828958700724a9cd1adecf1d93f9e3465de667c Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Sun, 31 Jul 2022 11:33:21 -0400 Subject: [PATCH 08/31] extending API to cover more side-effectful cases --- README.md | 4 +- __tests__/tests.ts | 93 +++++++++++++++++++++++++++++++++++++++ src/js-utils.ts | 107 ++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 190 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 5446d0d..d70cefd 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ babel.transform(someCode, { plugins: [makePlugin(loadTemplateCompiler)] }); # JSUtils: Manipulating Javascript from within AST transforms -AST transforms are plugins for modifying HBS templates at build time. Because those templates are embedded in Javascript and can access the Javascript scope, an AST plugin may want to introduce some new things into Javascript scope. +AST transforms are plugins for modifying HBS templates at build time. Because those templates are embedded in Javascript and can access the Javascript scope, an AST plugin may want to introduce some new things into Javascript scope. That is what the JSUtils API is for. Your AST transform can access the JSUtils API via `env.meta.jsutils`. Here's an example transform. @@ -136,7 +136,7 @@ let two = 1 + 1; precompileTemplate('', { scope: () => ({ two }) }); ``` -See the inline docs in js-utils.js for details on the methods available. +See the jsdoc comments in js-utils.js for details on the methods available. # Acknowledgement / History diff --git a/__tests__/tests.ts b/__tests__/tests.ts index 7f68bf1..6655ae1 100644 --- a/__tests__/tests.ts +++ b/__tests__/tests.ts @@ -750,6 +750,99 @@ describe('htmlbars-inline-precompile', function () { expect(transformed).toContain(`locals: [two]`); expect(transformed).toContain(`let two = 1 + 1`); }); + + it('can bind expressions that need imports', function () { + let nowTransform: ASTPluginBuilder> = (env) => { + return { + name: 'now-transform', + visitor: { + PathExpression(node, path) { + if (node.original === 'now') { + let name = env.meta.jsutils.bindExpression( + (context) => { + let identifier = context.import('luxon', 'DateTime'); + return `${identifier}.now()`; + }, + path, + { nameHint: 'current' } + ); + return env.syntax.builders.path(name); + } + return undefined; + }, + }, + }; + }; + + precompile = runASTTransform(compiler, nowTransform); + + let transformed = transform(stripIndent` + import { precompileTemplate } from '@ember/template-compilation'; + export default function() { + const template = precompileTemplate(''); + } + `); + + expect(transformed).toMatch(/let current = DateTime.now()/); + expect(transformed).toMatch(/import { DateTime } from "luxon"/); + expect(transformed).toContain('when={{current}}'); + }); + + it('can emit side-effectful expression that need imports', function () { + let compatTransform: ASTPluginBuilder> = (env) => { + return { + name: 'compat-transform', + visitor: { + ElementNode(node) { + if (node.tag === 'Thing') { + env.meta.jsutils.emitExpression((context) => { + let identifier = context.import('ember-thing', '*', 'thing'); + return `window.define('my-app/components/thing', ${identifier})`; + }); + } + }, + }, + }; + }; + + precompile = runASTTransform(compiler, compatTransform); + + let transformed = transform(stripIndent` + import { precompileTemplate } from '@ember/template-compilation'; + export default function() { + const template = precompileTemplate(''); + } + `); + + expect(transformed).toContain(`import * as thing from "ember-thing"`); + expect(transformed).toContain(`window.define('my-app/components/thing', thing)`); + }); + + it('can emit side-effectful import', function () { + let compatTransform: ASTPluginBuilder> = (env) => { + return { + name: 'compat-transform', + visitor: { + ElementNode(node) { + if (node.tag === 'Thing') { + env.meta.jsutils.importForSideEffect('setup-the-things'); + } + }, + }, + }; + }; + + precompile = runASTTransform(compiler, compatTransform); + + let transformed = transform(stripIndent` + import { precompileTemplate } from '@ember/template-compilation'; + export default function() { + const template = precompileTemplate(''); + } + `); + + expect(transformed).toContain(`import "setup-the-things"`); + }); }); describe('scope', function () { diff --git a/src/js-utils.ts b/src/js-utils.ts index d34c8ce..458b876 100644 --- a/src/js-utils.ts +++ b/src/js-utils.ts @@ -12,6 +12,7 @@ export class JSUtils { #template: NodePath; #locals: string[]; #importer: ImportUtil; + #lastInsertedPath: NodePath | undefined; constructor( babel: typeof Babel, @@ -31,8 +32,8 @@ export class JSUtils { * Create a new binding that you can use in your template, initialized with * the given Javascript expression. * - * @param expression A javascript expression whose value will initialize your - * new binding. + * @param { Expression } expression A javascript expression whose value will + * initialize your new binding. See docs on the Expression type for details. * @param target The location within your template where the binding will be * used. This matters so we can avoid naming collisions. * @param opts.nameHint Optionally, provide a descriptive name for your new @@ -42,7 +43,7 @@ export class JSUtils { * @return The name you can use in your template to access the binding. */ bindExpression( - expression: string, + expression: Expression, target: WalkerPath, opts?: { nameHint?: string } ): string { @@ -52,22 +53,29 @@ export class JSUtils { this.#template.scope.hasBinding(candidate) || astNodeHasBinding(target, candidate) ); let t = this.#babel.types; - this.#program.unshiftContainer( - 'body', + this.#emitStatement( t.variableDeclaration('let', [ - t.variableDeclarator(t.identifier(name), this.#parseExpression(expression)), + t.variableDeclarator(t.identifier(name), this.#parseExpression(this.#program, expression)), ]) ); this.#locals.push(name); return name; } + #emitStatement(statement: t.Statement): void { + if (this.#lastInsertedPath) { + this.#lastInsertedPath.insertAfter(statement); + } else { + this.#lastInsertedPath = this.#program.unshiftContainer('body', statement)[0]; + } + } + /** * Gain access to an imported value within your template. * * @param moduleSpecifier The path to import from. * @param exportedName The named export you wish to access, or "default" for - * the default export. + * the default export, or "*" for the namespace export. * @param target The location within your template where the binding will be * used. This matters so we can avoid naming collisions. * @param opts.nameHint Optionally, provide a descriptive name for your new @@ -102,8 +110,7 @@ export class JSUtils { // we might be re-using an existing import, and we don't want to go // rewriting all of its callsites that are unrelated to us. let t = this.#babel.types; - this.#program.unshiftContainer( - 'body', + this.#emitStatement( t.variableDeclaration('let', [ t.variableDeclarator(t.identifier(identifier), importedIdentifier), ]) @@ -113,7 +120,37 @@ export class JSUtils { return identifier; } - #parseExpression(expressionString: string): t.Expression { + /** + * Add an import statement purely for side effect. + * + * @param moduleSpecifier the module to import + */ + importForSideEffect(moduleSpecifier: string): void { + this.#importer.importForSideEffect(moduleSpecifier); + } + + /** + * Emit a javascript expresison for side-effect. This only accepts + * expressions, not statements, because you should not introduce new bindings. + * To introduce a binding see bindExpression or bindImport instead. + * + * @param { Expression } expression A javascript expression whose value will + * initialize your new binding. See docs on the Expression type below for + * details. + */ + emitExpression(expression: Expression): void { + let t = this.#babel.types; + this.#emitStatement(t.expressionStatement(this.#parseExpression(this.#program, expression))); + } + + #parseExpression(target: NodePath, expression: Expression): t.Expression { + let expressionString: string; + if (typeof expression === 'string') { + expressionString = expression; + } else { + expressionString = expression(new ExpressionContext(this.#importer, target)); + } + let parsed = this.#babel.parse(expressionString); if (!parsed) { throw new Error( @@ -172,8 +209,9 @@ function astNodeHasBinding(target: WalkerPath, name: string): boolea return false; } -// This extends Glimmer's ASTPluginEnvironment type to put our jsutils into -// meta. +/** + * This extends Glimmer's ASTPluginEnvironment type to put our jsutils into meta + */ export type WithJSUtils = { meta: T['meta'] & { jsutils: JSUtils }; } & T; @@ -185,3 +223,48 @@ function body(node: t.Program | t.File) { return node.body; } } + +/** + * Allows you to construct an expression that relies on imported values. + */ +class ExpressionContext { + #importer: ImportUtil; + #target: NodePath; + + constructor(importer: ImportUtil, target: NodePath) { + this.#importer = importer; + this.#target = target; + } + + /** + * Find or create a local binding for the given import. + * + * @param moduleSpecifier The path to import from. + * @param exportedName The named export you wish to access, or "default" for + * the default export, or "*" for the namespace export. + * @param nameHint Optionally, provide a descriptive name for your new + * binding. We will mangle this name as needed to avoid collisions, but + * picking a good name here can aid in debugging. + + * @return the local identifier for the imported value + */ + import(moduleSpecifier: string, exportedName: string, nameHint?: string): string { + return this.#importer.import(this.#target, moduleSpecifier, exportedName, nameHint).name; + } +} + +/** + * You can pass a Javascript expression as a string like: + * + * "new Date()" + * + * Or as a function that returns a string: + * + * () => "new Date()" + * + * When you use a function, it can use imported values: + * + * (context) => `new ${context.import("luxon", "DateTime")}()` + * + */ +export type Expression = string | ((context: ExpressionContext) => string); From 10c1d8ffaa6872822e4101689138af60c1ac55aa Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Tue, 2 Aug 2022 11:32:04 -0400 Subject: [PATCH 09/31] un-nesting function --- src/plugin.ts | 117 ++++++++++++++++++++++++++------------------------ 1 file changed, 60 insertions(+), 57 deletions(-) diff --git a/src/plugin.ts b/src/plugin.ts index 2a07eff..01daef7 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -88,61 +88,6 @@ export default function makePlugin( return function htmlbarsInlinePrecompile(babel: typeof Babel): Babel.PluginObj { let t = babel.types; - function insertCompiledTemplate( - target: NodePath, - state: State, - template: string, - userTypedOptions: Record - ): void { - if (!userTypedOptions.locals) { - userTypedOptions.locals = []; - } - let jsutils = new JSUtils( - babel, - state.program, - target, - userTypedOptions.locals as string[], - state.util - ); - let meta = Object.assign({ jsutils }, userTypedOptions?.meta); - let options = Object.assign({}, userTypedOptions, { contents: template, meta }); - let precompile = state.precompile; - let precompileResultString: string; - - if (options.insertRuntimeErrors) { - try { - precompileResultString = precompile(template, options); - } catch (error) { - target.replaceWith(runtimeErrorIIFE(babel, { ERROR_MESSAGE: (error as any).message })); - return; - } - } else { - precompileResultString = precompile(template, options); - } - - let precompileResultAST = babel.parse(`var precompileResult = ${precompileResultString};`, { - babelrc: false, - configFile: false, - }) as t.File; - - let templateExpression = (precompileResultAST.program.body[0] as t.VariableDeclaration) - .declarations[0].init as t.Expression; - - t.addComment( - templateExpression, - 'leading', - `\n ${template.replace(/\*\//g, '*\\/')}\n`, - /* line comment? */ false - ); - - let templateFactoryIdentifier = state.util.import( - target, - state.templateFactory.moduleName, - state.templateFactory.exportName - ); - target.replaceWith(t.callExpression(templateFactoryIdentifier, [templateExpression])); - } - return { visitor: { Program: { @@ -188,7 +133,7 @@ export default function makePlugin( } let template = path.node.quasi.quasis.map((quasi) => quasi.value.cooked).join(''); - insertCompiledTemplate(path, state, template, {}); + insertCompiledTemplate(babel, path, state, template, {}); }, CallExpression(path: NodePath, state: State) { @@ -251,7 +196,7 @@ export default function makePlugin( `${calleePath.node.name} can only be invoked with 2 arguments: the template string, and any static options` ); } - insertCompiledTemplate(path, state, template, userTypedOptions); + insertCompiledTemplate(babel, path, state, template, userTypedOptions); }, }, }; @@ -288,3 +233,61 @@ function runtimeErrorIIFE(babel: typeof Babel, replacements: { ERROR_MESSAGE: st ) as t.ExpressionStatement; return statement.expression; } + +function insertCompiledTemplate( + babel: typeof Babel, + target: NodePath, + state: State, + template: string, + userTypedOptions: Record +): void { + let t = babel.types; + + if (!userTypedOptions.locals) { + userTypedOptions.locals = []; + } + let jsutils = new JSUtils( + babel, + state.program, + target, + userTypedOptions.locals as string[], + state.util + ); + let meta = Object.assign({ jsutils }, userTypedOptions?.meta); + let options = Object.assign({}, userTypedOptions, { contents: template, meta }); + let precompile = state.precompile; + let precompileResultString: string; + + if (options.insertRuntimeErrors) { + try { + precompileResultString = precompile(template, options); + } catch (error) { + target.replaceWith(runtimeErrorIIFE(babel, { ERROR_MESSAGE: (error as any).message })); + return; + } + } else { + precompileResultString = precompile(template, options); + } + + let precompileResultAST = babel.parse(`var precompileResult = ${precompileResultString};`, { + babelrc: false, + configFile: false, + }) as t.File; + + let templateExpression = (precompileResultAST.program.body[0] as t.VariableDeclaration) + .declarations[0].init as t.Expression; + + t.addComment( + templateExpression, + 'leading', + `\n ${template.replace(/\*\//g, '*\\/')}\n`, + /* line comment? */ false + ); + + let templateFactoryIdentifier = state.util.import( + target, + state.templateFactory.moduleName, + state.templateFactory.exportName + ); + target.replaceWith(t.callExpression(templateFactoryIdentifier, [templateExpression])); +} From 44867df78e90260ec3980896b5e7ddcded21d2fa Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Tue, 2 Aug 2022 11:35:08 -0400 Subject: [PATCH 10/31] reving caniuse --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index bd4c364..25a4999 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2471,9 +2471,9 @@ can-symlink@^1.0.0: tmp "0.0.28" caniuse-lite@^1.0.30001219: - version "1.0.30001237" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001237.tgz#4b7783661515b8e7151fc6376cfd97f0e427b9e5" - integrity sha512-pDHgRndit6p1NR2GhzMbQ6CkRrp4VKuSsqbcLeOQppYPKOYkKT/6ZvZDvKJUqcmtyWIAHuZq3SVS2vc1egCZzw== + version "1.0.30001373" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001373.tgz" + integrity sha512-pJYArGHrPp3TUqQzFYRmP/lwJlj8RCbVe3Gd3eJQkAV8SAC6b19XS9BjMvRdvaS8RMkaTN8ZhoHP6S1y8zzwEQ== capture-exit@^2.0.0: version "2.0.0" From dc058a87ba9a0793b8e01848e85b87812aabeecc Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Tue, 2 Aug 2022 17:26:06 -0400 Subject: [PATCH 11/31] refactoring to take the whole template compiler Not just the precompile function. --- __tests__/mock-precompile.ts | 4 + __tests__/tests.ts | 576 ++++++++++++++++----------------- package.json | 2 + src/ember-template-compiler.ts | 31 ++ src/node-main.ts | 36 +-- src/plugin.ts | 14 +- yarn.lock | 89 ++++- 7 files changed, 432 insertions(+), 320 deletions(-) create mode 100644 src/ember-template-compiler.ts diff --git a/__tests__/mock-precompile.ts b/__tests__/mock-precompile.ts index 22e60fb..aa59740 100644 --- a/__tests__/mock-precompile.ts +++ b/__tests__/mock-precompile.ts @@ -1,3 +1,7 @@ export function precompile(value: string) { return `precompiledFromPath(${value})`; } + +export function _preprocess(...args: unknown[]) { + return args; +} diff --git a/__tests__/tests.ts b/__tests__/tests.ts index 6655ae1..51dffc0 100644 --- a/__tests__/tests.ts +++ b/__tests__/tests.ts @@ -1,18 +1,19 @@ import path from 'path'; import * as babel from '@babel/core'; -import HTMLBarsInlinePrecompile, { Options } from '..'; +import HTMLBarsInlinePrecompile from '..'; import TransformTemplateLiterals from '@babel/plugin-transform-template-literals'; import TransformModules from '@babel/plugin-transform-modules-amd'; import TransformUnicodeEscapes from '@babel/plugin-transform-unicode-escapes'; import { stripIndent } from 'common-tags'; import { WithJSUtils } from '../src/js-utils'; import type { ASTPluginBuilder, ASTPluginEnvironment } from '@glimmer/syntax'; +import { EmberTemplateCompiler } from '../src/ember-template-compiler'; +import sinon from 'sinon'; describe('htmlbars-inline-precompile', function () { - let precompile: NonNullable; + // eslint-disable-next-line @typescript-eslint/no-var-requires + let compiler: EmberTemplateCompiler = { ...require('ember-source/dist/ember-template-compiler') }; let plugins: any[]; - let optionsReceived: any; - let buildOptions: (o?: Partial) => Options; function transform(code: string) { let x = babel @@ -25,30 +26,17 @@ describe('htmlbars-inline-precompile', function () { } beforeEach(function () { - optionsReceived = undefined; - precompile = (template, options) => { - optionsReceived = { ...options }; - delete optionsReceived.meta; - return `"precompiled(${template})"`; - }; - - buildOptions = function (o?: Partial): Options { - let defaultOptions: Options = { - precompile(...args: Parameters) { - return precompile(...args); - }, - }; - - return Object.assign({}, defaultOptions, o); - }; + plugins = [[HTMLBarsInlinePrecompile, { compiler }]]; + }); - plugins = [[HTMLBarsInlinePrecompile, buildOptions()]]; + afterEach(function () { + sinon.restore(); }); it('supports compilation that returns a non-JSON.parseable object', function () { - precompile = (template) => { + sinon.replace(compiler, 'precompile', (template) => { return `function() { return "${template}"; }`; - }; + }); let transpiled = transform( "import { precompileTemplate } from '@ember/template-compilation';\nvar compiled = precompileTemplate('hello');" @@ -67,12 +55,7 @@ describe('htmlbars-inline-precompile', function () { }); it('supports compilation with templateCompilerPath', function () { - plugins = [ - [ - HTMLBarsInlinePrecompile, - buildOptions({ precompilerPath: require.resolve('./mock-precompile') }), - ], - ]; + plugins = [[HTMLBarsInlinePrecompile, { compilerPath: require.resolve('./mock-precompile') }]]; let transpiled = transform( "import { precompileTemplate } from '@ember/template-compilation';\nvar compiled = precompileTemplate('hello');" @@ -90,40 +73,35 @@ describe('htmlbars-inline-precompile', function () { it('passes options when used as a call expression', function () { let source = 'hello'; + let spy = sinon.spy(compiler, 'precompile'); + transform( `import { precompileTemplate } from '@ember/template-compilation';\nvar compiled = precompileTemplate('${source}');` ); - expect(optionsReceived).toEqual({ - contents: source, - locals: [], - }); + expect(spy.firstCall.lastArg).toHaveProperty('contents', source); }); it('uses the user provided isProduction option if present', function () { let source = 'hello'; + let spy = sinon.spy(compiler, 'precompile'); transform( `import { precompileTemplate } from '@ember/template-compilation';\nvar compiled = precompileTemplate('${source}', { isProduction: true });` ); - expect(optionsReceived).toEqual({ - contents: source, - isProduction: true, - locals: [], - }); + expect(spy.firstCall.lastArg).toHaveProperty('isProduction', true); }); it('allows a template string literal when used as a call expression', function () { let source = 'hello'; + let spy = sinon.spy(compiler, 'precompile'); + transform( `import { precompileTemplate } from '@ember/template-compilation';\nvar compiled = precompileTemplate(\`${source}\`);` ); - expect(optionsReceived).toEqual({ - contents: source, - locals: [], - }); + expect(spy.firstCall.lastArg).toHaveProperty('contents', source); }); it('errors when the template string contains placeholders', function () { @@ -138,9 +116,10 @@ describe('htmlbars-inline-precompile', function () { plugins = [ [ HTMLBarsInlinePrecompile, - buildOptions({ + { + compiler, enableLegacyModules: ['htmlbars-inline-precompile'], - }), + }, ], ]; expect(() => @@ -150,26 +129,26 @@ describe('htmlbars-inline-precompile', function () { it('allows static userland options when used as a call expression', function () { let source = 'hello'; + let spy = sinon.spy(compiler, 'precompile'); + transform( `import { precompileTemplate } from '@ember/template-compilation';\nvar compiled = precompileTemplate('${source}', { parseOptions: { srcName: 'bar.hbs' }, moduleName: 'foo/bar.hbs', xyz: 123, qux: true, stringifiedThing: ${JSON.stringify( { foo: 'baz' } )}});` ); - expect(optionsReceived).toEqual({ - contents: source, - parseOptions: { srcName: 'bar.hbs' }, - moduleName: 'foo/bar.hbs', - xyz: 123, - qux: true, - stringifiedThing: { - foo: 'baz', - }, - locals: [], - }); + expect(spy.firstCall.lastArg).toHaveProperty('parseOptions', { srcName: 'bar.hbs' }); + expect(spy.firstCall.lastArg).toHaveProperty('moduleName', 'foo/bar.hbs'); + expect(spy.firstCall.lastArg).toHaveProperty('xyz', 123); + expect(spy.firstCall.lastArg).toHaveProperty('qux', true); + expect(spy.firstCall.lastArg).toHaveProperty('stringifiedThing', { foo: 'baz' }); }); it('adds a comment with the original template string', function () { + sinon.replace(compiler, 'precompile', (template) => { + return `precompiled("${template}")`; + }); + let transformed = transform(stripIndent` import { precompileTemplate } from '@ember/template-compilation'; if ('foo') { @@ -185,15 +164,13 @@ describe('htmlbars-inline-precompile', function () { /* hello */ - "precompiled(hello)"); + precompiled("hello")); } `); }); it('avoids a build time error when passed `insertRuntimeErrors`', function () { - precompile = () => { - throw new Error('NOOOOOOOOOOOOOOOOOOOOOO'); - }; + sinon.stub(compiler, 'precompile').throws(new Error('NOOOOOOOOOOOOOOOOOOOOOO')); let transformed = transform( `import { precompileTemplate } from '@ember/template-compilation';\nvar compiled = precompileTemplate('hello', { insertRuntimeErrors: true });` @@ -208,14 +185,13 @@ describe('htmlbars-inline-precompile', function () { it('escapes any */ included in the template string', function () { plugins = [ - [ - HTMLBarsInlinePrecompile, - buildOptions({ - enableLegacyModules: ['htmlbars-inline-precompile'], - }), - ], + [HTMLBarsInlinePrecompile, { compiler, enableLegacyModules: ['htmlbars-inline-precompile'] }], ]; + sinon.replace(compiler, 'precompile', (template) => { + return `precompiled("${template}")`; + }); + let transformed = transform(stripIndent` import hbs from 'htmlbars-inline-precompile'; if ('foo') { @@ -223,36 +199,30 @@ describe('htmlbars-inline-precompile', function () { } `); - expect(transformed).toEqual(stripIndent` - import { createTemplateFactory } from "@ember/template-factory"; + expect(transformed).toMatchInlineSnapshot(` + "import { createTemplateFactory } from \\"@ember/template-factory\\"; if ('foo') { const template = createTemplateFactory( /* - hello *\\/ + hello *\\\\/ */ - "precompiled(hello */)"); - } + precompiled(\\"hello */\\")); + }" `); }); it('passes options when used as a tagged template string', function () { plugins = [ - [ - HTMLBarsInlinePrecompile, - buildOptions({ - enableLegacyModules: ['htmlbars-inline-precompile'], - }), - ], + [HTMLBarsInlinePrecompile, { compiler, enableLegacyModules: ['htmlbars-inline-precompile'] }], ]; let source = 'hello'; + let spy = sinon.spy(compiler, 'precompile'); + transform(`import hbs from 'htmlbars-inline-precompile';\nvar compiled = hbs\`${source}\`;`); - expect(optionsReceived).toEqual({ - contents: source, - locals: [], - }); + expect(spy.firstCall.lastArg).toHaveProperty('contents', source); }); it("strips import statement for '@ember/template-precompilation' module", function () { @@ -265,12 +235,17 @@ describe('htmlbars-inline-precompile', function () { }); it('replaces tagged template expressions with precompiled version', function () { + sinon.replace(compiler, 'precompile', (template) => { + return `precompiled("${template}")`; + }); + plugins = [ [ HTMLBarsInlinePrecompile, - buildOptions({ + { + compiler, enableLegacyModules: ['htmlbars-inline-precompile'], - }), + }, ], ]; let transformed = transform( @@ -283,17 +258,22 @@ describe('htmlbars-inline-precompile', function () { /* hello */ - \\"precompiled(hello)\\");" + precompiled(\\"hello\\"));" `); }); it('replaces tagged template expressions with precompiled version when ember-cli-htmlbars is enabled', function () { + sinon.replace(compiler, 'precompile', (template) => { + return `precompiled("${template}")`; + }); + plugins = [ [ HTMLBarsInlinePrecompile, - buildOptions({ + { + compiler, enableLegacyModules: ['ember-cli-htmlbars'], - }), + }, ], ]; @@ -307,7 +287,7 @@ describe('htmlbars-inline-precompile', function () { /* hello */ - \\"precompiled(hello)\\");" + precompiled(\\"hello\\"));" `); }); @@ -328,6 +308,10 @@ describe('htmlbars-inline-precompile', function () { }); it('works with multiple imports', function () { + sinon.replace(compiler, 'precompile', (template) => { + return `precompiled("${template}")`; + }); + let transformed = transform(` import { precompileTemplate } from '@ember/template-compilation'; import { precompileTemplate as other } from '@ember/template-compilation'; @@ -341,12 +325,12 @@ describe('htmlbars-inline-precompile', function () { /* hello */ - \\"precompiled(hello)\\"); + precompiled(\\"hello\\")); let b = createTemplateFactory( /* hello */ - \\"precompiled(hello)\\");" + precompiled(\\"hello\\"));" `); }); @@ -372,6 +356,10 @@ describe('htmlbars-inline-precompile', function () { }); it('works properly when used along with modules transform', function () { + sinon.replace(compiler, 'precompile', (template) => { + return `precompiled("${template}")`; + }); + plugins.push([TransformModules]); let transformed = transform( "import { precompileTemplate } from '@ember/template-compilation';\n" + @@ -387,17 +375,21 @@ describe('htmlbars-inline-precompile', function () { /* hello */ - \\"precompiled(hello)\\"); + precompiled(\\"hello\\")); var compiled2 = (0, _templateFactory.createTemplateFactory)( /* goodbye */ - \\"precompiled(goodbye)\\"); + precompiled(\\"goodbye\\")); });" `); }); it('does not error when reusing a preexisting import', function () { + sinon.replace(compiler, 'precompile', (template) => { + return `precompiled("${template}")`; + }); + let transformed = transform(` import { createTemplateFactory } from '@ember/template-factory'; import { precompileTemplate } from '@ember/template-compilation'; @@ -411,12 +403,16 @@ describe('htmlbars-inline-precompile', function () { /* hello */ - \\"precompiled(hello)\\"); + precompiled(\\"hello\\")); createTemplateFactory('whatever here');" `); }); it('works properly when used after modules transform', function () { + sinon.replace(compiler, 'precompile', (template) => { + return `precompiled("${template}")`; + }); + plugins.unshift([TransformModules]); let transformed = transform( "import { precompileTemplate } from '@ember/template-compilation';\nvar compiled = precompileTemplate('hello');" @@ -430,12 +426,16 @@ describe('htmlbars-inline-precompile', function () { /* hello */ - \\"precompiled(hello)\\"); + precompiled(\\"hello\\")); });" `); }); it('works properly when used along with @babel/plugin-transform-unicode-escapes', function () { + sinon.replace(compiler, 'precompile', (template) => { + return `precompiled("${template}")`; + }); + plugins.push([TransformUnicodeEscapes]); let transformed = transform( "import { precompileTemplate } from '@ember/template-compilation';\nvar compiled = precompileTemplate('some emoji goes 💥');" @@ -447,17 +447,22 @@ describe('htmlbars-inline-precompile', function () { /* some emoji goes 💥 */ - \\"precompiled(some emoji goes 💥)\\");" + precompiled(\\"some emoji goes 💥\\"));" `); }); it('replaces tagged template expressions when before babel-plugin-transform-es2015-template-literals', function () { + sinon.replace(compiler, 'precompile', (template) => { + return `precompiled("${template}")`; + }); + plugins = [ [ HTMLBarsInlinePrecompile, - buildOptions({ + { + compiler, enableLegacyModules: ['htmlbars-inline-precompile'], - }), + }, ], TransformTemplateLiterals, ]; @@ -472,7 +477,7 @@ describe('htmlbars-inline-precompile', function () { /* hello */ - \\"precompiled(hello)\\");" + precompiled(\\"hello\\"));" `); }); @@ -480,9 +485,10 @@ describe('htmlbars-inline-precompile', function () { plugins = [ [ HTMLBarsInlinePrecompile, - buildOptions({ + { + compiler, enableLegacyModules: ['htmlbars-inline-precompile'], - }), + }, ], ]; let transformed = transform( @@ -497,9 +503,10 @@ describe('htmlbars-inline-precompile', function () { plugins = [ [ HTMLBarsInlinePrecompile, - buildOptions({ + { + compiler, enableLegacyModules: ['htmlbars-inline-precompile'], - }), + }, ], ]; expect(() => @@ -510,16 +517,21 @@ describe('htmlbars-inline-precompile', function () { }); it('works with glimmer modules', function () { + sinon.replace(compiler, 'precompile', (template) => { + return `precompiled("${template}")`; + }); + plugins = [ [ HTMLBarsInlinePrecompile, - buildOptions({ + { + compiler, outputModuleOverrides: { '@ember/template-factory': { createTemplateFactory: ['createTemplateFactory', '@glimmer/core'], }, }, - }), + }, ], ]; @@ -528,13 +540,13 @@ describe('htmlbars-inline-precompile', function () { const template = precompileTemplate('hello'); `); - expect(transformed).toEqual(stripIndent` - import { createTemplateFactory } from "@glimmer/core"; + expect(transformed).toMatchInlineSnapshot(` + "import { createTemplateFactory } from \\"@glimmer/core\\"; const template = createTemplateFactory( /* hello */ - "precompiled(hello)"); + precompiled(\\"hello\\"));" `); }); @@ -574,94 +586,91 @@ describe('htmlbars-inline-precompile', function () { ); }); - describe('with ember-source', function () { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const compiler = require('ember-source/dist/ember-template-compiler'); - - let expressionTransform: ASTPluginBuilder> = (env) => { - return { - name: 'expression-transform', - visitor: { - PathExpression(node, path) { - if (node.original === 'onePlusOne') { - let name = env.meta.jsutils.bindExpression('1+1', path, { nameHint: 'two' }); - return env.syntax.builders.path(name); - } - return undefined; - }, + let expressionTransform: ASTPluginBuilder> = (env) => { + return { + name: 'expression-transform', + visitor: { + PathExpression(node, path) { + if (node.original === 'onePlusOne') { + let name = env.meta.jsutils.bindExpression('1+1', path, { nameHint: 'two' }); + return env.syntax.builders.path(name); + } + return undefined; }, - }; + }, }; + }; - let importTransform: ASTPluginBuilder> = (env) => { - return { - name: 'import-transform', - visitor: { - PathExpression(node, path) { - if (node.original === 'onePlusOne') { - let name = env.meta.jsutils.bindImport('my-library', 'default', path, { - nameHint: 'two', - }); - return env.syntax.builders.path(name); - } - return undefined; - }, + let importTransform: ASTPluginBuilder> = (env) => { + return { + name: 'import-transform', + visitor: { + PathExpression(node, path) { + if (node.original === 'onePlusOne') { + let name = env.meta.jsutils.bindImport('my-library', 'default', path, { + nameHint: 'two', + }); + return env.syntax.builders.path(name); + } + return undefined; }, - }; + }, }; + }; - it('includes the original template content', function () { - precompile = (template, options) => compiler.precompile(template, options); - - let transformed = transform(stripIndent` + it('includes the original template content', function () { + let transformed = transform(stripIndent` import { precompileTemplate } from '@ember/template-compilation'; const template = precompileTemplate('hello {{firstName}}'); `); - expect(transformed).toContain(`hello {{firstName}}`); - }); + expect(transformed).toContain(`hello {{firstName}}`); + }); - it('allows AST transform to bind a JS expression', function () { - precompile = runASTTransform(compiler, expressionTransform); + it('allows AST transform to bind a JS expression', function () { + sinon.replace(compiler, 'precompile', runASTTransform(compiler, expressionTransform)); - let transformed = transform(stripIndent` + let transformed = transform(stripIndent` import { precompileTemplate } from '@ember/template-compilation'; const template = precompileTemplate(''); `); - expect(transformed).toContain(`@text={{two}}`); - expect(transformed).toContain(`locals: [two]`); - expect(transformed).toContain(`let two = 1 + 1`); - }); + expect(transformed).toContain(`@text={{two}}`); + expect(transformed).toContain(`locals: [two]`); + expect(transformed).toContain(`let two = 1 + 1`); + }); - it('adds locals to the compiled output', function () { - precompile = compileASTTransform(compiler, expressionTransform); + it('adds locals to the compiled output', function () { + let orig = compiler.precompile; + sinon.replace(compiler, 'precompile', (template, opts) => + orig(template, { ...opts, plugins: { ast: [expressionTransform] } }) + ); - let transformed = transform(stripIndent` + let transformed = transform(stripIndent` import { precompileTemplate } from '@ember/template-compilation'; const template = precompileTemplate(''); `); - expect(transformed).toContain(`"scope": () => [two]`); - }); + expect(transformed).toContain(`"scope": () => [two]`); + }); - it('allows AST transform to bind a JS import', function () { - precompile = runASTTransform(compiler, importTransform); + it('allows AST transform to bind a JS import', function () { + sinon.replace(compiler, 'precompile', runASTTransform(compiler, importTransform)); - let transformed = transform(stripIndent` + let transformed = transform(stripIndent` import { precompileTemplate } from '@ember/template-compilation'; const template = precompileTemplate(''); `); - expect(transformed).toContain(`@text={{two}}`); - expect(transformed).toContain(`locals: [two]`); - expect(transformed).toContain(`import two from "my-library"`); - }); + expect(transformed).toContain(`@text={{two}}`); + expect(transformed).toContain(`locals: [two]`); + expect(transformed).toContain(`import two from "my-library"`); + }); - it('does not smash existing js binding for import', function () { - precompile = runASTTransform(compiler, importTransform); + it('does not smash existing js binding for import', function () { + sinon.replace(compiler, 'precompile', runASTTransform(compiler, importTransform)); - let transformed = transform(stripIndent` + let transformed = transform(stripIndent` import { precompileTemplate } from '@ember/template-compilation'; export function inner() { let two = 'twice'; @@ -669,31 +678,31 @@ describe('htmlbars-inline-precompile', function () { } `); - expect(transformed).toContain(`@text={{two0}}`); - expect(transformed).toContain(`locals: [two0]`); - expect(transformed).toContain(`import two0 from "my-library"`); - }); + expect(transformed).toContain(`@text={{two0}}`); + expect(transformed).toContain(`locals: [two0]`); + expect(transformed).toContain(`import two0 from "my-library"`); + }); - it('does not smash existing hbs binding for import', function () { - precompile = runASTTransform(compiler, importTransform); + it('does not smash existing hbs binding for import', function () { + sinon.replace(compiler, 'precompile', runASTTransform(compiler, importTransform)); - let transformed = transform(stripIndent` + let transformed = transform(stripIndent` import { precompileTemplate } from '@ember/template-compilation'; export function inner() { const template = precompileTemplate('{{#let "twice" as |two|}}{{/let}}'); } `); - expect(transformed).toContain(`@text={{two0}}`); - expect(transformed).toContain(`let two0 = two`); - expect(transformed).toContain(`locals: [two0]`); - expect(transformed).toContain(`import two from "my-library"`); - }); + expect(transformed).toContain(`@text={{two0}}`); + expect(transformed).toContain(`let two0 = two`); + expect(transformed).toContain(`locals: [two0]`); + expect(transformed).toContain(`import two from "my-library"`); + }); - it('does not smash existing js binding for expression', function () { - precompile = runASTTransform(compiler, expressionTransform); + it('does not smash existing js binding for expression', function () { + sinon.replace(compiler, 'precompile', runASTTransform(compiler, expressionTransform)); - let transformed = transform(stripIndent` + let transformed = transform(stripIndent` import { precompileTemplate } from '@ember/template-compilation'; export default function() { let two = 'twice'; @@ -701,193 +710,190 @@ describe('htmlbars-inline-precompile', function () { } `); - expect(transformed).toContain(`@text={{two0}}`); - expect(transformed).toContain(`locals: [two0]`); - expect(transformed).toContain(`let two0 = 1 + 1`); - }); + expect(transformed).toContain(`@text={{two0}}`); + expect(transformed).toContain(`locals: [two0]`); + expect(transformed).toContain(`let two0 = 1 + 1`); + }); - it('does not smash existing hbs block binding for expression', function () { - precompile = runASTTransform(compiler, expressionTransform); + it('does not smash existing hbs block binding for expression', function () { + sinon.replace(compiler, 'precompile', runASTTransform(compiler, expressionTransform)); - let transformed = transform(stripIndent` + let transformed = transform(stripIndent` import { precompileTemplate } from '@ember/template-compilation'; export default function() { const template = precompileTemplate('{{#let "twice" as |two|}}{{/let}}'); } `); - expect(transformed).toContain(`@text={{two0}}`); - expect(transformed).toContain(`locals: [two0]`); - expect(transformed).toContain(`let two0 = 1 + 1`); - }); + expect(transformed).toContain(`@text={{two0}}`); + expect(transformed).toContain(`locals: [two0]`); + expect(transformed).toContain(`let two0 = 1 + 1`); + }); - it('does not smash existing hbs element binding for expression', function () { - precompile = runASTTransform(compiler, expressionTransform); + it('does not smash existing hbs element binding for expression', function () { + sinon.replace(compiler, 'precompile', runASTTransform(compiler, expressionTransform)); - let transformed = transform(stripIndent` + let transformed = transform(stripIndent` import { precompileTemplate } from '@ember/template-compilation'; export default function() { const template = precompileTemplate(''); } `); - expect(transformed).toContain(`@text={{two0}}`); - expect(transformed).toContain(`locals: [two0]`); - expect(transformed).toContain(`let two0 = 1 + 1`); - }); + expect(transformed).toContain(`@text={{two0}}`); + expect(transformed).toContain(`locals: [two0]`); + expect(transformed).toContain(`let two0 = 1 + 1`); + }); - it('understands that block params are only defined in the body, not the arguments, of an element', function () { - precompile = runASTTransform(compiler, expressionTransform); + it('understands that block params are only defined in the body, not the arguments, of an element', function () { + sinon.replace(compiler, 'precompile', runASTTransform(compiler, expressionTransform)); - let transformed = transform(stripIndent` + let transformed = transform(stripIndent` import { precompileTemplate } from '@ember/template-compilation'; export default function() { const template = precompileTemplate('{{two}}'); } `); - expect(transformed).toContain(`@text={{two}}`); - expect(transformed).toContain(`locals: [two]`); - expect(transformed).toContain(`let two = 1 + 1`); - }); + expect(transformed).toContain(`@text={{two}}`); + expect(transformed).toContain(`locals: [two]`); + expect(transformed).toContain(`let two = 1 + 1`); + }); - it('can bind expressions that need imports', function () { - let nowTransform: ASTPluginBuilder> = (env) => { - return { - name: 'now-transform', - visitor: { - PathExpression(node, path) { - if (node.original === 'now') { - let name = env.meta.jsutils.bindExpression( - (context) => { - let identifier = context.import('luxon', 'DateTime'); - return `${identifier}.now()`; - }, - path, - { nameHint: 'current' } - ); - return env.syntax.builders.path(name); - } - return undefined; - }, + it('can bind expressions that need imports', function () { + let nowTransform: ASTPluginBuilder> = (env) => { + return { + name: 'now-transform', + visitor: { + PathExpression(node, path) { + if (node.original === 'now') { + let name = env.meta.jsutils.bindExpression( + (context) => { + let identifier = context.import('luxon', 'DateTime'); + return `${identifier}.now()`; + }, + path, + { nameHint: 'current' } + ); + return env.syntax.builders.path(name); + } + return undefined; }, - }; + }, }; + }; - precompile = runASTTransform(compiler, nowTransform); + sinon.replace(compiler, 'precompile', runASTTransform(compiler, nowTransform)); - let transformed = transform(stripIndent` + let transformed = transform(stripIndent` import { precompileTemplate } from '@ember/template-compilation'; export default function() { const template = precompileTemplate(''); } `); - expect(transformed).toMatch(/let current = DateTime.now()/); - expect(transformed).toMatch(/import { DateTime } from "luxon"/); - expect(transformed).toContain('when={{current}}'); - }); + expect(transformed).toMatch(/let current = DateTime.now()/); + expect(transformed).toMatch(/import { DateTime } from "luxon"/); + expect(transformed).toContain('when={{current}}'); + }); - it('can emit side-effectful expression that need imports', function () { - let compatTransform: ASTPluginBuilder> = (env) => { - return { - name: 'compat-transform', - visitor: { - ElementNode(node) { - if (node.tag === 'Thing') { - env.meta.jsutils.emitExpression((context) => { - let identifier = context.import('ember-thing', '*', 'thing'); - return `window.define('my-app/components/thing', ${identifier})`; - }); - } - }, + it('can emit side-effectful expression that need imports', function () { + let compatTransform: ASTPluginBuilder> = (env) => { + return { + name: 'compat-transform', + visitor: { + ElementNode(node) { + if (node.tag === 'Thing') { + env.meta.jsutils.emitExpression((context) => { + let identifier = context.import('ember-thing', '*', 'thing'); + return `window.define('my-app/components/thing', ${identifier})`; + }); + } }, - }; + }, }; + }; - precompile = runASTTransform(compiler, compatTransform); + sinon.replace(compiler, 'precompile', runASTTransform(compiler, compatTransform)); - let transformed = transform(stripIndent` + let transformed = transform(stripIndent` import { precompileTemplate } from '@ember/template-compilation'; export default function() { const template = precompileTemplate(''); } `); - expect(transformed).toContain(`import * as thing from "ember-thing"`); - expect(transformed).toContain(`window.define('my-app/components/thing', thing)`); - }); + expect(transformed).toContain(`import * as thing from "ember-thing"`); + expect(transformed).toContain(`window.define('my-app/components/thing', thing)`); + }); - it('can emit side-effectful import', function () { - let compatTransform: ASTPluginBuilder> = (env) => { - return { - name: 'compat-transform', - visitor: { - ElementNode(node) { - if (node.tag === 'Thing') { - env.meta.jsutils.importForSideEffect('setup-the-things'); - } - }, + it('can emit side-effectful import', function () { + let compatTransform: ASTPluginBuilder> = (env) => { + return { + name: 'compat-transform', + visitor: { + ElementNode(node) { + if (node.tag === 'Thing') { + env.meta.jsutils.importForSideEffect('setup-the-things'); + } }, - }; + }, }; + }; - precompile = runASTTransform(compiler, compatTransform); + sinon.replace(compiler, 'precompile', runASTTransform(compiler, compatTransform)); - let transformed = transform(stripIndent` + let transformed = transform(stripIndent` import { precompileTemplate } from '@ember/template-compilation'; export default function() { const template = precompileTemplate(''); } `); - expect(transformed).toContain(`import "setup-the-things"`); - }); + expect(transformed).toContain(`import "setup-the-things"`); }); describe('scope', function () { it('correctly handles scope function (non-block arrow function)', function () { let source = 'hello'; + let spy = sinon.spy(compiler, 'precompile'); + transform( `import { precompileTemplate } from '@ember/template-compilation';\nvar compiled = precompileTemplate('${source}', { scope: () => ({ foo, bar }) });` ); - expect(optionsReceived).toEqual({ - contents: source, - locals: ['foo', 'bar'], - }); + expect(spy.firstCall.lastArg).toHaveProperty('locals', ['foo', 'bar']); }); it('correctly handles scope function (block arrow function)', function () { let source = 'hello'; + let spy = sinon.spy(compiler, 'precompile'); + transform( `import { precompileTemplate } from '@ember/template-compilation';\nvar compiled = precompileTemplate('${source}', { scope: () => { return { foo, bar }; }});` ); - expect(optionsReceived).toEqual({ - contents: source, - locals: ['foo', 'bar'], - }); + + expect(spy.firstCall.lastArg).toHaveProperty('locals', ['foo', 'bar']); }); it('correctly handles scope function (normal function)', function () { let source = 'hello'; + let spy = sinon.spy(compiler, 'precompile'); + transform( `import { precompileTemplate } from '@ember/template-compilation';\nvar compiled = precompileTemplate('${source}', { scope: function() { return { foo, bar }; }});` ); - expect(optionsReceived).toEqual({ - contents: source, - locals: ['foo', 'bar'], - }); + + expect(spy.firstCall.lastArg).toHaveProperty('locals', ['foo', 'bar']); }); it('correctly handles scope function (object method)', function () { let source = 'hello'; + let spy = sinon.spy(compiler, 'precompile'); + transform( `import { precompileTemplate } from '@ember/template-compilation';\nvar compiled = precompileTemplate('${source}', { scope() { return { foo, bar }; }});` ); - expect(optionsReceived).toEqual({ - contents: source, - locals: ['foo', 'bar'], - }); + expect(spy.firstCall.lastArg).toHaveProperty('locals', ['foo', 'bar']); }); it('errors if scope contains mismatched keys/values', function () { @@ -940,15 +946,3 @@ function runASTTransform( ); }; } - -function compileASTTransform( - compiler: any, - customTransform: ASTPluginBuilder> -) { - return (template: string, options: Record) => { - return compiler.precompile(template, { - ...options, - plugins: { ast: [customTransform] }, - }); - }; -} diff --git a/package.json b/package.json index dea7b69..3505d99 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@glimmer/syntax": "^0.84.2", "@types/babel__traverse": "^7.11.1", "@types/jest": "^26.0.23", + "@types/sinon": "^10.0.13", "@typescript-eslint/eslint-plugin": "^4.28.4", "@typescript-eslint/parser": "^4.28.4", "common-tags": "^1.8.0", @@ -56,6 +57,7 @@ "prettier": "^2.2.1", "release-it": "^14.10.0", "release-it-lerna-changelog": "^3.1.0", + "sinon": "^14.0.0", "typescript": "^4.3.5" }, "engines": { diff --git a/src/ember-template-compiler.ts b/src/ember-template-compiler.ts new file mode 100644 index 0000000..64df296 --- /dev/null +++ b/src/ember-template-compiler.ts @@ -0,0 +1,31 @@ +import { ASTPluginBuilder, ASTv1 } from '@glimmer/syntax'; + +// The interface we use from ember-template-compiler.js +export interface EmberTemplateCompiler { + precompile(templateString: string, options: Record): string; + _buildCompileOptions(options: PreprocessOptions): PreprocessOptions; + _print(ast: ASTv1.Template, options?: { entityEncoding?: 'transformed' | 'raw' }): string; + _preprocess(src: string, options?: PreprocessOptions): ASTv1.Template; +} + +interface PreprocessOptions { + contents: string; + moduleName: string; + plugins?: { ast?: ASTPluginBuilder[] }; + filename?: string; + parseOptions?: { + srcName?: string; + ignoreStandalone?: boolean; + }; + mode?: 'codemod' | 'precompile'; + strictMode?: boolean; + locals?: string[]; +} + +export function assertTemplateCompiler( + emberTemplateCompiler: any +): asserts emberTemplateCompiler is EmberTemplateCompiler { + if (typeof emberTemplateCompiler._preprocess !== 'function') { + throw new Error(`Unexpected API on ember template compiler. This plugin supports Ember 3.27+.`); + } +} diff --git a/src/node-main.ts b/src/node-main.ts index e92a0d6..bc4421d 100644 --- a/src/node-main.ts +++ b/src/node-main.ts @@ -2,32 +2,30 @@ import { resolve } from 'path'; import makePlugin from './plugin'; import type * as Babel from '@babel/core'; -import { Options as PluginOptions, EmberPrecompile } from './plugin'; +import { Options as PluginOptions } from './plugin'; +import { assertTemplateCompiler, EmberTemplateCompiler } from './ember-template-compiler'; export interface Options extends PluginOptions { - // The on-disk path to a module that provides a `precompile` function as - // defined below. You need to either set `precompilePath` or set `precompile`. - precompilerPath?: string; + // The on-disk path to the ember-template-comipler.js module for our current + // ember version. You need to either set `compilerPath` or set `compiler`. + compilerPath?: string; - // A precompile function that invokes Ember's template compiler. - // - // Options handling rules: - // - // - we add `content`, which is the original string form of the template - // - we have special parsing for `scope` which becomes `locals` when passed - // to your precompile - // - anything else the user passes to `precompileTemplate` will be passed - // through to your `precompile`. - precompile?: EmberPrecompile; + // The ember-template-compiler.js module for your current ember version. You + // need to either set `compilerPath` or `compiler`. + compiler?: EmberTemplateCompiler; } const htmlbarsInlinePrecompile = makePlugin(function (opts: Options) { - if (opts.precompilerPath) { + if (opts.compilerPath) { // eslint-disable-next-line @typescript-eslint/no-var-requires - let mod: any = require(opts.precompilerPath); - return mod.precompile; - } else if (opts.precompile) { - return opts.precompile; + let mod: any = require(opts.compilerPath); + assertTemplateCompiler(mod); + return mod; + } else if (opts.compiler) { + assertTemplateCompiler(opts.compiler); + return opts.compiler; + } else { + throw new Error(`must provide compilerPath or compiler`); } }) as { (babel: typeof Babel): Babel.PluginObj; diff --git a/src/plugin.ts b/src/plugin.ts index 01daef7..2e2e4b5 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -4,8 +4,7 @@ import type { types as t } from '@babel/core'; import { ImportUtil } from 'babel-import-util'; import { ExpressionParser } from './expression-parser'; import { JSUtils } from './js-utils'; - -export type EmberPrecompile = (templateString: string, options: Record) => string; +import type { EmberTemplateCompiler } from './ember-template-compiler'; export type LegacyModuleName = | 'ember-cli-htmlbars' @@ -76,14 +75,14 @@ export interface Options { interface State { opts: Options; util: ImportUtil; - precompile: EmberPrecompile; + compiler: EmberTemplateCompiler; templateFactory: { moduleName: string; exportName: string }; program: NodePath; } export default function makePlugin( // receives the Babel plugin options, returns Ember's precompiler - loadPrecompiler: (opts: O) => EmberPrecompile + loadCompiler: (opts: O) => EmberTemplateCompiler ) { return function htmlbarsInlinePrecompile(babel: typeof Babel): Babel.PluginObj { let t = babel.types; @@ -99,7 +98,7 @@ export default function makePlugin( ? { exportName: overrides[0], moduleName: overrides[1] } : { exportName, moduleName }; state.util = new ImportUtil(t, path); - state.precompile = loadPrecompiler(state.opts as O); + state.compiler = loadCompiler(state.opts as O); state.program = path; }, exit(_path: NodePath, state: State) { @@ -255,18 +254,17 @@ function insertCompiledTemplate( ); let meta = Object.assign({ jsutils }, userTypedOptions?.meta); let options = Object.assign({}, userTypedOptions, { contents: template, meta }); - let precompile = state.precompile; let precompileResultString: string; if (options.insertRuntimeErrors) { try { - precompileResultString = precompile(template, options); + precompileResultString = state.compiler.precompile(template, options); } catch (error) { target.replaceWith(runtimeErrorIIFE(babel, { ERROR_MESSAGE: (error as any).message })); return; } } else { - precompileResultString = precompile(template, options); + precompileResultString = state.compiler.precompile(template, options); } let precompileResultAST = babel.parse(`var precompileResult = ${precompileResultString};`, { diff --git a/yarn.lock b/yarn.lock index 25a4999..c1667f4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1410,6 +1410,13 @@ resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-4.0.1.tgz#d26729db850fa327b7cacc5522252194404226f5" integrity sha512-Qm9hBEBu18wt1PO2flE7LPb30BHMQt1eQgbV76YntdNk73XZGpn3izvGTYxbGgzXKgbCjiia0uxTd3aTNQrY/g== +"@sinonjs/commons@^1.6.0", "@sinonjs/commons@^1.8.3": + version "1.8.3" + resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.3.tgz#3802ddd21a50a949b6721ddd72da36e67e7f1b2d" + integrity sha512-xkNcLAn/wZaX14RPlwizcKicDk9G3F8m2nU3L7Ukm5zBgTwiT0wsoFAHx9Jq56fJA1z/7uKGtCRu16sOUCLIHQ== + dependencies: + type-detect "4.0.8" + "@sinonjs/commons@^1.7.0": version "1.7.2" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.7.2.tgz#505f55c74e0272b43f6c52d81946bed7058fc0e2" @@ -1417,6 +1424,13 @@ dependencies: type-detect "4.0.8" +"@sinonjs/fake-timers@>=5", "@sinonjs/fake-timers@^9.1.2": + version "9.1.2" + resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-9.1.2.tgz#4eaab737fab77332ab132d396a3c0d364bd0ea8c" + integrity sha512-BPS4ynJW/o92PUR4wgriz2Ud5gpST5vz6GQfMixEDK0Z8ZCUv2M7SkBLykH56T++Xs+8ln9zTGbOvNGIe02/jw== + dependencies: + "@sinonjs/commons" "^1.7.0" + "@sinonjs/fake-timers@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-6.0.1.tgz#293674fccb3262ac782c7aadfdeca86b10c75c40" @@ -1424,6 +1438,20 @@ dependencies: "@sinonjs/commons" "^1.7.0" +"@sinonjs/samsam@^6.1.1": + version "6.1.1" + resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-6.1.1.tgz#627f7f4cbdb56e6419fa2c1a3e4751ce4f6a00b1" + integrity sha512-cZ7rKJTLiE7u7Wi/v9Hc2fs3Ucc3jrWeMgPHbbTCeVAB2S0wOBbYlkJVeNSL04i7fdhT8wIbDq1zhC/PXTD2SA== + dependencies: + "@sinonjs/commons" "^1.6.0" + lodash.get "^4.4.2" + type-detect "^4.0.8" + +"@sinonjs/text-encoding@^0.7.1": + version "0.7.2" + resolved "https://registry.yarnpkg.com/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz#5981a8db18b56ba38ef0efb7d995b12aa7b51918" + integrity sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ== + "@szmarczak/http-timer@^1.1.2": version "1.1.2" resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-1.1.2.tgz#b1665e2c461a2cd92f4c1bbf50d5454de0d4b421" @@ -1595,6 +1623,18 @@ "@types/glob" "*" "@types/node" "*" +"@types/sinon@^10.0.13": + version "10.0.13" + resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-10.0.13.tgz#60a7a87a70d9372d0b7b38cc03e825f46981fb83" + integrity sha512-UVjDqJblVNQYvVNUsj0PuYYw0ELRmgt1Nt5Vk0pT5f16ROGfcKJY8o1HVuMOJOpD727RrGB9EGvoaTQE5tgxZQ== + dependencies: + "@types/sinonjs__fake-timers" "*" + +"@types/sinonjs__fake-timers@*": + version "8.1.2" + resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.2.tgz#bf2e02a3dbd4aecaf95942ecd99b7402e03fad5e" + integrity sha512-9GcLXF0/v3t80caGs5p2rRfkB+a8VBGLJZVih6CNFkx8IZ994wiKKLSRs9nuFwk1HevWs/1mnUmkApGrSGsShA== + "@types/stack-utils@^2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.0.tgz#7036640b4e21cc2f259ae826ce843d277dad8cff" @@ -2961,6 +3001,11 @@ diff-sequences@^26.6.2: resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-26.6.2.tgz#48ba99157de1923412eed41db6b6d4aa9ca7c0b1" integrity sha512-Mv/TDa3nZ9sbc5soK+OoA74BsS3mL37yixCvUAQkiuA4Wz6YtwP/K47n2rv2ovzHZvoiQeA5FTQOschKkEwB0Q== +diff@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-5.1.0.tgz#bc52d298c5ea8df9194800224445ed43ffc87e40" + integrity sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw== + dir-glob@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" @@ -5166,6 +5211,11 @@ jsprim@^1.2.2: json-schema "0.2.3" verror "1.10.0" +just-extend@^4.0.2: + version "4.2.1" + resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-4.2.1.tgz#ef5e589afb61e5d66b24eca749409a8939a8c744" + integrity sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg== + keyv@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9" @@ -5285,6 +5335,11 @@ lodash.foreach@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.foreach/-/lodash.foreach-4.5.0.tgz#1a6a35eace401280c7f06dddec35165ab27e3e53" integrity sha1-Gmo16s5AEoDH8G3d7DUWWrJ+PlM= +lodash.get@^4.4.2: + version "4.4.2" + resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" + integrity sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ== + lodash.merge@^4.6.2: version "4.6.2" resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" @@ -5674,6 +5729,17 @@ nice-try@^1.0.4: resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== +nise@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/nise/-/nise-5.1.1.tgz#ac4237e0d785ecfcb83e20f389185975da5c31f3" + integrity sha512-yr5kW2THW1AkxVmCnKEh4nbYkJdB3I7LUkiUgOvEkOp414mc2UMaHMA7pjq1nYowhdoJZGwEKGaQVbxfpWj10A== + dependencies: + "@sinonjs/commons" "^1.8.3" + "@sinonjs/fake-timers" ">=5" + "@sinonjs/text-encoding" "^0.7.1" + just-extend "^4.0.2" + path-to-regexp "^1.7.0" + node-fetch@^2.6.1: version "2.6.1" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" @@ -6073,6 +6139,13 @@ path-root@^0.1.1: dependencies: path-root-regex "^0.1.0" +path-to-regexp@^1.7.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.8.0.tgz#887b3ba9d84393e87a0a0b9f4cb756198b53548a" + integrity sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA== + dependencies: + isarray "0.0.1" + path-type@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" @@ -6845,6 +6918,18 @@ simple-html-tokenizer@^0.5.11: resolved "https://registry.yarnpkg.com/simple-html-tokenizer/-/simple-html-tokenizer-0.5.11.tgz#4c5186083c164ba22a7b477b7687ac056ad6b1d9" integrity sha512-C2WEK/Z3HoSFbYq8tI7ni3eOo/NneSPRoPpcM7WdLjFOArFuyXEjAoCdOC3DgMfRyziZQ1hCNR4mrNdWEvD0og== +sinon@^14.0.0: + version "14.0.0" + resolved "https://registry.yarnpkg.com/sinon/-/sinon-14.0.0.tgz#203731c116d3a2d58dc4e3cbe1f443ba9382a031" + integrity sha512-ugA6BFmE+WrJdh0owRZHToLd32Uw3Lxq6E6LtNRU+xTVBefx632h03Q7apXWRsRdZAJ41LB8aUfn2+O4jsDNMw== + dependencies: + "@sinonjs/commons" "^1.8.3" + "@sinonjs/fake-timers" "^9.1.2" + "@sinonjs/samsam" "^6.1.1" + diff "^5.0.0" + nise "^5.1.1" + supports-color "^7.2.0" + sisteransi@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.0.tgz#77d9622ff909080f1c19e5f4a1df0c1b0a27b88c" @@ -7154,7 +7239,7 @@ supports-color@^5.3.0: dependencies: has-flag "^3.0.0" -supports-color@^7.0.0, supports-color@^7.1.0: +supports-color@^7.0.0, supports-color@^7.1.0, supports-color@^7.2.0: version "7.2.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== @@ -7389,7 +7474,7 @@ type-check@~0.3.2: dependencies: prelude-ls "~1.1.2" -type-detect@4.0.8: +type-detect@4.0.8, type-detect@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== From 352e1d1e5fcd3dcfb1c461ba557d784c1c241062 Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Wed, 3 Aug 2022 16:49:17 -0400 Subject: [PATCH 12/31] progress on source-to-source mode --- __tests__/tests.ts | 193 ++++++++++++++++++++++++++++---- src/ember-template-compiler.ts | 9 +- src/js-utils.ts | 4 +- src/node-main.ts | 52 ++++++--- src/plugin.ts | 195 +++++++++++++++++++++++++++------ 5 files changed, 375 insertions(+), 78 deletions(-) diff --git a/__tests__/tests.ts b/__tests__/tests.ts index 51dffc0..47d237d 100644 --- a/__tests__/tests.ts +++ b/__tests__/tests.ts @@ -1,19 +1,18 @@ import path from 'path'; import * as babel from '@babel/core'; -import HTMLBarsInlinePrecompile from '..'; +import HTMLBarsInlinePrecompile, { Options } from '..'; import TransformTemplateLiterals from '@babel/plugin-transform-template-literals'; import TransformModules from '@babel/plugin-transform-modules-amd'; import TransformUnicodeEscapes from '@babel/plugin-transform-unicode-escapes'; import { stripIndent } from 'common-tags'; -import { WithJSUtils } from '../src/js-utils'; -import type { ASTPluginBuilder, ASTPluginEnvironment } from '@glimmer/syntax'; -import { EmberTemplateCompiler } from '../src/ember-template-compiler'; +import { EmberTemplateCompiler, PreprocessOptions } from '../src/ember-template-compiler'; import sinon from 'sinon'; +import { ExtendedPluginBuilder } from '../src/js-utils'; describe('htmlbars-inline-precompile', function () { // eslint-disable-next-line @typescript-eslint/no-var-requires let compiler: EmberTemplateCompiler = { ...require('ember-source/dist/ember-template-compiler') }; - let plugins: any[]; + let plugins: ([typeof HTMLBarsInlinePrecompile, Options] | [unknown])[]; function transform(code: string) { let x = babel @@ -464,7 +463,7 @@ describe('htmlbars-inline-precompile', function () { enableLegacyModules: ['htmlbars-inline-precompile'], }, ], - TransformTemplateLiterals, + [TransformTemplateLiterals], ]; let transformed = transform( @@ -586,7 +585,7 @@ describe('htmlbars-inline-precompile', function () { ); }); - let expressionTransform: ASTPluginBuilder> = (env) => { + let expressionTransform: ExtendedPluginBuilder = (env) => { return { name: 'expression-transform', visitor: { @@ -601,7 +600,7 @@ describe('htmlbars-inline-precompile', function () { }; }; - let importTransform: ASTPluginBuilder> = (env) => { + let importTransform: ExtendedPluginBuilder = (env) => { return { name: 'import-transform', visitor: { @@ -631,6 +630,14 @@ describe('htmlbars-inline-precompile', function () { it('allows AST transform to bind a JS expression', function () { sinon.replace(compiler, 'precompile', runASTTransform(compiler, expressionTransform)); + // NEXT: + // plugins = [ + // [ + // HTMLBarsInlinePrecompile, + // { compiler, targetFormat: 'hbs', transforms: [expressionTransform] }, + // ], + // ]; + let transformed = transform(stripIndent` import { precompileTemplate } from '@ember/template-compilation'; const template = precompileTemplate(''); @@ -642,10 +649,15 @@ describe('htmlbars-inline-precompile', function () { }); it('adds locals to the compiled output', function () { - let orig = compiler.precompile; - sinon.replace(compiler, 'precompile', (template, opts) => - orig(template, { ...opts, plugins: { ast: [expressionTransform] } }) - ); + plugins = [ + [ + HTMLBarsInlinePrecompile, + { + compiler, + transforms: [expressionTransform], + }, + ], + ]; let transformed = transform(stripIndent` import { precompileTemplate } from '@ember/template-compilation'; @@ -761,7 +773,7 @@ describe('htmlbars-inline-precompile', function () { }); it('can bind expressions that need imports', function () { - let nowTransform: ASTPluginBuilder> = (env) => { + let nowTransform: ExtendedPluginBuilder = (env) => { return { name: 'now-transform', visitor: { @@ -798,7 +810,7 @@ describe('htmlbars-inline-precompile', function () { }); it('can emit side-effectful expression that need imports', function () { - let compatTransform: ASTPluginBuilder> = (env) => { + let compatTransform: ExtendedPluginBuilder = (env) => { return { name: 'compat-transform', visitor: { @@ -828,7 +840,7 @@ describe('htmlbars-inline-precompile', function () { }); it('can emit side-effectful import', function () { - let compatTransform: ASTPluginBuilder> = (env) => { + let compatTransform: ExtendedPluginBuilder = (env) => { return { name: 'compat-transform', visitor: { @@ -853,6 +865,150 @@ describe('htmlbars-inline-precompile', function () { expect(transformed).toContain(`import "setup-the-things"`); }); + describe('source-to-source', 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('can run an ast transform inside precompileTemplate', function () { + plugins = [ + [HTMLBarsInlinePrecompile, { compiler, targetFormat: 'hbs', transforms: [color] }], + ]; + + let transformed = transform(stripIndent` + import { precompileTemplate } from '@ember/template-compilation'; + const template = precompileTemplate(''); + `); + + expect(transformed).toMatchInlineSnapshot(` + "import { precompileTemplate } from '@ember/template-compilation'; + const template = precompileTemplate(\\"\\");" + `); + }); + + it('can run an ast transform inside hbs backticks', function () { + plugins = [ + [ + HTMLBarsInlinePrecompile, + { + compiler, + targetFormat: 'hbs', + transforms: [color], + enableLegacyModules: ['ember-cli-htmlbars'], + }, + ], + ]; + + let transformed = transform( + "import { hbs } from 'ember-cli-htmlbars'; const template = hbs``;" + ); + + expect(transformed).toMatchInlineSnapshot(` + "import { hbs } from 'ember-cli-htmlbars'; + const template = hbs\`\`;" + `); + }); + + it('can create the options object for precompileTemplate', function () { + plugins = [ + [ + HTMLBarsInlinePrecompile, + { compiler, targetFormat: 'hbs', transforms: [expressionTransform] }, + ], + ]; + + let transformed = transform(stripIndent` + import { precompileTemplate } from '@ember/template-compilation'; + const template = precompileTemplate(''); + `); + + expect(transformed).toMatchInlineSnapshot(` + "let two = 1 + 1; + import { precompileTemplate } from '@ember/template-compilation'; + const template = precompileTemplate(\\"\\", { + scope: () => ({ + two + }) + });" + `); + }); + + it('adds scope to existing options object', function () { + plugins = [ + [ + HTMLBarsInlinePrecompile, + { compiler, targetFormat: 'hbs', transforms: [expressionTransform] }, + ], + ]; + + let transformed = transform(stripIndent` + import { precompileTemplate } from '@ember/template-compilation'; + import Message from 'message'; + const template = precompileTemplate('', { + moduleName: 'customModuleName' + }); + `); + + expect(transformed).toMatchInlineSnapshot(` + "let two = 1 + 1; + import { precompileTemplate } from '@ember/template-compilation'; + import Message from 'message'; + const template = precompileTemplate(\\"\\", { + moduleName: 'customModuleName', + scope: () => ({ + two + }) + });" + `); + }); + + it('adds new locals to preexisting scope', function () { + plugins = [ + [ + HTMLBarsInlinePrecompile, + { compiler, targetFormat: 'hbs', transforms: [expressionTransform] }, + ], + ]; + + let transformed = transform(stripIndent` + import { precompileTemplate } from '@ember/template-compilation'; + import Message from 'message'; + const template = precompileTemplate('', { + scope: () => ({ + Message + }) + }); + `); + + expect(transformed).toMatchInlineSnapshot(` + "let two = 1 + 1; + import { precompileTemplate } from '@ember/template-compilation'; + import Message from 'message'; + const template = precompileTemplate(\\"\\", { + scope: () => ({ + Message, + two + }) + });" + `); + }); + + it.todo( + 'switches from legacy callExpressions to precompileTemplate when needed to support scope' + ); + it.todo('switches from hbs backticks to precompileTemplate when needed to support scope'); + }); + describe('scope', function () { it('correctly handles scope function (non-block arrow function)', function () { let source = 'hello'; @@ -928,11 +1084,8 @@ describe('htmlbars-inline-precompile', function () { }); }); -function runASTTransform( - compiler: any, - customTransform: ASTPluginBuilder> -) { - return (template: string, options: Record) => { +function runASTTransform(compiler: any, customTransform: ExtendedPluginBuilder) { + return (template: string, options: PreprocessOptions) => { let ast = compiler._preprocess(template, { ...options, plugins: { ast: [customTransform] }, diff --git a/src/ember-template-compiler.ts b/src/ember-template-compiler.ts index 64df296..c49badd 100644 --- a/src/ember-template-compiler.ts +++ b/src/ember-template-compiler.ts @@ -1,17 +1,18 @@ -import { ASTPluginBuilder, ASTv1 } from '@glimmer/syntax'; +import { ASTv1 } from '@glimmer/syntax'; +import { ExtendedPluginBuilder } from './js-utils'; // The interface we use from ember-template-compiler.js export interface EmberTemplateCompiler { - precompile(templateString: string, options: Record): string; + precompile(templateString: string, options: PreprocessOptions): string; _buildCompileOptions(options: PreprocessOptions): PreprocessOptions; _print(ast: ASTv1.Template, options?: { entityEncoding?: 'transformed' | 'raw' }): string; _preprocess(src: string, options?: PreprocessOptions): ASTv1.Template; } -interface PreprocessOptions { +export interface PreprocessOptions { contents: string; moduleName: string; - plugins?: { ast?: ASTPluginBuilder[] }; + plugins?: { ast?: ExtendedPluginBuilder[] }; filename?: string; parseOptions?: { srcName?: string; diff --git a/src/js-utils.ts b/src/js-utils.ts index 458b876..2de5e0d 100644 --- a/src/js-utils.ts +++ b/src/js-utils.ts @@ -1,7 +1,7 @@ import type { types as t } from '@babel/core'; import type * as Babel from '@babel/core'; import type { NodePath } from '@babel/traverse'; -import type { ASTv1, WalkerPath } from '@glimmer/syntax'; +import type { ASTPluginBuilder, ASTPluginEnvironment, ASTv1, WalkerPath } from '@glimmer/syntax'; import type { ImportUtil } from 'babel-import-util'; // This exists to give AST plugins a controlled interface for influencing the @@ -216,6 +216,8 @@ export type WithJSUtils = { meta: T['meta'] & { jsutils: JSUtils }; } & T; +export type ExtendedPluginBuilder = ASTPluginBuilder>; + function body(node: t.Program | t.File) { if (node.type === 'File') { return node.program.body; diff --git a/src/node-main.ts b/src/node-main.ts index bc4421d..dc0a783 100644 --- a/src/node-main.ts +++ b/src/node-main.ts @@ -1,44 +1,62 @@ import { resolve } from 'path'; import makePlugin from './plugin'; -import type * as Babel from '@babel/core'; -import { Options as PluginOptions } from './plugin'; +import { Options as SharedOptions } from './plugin'; import { assertTemplateCompiler, EmberTemplateCompiler } from './ember-template-compiler'; +import { ExtendedPluginBuilder } from './js-utils'; -export interface Options extends PluginOptions { +export type Options = Omit & { // The on-disk path to the ember-template-comipler.js module for our current // ember version. You need to either set `compilerPath` or set `compiler`. compilerPath?: string; - // The ember-template-compiler.js module for your current ember version. You - // need to either set `compilerPath` or `compiler`. + // The ember-template-compiler.js module that ships within your ember-source + // version. You need to set either `compilerPath` or `compiler`. compiler?: EmberTemplateCompiler; -} -const htmlbarsInlinePrecompile = makePlugin(function (opts: Options) { + // List of custom transformations to apply to the handlebars AST before + // compilation. These can be the actual functions or resolvable module names. + transforms?: (ExtendedPluginBuilder | string)[]; +}; + +function handleNodeSpecificOptions(opts: Options): SharedOptions { + let compiler: EmberTemplateCompiler; if (opts.compilerPath) { // eslint-disable-next-line @typescript-eslint/no-var-requires let mod: any = require(opts.compilerPath); assertTemplateCompiler(mod); - return mod; + compiler = mod; } else if (opts.compiler) { assertTemplateCompiler(opts.compiler); - return opts.compiler; + compiler = opts.compiler; } else { throw new Error(`must provide compilerPath or compiler`); } -}) as { - (babel: typeof Babel): Babel.PluginObj; - _parallelBabel: { requireFile: string }; - baseDir(): string; -}; -htmlbarsInlinePrecompile._parallelBabel = { + let transforms = []; + if (opts.transforms) { + transforms = opts.transforms.map((t) => { + if (typeof t === 'string') { + return require(t); + } else { + return t; + } + }); + } + return { ...opts, transforms, compiler }; +} + +const htmlbarsInlinePrecompile = makePlugin(handleNodeSpecificOptions); + +(htmlbarsInlinePrecompile as any)._parallelBabel = { requireFile: __filename, }; -htmlbarsInlinePrecompile.baseDir = function () { +(htmlbarsInlinePrecompile as any).baseDir = function () { return resolve(__dirname, '..'); }; -export default htmlbarsInlinePrecompile; +export default htmlbarsInlinePrecompile as typeof htmlbarsInlinePrecompile & { + baseDir(): string; + _parallelBabel: { requireFile: string }; +}; diff --git a/src/plugin.ts b/src/plugin.ts index 2e2e4b5..1742941 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -3,8 +3,8 @@ import type * as Babel from '@babel/core'; import type { types as t } from '@babel/core'; import { ImportUtil } from 'babel-import-util'; import { ExpressionParser } from './expression-parser'; -import { JSUtils } from './js-utils'; -import type { EmberTemplateCompiler } from './ember-template-compiler'; +import { JSUtils, ExtendedPluginBuilder } from './js-utils'; +import type { EmberTemplateCompiler, PreprocessOptions } from './ember-template-compiler'; export type LegacyModuleName = | 'ember-cli-htmlbars' @@ -48,6 +48,9 @@ const INLINE_PRECOMPILE_MODULES: ModuleConfig[] = [ ]; export interface Options { + // The ember-template-compiler.js module that ships within your ember-source version. + compiler: EmberTemplateCompiler; + // Allows you to remap what imports will be emitted in our compiled output. By // example: // @@ -70,45 +73,69 @@ export interface Options { // use, and we can enable those too by including their module names in this // list. enableLegacyModules?: LegacyModuleName[]; + + // Controls the output format. + // + // "wire": The default. In the output, your templates are ready to execute in + // the most performant way. + // + // "hbs": In the output, your templates will still be in HBS format. + // Generally this means they will still need further processing before + // they're ready to execute. The purpose of this mode is to support things + // like codemods and pre-publication transformations in libraries. + targetFormat?: 'wire' | 'hbs'; + + // Optional list of custom transforms to apply to the handlebars AST before + // compilation. + transforms?: ExtendedPluginBuilder[]; } -interface State { - opts: Options; +export interface State { + opts: EnvSpecificOptions; + normalizedOpts: Required; util: ImportUtil; - compiler: EmberTemplateCompiler; templateFactory: { moduleName: string; exportName: string }; program: NodePath; + filename: string; } -export default function makePlugin( - // receives the Babel plugin options, returns Ember's precompiler - loadCompiler: (opts: O) => EmberTemplateCompiler +export default function makePlugin( + loadOptions: (opts: EnvSpecificOptions) => Options ) { - return function htmlbarsInlinePrecompile(babel: typeof Babel): Babel.PluginObj { + return function htmlbarsInlinePrecompile( + babel: typeof Babel + ): Babel.PluginObj> { let t = babel.types; return { visitor: { Program: { - enter(path: NodePath, state: State) { - let moduleName = '@ember/template-factory'; - let exportName = 'createTemplateFactory'; - let overrides = state.opts.outputModuleOverrides?.[moduleName]?.[exportName]; - state.templateFactory = overrides - ? { exportName: overrides[0], moduleName: overrides[1] } - : { exportName, moduleName }; + enter(path: NodePath, state: State) { + state.normalizedOpts = { + targetFormat: 'wire', + outputModuleOverrides: {}, + enableLegacyModules: [], + transforms: [], + ...loadOptions(state.opts), + }; + + state.templateFactory = templateFactoryConfig(state.normalizedOpts); state.util = new ImportUtil(t, path); - state.compiler = loadCompiler(state.opts as O); state.program = path; }, - exit(_path: NodePath, state: State) { - for (let { moduleName, export: exportName } of configuredModules(state)) { - state.util.removeImport(moduleName, exportName); + exit(_path: NodePath, state: State) { + if (state.normalizedOpts.targetFormat === 'wire') { + for (let { moduleName, export: exportName } of configuredModules(state)) { + state.util.removeImport(moduleName, exportName); + } } }, }, - TaggedTemplateExpression(path: NodePath, state: State) { + TaggedTemplateExpression( + path: NodePath, + state: State + ) { let tagPath = path.get('tag'); if (!tagPath.isIdentifier()) { @@ -132,10 +159,14 @@ export default function makePlugin( } let template = path.node.quasi.quasis.map((quasi) => quasi.value.cooked).join(''); - insertCompiledTemplate(babel, path, state, template, {}); + if (state.normalizedOpts.targetFormat === 'wire') { + insertCompiledTemplate(babel, state, template, path, {}); + } else { + insertTransformedTemplate(babel, state, template, path, {}); + } }, - CallExpression(path: NodePath, state: State) { + CallExpression(path: NodePath, state: State) { let calleePath = path.get('callee'); if (!calleePath.isIdentifier()) { @@ -195,18 +226,22 @@ export default function makePlugin( `${calleePath.node.name} can only be invoked with 2 arguments: the template string, and any static options` ); } - insertCompiledTemplate(babel, path, state, template, userTypedOptions); + if (state.normalizedOpts.targetFormat === 'wire') { + insertCompiledTemplate(babel, state, template, path, userTypedOptions); + } else { + insertTransformedTemplate(babel, state, template, path, userTypedOptions); + } }, }, }; }; } -function* configuredModules(state: State) { +function* configuredModules(state: State) { for (let moduleConfig of INLINE_PRECOMPILE_MODULES) { if ( moduleConfig.moduleName !== '@ember/template-compilation' && - !state.opts.enableLegacyModules?.includes(moduleConfig.moduleName) + !state.normalizedOpts.enableLegacyModules.includes(moduleConfig.moduleName) ) { continue; } @@ -214,9 +249,9 @@ function* configuredModules(state: State) { } } -function referencesInlineCompiler( +function referencesInlineCompiler( path: NodePath, - state: State + state: State ): ModuleConfig | undefined { for (let moduleConfig of configuredModules(state)) { if (path.referencesImport(moduleConfig.moduleName, moduleConfig.export)) { @@ -233,15 +268,13 @@ function runtimeErrorIIFE(babel: typeof Babel, replacements: { ERROR_MESSAGE: st return statement.expression; } -function insertCompiledTemplate( +function buildPrecompileOptions( babel: typeof Babel, target: NodePath, - state: State, + state: State, template: string, userTypedOptions: Record -): void { - let t = babel.types; - +): PreprocessOptions & Record { if (!userTypedOptions.locals) { userTypedOptions.locals = []; } @@ -253,18 +286,45 @@ function insertCompiledTemplate( state.util ); let meta = Object.assign({ jsutils }, userTypedOptions?.meta); - let options = Object.assign({}, userTypedOptions, { contents: template, meta }); + return Object.assign( + { + contents: template, + meta, + + // TODO: embroider's template-compiler allows this to be overriden to get + // backward-compatible module names that don't match the real name of the + // on-disk file. What's our plan for migrating people away from that? + moduleName: state.filename, + + plugins: { + ast: state.normalizedOpts.transforms, + }, + }, + userTypedOptions + ); +} + +function insertCompiledTemplate( + babel: typeof Babel, + state: State, + template: string, + target: NodePath, + userTypedOptions: Record +) { + let t = babel.types; + let options = buildPrecompileOptions(babel, target, state, template, userTypedOptions); + let precompileResultString: string; if (options.insertRuntimeErrors) { try { - precompileResultString = state.compiler.precompile(template, options); + precompileResultString = state.normalizedOpts.compiler.precompile(template, options); } catch (error) { target.replaceWith(runtimeErrorIIFE(babel, { ERROR_MESSAGE: (error as any).message })); return; } } else { - precompileResultString = state.compiler.precompile(template, options); + precompileResultString = state.normalizedOpts.compiler.precompile(template, options); } let precompileResultAST = babel.parse(`var precompileResult = ${precompileResultString};`, { @@ -289,3 +349,66 @@ function insertCompiledTemplate( ); target.replaceWith(t.callExpression(templateFactoryIdentifier, [templateExpression])); } + +function insertTransformedTemplate( + babel: typeof Babel, + state: State, + template: string, + target: NodePath | NodePath, + userTypedOptions: Record +) { + let t = babel.types; + let options = buildPrecompileOptions(babel, target, state, template, userTypedOptions); + let ast = state.normalizedOpts.compiler._preprocess(template, { ...options, mode: 'codemod' }); + let transformed = state.normalizedOpts.compiler._print(ast); + if (target.isCallExpression()) { + (target.get('arguments.0') as NodePath).replaceWith(t.stringLiteral(transformed)); + if (options.locals && options.locals.length > 0) { + let secondArg = target.get('arguments.1') as NodePath | undefined; + if (secondArg) { + let scope = secondArg.get('properties').find((p) => { + let key = p.get('key') as NodePath; + return key.isIdentifier() && key.node.name === 'scope'; + }); + if (scope) { + scope.set('value', buildScope(babel, options.locals)); + } else { + secondArg.pushContainer( + 'properties', + t.objectProperty(t.identifier('scope'), buildScope(babel, options.locals)) + ); + } + } else { + target.pushContainer( + 'arguments', + t.objectExpression([ + t.objectProperty(t.identifier('scope'), buildScope(babel, options.locals)), + ]) + ); + } + } + } else { + (target.get('quasi').get('quasis.0') as NodePath).replaceWith( + t.templateElement({ raw: transformed }) + ); + } +} + +function templateFactoryConfig(opts: Required) { + let moduleName = '@ember/template-factory'; + let exportName = 'createTemplateFactory'; + let overrides = opts.outputModuleOverrides[moduleName]?.[exportName]; + return overrides + ? { exportName: overrides[0], moduleName: overrides[1] } + : { exportName, moduleName }; +} + +function buildScope(babel: typeof Babel, locals: string[]) { + let t = babel.types; + return t.arrowFunctionExpression( + [], + t.objectExpression( + locals.map((name) => t.objectProperty(t.identifier(name), t.identifier(name), false, true)) + ) + ); +} From d03774c9f34b90fc2c7df2a5f34851cb3723d9e4 Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Wed, 3 Aug 2022 19:50:40 -0400 Subject: [PATCH 13/31] implementing swapping between forms --- __tests__/tests.ts | 90 +++++++++++++++++++++++++++++++++++++++++++--- src/plugin.ts | 82 +++++++++++++++++++++++++++--------------- 2 files changed, 140 insertions(+), 32 deletions(-) diff --git a/__tests__/tests.ts b/__tests__/tests.ts index 47d237d..f321f7c 100644 --- a/__tests__/tests.ts +++ b/__tests__/tests.ts @@ -1003,10 +1003,92 @@ describe('htmlbars-inline-precompile', function () { `); }); - it.todo( - 'switches from legacy callExpressions to precompileTemplate when needed to support scope' - ); - it.todo('switches from hbs backticks to precompileTemplate when needed to support scope'); + it('switches from legacy callExpressions to precompileTemplate when needed to support scope', function () { + plugins = [ + [ + HTMLBarsInlinePrecompile, + { + compiler, + targetFormat: 'hbs', + transforms: [expressionTransform], + enableLegacyModules: ['ember-cli-htmlbars'], + }, + ], + ]; + + let transformed = transform(stripIndent` + import { hbs } from 'ember-cli-htmlbars'; + const template = hbs(''); + `); + + expect(transformed).toMatchInlineSnapshot(` + "import { precompileTemplate } from \\"@ember/template-compilation\\"; + let two = 1 + 1; + const template = precompileTemplate(\\"\\", { + scope: () => ({ + two + }) + });" + `); + }); + + it('switches from hbs backticks to precompileTemplate when needed to support scope', function () { + plugins = [ + [ + HTMLBarsInlinePrecompile, + { + compiler, + targetFormat: 'hbs', + transforms: [expressionTransform], + enableLegacyModules: ['ember-cli-htmlbars'], + }, + ], + ]; + + let transformed = transform( + "import { hbs } from 'ember-cli-htmlbars'; const template = hbs``;" + ); + + expect(transformed).toMatchInlineSnapshot(` + "import { precompileTemplate } from \\"@ember/template-compilation\\"; + let two = 1 + 1; + const template = precompileTemplate(\\"\\", { + scope: () => ({ + two + }) + });" + `); + }); + + it.skip('does not remove original import if there are still callsites using it', function () { + plugins = [ + [ + HTMLBarsInlinePrecompile, + { + compiler, + targetFormat: 'hbs', + transforms: [expressionTransform], + enableLegacyModules: ['ember-cli-htmlbars'], + }, + ], + ]; + + let transformed = transform( + "import { hbs } from 'ember-cli-htmlbars'; const template = hbs``; const other = hbs`hello`;" + ); + + expect(transformed).toMatchInlineSnapshot(` + "import { precompileTemplate } from \\"@ember/template-compilation\\"; + let two = 1 + 1; + import { hbs } from 'ember-cli-htmlbars'; + const template = precompileTemplate(\\"\\", { + scope: () => ({ + two + }) + }); + const other = hbs\`hello\`;" + `); + }); }); describe('scope', function () { diff --git a/src/plugin.ts b/src/plugin.ts index 1742941..d77b64c 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -162,7 +162,7 @@ export default function makePlugin( if (state.normalizedOpts.targetFormat === 'wire') { insertCompiledTemplate(babel, state, template, path, {}); } else { - insertTransformedTemplate(babel, state, template, path, {}); + insertTransformedTemplate(babel, state, template, path, {}, options); } }, @@ -218,7 +218,7 @@ export default function makePlugin( userTypedOptions = new ExpressionParser(babel).parseObjectExpression( calleePath.node.name, secondArg, - true + options.enableScope ); } if (restArgs.length > 0) { @@ -229,7 +229,7 @@ export default function makePlugin( if (state.normalizedOpts.targetFormat === 'wire') { insertCompiledTemplate(babel, state, template, path, userTypedOptions); } else { - insertTransformedTemplate(babel, state, template, path, userTypedOptions); + insertTransformedTemplate(babel, state, template, path, userTypedOptions, options); } }, }, @@ -355,7 +355,8 @@ function insertTransformedTemplate( state: State, template: string, target: NodePath | NodePath, - userTypedOptions: Record + userTypedOptions: Record, + formatOptions: ModuleConfig ) { let t = babel.types; let options = buildPrecompileOptions(babel, target, state, template, userTypedOptions); @@ -364,33 +365,26 @@ function insertTransformedTemplate( if (target.isCallExpression()) { (target.get('arguments.0') as NodePath).replaceWith(t.stringLiteral(transformed)); if (options.locals && options.locals.length > 0) { - let secondArg = target.get('arguments.1') as NodePath | undefined; - if (secondArg) { - let scope = secondArg.get('properties').find((p) => { - let key = p.get('key') as NodePath; - return key.isIdentifier() && key.node.name === 'scope'; - }); - if (scope) { - scope.set('value', buildScope(babel, options.locals)); - } else { - secondArg.pushContainer( - 'properties', - t.objectProperty(t.identifier('scope'), buildScope(babel, options.locals)) - ); - } - } else { - target.pushContainer( - 'arguments', - t.objectExpression([ - t.objectProperty(t.identifier('scope'), buildScope(babel, options.locals)), - ]) - ); + if (!formatOptions.enableScope) { + target.set('callee', precompileTemplate(state.util, target)); + maybePruneImport(state.util, formatOptions.moduleName, formatOptions.export); } + updateScope(babel, target, options.locals); } } else { - (target.get('quasi').get('quasis.0') as NodePath).replaceWith( - t.templateElement({ raw: transformed }) - ); + if (options.locals && options.locals.length > 0) { + // need to add scope, so need to replace the backticks form with a call + // expression to precompileTemplate + let newCall = target.replaceWith( + t.callExpression(precompileTemplate(state.util, target), [t.stringLiteral(transformed)]) + )[0]; + updateScope(babel, newCall, options.locals); + maybePruneImport(state.util, formatOptions.moduleName, formatOptions.export); + } else { + (target.get('quasi').get('quasis.0') as NodePath).replaceWith( + t.templateElement({ raw: transformed }) + ); + } } } @@ -412,3 +406,35 @@ function buildScope(babel: typeof Babel, locals: string[]) { ) ); } +function updateScope(babel: typeof Babel, target: NodePath, locals: string[]) { + let t = babel.types; + let secondArg = target.get('arguments.1') as NodePath | undefined; + if (secondArg) { + let scope = secondArg.get('properties').find((p) => { + let key = p.get('key') as NodePath; + return key.isIdentifier() && key.node.name === 'scope'; + }); + if (scope) { + scope.set('value', buildScope(babel, locals)); + } else { + secondArg.pushContainer( + 'properties', + t.objectProperty(t.identifier('scope'), buildScope(babel, locals)) + ); + } + } else { + target.pushContainer( + 'arguments', + t.objectExpression([t.objectProperty(t.identifier('scope'), buildScope(babel, locals))]) + ); + } +} + +function maybePruneImport(util: ImportUtil, moduleName: string, exportName: string) { + // TODO: count references and only remove if we're the last + util.removeImport(moduleName, exportName); +} + +function precompileTemplate(util: ImportUtil, target: NodePath) { + return util.import(target, '@ember/template-compilation', 'precompileTemplate'); +} From c140edbb69f03b2c254afb74eef52647f282a690 Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Wed, 3 Aug 2022 21:47:51 -0400 Subject: [PATCH 14/31] progress on removal --- __tests__/tests.ts | 36 +++++++++++++++++++++++++++++++++++- src/plugin.ts | 32 +++++++++++++++++++++++++++----- 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/__tests__/tests.ts b/__tests__/tests.ts index f321f7c..19cd9ce 100644 --- a/__tests__/tests.ts +++ b/__tests__/tests.ts @@ -1060,7 +1060,7 @@ describe('htmlbars-inline-precompile', function () { `); }); - it.skip('does not remove original import if there are still callsites using it', function () { + it('does not remove original import if there are still callsites using it', function () { plugins = [ [ HTMLBarsInlinePrecompile, @@ -1091,6 +1091,40 @@ describe('htmlbars-inline-precompile', function () { }); }); + it.skip('removes original import when there are multiple callsites that all needed replacement', function () { + plugins = [ + [ + HTMLBarsInlinePrecompile, + { + compiler, + targetFormat: 'hbs', + transforms: [expressionTransform], + enableLegacyModules: ['ember-cli-htmlbars'], + }, + ], + ]; + + let transformed = transform( + "import { hbs } from 'ember-cli-htmlbars'; const template = hbs``; const other = hbs`{{onePlusOne}}`;" + ); + + expect(transformed).toMatchInlineSnapshot(` + import { precompileTemplate } from \\"@ember/template-compilation\\"; + let two = 1 + 1; + let two0 = 1 + 1; + const template = precompileTemplate(\\"\\", { + scope: () => ({ + two + }) + }); + const other = precompileTemplate(\\"{{two0}}\\", { + scope: () => ({ + two0 + }) + });" + `); + }); + describe('scope', function () { it('correctly handles scope function (non-block arrow function)', function () { let source = 'hello'; diff --git a/src/plugin.ts b/src/plugin.ts index d77b64c..da434f6 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -366,8 +366,8 @@ function insertTransformedTemplate( (target.get('arguments.0') as NodePath).replaceWith(t.stringLiteral(transformed)); if (options.locals && options.locals.length > 0) { if (!formatOptions.enableScope) { + maybePruneImport(state.util, target.get('callee')); target.set('callee', precompileTemplate(state.util, target)); - maybePruneImport(state.util, formatOptions.moduleName, formatOptions.export); } updateScope(babel, target, options.locals); } @@ -375,11 +375,11 @@ function insertTransformedTemplate( if (options.locals && options.locals.length > 0) { // need to add scope, so need to replace the backticks form with a call // expression to precompileTemplate + maybePruneImport(state.util, target.get('tag')); let newCall = target.replaceWith( t.callExpression(precompileTemplate(state.util, target), [t.stringLiteral(transformed)]) )[0]; updateScope(babel, newCall, options.locals); - maybePruneImport(state.util, formatOptions.moduleName, formatOptions.export); } else { (target.get('quasi').get('quasis.0') as NodePath).replaceWith( t.templateElement({ raw: transformed }) @@ -430,11 +430,33 @@ function updateScope(babel: typeof Babel, target: NodePath, lo } } -function maybePruneImport(util: ImportUtil, moduleName: string, exportName: string) { - // TODO: count references and only remove if we're the last - util.removeImport(moduleName, exportName); +function maybePruneImport( + util: ImportUtil, + identifier: NodePath +) { + if (!identifier.isIdentifier()) { + return; + } + let binding = identifier.scope.getBinding(identifier.node.name); + // this checks if the identifier (that we're about to remove) is used in + // exactly one place. + if (binding?.references === 1) { + let specifier = binding.path; + if (specifier.isImportSpecifier()) { + let declaration = specifier.parentPath as NodePath; + util.removeImport(declaration.node.source.value, name(specifier.node.imported)); + } + } } function precompileTemplate(util: ImportUtil, target: NodePath) { return util.import(target, '@ember/template-compilation', 'precompileTemplate'); } + +function name(node: t.StringLiteral | t.Identifier) { + if (node.type === 'StringLiteral') { + return node.value; + } else { + return node.name; + } +} From bcb1bfeabd26bd55178410d54d8c9c94d2f28bbb Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Wed, 3 Aug 2022 22:01:02 -0400 Subject: [PATCH 15/31] improving tests --- __tests__/tests.ts | 254 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 193 insertions(+), 61 deletions(-) diff --git a/__tests__/tests.ts b/__tests__/tests.ts index 19cd9ce..4a8c098 100644 --- a/__tests__/tests.ts +++ b/__tests__/tests.ts @@ -5,7 +5,7 @@ import TransformTemplateLiterals from '@babel/plugin-transform-template-literals import TransformModules from '@babel/plugin-transform-modules-amd'; import TransformUnicodeEscapes from '@babel/plugin-transform-unicode-escapes'; import { stripIndent } from 'common-tags'; -import { EmberTemplateCompiler, PreprocessOptions } from '../src/ember-template-compiler'; +import { EmberTemplateCompiler } from '../src/ember-template-compiler'; import sinon from 'sinon'; import { ExtendedPluginBuilder } from '../src/js-utils'; @@ -628,24 +628,27 @@ describe('htmlbars-inline-precompile', function () { }); it('allows AST transform to bind a JS expression', function () { - sinon.replace(compiler, 'precompile', runASTTransform(compiler, expressionTransform)); - - // NEXT: - // plugins = [ - // [ - // HTMLBarsInlinePrecompile, - // { compiler, targetFormat: 'hbs', transforms: [expressionTransform] }, - // ], - // ]; + plugins = [ + [ + HTMLBarsInlinePrecompile, + { compiler, targetFormat: 'hbs', transforms: [expressionTransform] }, + ], + ]; let transformed = transform(stripIndent` import { precompileTemplate } from '@ember/template-compilation'; const template = precompileTemplate(''); `); - expect(transformed).toContain(`@text={{two}}`); - expect(transformed).toContain(`locals: [two]`); - expect(transformed).toContain(`let two = 1 + 1`); + expect(transformed).toMatchInlineSnapshot(` + "let two = 1 + 1; + import { precompileTemplate } from '@ember/template-compilation'; + const template = precompileTemplate(\\"\\", { + scope: () => ({ + two + }) + });" + `); }); it('adds locals to the compiled output', function () { @@ -663,24 +666,50 @@ describe('htmlbars-inline-precompile', function () { import { precompileTemplate } from '@ember/template-compilation'; const template = precompileTemplate(''); `); + expect(transformed).toMatchInlineSnapshot(` + "import { createTemplateFactory } from \\"@ember/template-factory\\"; + let two = 1 + 1; + const template = createTemplateFactory( + /* + + */ + { + \\"id\\": \\"DTxNS4zF\\", + \\"block\\": \\"[[[8,[39,0],null,[[\\\\\\"@text\\\\\\"],[[32,0]]],null]],[],false,[\\\\\\"message\\\\\\"]]\\", + \\"moduleName\\": \\"/Users/edward/hacking/babel-plugin-ember-template-compilation/foo-bar.js\\", + \\"scope\\": () => [two], + \\"isStrictMode\\": false + });" + `); + expect(transformed).toContain(`"scope": () => [two]`); }); it('allows AST transform to bind a JS import', function () { - sinon.replace(compiler, 'precompile', runASTTransform(compiler, importTransform)); + plugins = [ + [HTMLBarsInlinePrecompile, { compiler, targetFormat: 'hbs', transforms: [importTransform] }], + ]; let transformed = transform(stripIndent` import { precompileTemplate } from '@ember/template-compilation'; const template = precompileTemplate(''); `); - expect(transformed).toContain(`@text={{two}}`); - expect(transformed).toContain(`locals: [two]`); - expect(transformed).toContain(`import two from "my-library"`); + expect(transformed).toMatchInlineSnapshot(` + "import two from \\"my-library\\"; + import { precompileTemplate } from '@ember/template-compilation'; + const template = precompileTemplate(\\"\\", { + scope: () => ({ + two + }) + });" + `); }); it('does not smash existing js binding for import', function () { - sinon.replace(compiler, 'precompile', runASTTransform(compiler, importTransform)); + plugins = [ + [HTMLBarsInlinePrecompile, { compiler, targetFormat: 'hbs', transforms: [importTransform] }], + ]; let transformed = transform(stripIndent` import { precompileTemplate } from '@ember/template-compilation'; @@ -690,13 +719,24 @@ describe('htmlbars-inline-precompile', function () { } `); - expect(transformed).toContain(`@text={{two0}}`); - expect(transformed).toContain(`locals: [two0]`); - expect(transformed).toContain(`import two0 from "my-library"`); + expect(transformed).toMatchInlineSnapshot(` + "import two0 from \\"my-library\\"; + import { precompileTemplate } from '@ember/template-compilation'; + export function inner() { + let two = 'twice'; + const template = precompileTemplate(\\"\\", { + scope: () => ({ + two0 + }) + }); + }" + `); }); it('does not smash existing hbs binding for import', function () { - sinon.replace(compiler, 'precompile', runASTTransform(compiler, importTransform)); + plugins = [ + [HTMLBarsInlinePrecompile, { compiler, targetFormat: 'hbs', transforms: [importTransform] }], + ]; let transformed = transform(stripIndent` import { precompileTemplate } from '@ember/template-compilation'; @@ -705,14 +745,27 @@ describe('htmlbars-inline-precompile', function () { } `); - expect(transformed).toContain(`@text={{two0}}`); - expect(transformed).toContain(`let two0 = two`); - expect(transformed).toContain(`locals: [two0]`); - expect(transformed).toContain(`import two from "my-library"`); + expect(transformed).toMatchInlineSnapshot(` + "let two0 = two; + import two from \\"my-library\\"; + import { precompileTemplate } from '@ember/template-compilation'; + export function inner() { + const template = precompileTemplate(\\"{{#let \\\\\\"twice\\\\\\" as |two|}}{{/let}}\\", { + scope: () => ({ + two0 + }) + }); + }" + `); }); it('does not smash existing js binding for expression', function () { - sinon.replace(compiler, 'precompile', runASTTransform(compiler, expressionTransform)); + plugins = [ + [ + HTMLBarsInlinePrecompile, + { compiler, targetFormat: 'hbs', transforms: [expressionTransform] }, + ], + ]; let transformed = transform(stripIndent` import { precompileTemplate } from '@ember/template-compilation'; @@ -722,13 +775,68 @@ describe('htmlbars-inline-precompile', function () { } `); - expect(transformed).toContain(`@text={{two0}}`); - expect(transformed).toContain(`locals: [two0]`); - expect(transformed).toContain(`let two0 = 1 + 1`); + expect(transformed).toMatchInlineSnapshot(` + "let two0 = 1 + 1; + import { precompileTemplate } from '@ember/template-compilation'; + export default function () { + let two = 'twice'; + const template = precompileTemplate(\\"\\", { + scope: () => ({ + two0 + }) + }); + }" + `); + }); + + it.skip('does not smash own newly-created js binding for expression', function () { + plugins = [ + [ + HTMLBarsInlinePrecompile, + { compiler, targetFormat: 'hbs', transforms: [expressionTransform] }, + ], + ]; + + let transformed = transform(stripIndent` + import { precompileTemplate } from '@ember/template-compilation'; + export default function() { + const template1 = precompileTemplate(''); + const template2 = precompileTemplate(''); + } + `); + + expect(transformed).toMatchInlineSnapshot(` + "let two = 1 + 1; + import { createTemplateFactory } from \\"@ember/template-factory\\"; + let two0 = 1 + 1; + export default function () { + const template1 = createTemplateFactory( + /* + + */ + { + transformedHBS: \`\`, + locals: [two] + }); + const template2 = createTemplateFactory( + /* + + */ + { + transformedHBS: \`\`, + locals: [two0] + }); + }" + `); }); it('does not smash existing hbs block binding for expression', function () { - sinon.replace(compiler, 'precompile', runASTTransform(compiler, expressionTransform)); + plugins = [ + [ + HTMLBarsInlinePrecompile, + { compiler, targetFormat: 'hbs', transforms: [expressionTransform] }, + ], + ]; let transformed = transform(stripIndent` import { precompileTemplate } from '@ember/template-compilation'; @@ -737,13 +845,26 @@ describe('htmlbars-inline-precompile', function () { } `); - expect(transformed).toContain(`@text={{two0}}`); - expect(transformed).toContain(`locals: [two0]`); - expect(transformed).toContain(`let two0 = 1 + 1`); + expect(transformed).toMatchInlineSnapshot(` + "let two0 = 1 + 1; + import { precompileTemplate } from '@ember/template-compilation'; + export default function () { + const template = precompileTemplate(\\"{{#let \\\\\\"twice\\\\\\" as |two|}}{{/let}}\\", { + scope: () => ({ + two0 + }) + }); + }" + `); }); it('does not smash existing hbs element binding for expression', function () { - sinon.replace(compiler, 'precompile', runASTTransform(compiler, expressionTransform)); + plugins = [ + [ + HTMLBarsInlinePrecompile, + { compiler, targetFormat: 'hbs', transforms: [expressionTransform] }, + ], + ]; let transformed = transform(stripIndent` import { precompileTemplate } from '@ember/template-compilation'; @@ -752,13 +873,26 @@ describe('htmlbars-inline-precompile', function () { } `); - expect(transformed).toContain(`@text={{two0}}`); - expect(transformed).toContain(`locals: [two0]`); - expect(transformed).toContain(`let two0 = 1 + 1`); + expect(transformed).toMatchInlineSnapshot(` + "let two0 = 1 + 1; + import { precompileTemplate } from '@ember/template-compilation'; + export default function () { + const template = precompileTemplate(\\"\\", { + scope: () => ({ + two0 + }) + }); + }" + `); }); it('understands that block params are only defined in the body, not the arguments, of an element', function () { - sinon.replace(compiler, 'precompile', runASTTransform(compiler, expressionTransform)); + plugins = [ + [ + HTMLBarsInlinePrecompile, + { compiler, targetFormat: 'hbs', transforms: [expressionTransform] }, + ], + ]; let transformed = transform(stripIndent` import { precompileTemplate } from '@ember/template-compilation'; @@ -767,9 +901,17 @@ describe('htmlbars-inline-precompile', function () { } `); - expect(transformed).toContain(`@text={{two}}`); - expect(transformed).toContain(`locals: [two]`); - expect(transformed).toContain(`let two = 1 + 1`); + expect(transformed).toMatchInlineSnapshot(` + "let two = 1 + 1; + import { precompileTemplate } from '@ember/template-compilation'; + export default function () { + const template = precompileTemplate(\\"{{two}}\\", { + scope: () => ({ + two + }) + }); + }" + `); }); it('can bind expressions that need imports', function () { @@ -795,7 +937,9 @@ describe('htmlbars-inline-precompile', function () { }; }; - sinon.replace(compiler, 'precompile', runASTTransform(compiler, nowTransform)); + plugins = [ + [HTMLBarsInlinePrecompile, { compiler, targetFormat: 'hbs', transforms: [nowTransform] }], + ]; let transformed = transform(stripIndent` import { precompileTemplate } from '@ember/template-compilation'; @@ -826,7 +970,9 @@ describe('htmlbars-inline-precompile', function () { }; }; - sinon.replace(compiler, 'precompile', runASTTransform(compiler, compatTransform)); + plugins = [ + [HTMLBarsInlinePrecompile, { compiler, targetFormat: 'hbs', transforms: [compatTransform] }], + ]; let transformed = transform(stripIndent` import { precompileTemplate } from '@ember/template-compilation'; @@ -853,7 +999,9 @@ describe('htmlbars-inline-precompile', function () { }; }; - sinon.replace(compiler, 'precompile', runASTTransform(compiler, compatTransform)); + plugins = [ + [HTMLBarsInlinePrecompile, { compiler, targetFormat: 'hbs', transforms: [compatTransform] }], + ]; let transformed = transform(stripIndent` import { precompileTemplate } from '@ember/template-compilation'; @@ -1199,19 +1347,3 @@ describe('htmlbars-inline-precompile', function () { }); }); }); - -function runASTTransform(compiler: any, customTransform: ExtendedPluginBuilder) { - return (template: string, options: PreprocessOptions) => { - let ast = compiler._preprocess(template, { - ...options, - plugins: { ast: [customTransform] }, - }); - return ( - '{ transformedHBS: `' + - compiler._print(ast) + - '`, locals: [' + - ((options.locals as string[]) ?? []).join(',') + - ']}' - ); - }; -} From 3186af33467d69c706b6b7333ba48fe9a94d8d6e Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Wed, 3 Aug 2022 22:35:05 -0400 Subject: [PATCH 16/31] finish import pruning --- __tests__/tests.ts | 30 ++++++++++++------------------ src/js-utils.ts | 29 ++++++++++++++++++----------- src/plugin.ts | 14 ++++++-------- 3 files changed, 36 insertions(+), 37 deletions(-) diff --git a/__tests__/tests.ts b/__tests__/tests.ts index 4a8c098..fd0f32c 100644 --- a/__tests__/tests.ts +++ b/__tests__/tests.ts @@ -789,7 +789,7 @@ describe('htmlbars-inline-precompile', function () { `); }); - it.skip('does not smash own newly-created js binding for expression', function () { + it('does not smash own newly-created js binding for expression', function () { plugins = [ [ HTMLBarsInlinePrecompile, @@ -807,24 +807,18 @@ describe('htmlbars-inline-precompile', function () { expect(transformed).toMatchInlineSnapshot(` "let two = 1 + 1; - import { createTemplateFactory } from \\"@ember/template-factory\\"; let two0 = 1 + 1; + import { precompileTemplate } from '@ember/template-compilation'; export default function () { - const template1 = createTemplateFactory( - /* - - */ - { - transformedHBS: \`\`, - locals: [two] + const template1 = precompileTemplate(\\"\\", { + scope: () => ({ + two + }) }); - const template2 = createTemplateFactory( - /* - - */ - { - transformedHBS: \`\`, - locals: [two0] + const template2 = precompileTemplate(\\"\\", { + scope: () => ({ + two0 + }) }); }" `); @@ -1239,7 +1233,7 @@ describe('htmlbars-inline-precompile', function () { }); }); - it.skip('removes original import when there are multiple callsites that all needed replacement', function () { + it('removes original import when there are multiple callsites that all needed replacement', function () { plugins = [ [ HTMLBarsInlinePrecompile, @@ -1257,7 +1251,7 @@ describe('htmlbars-inline-precompile', function () { ); expect(transformed).toMatchInlineSnapshot(` - import { precompileTemplate } from \\"@ember/template-compilation\\"; + "import { precompileTemplate } from \\"@ember/template-compilation\\"; let two = 1 + 1; let two0 = 1 + 1; const template = precompileTemplate(\\"\\", { diff --git a/src/js-utils.ts b/src/js-utils.ts index 2de5e0d..288de55 100644 --- a/src/js-utils.ts +++ b/src/js-utils.ts @@ -3,26 +3,26 @@ import type * as Babel from '@babel/core'; import type { NodePath } from '@babel/traverse'; import type { ASTPluginBuilder, ASTPluginEnvironment, ASTv1, WalkerPath } from '@glimmer/syntax'; import type { ImportUtil } from 'babel-import-util'; +import type { State } from './plugin'; // This exists to give AST plugins a controlled interface for influencing the // surrounding Javascript scope export class JSUtils { #babel: typeof Babel; - #program: NodePath; + #state: State; #template: NodePath; #locals: string[]; #importer: ImportUtil; - #lastInsertedPath: NodePath | undefined; constructor( babel: typeof Babel, - program: NodePath, + state: State, template: NodePath, locals: string[], importer: ImportUtil ) { this.#babel = babel; - this.#program = program; + this.#state = state; this.#template = template; this.#locals = locals; this.#importer = importer; @@ -53,21 +53,26 @@ export class JSUtils { this.#template.scope.hasBinding(candidate) || astNodeHasBinding(target, candidate) ); let t = this.#babel.types; - this.#emitStatement( + let declaration: NodePath = this.#emitStatement( t.variableDeclaration('let', [ - t.variableDeclarator(t.identifier(name), this.#parseExpression(this.#program, expression)), + t.variableDeclarator( + t.identifier(name), + this.#parseExpression(this.#state.program, expression) + ), ]) ); + declaration.scope.registerBinding('module', declaration.get('declarations.0') as NodePath); this.#locals.push(name); return name; } - #emitStatement(statement: t.Statement): void { - if (this.#lastInsertedPath) { - this.#lastInsertedPath.insertAfter(statement); + #emitStatement(statement: T): NodePath { + if (this.#state.lastInsertedPath) { + this.#state.lastInsertedPath = this.#state.lastInsertedPath.insertAfter(statement)[0]; } else { - this.#lastInsertedPath = this.#program.unshiftContainer('body', statement)[0]; + this.#state.lastInsertedPath = this.#state.program.unshiftContainer('body', statement)[0]; } + return this.#state.lastInsertedPath as NodePath; } /** @@ -140,7 +145,9 @@ export class JSUtils { */ emitExpression(expression: Expression): void { let t = this.#babel.types; - this.#emitStatement(t.expressionStatement(this.#parseExpression(this.#program, expression))); + this.#emitStatement( + t.expressionStatement(this.#parseExpression(this.#state.program, expression)) + ); } #parseExpression(target: NodePath, expression: Expression): t.Expression { diff --git a/src/plugin.ts b/src/plugin.ts index da434f6..2844fd3 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -96,6 +96,7 @@ export interface State { util: ImportUtil; templateFactory: { moduleName: string; exportName: string }; program: NodePath; + lastInsertedPath: NodePath | undefined; filename: string; } @@ -278,13 +279,7 @@ function buildPrecompileOptions( if (!userTypedOptions.locals) { userTypedOptions.locals = []; } - let jsutils = new JSUtils( - babel, - state.program, - target, - userTypedOptions.locals as string[], - state.util - ); + let jsutils = new JSUtils(babel, state, target, userTypedOptions.locals as string[], state.util); let meta = Object.assign({ jsutils }, userTypedOptions?.meta); return Object.assign( { @@ -440,13 +435,16 @@ function maybePruneImport( let binding = identifier.scope.getBinding(identifier.node.name); // this checks if the identifier (that we're about to remove) is used in // exactly one place. - if (binding?.references === 1) { + if ( + binding?.referencePaths.reduce((count, path) => (path.removed ? count : count + 1), 0) === 1 + ) { let specifier = binding.path; if (specifier.isImportSpecifier()) { let declaration = specifier.parentPath as NodePath; util.removeImport(declaration.node.source.value, name(specifier.node.imported)); } } + identifier.removed = true; } function precompileTemplate(util: ImportUtil, target: NodePath) { From c49eaf28363681ca23ba281fcb23bdd780dafea8 Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Wed, 3 Aug 2022 22:40:04 -0400 Subject: [PATCH 17/31] don't snapshot the wire format --- __tests__/tests.ts | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/__tests__/tests.ts b/__tests__/tests.ts index fd0f32c..3230313 100644 --- a/__tests__/tests.ts +++ b/__tests__/tests.ts @@ -666,22 +666,6 @@ describe('htmlbars-inline-precompile', function () { import { precompileTemplate } from '@ember/template-compilation'; const template = precompileTemplate(''); `); - expect(transformed).toMatchInlineSnapshot(` - "import { createTemplateFactory } from \\"@ember/template-factory\\"; - let two = 1 + 1; - const template = createTemplateFactory( - /* - - */ - { - \\"id\\": \\"DTxNS4zF\\", - \\"block\\": \\"[[[8,[39,0],null,[[\\\\\\"@text\\\\\\"],[[32,0]]],null]],[],false,[\\\\\\"message\\\\\\"]]\\", - \\"moduleName\\": \\"/Users/edward/hacking/babel-plugin-ember-template-compilation/foo-bar.js\\", - \\"scope\\": () => [two], - \\"isStrictMode\\": false - });" - `); - expect(transformed).toContain(`"scope": () => [two]`); }); From e4cafdaaf782fc120a99ef639631ece17ed67d1c Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Fri, 12 Aug 2022 22:10:11 -0400 Subject: [PATCH 18/31] exporting types and making base plugin easier to use directly --- src/node-main.ts | 4 +++- src/plugin.ts | 7 ++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/node-main.ts b/src/node-main.ts index dc0a783..14abcee 100644 --- a/src/node-main.ts +++ b/src/node-main.ts @@ -1,5 +1,5 @@ import { resolve } from 'path'; -import makePlugin from './plugin'; +import { makePlugin } from './plugin'; import { Options as SharedOptions } from './plugin'; import { assertTemplateCompiler, EmberTemplateCompiler } from './ember-template-compiler'; @@ -60,3 +60,5 @@ export default htmlbarsInlinePrecompile as typeof htmlbarsInlinePrecompile & { baseDir(): string; _parallelBabel: { requireFile: string }; }; + +export type { JSUtils, WithJSUtils } from './plugin'; diff --git a/src/plugin.ts b/src/plugin.ts index 2844fd3..c2c6afc 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -100,9 +100,7 @@ export interface State { filename: string; } -export default function makePlugin( - loadOptions: (opts: EnvSpecificOptions) => Options -) { +export function makePlugin(loadOptions: (opts: EnvSpecificOptions) => Options) { return function htmlbarsInlinePrecompile( babel: typeof Babel ): Babel.PluginObj> { @@ -458,3 +456,6 @@ function name(node: t.StringLiteral | t.Identifier) { return node.name; } } + +export default makePlugin((options) => options); +export type { JSUtils, WithJSUtils } from './js-utils'; From 3b49509cbbd365a9478b6a4cb74af764ccc50e34 Mon Sep 17 00:00:00 2001 From: Dan Freeman Date: Thu, 11 Aug 2022 16:05:04 +0200 Subject: [PATCH 19/31] Avoid reusing identifiers for bound expressions --- __tests__/tests.ts | 21 +++++++++++++++++++++ src/js-utils.ts | 4 +++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/__tests__/tests.ts b/__tests__/tests.ts index 3230313..5c976fd 100644 --- a/__tests__/tests.ts +++ b/__tests__/tests.ts @@ -892,6 +892,27 @@ describe('htmlbars-inline-precompile', function () { `); }); + it('does not smash other previously-bound expressions with new ones', () => { + plugins = [ + [ + HTMLBarsInlinePrecompile, + { compiler, targetFormat: 'hbs', transforms: [expressionTransform] }, + ], + ]; + + let transformed = transform(stripIndent` + import { precompileTemplate } from '@ember/template-compilation'; + export default function() { + const template = precompileTemplate('{{onePlusOne}}{{onePlusOne}}'); + } + `); + + expect(transformed).toContain(`{{two}}{{two0}}`); + expect(transformed).toContain(`locals: [two, two0]`); + expect(transformed).toContain(`let two = 1 + 1`); + expect(transformed).toContain(`let two0 = 1 + 1`); + }); + it('can bind expressions that need imports', function () { let nowTransform: ExtendedPluginBuilder = (env) => { return { diff --git a/src/js-utils.ts b/src/js-utils.ts index 288de55..535ce05 100644 --- a/src/js-utils.ts +++ b/src/js-utils.ts @@ -50,7 +50,9 @@ export class JSUtils { let name = unusedNameLike( opts?.nameHint ?? 'a', (candidate) => - this.#template.scope.hasBinding(candidate) || astNodeHasBinding(target, candidate) + this.#template.scope.hasBinding(candidate) || + this.#locals.includes(candidate) || + astNodeHasBinding(target, candidate) ); let t = this.#babel.types; let declaration: NodePath = this.#emitStatement( From 43b6bff4ee12ac0f151d4cde02feb2e84be94059 Mon Sep 17 00:00:00 2001 From: Dan Freeman Date: Thu, 11 Aug 2022 16:07:52 +0200 Subject: [PATCH 20/31] Reuse (or don't) imported identifiers as appropriate --- __tests__/tests.ts | 35 +++++++++++++++++++++++++++++++++++ src/js-utils.ts | 15 +++++++++++++-- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/__tests__/tests.ts b/__tests__/tests.ts index 5c976fd..4b945de 100644 --- a/__tests__/tests.ts +++ b/__tests__/tests.ts @@ -773,6 +773,41 @@ describe('htmlbars-inline-precompile', function () { `); }); + it('reuses existing imports when possible', () => { + plugins = [ + [HTMLBarsInlinePrecompile, { compiler, targetFormat: 'hbs', transforms: [importTransform] }], + ]; + + let transformed = transform(stripIndent` + import { precompileTemplate } from '@ember/template-compilation'; + export default function() { + const template = precompileTemplate('{{onePlusOne}}{{onePlusOne}}'); + } + `); + + expect(transformed).toContain(`{{two}}{{two}}`); + expect(transformed).toContain(`locals: [two]`); + expect(transformed).toContain(`import two from "my-library"`); + }); + + it('rebinds existing imports when necessary', () => { + plugins = [ + [HTMLBarsInlinePrecompile, { compiler, targetFormat: 'hbs', transforms: [importTransform] }], + ]; + + let transformed = transform(stripIndent` + import { precompileTemplate } from '@ember/template-compilation'; + export default function() { + const template = precompileTemplate('{{onePlusOne}}{{#let "twice" as |two|}}{{onePlusOne}}{{/let}}'); + } + `); + + expect(transformed).toContain(`{{two}}{{#let "twice" as |two|}}{{two0}}{{/let}}`); + expect(transformed).toContain(`locals: [two, two0]`); + expect(transformed).toContain(`import two from "my-library"`); + expect(transformed).toContain('let two0 = two'); + }); + it('does not smash own newly-created js binding for expression', function () { plugins = [ [ diff --git a/src/js-utils.ts b/src/js-utils.ts index 535ce05..0d44883 100644 --- a/src/js-utils.ts +++ b/src/js-utils.ts @@ -105,8 +105,19 @@ export class JSUtils { opts?.nameHint ); - let identifier = unusedNameLike(importedIdentifier.name, (candidate) => - astNodeHasBinding(target, candidate) + // If we're already referencing the imported name from the outer scope and + // it's not shadowed at our target location in the template, we can reuse + // the existing import. + if ( + this.#locals.includes(importedIdentifier.name) && + !astNodeHasBinding(target, importedIdentifier.name) + ) { + return importedIdentifier.name; + } + + let identifier = unusedNameLike( + importedIdentifier.name, + (candidate) => this.#locals.includes(candidate) || astNodeHasBinding(target, candidate) ); if (identifier !== importedIdentifier.name) { // The importedIdentifier that we have in Javascript is not usable within From 9a2a7cf5fd66c32c9dd68fd9b349ed6db76fc34c Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Mon, 31 Oct 2022 16:48:05 -0400 Subject: [PATCH 21/31] post-merge test adjustments --- __tests__/tests.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/__tests__/tests.ts b/__tests__/tests.ts index 4b945de..64c1ef5 100644 --- a/__tests__/tests.ts +++ b/__tests__/tests.ts @@ -786,7 +786,9 @@ describe('htmlbars-inline-precompile', function () { `); expect(transformed).toContain(`{{two}}{{two}}`); - expect(transformed).toContain(`locals: [two]`); + expect(transformed).toContain(`scope: () => ({ + two + })`); expect(transformed).toContain(`import two from "my-library"`); }); @@ -802,8 +804,12 @@ describe('htmlbars-inline-precompile', function () { } `); - expect(transformed).toContain(`{{two}}{{#let "twice" as |two|}}{{two0}}{{/let}}`); - expect(transformed).toContain(`locals: [two, two0]`); + //expect(transformed).toContain(`{{two}}{{#let "twice" as |two|}}{{two0}}{{/let}}`); + expect(transformed).toContain(`{{two}}{{#let \\"twice\\" as |two|}}{{two0}}{{/let}}`); + expect(transformed).toContain(`scope: () => ({ + two, + two0 + })`); expect(transformed).toContain(`import two from "my-library"`); expect(transformed).toContain('let two0 = two'); }); @@ -943,7 +949,10 @@ describe('htmlbars-inline-precompile', function () { `); expect(transformed).toContain(`{{two}}{{two0}}`); - expect(transformed).toContain(`locals: [two, two0]`); + expect(transformed).toContain(`scope: () => ({ + two, + two0 + })`); expect(transformed).toContain(`let two = 1 + 1`); expect(transformed).toContain(`let two0 = 1 + 1`); }); From d87621bff8775608a53f3560176f4ba736fc877c Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Mon, 31 Oct 2022 16:52:09 -0400 Subject: [PATCH 22/31] publishing 2.0.0-alpha.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7494d5b..f5ba3b6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "babel-plugin-ember-template-compilation", - "version": "1.0.2", + "version": "2.0.0-alpha.1", "description": "Babel implementation of Ember's low-level template-compilation API", "repository": "https://github.com/emberjs/babel-plugin-ember-template-compilation", "license": "MIT", From 204bb416c8b9cb219c8f3090487d971f0f065f78 Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Wed, 2 Nov 2022 17:56:36 -0400 Subject: [PATCH 23/31] resolve config relative to process.cwd --- src/node-main.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/node-main.ts b/src/node-main.ts index 14abcee..ff6e4e3 100644 --- a/src/node-main.ts +++ b/src/node-main.ts @@ -19,11 +19,15 @@ export type Options = Omit & { transforms?: (ExtendedPluginBuilder | string)[]; }; +function cwdRequire(moduleName: string) { + // eslint-disable-next-line @typescript-eslint/no-var-requires + return require(require.resolve(moduleName, { paths: [process.cwd()] })); +} + function handleNodeSpecificOptions(opts: Options): SharedOptions { let compiler: EmberTemplateCompiler; if (opts.compilerPath) { - // eslint-disable-next-line @typescript-eslint/no-var-requires - let mod: any = require(opts.compilerPath); + let mod: any = cwdRequire(opts.compilerPath); assertTemplateCompiler(mod); compiler = mod; } else if (opts.compiler) { @@ -37,7 +41,7 @@ function handleNodeSpecificOptions(opts: Options): SharedOptions { if (opts.transforms) { transforms = opts.transforms.map((t) => { if (typeof t === 'string') { - return require(t); + return cwdRequire(t); } else { return t; } From 8d4570b2b2b1af8344080e0dc9b3fcd2163aec41 Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Wed, 2 Nov 2022 17:56:51 -0400 Subject: [PATCH 24/31] releasing 2.0.0-alpha.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f5ba3b6..74ed4d8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "babel-plugin-ember-template-compilation", - "version": "2.0.0-alpha.1", + "version": "2.0.0-alpha.2", "description": "Babel implementation of Ember's low-level template-compilation API", "repository": "https://github.com/emberjs/babel-plugin-ember-template-compilation", "license": "MIT", From 8c13b2266966fd3d39c5b5213e9ba756dabaf31b Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Mon, 7 Nov 2022 22:22:58 -0500 Subject: [PATCH 25/31] provide env.filename --- src/plugin.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/plugin.ts b/src/plugin.ts index c2c6afc..663a584 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -289,6 +289,11 @@ function buildPrecompileOptions( // on-disk file. What's our plan for migrating people away from that? moduleName: state.filename, + // This is here so it's *always* the real filename. Historically, there is + // also `moduleName` but that did not match the real on-disk filename, it + // was the notional runtime module name from classic ember builds. + filename: state.filename, + plugins: { ast: state.normalizedOpts.transforms, }, From 0dafb5ba6f1cb7ac1e84717536293b149514875e Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Wed, 9 Nov 2022 18:17:32 -0500 Subject: [PATCH 26/31] upgrade babel-import-util So we can rely on it to handle nameHint sanitization --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 74ed4d8..a065abc 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "src/**/*.js.map" ], "dependencies": { - "babel-import-util": "^1.2.0" + "babel-import-util": "^1.3.0" }, "devDependencies": { "@babel/core": "^7.14.8", diff --git a/yarn.lock b/yarn.lock index c1667f4..3042164 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1977,10 +1977,10 @@ aws4@^1.8.0: version "1.8.0" resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.8.0.tgz#f0e003d9ca9e7f59c7a508945d7b2ef9a04a542f" -babel-import-util@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/babel-import-util/-/babel-import-util-1.2.0.tgz#96dc3ad9a186b4383229bcce35e1c4fb4de9a0d8" - integrity sha512-hsjWpyurnbXLNPcIKSkV6l23YfMSCLdA39C5pMh9VSFtGbGU+UFv5Uyz3u//pOOc/FUknGod1KgUd8KM8VQ07g== +babel-import-util@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/babel-import-util/-/babel-import-util-1.3.0.tgz#dc9251ea39a7747bd586c1c13b8d785a42797f8e" + integrity sha512-PPzUT17eAI18zn6ek1R3sB4Krc/MbnmT1MkZQFmyhjoaEGBVwNABhfVU9+EKcDSKrrOm9OIpGhjxukx1GCiy1g== babel-jest@^26.6.3: version "26.6.3" From 3e0f87494b784867bc3d4e63787effb8de5181a3 Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Wed, 9 Nov 2022 18:18:21 -0500 Subject: [PATCH 27/31] Provide a nicer-looking order Imports are legal anywhere and their order doesn't really matter, but it looks weird when they are interleaved with other statements. --- __tests__/tests.ts | 42 +++++++++++++++++++++--------------------- src/js-utils.ts | 23 ++++++++++++++++++++--- 2 files changed, 41 insertions(+), 24 deletions(-) diff --git a/__tests__/tests.ts b/__tests__/tests.ts index 64c1ef5..482fdfb 100644 --- a/__tests__/tests.ts +++ b/__tests__/tests.ts @@ -641,8 +641,8 @@ describe('htmlbars-inline-precompile', function () { `); expect(transformed).toMatchInlineSnapshot(` - "let two = 1 + 1; - import { precompileTemplate } from '@ember/template-compilation'; + "import { precompileTemplate } from '@ember/template-compilation'; + let two = 1 + 1; const template = precompileTemplate(\\"\\", { scope: () => ({ two @@ -730,9 +730,9 @@ describe('htmlbars-inline-precompile', function () { `); expect(transformed).toMatchInlineSnapshot(` - "let two0 = two; - import two from \\"my-library\\"; + "import two from \\"my-library\\"; import { precompileTemplate } from '@ember/template-compilation'; + let two0 = two; export function inner() { const template = precompileTemplate(\\"{{#let \\\\\\"twice\\\\\\" as |two|}}{{/let}}\\", { scope: () => ({ @@ -760,8 +760,8 @@ describe('htmlbars-inline-precompile', function () { `); expect(transformed).toMatchInlineSnapshot(` - "let two0 = 1 + 1; - import { precompileTemplate } from '@ember/template-compilation'; + "import { precompileTemplate } from '@ember/template-compilation'; + let two0 = 1 + 1; export default function () { let two = 'twice'; const template = precompileTemplate(\\"\\", { @@ -831,9 +831,9 @@ describe('htmlbars-inline-precompile', function () { `); expect(transformed).toMatchInlineSnapshot(` - "let two = 1 + 1; + "import { precompileTemplate } from '@ember/template-compilation'; + let two = 1 + 1; let two0 = 1 + 1; - import { precompileTemplate } from '@ember/template-compilation'; export default function () { const template1 = precompileTemplate(\\"\\", { scope: () => ({ @@ -865,8 +865,8 @@ describe('htmlbars-inline-precompile', function () { `); expect(transformed).toMatchInlineSnapshot(` - "let two0 = 1 + 1; - import { precompileTemplate } from '@ember/template-compilation'; + "import { precompileTemplate } from '@ember/template-compilation'; + let two0 = 1 + 1; export default function () { const template = precompileTemplate(\\"{{#let \\\\\\"twice\\\\\\" as |two|}}{{/let}}\\", { scope: () => ({ @@ -893,8 +893,8 @@ describe('htmlbars-inline-precompile', function () { `); expect(transformed).toMatchInlineSnapshot(` - "let two0 = 1 + 1; - import { precompileTemplate } from '@ember/template-compilation'; + "import { precompileTemplate } from '@ember/template-compilation'; + let two0 = 1 + 1; export default function () { const template = precompileTemplate(\\"\\", { scope: () => ({ @@ -921,8 +921,8 @@ describe('htmlbars-inline-precompile', function () { `); expect(transformed).toMatchInlineSnapshot(` - "let two = 1 + 1; - import { precompileTemplate } from '@ember/template-compilation'; + "import { precompileTemplate } from '@ember/template-compilation'; + let two = 1 + 1; export default function () { const template = precompileTemplate(\\"{{two}}\\", { scope: () => ({ @@ -1124,8 +1124,8 @@ describe('htmlbars-inline-precompile', function () { `); expect(transformed).toMatchInlineSnapshot(` - "let two = 1 + 1; - import { precompileTemplate } from '@ember/template-compilation'; + "import { precompileTemplate } from '@ember/template-compilation'; + let two = 1 + 1; const template = precompileTemplate(\\"\\", { scope: () => ({ two @@ -1151,9 +1151,9 @@ describe('htmlbars-inline-precompile', function () { `); expect(transformed).toMatchInlineSnapshot(` - "let two = 1 + 1; - import { precompileTemplate } from '@ember/template-compilation'; + "import { precompileTemplate } from '@ember/template-compilation'; import Message from 'message'; + let two = 1 + 1; const template = precompileTemplate(\\"\\", { moduleName: 'customModuleName', scope: () => ({ @@ -1182,9 +1182,9 @@ describe('htmlbars-inline-precompile', function () { `); expect(transformed).toMatchInlineSnapshot(` - "let two = 1 + 1; - import { precompileTemplate } from '@ember/template-compilation'; + "import { precompileTemplate } from '@ember/template-compilation'; import Message from 'message'; + let two = 1 + 1; const template = precompileTemplate(\\"\\", { scope: () => ({ Message, @@ -1270,8 +1270,8 @@ describe('htmlbars-inline-precompile', function () { expect(transformed).toMatchInlineSnapshot(` "import { precompileTemplate } from \\"@ember/template-compilation\\"; - let two = 1 + 1; import { hbs } from 'ember-cli-htmlbars'; + let two = 1 + 1; const template = precompileTemplate(\\"\\", { scope: () => ({ two diff --git a/src/js-utils.ts b/src/js-utils.ts index 0d44883..f74b519 100644 --- a/src/js-utils.ts +++ b/src/js-utils.ts @@ -3,20 +3,24 @@ import type * as Babel from '@babel/core'; import type { NodePath } from '@babel/traverse'; import type { ASTPluginBuilder, ASTPluginEnvironment, ASTv1, WalkerPath } from '@glimmer/syntax'; import type { ImportUtil } from 'babel-import-util'; -import type { State } from './plugin'; + +interface State { + program: NodePath; + lastInsertedPath: NodePath | undefined; +} // This exists to give AST plugins a controlled interface for influencing the // surrounding Javascript scope export class JSUtils { #babel: typeof Babel; - #state: State; + #state: State; #template: NodePath; #locals: string[]; #importer: ImportUtil; constructor( babel: typeof Babel, - state: State, + state: State, template: NodePath, locals: string[], importer: ImportUtil @@ -26,6 +30,19 @@ export class JSUtils { this.#template = template; this.#locals = locals; this.#importer = importer; + + if (!this.#state.lastInsertedPath) { + let target: NodePath | undefined; + for (let statement of this.#state.program.get('body')) { + if (!statement.isImportDeclaration()) { + break; + } + target = statement; + } + if (target) { + this.#state.lastInsertedPath = target; + } + } } /** From 1c6cbb15e18b8a08d8b4fca9b454e57cff5fbf87 Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Wed, 9 Nov 2022 18:18:48 -0500 Subject: [PATCH 28/31] support options when loading transform plugins on node, and clarify the types --- src/node-main.ts | 19 ++++++++++++++----- src/plugin.ts | 11 ++++------- src/public-types.ts | 6 ++++++ 3 files changed, 24 insertions(+), 12 deletions(-) create mode 100644 src/public-types.ts diff --git a/src/node-main.ts b/src/node-main.ts index ff6e4e3..bc22c9e 100644 --- a/src/node-main.ts +++ b/src/node-main.ts @@ -5,6 +5,10 @@ import { Options as SharedOptions } from './plugin'; import { assertTemplateCompiler, EmberTemplateCompiler } from './ember-template-compiler'; import { ExtendedPluginBuilder } from './js-utils'; +export * from './public-types'; + +export type Transform = ExtendedPluginBuilder | string | [string, unknown]; + export type Options = Omit & { // The on-disk path to the ember-template-comipler.js module for our current // ember version. You need to either set `compilerPath` or set `compiler`. @@ -15,8 +19,13 @@ export type Options = Omit & { compiler?: EmberTemplateCompiler; // List of custom transformations to apply to the handlebars AST before - // compilation. These can be the actual functions or resolvable module names. - transforms?: (ExtendedPluginBuilder | string)[]; + // compilation. These can be + // - the actual functions + // - resolvable module names + // - pairs of [resolvableModuleName, options], in which case we will invoke + // the default export of the module with the options as argument, and the + // actual ast transform function should be returned. + transforms?: Transform[]; }; function cwdRequire(moduleName: string) { @@ -41,7 +50,9 @@ function handleNodeSpecificOptions(opts: Options): SharedOptions { if (opts.transforms) { transforms = opts.transforms.map((t) => { if (typeof t === 'string') { - return cwdRequire(t); + return cwdRequire(t).default; + } else if (Array.isArray(t) && typeof t[0] === 'string') { + return cwdRequire(t[0]).default.call(undefined, t[1]); } else { return t; } @@ -64,5 +75,3 @@ export default htmlbarsInlinePrecompile as typeof htmlbarsInlinePrecompile & { baseDir(): string; _parallelBabel: { requireFile: string }; }; - -export type { JSUtils, WithJSUtils } from './plugin'; diff --git a/src/plugin.ts b/src/plugin.ts index 663a584..87e45f9 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -5,11 +5,9 @@ import { ImportUtil } from 'babel-import-util'; import { ExpressionParser } from './expression-parser'; import { JSUtils, ExtendedPluginBuilder } from './js-utils'; import type { EmberTemplateCompiler, PreprocessOptions } from './ember-template-compiler'; +import { LegacyModuleName } from './public-types'; -export type LegacyModuleName = - | 'ember-cli-htmlbars' - | 'ember-cli-htmlbars-inline-precompile' - | 'htmlbars-inline-precompile'; +export * from './public-types'; type ModuleName = LegacyModuleName | '@ember/template-compilation'; @@ -90,7 +88,7 @@ export interface Options { transforms?: ExtendedPluginBuilder[]; } -export interface State { +interface State { opts: EnvSpecificOptions; normalizedOpts: Required; util: ImportUtil; @@ -233,7 +231,7 @@ export function makePlugin(loadOptions: (opts: EnvSpecificOp }, }, }; - }; + } as (babel: typeof Babel) => Babel.PluginObj; } function* configuredModules(state: State) { @@ -463,4 +461,3 @@ function name(node: t.StringLiteral | t.Identifier) { } export default makePlugin((options) => options); -export type { JSUtils, WithJSUtils } from './js-utils'; diff --git a/src/public-types.ts b/src/public-types.ts new file mode 100644 index 0000000..8ae2c9c --- /dev/null +++ b/src/public-types.ts @@ -0,0 +1,6 @@ +export type LegacyModuleName = + | 'ember-cli-htmlbars' + | 'ember-cli-htmlbars-inline-precompile' + | 'htmlbars-inline-precompile'; + +export type { JSUtils, WithJSUtils } from './js-utils'; From aeb27460355d29c30b71d49120beef5b2458d39d Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Wed, 9 Nov 2022 18:19:38 -0500 Subject: [PATCH 29/31] releasing alpha.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a065abc..dcccd45 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "babel-plugin-ember-template-compilation", - "version": "2.0.0-alpha.2", + "version": "2.0.0-alpha.3", "description": "Babel implementation of Ember's low-level template-compilation API", "repository": "https://github.com/emberjs/babel-plugin-ember-template-compilation", "license": "MIT", From a07b5387e5130c98015b6db0ea5eec546085f259 Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Wed, 16 Nov 2022 18:00:25 -0500 Subject: [PATCH 30/31] support both cjs and transpiled esm --- src/node-main.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/node-main.ts b/src/node-main.ts index bc22c9e..1825749 100644 --- a/src/node-main.ts +++ b/src/node-main.ts @@ -50,9 +50,9 @@ function handleNodeSpecificOptions(opts: Options): SharedOptions { if (opts.transforms) { transforms = opts.transforms.map((t) => { if (typeof t === 'string') { - return cwdRequire(t).default; + return esCompat(cwdRequire(t)).default; } else if (Array.isArray(t) && typeof t[0] === 'string') { - return cwdRequire(t[0]).default.call(undefined, t[1]); + return esCompat(cwdRequire(t[0])).default.call(undefined, t[1]); } else { return t; } @@ -75,3 +75,7 @@ export default htmlbarsInlinePrecompile as typeof htmlbarsInlinePrecompile & { baseDir(): string; _parallelBabel: { requireFile: string }; }; + +function esCompat(m: Record) { + return m?.__esModule ? m : { default: m }; +} From 73b7789bb66203288aeca2a2c37f9d68aeb106d4 Mon Sep 17 00:00:00 2001 From: Edward Faulkner Date: Wed, 16 Nov 2022 18:00:36 -0500 Subject: [PATCH 31/31] releasing alpha.4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index dcccd45..2077157 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "babel-plugin-ember-template-compilation", - "version": "2.0.0-alpha.3", + "version": "2.0.0-alpha.4", "description": "Babel implementation of Ember's low-level template-compilation API", "repository": "https://github.com/emberjs/babel-plugin-ember-template-compilation", "license": "MIT",