Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
aec31f3
Allow AST plugins to manipulate Javascript scope
ef4 Jun 30, 2022
a85d49a
Merge branch 'main' into jsutils
ef4 Jun 30, 2022
d140e53
add failing tests for hbs scope collision
ef4 Jun 30, 2022
ac77392
handle HBS scope collision
ef4 Jun 30, 2022
c219c93
renaming bindValue->bindExpression
ef4 Jun 30, 2022
7f4eb94
documenting some gnarlier bits
ef4 Jun 30, 2022
bfd5900
update readme
ef4 Jul 27, 2022
01e628a
adding docs
ef4 Jul 28, 2022
3828958
extending API to cover more side-effectful cases
ef4 Jul 31, 2022
10c1d8f
un-nesting function
ef4 Aug 2, 2022
44867df
reving caniuse
ef4 Aug 2, 2022
dc058a8
refactoring to take the whole template compiler
ef4 Aug 2, 2022
352e1d1
progress on source-to-source mode
ef4 Aug 3, 2022
d03774c
implementing swapping between forms
ef4 Aug 3, 2022
c140edb
progress on removal
ef4 Aug 4, 2022
bcb1bfe
improving tests
ef4 Aug 4, 2022
3186af3
finish import pruning
ef4 Aug 4, 2022
c49eaf2
don't snapshot the wire format
ef4 Aug 4, 2022
691ea9d
Merge remote-tracking branch 'origin/main' into source-to-source
ef4 Aug 10, 2022
e4cafda
exporting types and making base plugin easier to use directly
ef4 Aug 13, 2022
d846415
Merge pull request #9 from emberjs/source-to-source
ef4 Oct 31, 2022
3b49509
Avoid reusing identifiers for bound expressions
dfreeman Aug 11, 2022
43b6bff
Reuse (or don't) imported identifiers as appropriate
dfreeman Aug 11, 2022
9a2a7cf
post-merge test adjustments
ef4 Oct 31, 2022
d87621b
publishing 2.0.0-alpha.1
ef4 Oct 31, 2022
204bb41
resolve config relative to process.cwd
ef4 Nov 2, 2022
8d4570b
releasing 2.0.0-alpha.2
ef4 Nov 2, 2022
8c13b22
provide env.filename
ef4 Nov 8, 2022
0dafb5b
upgrade babel-import-util
ef4 Nov 9, 2022
3e0f874
Provide a nicer-looking order
ef4 Nov 9, 2022
1c6cbb1
support options when loading transform plugins on node, and clarify t…
ef4 Nov 9, 2022
aeb2746
releasing alpha.3
ef4 Nov 9, 2022
a07b538
support both cjs and transpiled esm
ef4 Nov 16, 2022
73b7789
releasing alpha.4
ef4 Nov 16, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
186 changes: 179 additions & 7 deletions __tests__/tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Options['precompile']>;
Expand All @@ -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})"`;
};

Expand Down Expand Up @@ -93,6 +96,7 @@ describe('htmlbars-inline-precompile', function () {

expect(optionsReceived).toEqual({
contents: source,
locals: [],
});
});

Expand All @@ -106,6 +110,7 @@ describe('htmlbars-inline-precompile', function () {
expect(optionsReceived).toEqual({
contents: source,
isProduction: true,
locals: [],
});
});

Expand All @@ -117,6 +122,7 @@ describe('htmlbars-inline-precompile', function () {

expect(optionsReceived).toEqual({
contents: source,
locals: [],
});
});

Expand Down Expand Up @@ -159,6 +165,7 @@ describe('htmlbars-inline-precompile', function () {
stringifiedThing: {
foo: 'baz',
},
locals: [],
});
});

Expand Down Expand Up @@ -244,6 +251,7 @@ describe('htmlbars-inline-precompile', function () {

expect(optionsReceived).toEqual({
contents: source,
locals: [],
});
});

Expand Down Expand Up @@ -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';

Expand All @@ -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('<Message @text={{onePlusOne}} />');
`);

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('<Message @text={{onePlusOne}} />');
`);
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('<Message @text={{onePlusOne}} />');
`);

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('<Message @text={{onePlusOne}} />');
}
`);

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('<Message @text={{onePlusOne}} />');
}
`);

expect(transformed).toContain(`@text={{two0}}`);
expect(transformed).toContain(`locals: [two0]`);
expect(transformed).toContain(`let two0 = 1 + 1`);
});
});

describe('scope', function () {
Expand Down Expand Up @@ -663,3 +804,34 @@ describe('htmlbars-inline-precompile', function () {
});
});
});

function runASTTransform(
compiler: any,
customTransform: ASTPluginBuilder<WithJSUtils<ASTPluginEnvironment>>
) {
return (template: string, options: Record<string, unknown>) => {
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<WithJSUtils<ASTPluginEnvironment>>
) {
return (template: string, options: Record<string, unknown>) => {
return compiler.precompile(template, {
...options,
plugins: { ast: [customTransform] },
});
};
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,13 @@
"@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",
"@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",
Expand Down
95 changes: 95 additions & 0 deletions src/js-utils.ts
Original file line number Diff line number Diff line change
@@ -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<t.Program>;
#template: NodePath<t.Expression>;
#locals: string[];
#importer: ImportUtil;

constructor(
babel: typeof Babel,
program: NodePath<t.Program>,
template: NodePath<t.Expression>,
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<T extends { meta?: object }> = {
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;
}
}
Loading