diff --git a/packages/babel-template/src/literal.js b/packages/babel-template/src/literal.js index ef78142497..0cc7415e72 100644 --- a/packages/babel-template/src/literal.js +++ b/packages/babel-template/src/literal.js @@ -74,6 +74,7 @@ function buildLiteralData( ), placeholderPattern: opts.placeholderPattern, preserveComments: opts.preserveComments, + syntacticPlaceholders: opts.syntacticPlaceholders, }); } while ( metadata.placeholders.some( diff --git a/packages/babel-template/src/options.js b/packages/babel-template/src/options.js index 54e3972de2..cb2639319e 100644 --- a/packages/babel-template/src/options.js +++ b/packages/babel-template/src/options.js @@ -1,5 +1,9 @@ // @flow +import type { Options as ParserOpts } from "@babel/parser/src/options"; + +export type { ParserOpts }; + /** * These are the options that 'babel-template' actually accepts and typechecks * when called. All other options are passed through to the parser. @@ -8,6 +12,8 @@ export type PublicOpts = { /** * A set of placeholder names to automatically accept, ignoring the given * pattern entirely. + * + * This option can be used when using %%foo%% style placeholders. */ placeholderWhitelist?: ?Set, @@ -19,6 +25,8 @@ export type PublicOpts = { * 'placeholderWhitelist' value to find replacements. * * Defaults to /^[_$A-Z0-9]+$/. + * + * This option can be used when using %%foo%% style placeholders. */ placeholderPattern?: ?(RegExp | false), @@ -27,13 +35,22 @@ export type PublicOpts = { * or 'false' to automatically discard comments. Defaults to 'false'. */ preserveComments?: ?boolean, + + /** + * 'true' to use %%foo%% style placeholders, 'false' to use legacy placeholders + * described by placeholderPattern or placeholderWhitelist. + * When it is not set, it behaves as 'true' if there are syntactic placeholders, + * otherwise as 'false'. + */ + syntacticPlaceholders?: ?boolean, }; export type TemplateOpts = {| - parser: {}, + parser: ParserOpts, placeholderWhitelist: Set | void, placeholderPattern: RegExp | false | void, preserveComments: boolean | void, + syntacticPlaceholders: boolean | void, |}; export function merge(a: TemplateOpts, b: TemplateOpts): TemplateOpts { @@ -41,6 +58,7 @@ export function merge(a: TemplateOpts, b: TemplateOpts): TemplateOpts { placeholderWhitelist = a.placeholderWhitelist, placeholderPattern = a.placeholderPattern, preserveComments = a.preserveComments, + syntacticPlaceholders = a.syntacticPlaceholders, } = b; return { @@ -51,6 +69,7 @@ export function merge(a: TemplateOpts, b: TemplateOpts): TemplateOpts { placeholderWhitelist, placeholderPattern, preserveComments, + syntacticPlaceholders, }; } @@ -63,6 +82,7 @@ export function validate(opts: mixed): TemplateOpts { placeholderWhitelist, placeholderPattern, preserveComments, + syntacticPlaceholders, ...parser } = opts || {}; @@ -88,12 +108,32 @@ export function validate(opts: mixed): TemplateOpts { ); } + if ( + syntacticPlaceholders != null && + typeof syntacticPlaceholders !== "boolean" + ) { + throw new Error( + "'.syntacticPlaceholders' must be a boolean, null, or undefined", + ); + } + if ( + syntacticPlaceholders === true && + (placeholderWhitelist != null || placeholderPattern != null) + ) { + throw new Error( + "'.placeholderWhitelist' and '.placeholderPattern' aren't compatible" + + " with '.syntacticPlaceholders: true'", + ); + } + return { parser, placeholderWhitelist: placeholderWhitelist || undefined, placeholderPattern: placeholderPattern == null ? undefined : placeholderPattern, preserveComments: preserveComments == null ? false : preserveComments, + syntacticPlaceholders: + syntacticPlaceholders == null ? undefined : syntacticPlaceholders, }; } diff --git a/packages/babel-template/src/parse.js b/packages/babel-template/src/parse.js index 9186780f1c..d4d5489048 100644 --- a/packages/babel-template/src/parse.js +++ b/packages/babel-template/src/parse.js @@ -3,7 +3,7 @@ import * as t from "@babel/types"; import type { TraversalAncestors, TraversalHandler } from "@babel/types"; import { parse } from "@babel/parser"; import { codeFrameColumns } from "@babel/code-frame"; -import type { TemplateOpts } from "./options"; +import type { TemplateOpts, ParserOpts } from "./options"; import type { Formatter } from "./formatters"; export type Metadata = { @@ -31,8 +31,9 @@ export default function parseAndBuildMetadata( const { placeholderWhitelist, - placeholderPattern = PATTERN, + placeholderPattern, preserveComments, + syntacticPlaceholders, } = opts; t.removePropertiesDeep(ast, { @@ -41,20 +42,28 @@ export default function parseAndBuildMetadata( formatter.validate(ast); - const placeholders = []; - const placeholderNames = new Set(); + const syntactic = { + placeholders: [], + placeholderNames: new Set(), + }; + const legacy = { + placeholders: [], + placeholderNames: new Set(), + }; + const isLegacyRef = { value: undefined }; t.traverse(ast, (placeholderVisitorHandler: TraversalHandler<*>), { - placeholders, - placeholderNames, + syntactic, + legacy, + isLegacyRef, placeholderWhitelist, placeholderPattern, + syntacticPlaceholders, }); return { ast, - placeholders, - placeholderNames, + ...(isLegacyRef.value ? legacy : syntactic), }; } @@ -64,16 +73,45 @@ function placeholderVisitorHandler( state: MetadataState, ) { let name; - if (t.isIdentifier(node) || t.isJSXIdentifier(node)) { + + if (t.isPlaceholder(node)) { + if (state.syntacticPlaceholders === false) { + throw new Error( + "%%foo%%-style placeholders can't be used when " + + "'.syntacticPlaceholders' is false.", + ); + } else { + name = ((node: any).name: BabelNodeIdentifier).name; + state.isLegacyRef.value = false; + } + } else if (state.isLegacyRef.value === false || state.syntacticPlaceholders) { + return; + } else if (t.isIdentifier(node) || t.isJSXIdentifier(node)) { name = ((node: any): BabelNodeIdentifier).name; + state.isLegacyRef.value = true; } else if (t.isStringLiteral(node)) { name = ((node: any): BabelNodeStringLiteral).value; + state.isLegacyRef.value = true; } else { return; } if ( - (!state.placeholderPattern || !state.placeholderPattern.test(name)) && + !state.isLegacyRef.value && + (state.placeholderPattern != null || state.placeholderWhitelist != null) + ) { + // This check is also in options.js. We need it there to handle the default + // .syntacticPlaceholders behavior. + throw new Error( + "'.placeholderWhitelist' and '.placeholderPattern' aren't compatible" + + " with '.syntacticPlaceholders: true'", + ); + } + + if ( + state.isLegacyRef.value && + (state.placeholderPattern === false || + !(state.placeholderPattern || PATTERN).test(name)) && (!state.placeholderWhitelist || !state.placeholderWhitelist.has(name)) ) { return; @@ -85,7 +123,10 @@ function placeholderVisitorHandler( const { node: parent, key } = ancestors[ancestors.length - 1]; let type: PlaceholderType; - if (t.isStringLiteral(node)) { + if ( + t.isStringLiteral(node) || + t.isPlaceholder(node, { expectedNode: "StringLiteral" }) + ) { type = "string"; } else if ( (t.isNewExpression(parent) && key === "arguments") || @@ -93,20 +134,26 @@ function placeholderVisitorHandler( (t.isFunction(parent) && key === "params") ) { type = "param"; - } else if (t.isExpressionStatement(parent)) { + } else if (t.isExpressionStatement(parent) && !t.isPlaceholder(node)) { type = "statement"; ancestors = ancestors.slice(0, -1); + } else if (t.isStatement(node) && t.isPlaceholder(node)) { + type = "statement"; } else { type = "other"; } - state.placeholders.push({ + const { placeholders, placeholderNames } = state.isLegacyRef.value + ? state.legacy + : state.syntactic; + + placeholders.push({ name, type, resolve: ast => resolveAncestors(ast, ancestors), - isDuplicate: state.placeholderNames.has(name), + isDuplicate: placeholderNames.has(name), }); - state.placeholderNames.add(name); + placeholderNames.add(name); } function resolveAncestors(ast: BabelNodeFile, ancestors: TraversalAncestors) { @@ -127,18 +174,30 @@ function resolveAncestors(ast: BabelNodeFile, ancestors: TraversalAncestors) { } type MetadataState = { - placeholders: Array, - placeholderNames: Set, + syntactic: { + placeholders: Array, + placeholderNames: Set, + }, + legacy: { + placeholders: Array, + placeholderNames: Set, + }, + isLegacyRef: { value: boolean | void }, placeholderWhitelist: Set | void, - placeholderPattern: RegExp | false, + placeholderPattern: RegExp | false | void, + syntacticPlaceholders: boolean | void, }; -function parseWithCodeFrame(code: string, parserOpts: {}): BabelNodeFile { +function parseWithCodeFrame( + code: string, + parserOpts: ParserOpts, +): BabelNodeFile { parserOpts = { allowReturnOutsideFunction: true, allowSuperOutsideMethod: true, sourceType: "module", ...parserOpts, + plugins: (parserOpts.plugins || []).concat("placeholders"), }; try { diff --git a/packages/babel-template/test/index.js b/packages/babel-template/test/index.js index 854ad2f4b5..a90db40540 100644 --- a/packages/babel-template/test/index.js +++ b/packages/babel-template/test/index.js @@ -218,4 +218,105 @@ describe("@babel/template", function() { expect(generator(result).code).toEqual("
{'content'}
"); }); }); + + describe.only(".syntacticPlaceholders", () => { + it("works in function body", () => { + const output = template(`function f() %%A%%`)({ + A: t.blockStatement([]), + }); + expect(generator(output).code).toMatchInlineSnapshot(`"function f() {}"`); + }); + + it("works in class body", () => { + const output = template(`class C %%A%%`)({ + A: t.classBody([]), + }); + expect(generator(output).code).toMatchInlineSnapshot(`"class C {}"`); + }); + + it("replaces lowercase names", () => { + const output = template(`%%foo%%`)({ + foo: t.numericLiteral(1), + }); + expect(generator(output).code).toMatchInlineSnapshot(`"1;"`); + }); + + it("pattern", () => { + expect(() => { + template(`%%A%% + %%B%%`, { + placeholderPattern: /B/, + })(); + }).toThrow(/aren't compatible with '.syntacticPlaceholders: true'/); + }); + + it("whitelist", () => { + expect(() => { + template(`%%A%% + %%B%%`, { + placeholderPattern: false, + placeholderWhitelist: new Set(["B"]), + })(); + }).toThrow(/aren't compatible with '.syntacticPlaceholders: true'/); + }); + + describe("option value", () => { + describe("true", () => { + it("allows placeholders", () => { + const output = template(`%%FOO%%`, { syntacticPlaceholders: true })({ + FOO: t.numericLiteral(1), + }); + expect(generator(output).code).toMatchInlineSnapshot(`"1;"`); + }); + + it("doesn't replace identifiers", () => { + expect(() => { + template(`FOO`, { syntacticPlaceholders: true })({ + FOO: t.numericLiteral(1), + }); + }).toThrow(/Unknown substitution/); + }); + }); + + describe("false", () => { + it("disallow placeholders", () => { + expect(() => { + template(`%%FOO%%`, { syntacticPlaceholders: false })({ + FOO: t.numericLiteral(1), + }); + }).toThrow(/%%.*placeholders can't be used/); + }); + + it("replaces identifiers", () => { + const output = template(`FOO`, { syntacticPlaceholders: false })({ + FOO: t.numericLiteral(1), + }); + expect(generator(output).code).toMatchInlineSnapshot(`"1;"`); + }); + }); + + describe("undefined", () => { + it("allows placeholders", () => { + const output = template(`%%FOO%%`)({ + FOO: t.numericLiteral(1), + }); + expect(generator(output).code).toMatchInlineSnapshot(`"1;"`); + }); + + it("replaces identifiers", () => { + expect(() => { + const output = template(`FOO`)({ + FOO: t.numericLiteral(1), + }); + expect(generator(output).code).toMatchInlineSnapshot(`"1;"`); + }); + }); + + it("doesn't mix placeholder styles", () => { + const output = template(`FOO + %%FOO%%`)({ + FOO: t.numericLiteral(1), + }); + expect(generator(output).code).toMatchInlineSnapshot(`"FOO + 1;"`); + }); + }); + }); + }); });