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..3230313 100644 --- a/__tests__/tests.ts +++ b/__tests__/tests.ts @@ -5,14 +5,14 @@ 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'; +import { ExtendedPluginBuilder } from '../src/js-utils'; describe('htmlbars-inline-precompile', function () { - let precompile: NonNullable; - let plugins: any[]; - let optionsReceived: any; - let buildOptions: (o?: Partial) => Options; + // eslint-disable-next-line @typescript-eslint/no-var-requires + let compiler: EmberTemplateCompiler = { ...require('ember-source/dist/ember-template-compiler') }; + let plugins: ([typeof HTMLBarsInlinePrecompile, Options] | [unknown])[]; function transform(code: string) { let x = babel @@ -25,30 +25,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 +54,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 +72,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 +115,10 @@ describe('htmlbars-inline-precompile', function () { plugins = [ [ HTMLBarsInlinePrecompile, - buildOptions({ + { + compiler, enableLegacyModules: ['htmlbars-inline-precompile'], - }), + }, ], ]; expect(() => @@ -150,26 +128,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 +163,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 +184,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 +198,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 +234,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 +257,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 +286,7 @@ describe('htmlbars-inline-precompile', function () { /* hello */ - \\"precompiled(hello)\\");" + precompiled(\\"hello\\"));" `); }); @@ -328,6 +307,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 +324,12 @@ describe('htmlbars-inline-precompile', function () { /* hello */ - \\"precompiled(hello)\\"); + precompiled(\\"hello\\")); let b = createTemplateFactory( /* hello */ - \\"precompiled(hello)\\");" + precompiled(\\"hello\\"));" `); }); @@ -372,6 +355,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 +374,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 +402,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 +425,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,19 +446,24 @@ 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, + [TransformTemplateLiterals], ]; let transformed = transform( @@ -472,7 +476,7 @@ describe('htmlbars-inline-precompile', function () { /* hello */ - \\"precompiled(hello)\\");" + precompiled(\\"hello\\"));" `); }); @@ -480,9 +484,10 @@ describe('htmlbars-inline-precompile', function () { plugins = [ [ HTMLBarsInlinePrecompile, - buildOptions({ + { + compiler, enableLegacyModules: ['htmlbars-inline-precompile'], - }), + }, ], ]; let transformed = transform( @@ -497,9 +502,10 @@ describe('htmlbars-inline-precompile', function () { plugins = [ [ HTMLBarsInlinePrecompile, - buildOptions({ + { + compiler, enableLegacyModules: ['htmlbars-inline-precompile'], - }), + }, ], ]; expect(() => @@ -510,16 +516,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 +539,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 +585,117 @@ 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: ExtendedPluginBuilder = (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: ExtendedPluginBuilder = (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 () { + plugins = [ + [ + HTMLBarsInlinePrecompile, + { compiler, targetFormat: 'hbs', transforms: [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).toMatchInlineSnapshot(` + "let two = 1 + 1; + import { precompileTemplate } from '@ember/template-compilation'; + const template = precompileTemplate(\\"\\", { + scope: () => ({ + two + }) + });" + `); + }); - it('adds locals to the compiled output', function () { - precompile = compileASTTransform(compiler, expressionTransform); + it('adds locals to the compiled output', function () { + plugins = [ + [ + HTMLBarsInlinePrecompile, + { + compiler, + transforms: [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 () { + plugins = [ + [HTMLBarsInlinePrecompile, { compiler, targetFormat: 'hbs', transforms: [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).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 () { - precompile = runASTTransform(compiler, importTransform); + it('does not smash existing js binding for import', function () { + plugins = [ + [HTMLBarsInlinePrecompile, { compiler, targetFormat: 'hbs', transforms: [importTransform] }], + ]; - let transformed = transform(stripIndent` + let transformed = transform(stripIndent` import { precompileTemplate } from '@ember/template-compilation'; export function inner() { let two = 'twice'; @@ -669,31 +703,55 @@ 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 () { - precompile = runASTTransform(compiler, importTransform); + it('does not smash existing hbs binding for import', function () { + plugins = [ + [HTMLBarsInlinePrecompile, { compiler, targetFormat: 'hbs', transforms: [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).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 () { - precompile = runASTTransform(compiler, expressionTransform); + it('does not smash existing js binding for expression', function () { + plugins = [ + [ + HTMLBarsInlinePrecompile, + { compiler, targetFormat: 'hbs', transforms: [expressionTransform] }, + ], + ]; - let transformed = transform(stripIndent` + let transformed = transform(stripIndent` import { precompileTemplate } from '@ember/template-compilation'; export default function() { let two = 'twice'; @@ -701,193 +759,539 @@ 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('does not smash existing hbs block binding for expression', function () { - precompile = runASTTransform(compiler, expressionTransform); + it('does not smash own newly-created js binding for expression', function () { + plugins = [ + [ + HTMLBarsInlinePrecompile, + { compiler, targetFormat: 'hbs', transforms: [expressionTransform] }, + ], + ]; - let transformed = transform(stripIndent` + 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; + let two0 = 1 + 1; + import { precompileTemplate } from '@ember/template-compilation'; + export default function () { + const template1 = precompileTemplate(\\"\\", { + scope: () => ({ + two + }) + }); + const template2 = precompileTemplate(\\"\\", { + scope: () => ({ + two0 + }) + }); + }" + `); + }); + + it('does not smash existing hbs block 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 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).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 () { - precompile = runASTTransform(compiler, expressionTransform); + it('does not smash existing hbs element binding for expression', function () { + plugins = [ + [ + HTMLBarsInlinePrecompile, + { compiler, targetFormat: 'hbs', transforms: [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).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 () { - precompile = runASTTransform(compiler, expressionTransform); + it('understands that block params are only defined in the body, not the arguments, of an element', function () { + plugins = [ + [ + HTMLBarsInlinePrecompile, + { compiler, targetFormat: 'hbs', transforms: [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).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 () { - 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: ExtendedPluginBuilder = (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); + plugins = [ + [HTMLBarsInlinePrecompile, { compiler, targetFormat: 'hbs', transforms: [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: ExtendedPluginBuilder = (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); + plugins = [ + [HTMLBarsInlinePrecompile, { compiler, targetFormat: 'hbs', transforms: [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: ExtendedPluginBuilder = (env) => { + return { + name: 'compat-transform', + visitor: { + ElementNode(node) { + if (node.tag === 'Thing') { + env.meta.jsutils.importForSideEffect('setup-the-things'); + } }, - }; + }, }; + }; - precompile = runASTTransform(compiler, compatTransform); + plugins = [ + [HTMLBarsInlinePrecompile, { compiler, targetFormat: 'hbs', transforms: [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('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('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('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\`;" + `); + }); + }); + + it('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'; + 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 () { @@ -921,34 +1325,3 @@ 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 dea7b69..7494d5b 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,9 @@ ".": { "browser": "./src/plugin.js", "default": "./src/node-main.js" - } + }, + "./browser": "./src/plugin.js", + "./node": "./src/node-main.js" }, "files": [ "src/**/*.js", @@ -44,6 +46,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 +59,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..c49badd --- /dev/null +++ b/src/ember-template-compiler.ts @@ -0,0 +1,32 @@ +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: PreprocessOptions): string; + _buildCompileOptions(options: PreprocessOptions): PreprocessOptions; + _print(ast: ASTv1.Template, options?: { entityEncoding?: 'transformed' | 'raw' }): string; + _preprocess(src: string, options?: PreprocessOptions): ASTv1.Template; +} + +export interface PreprocessOptions { + contents: string; + moduleName: string; + plugins?: { ast?: ExtendedPluginBuilder[] }; + 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/js-utils.ts b/src/js-utils.ts index 458b876..288de55 100644 --- a/src/js-utils.ts +++ b/src/js-utils.ts @@ -1,28 +1,28 @@ 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'; +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 { @@ -216,6 +223,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 e92a0d6..14abcee 100644 --- a/src/node-main.ts +++ b/src/node-main.ts @@ -1,46 +1,64 @@ import { resolve } from 'path'; -import makePlugin from './plugin'; -import type * as Babel from '@babel/core'; - -import { Options as PluginOptions, EmberPrecompile } from './plugin'; - -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; - - // 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; -} +import { makePlugin } from './plugin'; + +import { Options as SharedOptions } from './plugin'; +import { assertTemplateCompiler, EmberTemplateCompiler } from './ember-template-compiler'; +import { ExtendedPluginBuilder } from './js-utils'; + +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; -const htmlbarsInlinePrecompile = makePlugin(function (opts: Options) { - if (opts.precompilerPath) { + // The ember-template-compiler.js module that ships within your ember-source + // version. You need to set either `compilerPath` or `compiler`. + 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)[]; +}; + +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.precompilerPath); - return mod.precompile; - } else if (opts.precompile) { - return opts.precompile; + let mod: any = require(opts.compilerPath); + assertTemplateCompiler(mod); + compiler = mod; + } else if (opts.compiler) { + assertTemplateCompiler(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 }; +}; + +export type { JSUtils, WithJSUtils } from './plugin'; diff --git a/src/plugin.ts b/src/plugin.ts index 2a07eff..c2c6afc 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -3,9 +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'; - -export type EmberPrecompile = (templateString: string, options: Record) => string; +import { JSUtils, ExtendedPluginBuilder } from './js-utils'; +import type { EmberTemplateCompiler, PreprocessOptions } from './ember-template-compiler'; export type LegacyModuleName = | 'ember-cli-htmlbars' @@ -49,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: // @@ -71,100 +73,68 @@ 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; - precompile: EmberPrecompile; templateFactory: { moduleName: string; exportName: string }; program: NodePath; + lastInsertedPath: NodePath | undefined; + filename: string; } -export default function makePlugin( - // receives the Babel plugin options, returns Ember's precompiler - loadPrecompiler: (opts: O) => EmberPrecompile -) { - return function htmlbarsInlinePrecompile(babel: typeof Babel): Babel.PluginObj { +export function makePlugin(loadOptions: (opts: EnvSpecificOptions) => Options) { + 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: { - 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.precompile = loadPrecompiler(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()) { @@ -188,10 +158,14 @@ export default function makePlugin( } let template = path.node.quasi.quasis.map((quasi) => quasi.value.cooked).join(''); - insertCompiledTemplate(path, state, template, {}); + if (state.normalizedOpts.targetFormat === 'wire') { + insertCompiledTemplate(babel, state, template, path, {}); + } else { + insertTransformedTemplate(babel, state, template, path, {}, options); + } }, - CallExpression(path: NodePath, state: State) { + CallExpression(path: NodePath, state: State) { let calleePath = path.get('callee'); if (!calleePath.isIdentifier()) { @@ -243,7 +217,7 @@ export default function makePlugin( userTypedOptions = new ExpressionParser(babel).parseObjectExpression( calleePath.node.name, secondArg, - true + options.enableScope ); } if (restArgs.length > 0) { @@ -251,18 +225,22 @@ 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); + if (state.normalizedOpts.targetFormat === 'wire') { + insertCompiledTemplate(babel, state, template, path, userTypedOptions); + } else { + insertTransformedTemplate(babel, state, template, path, userTypedOptions, options); + } }, }, }; }; } -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; } @@ -270,9 +248,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)) { @@ -288,3 +266,196 @@ function runtimeErrorIIFE(babel: typeof Babel, replacements: { ERROR_MESSAGE: st ) as t.ExpressionStatement; return statement.expression; } + +function buildPrecompileOptions( + babel: typeof Babel, + target: NodePath, + state: State, + template: string, + userTypedOptions: Record +): PreprocessOptions & Record { + if (!userTypedOptions.locals) { + userTypedOptions.locals = []; + } + let jsutils = new JSUtils(babel, state, target, userTypedOptions.locals as string[], state.util); + let meta = Object.assign({ jsutils }, userTypedOptions?.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.normalizedOpts.compiler.precompile(template, options); + } catch (error) { + target.replaceWith(runtimeErrorIIFE(babel, { ERROR_MESSAGE: (error as any).message })); + return; + } + } else { + precompileResultString = state.normalizedOpts.compiler.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])); +} + +function insertTransformedTemplate( + babel: typeof Babel, + state: State, + template: string, + target: NodePath | NodePath, + userTypedOptions: Record, + formatOptions: ModuleConfig +) { + 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) { + if (!formatOptions.enableScope) { + maybePruneImport(state.util, target.get('callee')); + target.set('callee', precompileTemplate(state.util, target)); + } + updateScope(babel, target, options.locals); + } + } else { + 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); + } 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)) + ) + ); +} +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, + 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?.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) { + 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; + } +} + +export default makePlugin((options) => options); +export type { JSUtils, WithJSUtils } from './js-utils'; diff --git a/yarn.lock b/yarn.lock index bd4c364..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" @@ -2471,9 +2511,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" @@ -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==