diff --git a/packages/babel-helpers/src/helpers.js b/packages/babel-helpers/src/helpers.js index e882db2246..a4f9c49eed 100644 --- a/packages/babel-helpers/src/helpers.js +++ b/packages/babel-helpers/src/helpers.js @@ -5,9 +5,9 @@ import template from "@babel/template"; const helpers = {}; export default helpers; -function defineHelper(str) { - return template(str); -} +// Helpers never include placeholders, so we disable placeholder pattern +// matching to allow us to use pattern-like variable names. +const defineHelper = template({ placeholderPattern: false }); helpers.typeof = defineHelper(` export default function _typeof(obj) { diff --git a/packages/babel-plugin-transform-es2015-modules-systemjs/src/index.js b/packages/babel-plugin-transform-es2015-modules-systemjs/src/index.js index 2e3de1e3c5..61685a071a 100644 --- a/packages/babel-plugin-transform-es2015-modules-systemjs/src/index.js +++ b/packages/babel-plugin-transform-es2015-modules-systemjs/src/index.js @@ -2,11 +2,11 @@ import hoistVariables from "@babel/helper-hoist-variables"; import template from "@babel/template"; const buildTemplate = template(` - SYSTEM_REGISTER(MODULE_NAME, [SOURCES], function (EXPORT_IDENTIFIER, CONTEXT_IDENTIFIER) { + SYSTEM_REGISTER(MODULE_NAME, SOURCES, function (EXPORT_IDENTIFIER, CONTEXT_IDENTIFIER) { "use strict"; BEFORE_BODY; return { - setters: [SETTERS], + setters: SETTERS, execute: function () { BODY; } @@ -365,8 +365,8 @@ export default function({ types: t }, options) { ), BEFORE_BODY: beforeBody, MODULE_NAME: moduleName, - SETTERS: setters, - SOURCES: sources, + SETTERS: t.arrayExpression(setters), + SOURCES: t.arrayExpression(sources), BODY: path.node.body, EXPORT_IDENTIFIER: exportIdent, CONTEXT_IDENTIFIER: contextIdent, diff --git a/packages/babel-plugin-transform-es2015-parameters/src/params.js b/packages/babel-plugin-transform-es2015-parameters/src/params.js index f9e8d0cb20..036cd96d49 100644 --- a/packages/babel-plugin-transform-es2015-parameters/src/params.js +++ b/packages/babel-plugin-transform-es2015-parameters/src/params.js @@ -111,7 +111,7 @@ export default function convertFunctionParams(path, loose) { }); body.push(defNode); } else if (firstOptionalIndex !== null) { - const defNode = buildArgumentsAccess(param.node, t.numericLiteral(i)); + const defNode = buildArgumentsAccess([param.node, t.numericLiteral(i)]); body.push(defNode); } else if (param.isObjectPattern() || param.isArrayPattern()) { const uid = path.scope.generateUidIdentifier("ref"); diff --git a/packages/babel-template/README.md b/packages/babel-template/README.md index cfc16cf5d4..1cf29eaf17 100644 --- a/packages/babel-template/README.md +++ b/packages/babel-template/README.md @@ -1,6 +1,6 @@ # @babel/template -> Generate an AST from a string template. +> Generate an AST from a string template or template literal. In computer science, this is known as an implementation of quasiquotes. @@ -10,7 +10,7 @@ In computer science, this is known as an implementation of quasiquotes. npm install --save-dev @babel/template ``` -## Usage +## String Usage ```js import template from "@babel/template"; @@ -33,6 +33,93 @@ console.log(generate(ast).code); const myModule = require("my-module"); ``` +### `.ast` + +If no placeholders are in use and you just want a simple way to parse a +string into an AST, you can use the `.ast` version of the template. + +```js +const ast = template.ast(` + var myModule = require("my-module"); +`); +``` +which will parse and return the AST directly. + + +## Template Literal Usage + +```js +import template from "babel-template"; +import generate from "babel-generator"; +import * as t from "babel-types"; + +const fn = template` + var IMPORT_NAME = require('${"my-module"}'); +`); + +const ast = fn({ + IMPORT_NAME: t.identifier("myModule"); +}); + +console.log(generate(ast).code); +``` + +Note that placeholders can be passed directly as part of the template literal +in order to make things as readable as possible, or they can be passed into +the template function. + +### `.ast` + +If no placeholders are in use and you just want a simple way to parse a +string into an AST, you can use the `.ast` version of the template. + +```js +const name = "my-module"; +const mod = "myModule"; + +const ast = template.ast` + var ${mod} = require("${name}"); +`; +``` +which will parse and return the AST directly. Note that unlike the string-based +version mentioned earlier, since this is a template literal, it is still +valid to perform replacements using template literal replacements. + + +## AST results + +The `babel-template` API exposes a few flexible APIs to make it as easy as +possible to create ASTs with an expected structure. Each of these also has +the `.ast` property mentioned above. + +### `template` + +`template` returns either a single statement, or an array of +statements, depending on the parsed result. + +### `template.smart` + +This is the same as the default `template` API, returning either a single +node, or an array of nodes, depending on the parsed result. + +### `template.statement` + +`template.statement("foo;")()` returns a single statement node, and throw +an exception if the result is anything but a single statement. + +### `template.statements` + +`template.statements("foo;foo;")()` returns an array of statement nodes. + +### `template.expression` + +`template.expression("foo")()` returns the expression node. + +### `template.program` + +`template.program("foo;")()` returns the `Program` node for the template. + + ## API ### `template(code, [opts])` @@ -48,6 +135,25 @@ some defaults of its own: * `allowReturnOutsideFunction` is set to `true` by default. * `allowSuperOutsideMethod` is set to `true` by default. +* `sourceType` is set to `module` by default. + +##### placeholderWhitelist + +Type: `Set` +Default: `undefined` + +A set of placeholder names to automatically accept. Items in this list do +not need to match the given placeholder pattern. + +##### placeholderPattern + +Type: `RegExp | false` +Default: `/^[_$A-Z0-9]+$/` + +A pattern to search for when looking for Identifier and StringLiteral +nodes that should be considered placeholders. +'false' will disable placeholder searching entirely, leaving only the +'placeholderWhitelist' value to find placeholders. ##### preserveComments @@ -58,7 +164,9 @@ Set this to `true` to preserve any comments from the `code` parameter. #### Return value -`@babel/template` returns a `function` which is invoked with an optional object -of replacements. See the usage section for an example. +By default `@babel/template` returns a `function` which is invoked with an +optional object of replacements. See the usage section for an example. + +When using `.ast`, the AST will be returned directly. [babylon]: https://github.com/babel/babylon#options diff --git a/packages/babel-template/package.json b/packages/babel-template/package.json index 74860000d8..2670c58fd0 100644 --- a/packages/babel-template/package.json +++ b/packages/babel-template/package.json @@ -9,7 +9,6 @@ "main": "lib/index.js", "dependencies": { "@babel/code-frame": "7.0.0-beta.3", - "@babel/traverse": "7.0.0-beta.3", "@babel/types": "7.0.0-beta.3", "babylon": "7.0.0-beta.29", "lodash": "^4.2.0" diff --git a/packages/babel-template/src/builder.js b/packages/babel-template/src/builder.js new file mode 100644 index 0000000000..93b2403272 --- /dev/null +++ b/packages/babel-template/src/builder.js @@ -0,0 +1,131 @@ +// @flow + +import { + merge, + validate, + type TemplateOpts, + type PublicOpts, + type PublicReplacements, +} from "./options"; +import type { Formatter } from "./formatters"; + +import stringTemplate from "./string"; +import literalTemplate from "./literal"; + +export type TemplateBuilder = { + // Build a new builder, merging the given options with the previous ones. + (opts: PublicOpts): TemplateBuilder, + + // Building from a string produces an AST builder function by default. + (tpl: string, opts: ?PublicOpts): (?PublicReplacements) => T, + + // Building from a template literal produces an AST builder function by default. + (tpl: Array, ...args: Array): (?PublicReplacements) => T, + + // Allow users to explicitly create templates that produce ASTs, skipping + // the need for an intermediate function. + ast: { + (tpl: string, opts: ?PublicOpts): T, + (tpl: Array, ...args: Array): T, + }, +}; + +// Prebuild the options that will be used when parsing a `.ast` template. +// These do not use a pattern because there is no way for users to pass in +// replacement patterns to begin with, and disabling pattern matching means +// users have more flexibility in what type of content they have in their +// template JS. +const NO_PLACEHOLDER: TemplateOpts = validate({ + placeholderPattern: false, +}); + +export default function createTemplateBuilder( + formatter: Formatter, + defaultOpts?: TemplateOpts, +): TemplateBuilder { + const templateFnCache = new WeakMap(); + const templateAstCache = new WeakMap(); + const cachedOpts = defaultOpts || validate(null); + + return Object.assign( + ((tpl, ...args) => { + if (typeof tpl === "string") { + if (args.length > 1) throw new Error("Unexpected extra params."); + return extendedTrace( + stringTemplate(formatter, tpl, merge(cachedOpts, validate(args[0]))), + ); + } else if (Array.isArray(tpl)) { + let builder = templateFnCache.get(tpl); + if (!builder) { + builder = literalTemplate(formatter, tpl, cachedOpts); + templateFnCache.set(tpl, builder); + } + return extendedTrace(builder(args)); + } else if (typeof tpl === "object" && tpl) { + if (args.length > 0) throw new Error("Unexpected extra params."); + return createTemplateBuilder( + formatter, + merge(cachedOpts, validate(tpl)), + ); + } + throw new Error(`Unexpected template param ${typeof tpl}`); + }: Function), + { + ast: (tpl, ...args) => { + if (typeof tpl === "string") { + if (args.length > 1) throw new Error("Unexpected extra params."); + return stringTemplate( + formatter, + tpl, + merge(merge(cachedOpts, validate(args[0])), NO_PLACEHOLDER), + )(); + } else if (Array.isArray(tpl)) { + let builder = templateAstCache.get(tpl); + if (!builder) { + builder = literalTemplate( + formatter, + tpl, + merge(cachedOpts, NO_PLACEHOLDER), + ); + templateAstCache.set(tpl, builder); + } + return builder(args)(); + } + + throw new Error(`Unexpected template param ${typeof tpl}`); + }, + }, + ); +} + +function extendedTrace(fn: Arg => Result): Arg => Result { + // Since we lazy parse the template, we get the current stack so we have the + // original stack to append if it errors when parsing + let rootStack = ""; + try { + // error stack gets populated in IE only on throw + // (https://msdn.microsoft.com/en-us/library/hh699850(v=vs.94).aspx) + throw new Error(); + } catch (error) { + if (error.stack) { + // error.stack does not exists in IE <= 9 + // We slice off the top 3 items in the stack to remove the call to + // 'extendedTrace', and the anonymous builder function, with the final + // stripped line being the error message itself since we threw it + // in the first place and it doesn't matter. + rootStack = error.stack + .split("\n") + .slice(3) + .join("\n"); + } + } + + return (arg: Arg) => { + try { + return fn(arg); + } catch (err) { + err.stack += `\n =============\n${rootStack}`; + throw err; + } + }; +} diff --git a/packages/babel-template/src/formatters.js b/packages/babel-template/src/formatters.js new file mode 100644 index 0000000000..ccb7433112 --- /dev/null +++ b/packages/babel-template/src/formatters.js @@ -0,0 +1,74 @@ +// @flow + +export type Formatter = { + code: string => string, + validate: BabelNodeFile => void, + unwrap: BabelNodeFile => T, +}; + +function makeStatementFormatter( + fn: (Array) => T, +): Formatter { + return { + // We need to prepend a ";" to force statement parsing so that + // ExpressionStatement strings won't be parsed as directives. + // Alonside that, we also prepend a comment so that when a syntax error + // is encountered, the user will be less likely to get confused about + // where the random semicolon came from. + code: str => `/* @babel/template */;\n${str}`, + validate: () => {}, + unwrap: (ast: BabelNodeFile): T => { + return fn(ast.program.body.slice(1)); + }, + }; +} + +export const smart: Formatter< + Array | BabelNodeStatement, +> = makeStatementFormatter(body => { + if (body.length > 1) { + return body; + } else { + return body[0]; + } +}); + +export const statements: Formatter< + Array, +> = makeStatementFormatter(body => body); + +export const statement: Formatter< + BabelNodeStatement, +> = makeStatementFormatter(body => { + // We do this validation when unwrapping since the replacement process + // could have added or removed statements. + if (body.length === 0) { + throw new Error("Found nothing to return."); + } + if (body.length > 1) { + throw new Error("Found multiple statements but wanted one"); + } + + return body[0]; +}); + +export const expression: Formatter = { + code: str => `(\n${str}\n)`, + validate: (ast: BabelNodeFile) => { + const { program } = ast; + if (program.body.length > 1) { + throw new Error("Found multiple statements but wanted one"); + } + const expression = program.body[0].expression; + if (expression.start === 0) { + throw new Error("Parse result included parens."); + } + }, + unwrap: ast => ast.program.body[0].expression, +}; + +export const program: Formatter = { + code: str => str, + validate: () => {}, + unwrap: ast => ast.program, +}; diff --git a/packages/babel-template/src/index.js b/packages/babel-template/src/index.js index 67d1472359..d3946dfb59 100644 --- a/packages/babel-template/src/index.js +++ b/packages/babel-template/src/index.js @@ -1,10 +1,31 @@ -import factory from "./string"; -import template from "./literal"; +// @flow -export default function(firstArg, ...rest) { - if (typeof firstArg === "string") { - return factory(firstArg, ...rest); - } else { - return template(firstArg, ...rest); - } -} +import * as formatters from "./formatters"; +import createTemplateBuilder from "./builder"; + +export const smart = createTemplateBuilder(formatters.smart); +export const statement = createTemplateBuilder(formatters.statement); +export const statements = createTemplateBuilder(formatters.statements); +export const expression = createTemplateBuilder(formatters.expression); +export const program = createTemplateBuilder(formatters.program); + +type DefaultTemplateBuilder = typeof smart & { + smart: typeof smart, + statement: typeof statement, + statements: typeof statements, + expression: typeof expression, + program: typeof program, + ast: typeof smart.ast, +}; + +export default Object.assign( + ((smart.bind(undefined): any): DefaultTemplateBuilder), + { + smart, + statement, + statements, + expression, + program, + ast: smart.ast, + }, +); diff --git a/packages/babel-template/src/literal.js b/packages/babel-template/src/literal.js index 4e7a12c36b..ef78142497 100644 --- a/packages/babel-template/src/literal.js +++ b/packages/babel-template/src/literal.js @@ -1,97 +1,103 @@ -import * as t from "@babel/types"; +// @flow -import factory from "./string"; +import type { Formatter } from "./formatters"; +import { normalizeReplacements, type TemplateOpts } from "./options"; +import parseAndBuildMetadata from "./parse"; +import populatePlaceholders from "./populate"; -export default function template( - partials: Object | string[], - ...args: Array -) { - if (!Array.isArray(partials)) { - // support template({ options })`string` - return templateApply.bind(undefined, partials); - } - return templateApply(null, partials, ...args); -} +export default function literalTemplate( + formatter: Formatter, + tpl: Array, + opts: TemplateOpts, +): (Array) => mixed => T { + const { metadata, names } = buildLiteralData(formatter, tpl, opts); -function templateApply( - opts: Object | null, - partials: string[], - ...args: Array -) { - if (partials.some(str => str.includes("$BABEL_TEMPLATE$"))) { - throw new Error("Template contains illegal substring $BABEL_TEMPLATE$"); - } + return (arg: Array) => { + const defaultReplacements = arg.reduce((acc, replacement, i) => { + acc[names[i]] = replacement; + return acc; + }, {}); - if (partials.length == 1) { - return factory(partials[0], opts); - } + return (arg: mixed) => { + const replacements = normalizeReplacements(arg); - const replacementSet = new Set(); - const replacementMap = new Map(); - const replacementValueMap = new Map(); - let hasNonNumericReplacement = false; - for (const arg of args) { - if (replacementMap.has(arg)) { - continue; - } - - if (typeof arg === "number") { - replacementMap.set(arg, `$${arg}`); - } else if (typeof arg === "string") { - // avoid duplicates should t.toIdentifier produce the same result for different arguments - const replacementBase = `$BABEL_TEMPLATE$$${t.toIdentifier(arg)}`; - let replacement = replacementBase; - for (let i = 2; replacementSet.has(replacement); i++) { - replacement = `${replacementBase}${i}`; - } - replacementSet.add(replacement); - replacementMap.set(arg, replacement); - hasNonNumericReplacement = true; - } else { - // there can't be duplicates as the size always grows - const name = `$BABEL_TEMPLATE$VALUE$${replacementValueMap.size}`; - - // TODO: check if the arg is a Node - replacementMap.set(arg, name); - replacementValueMap.set(name, arg); - hasNonNumericReplacement = true; - } - } - - if (hasNonNumericReplacement && replacementMap.has(0)) { - throw new Error( - "Template cannot have a '0' replacement and a named replacement at the same time", - ); - } - - const code = partials.reduce((acc, partial, i) => { - if (acc == null) { - return partial; - } - - const replacement = replacementMap.get(args[i - 1]); - return `${acc}${replacement}${partial}`; - }, null); - - const func = factory(code, opts); - - return (...args: Array) => { - if (hasNonNumericReplacement) { - const argObj = args[0] || {}; - const converted = {}; - - for (const [key, replacement] of replacementMap) { - if (typeof key === "number") continue; - if (replacementValueMap.has(replacement)) { - converted[replacement] = replacementValueMap.get(replacement); - } else { - converted[replacement] = argObj[key]; - } + if (replacements) { + Object.keys(replacements).forEach(key => { + if (Object.prototype.hasOwnProperty.call(defaultReplacements, key)) { + throw new Error("Unexpected replacement overlap."); + } + }); } - args[0] = converted; - } - - return func(...args); + return formatter.unwrap( + populatePlaceholders( + metadata, + replacements + ? Object.assign(replacements, defaultReplacements) + : defaultReplacements, + ), + ); + }; }; } + +function buildLiteralData( + formatter: Formatter, + tpl: Array, + opts: TemplateOpts, +) { + let names; + let nameSet; + let metadata; + let prefix = ""; + + do { + // If there are cases where the template already contains $0 or any other + // matching pattern, we keep adding "$" characters until a unique prefix + // is found. + prefix += "$"; + const result = buildTemplateCode(tpl, prefix); + + names = result.names; + nameSet = new Set(names); + metadata = parseAndBuildMetadata(formatter, formatter.code(result.code), { + parser: opts.parser, + + // Explicitly include our generated names in the whitelist so users never + // have to think about whether their placeholder pattern will match. + placeholderWhitelist: new Set( + result.names.concat( + opts.placeholderWhitelist + ? Array.from(opts.placeholderWhitelist) + : [], + ), + ), + placeholderPattern: opts.placeholderPattern, + preserveComments: opts.preserveComments, + }); + } while ( + metadata.placeholders.some( + placeholder => placeholder.isDuplicate && nameSet.has(placeholder.name), + ) + ); + + return { metadata, names }; +} + +function buildTemplateCode( + tpl: Array, + prefix: string, +): { names: Array, code: string } { + const names = []; + + let code = tpl[0]; + + for (let i = 1; i < tpl.length; i++) { + const value = `${prefix}${i - 1}`; + names.push(value); + + code += value + tpl[i]; + } + + return { names, code }; +} diff --git a/packages/babel-template/src/options.js b/packages/babel-template/src/options.js new file mode 100644 index 0000000000..81fafb7ae6 --- /dev/null +++ b/packages/babel-template/src/options.js @@ -0,0 +1,116 @@ +// @flow + +/** + * These are the options that 'babel-template' actually accepts and typechecks + * when called. All other options are passed through to the parser. + */ +export type PublicOpts = { + /** + * A set of placeholder names to automatically accept, ignoring the given + * pattern entirely. + */ + placeholderWhitelist?: ?Set, + + /** + * A pattern to search for when looking for Identifier and StringLiteral + * nodes that can be replaced. + * + * 'false' will disable placeholder searching entirely, leaving only the + * 'placeholderWhitelist' value to find replacements. + * + * Defaults to /^[_$A-Z0-9]+$/. + */ + placeholderPattern?: ?(RegExp | false), + + /** + * 'true' to pass through comments from the template into the resulting AST, + * or 'false' to automatically discard comments. Defaults to 'false'. + */ + preserveComments?: ?boolean, +}; + +export type TemplateOpts = {| + parser: {}, + placeholderWhitelist: Set | void, + placeholderPattern: RegExp | false | void, + preserveComments: boolean | void, +|}; + +export function merge(a: TemplateOpts, b: TemplateOpts): TemplateOpts { + const { + placeholderWhitelist = a.placeholderWhitelist, + placeholderPattern = a.placeholderPattern, + preserveComments = a.preserveComments, + } = b; + + return { + parser: Object.assign({}, a.parser, b.parser), + placeholderWhitelist, + placeholderPattern, + preserveComments, + }; +} + +export function validate(opts: mixed): TemplateOpts { + if (opts != null && typeof opts !== "object") { + throw new Error("Unknown template options."); + } + + const { + placeholderWhitelist, + placeholderPattern, + preserveComments, + ...parser + } = + opts || {}; + + if (placeholderWhitelist != null && !(placeholderWhitelist instanceof Set)) { + throw new Error( + "'.placeholderWhitelist' must be a Set, null, or undefined", + ); + } + + if ( + placeholderPattern != null && + !(placeholderPattern instanceof RegExp) && + placeholderPattern !== false + ) { + throw new Error( + "'.placeholderPattern' must be a RegExp, false, null, or undefined", + ); + } + + if (preserveComments != null && typeof preserveComments !== "boolean") { + throw new Error( + "'.preserveComments' must be a boolean, null, or undefined", + ); + } + + return { + parser, + placeholderWhitelist: placeholderWhitelist || undefined, + placeholderPattern: + placeholderPattern == null ? undefined : placeholderPattern, + preserveComments: preserveComments == null ? false : preserveComments, + }; +} + +export type PublicReplacements = { [string]: mixed } | Array; +export type TemplateReplacements = { [string]: mixed } | void; + +export function normalizeReplacements( + replacements: mixed, +): TemplateReplacements { + if (Array.isArray(replacements)) { + return replacements.reduce((acc, replacement, i) => { + acc["$" + i] = replacement; + return acc; + }, {}); + } else if (typeof replacements === "object" || replacements == null) { + return replacements || undefined; + } + + throw new Error( + "Template replacements must be an array, object, null, or undefined", + ); +} diff --git a/packages/babel-template/src/parse.js b/packages/babel-template/src/parse.js new file mode 100644 index 0000000000..c1ec46bde2 --- /dev/null +++ b/packages/babel-template/src/parse.js @@ -0,0 +1,156 @@ +// @flow +import * as t from "@babel/types"; +import type { TraversalAncestors, TraversalHandler } from "@babel/types"; +import { parse } from "babylon"; +import { codeFrameColumns } from "@babel/code-frame"; +import type { TemplateOpts } from "./options"; +import type { Formatter } from "./formatters"; + +export type Metadata = { + ast: BabelNodeFile, + placeholders: Array, + placeholderNames: Set, +}; + +type PlaceholderType = "string" | "param" | "statement" | "other"; +export type Placeholder = {| + name: string, + resolve: BabelNodeFile => { parent: BabelNode, key: string, index?: number }, + type: PlaceholderType, + isDuplicate: boolean, +|}; + +const PATTERN = /^[_$A-Z0-9]+$/; + +export default function parseAndBuildMetadata( + formatter: Formatter, + code: string, + opts: TemplateOpts, +): Metadata { + const ast = parseWithCodeFrame(code, opts.parser); + + const { + placeholderWhitelist, + placeholderPattern = PATTERN, + preserveComments, + } = opts; + + t.removePropertiesDeep(ast, { + preserveComments, + }); + + formatter.validate(ast); + + const placeholders = []; + const placeholderNames = new Set(); + + t.traverse(ast, (placeholderVisitorHandler: TraversalHandler<*>), { + placeholders, + placeholderNames, + placeholderWhitelist, + placeholderPattern, + }); + + return { + ast, + placeholders, + placeholderNames, + }; +} + +function placeholderVisitorHandler( + node: BabelNode, + ancestors: TraversalAncestors, + state: MetadataState, +) { + let name; + if (t.isIdentifier(node)) { + name = ((node: any): BabelNodeIdentifier).name; + } else if (t.isStringLiteral(node)) { + name = ((node: any): BabelNodeStringLiteral).value; + } else { + return; + } + + if ( + (!state.placeholderPattern || !state.placeholderPattern.test(name)) && + (!state.placeholderWhitelist || !state.placeholderWhitelist.has(name)) + ) { + return; + } + + // Keep our own copy of the ancestors so we can use it in .resolve(). + ancestors = ancestors.slice(); + + const { node: parent, key } = ancestors[ancestors.length - 1]; + + let type: PlaceholderType; + if (t.isStringLiteral(node)) { + type = "string"; + } else if ( + (t.isNewExpression(parent) && key === "arguments") || + (t.isCallExpression(parent) && key === "arguments") || + (t.isFunction(parent) && key === "params") + ) { + type = "param"; + } else if (t.isExpressionStatement(parent)) { + type = "statement"; + ancestors = ancestors.slice(0, -1); + } else { + type = "other"; + } + + state.placeholders.push({ + name, + type, + resolve: ast => resolveAncestors(ast, ancestors), + isDuplicate: state.placeholderNames.has(name), + }); + state.placeholderNames.add(name); +} + +function resolveAncestors(ast: BabelNodeFile, ancestors: TraversalAncestors) { + let parent: BabelNode = ast; + for (let i = 0; i < ancestors.length - 1; i++) { + const { key, index } = ancestors[i]; + + if (index === undefined) { + parent = (parent: any)[key]; + } else { + parent = (parent: any)[key][index]; + } + } + + const { key, index } = ancestors[ancestors.length - 1]; + + return { parent, key, index }; +} + +type MetadataState = { + placeholders: Array, + placeholderNames: Set, + placeholderWhitelist: Set | void, + placeholderPattern: RegExp | false, +}; + +function parseWithCodeFrame(code: string, parserOpts: {}): BabelNodeFile { + parserOpts = Object.assign( + { + allowReturnOutsideFunction: true, + allowSuperOutsideMethod: true, + sourceType: "module", + }, + parserOpts, + ); + + try { + return parse(code, parserOpts); + } catch (err) { + const loc = err.loc; + if (loc) { + err.loc = null; + err.message += "\n" + codeFrameColumns(code, { start: loc }); + } + throw err; + } +} diff --git a/packages/babel-template/src/populate.js b/packages/babel-template/src/populate.js new file mode 100644 index 0000000000..d4fa00dec1 --- /dev/null +++ b/packages/babel-template/src/populate.js @@ -0,0 +1,131 @@ +// @flow +import * as t from "@babel/types"; + +import type { TemplateReplacements } from "./options"; +import type { Metadata, Placeholder } from "./parse"; + +export default function populatePlaceholders( + metadata: Metadata, + replacements: TemplateReplacements, +): BabelNodeFile { + const ast = t.cloneDeep(metadata.ast); + + if (replacements) { + metadata.placeholders.forEach(placeholder => { + if ( + !Object.prototype.hasOwnProperty.call(replacements, placeholder.name) + ) { + throw new Error(`No substitution given for "${placeholder.name}"`); + } + }); + Object.keys(replacements).forEach(key => { + if (!metadata.placeholderNames.has(key)) { + throw new Error(`Unknown substitution "${key}" given`); + } + }); + } + + // Process in reverse order to AST mutation doesn't change indices that + // will be needed for later calls to `placeholder.resolve()`. + metadata.placeholders + .slice() + .reverse() + .forEach(placeholder => { + try { + applyReplacement( + placeholder, + ast, + (replacements && replacements[placeholder.name]) || null, + ); + } catch (e) { + e.message = `babel-template placeholder "${placeholder.name}": ${e.message}`; + throw e; + } + }); + + return ast; +} + +function applyReplacement( + placeholder: Placeholder, + ast: BabelNodeFile, + replacement: any, +) { + // Track inserted nodes and clone them if they are inserted more than + // once to avoid injecting the same node multiple times. + if (placeholder.isDuplicate) { + if (Array.isArray(replacement)) { + replacement = replacement.map(node => t.cloneDeep(node)); + } else if (typeof replacement === "object") { + replacement = t.cloneDeep(replacement); + } + } + + const { parent, key, index } = placeholder.resolve(ast); + + if (placeholder.type === "string") { + if (typeof replacement === "string") { + replacement = t.stringLiteral(replacement); + } + if (!replacement || !t.isStringLiteral(replacement)) { + throw new Error("Expected string substitution"); + } + } else if (placeholder.type === "statement") { + if (index === undefined) { + if (!replacement) { + replacement = t.emptyStatement(); + } else if (Array.isArray(replacement)) { + replacement = t.blockStatement(replacement); + } else if (typeof replacement === "string") { + replacement = t.expressionStatement(t.identifier(replacement)); + } else if (!t.isStatement(replacement)) { + replacement = t.expressionStatement((replacement: any)); + } + } else { + if (replacement && !Array.isArray(replacement)) { + if (typeof replacement === "string") { + replacement = t.identifier(replacement); + } + if (!t.isStatement(replacement)) { + replacement = t.expressionStatement((replacement: any)); + } + } + } + } else if (placeholder.type === "param") { + if (typeof replacement === "string") { + replacement = t.identifier(replacement); + } + + if (index === undefined) throw new Error("Assertion failure."); + } else { + if (typeof replacement === "string") { + replacement = t.identifier(replacement); + } + if (Array.isArray(replacement)) { + throw new Error("Cannot replace single expression with an array."); + } + } + + if (index === undefined) { + t.validate(parent, key, replacement); + + (parent: any)[key] = replacement; + } else { + const items: Array = (parent: any)[key].slice(); + + if (placeholder.type === "statement" || placeholder.type === "param") { + if (replacement == null) { + items.splice(index, 1); + } else if (Array.isArray(replacement)) { + items.splice(index, 1, ...replacement); + } else { + items[index] = replacement; + } + } else { + items[index] = replacement; + } + + t.validate(parent, key, items); + (parent: any)[key] = items; + } +} diff --git a/packages/babel-template/src/string.js b/packages/babel-template/src/string.js index 533a85657f..3fbf7df419 100644 --- a/packages/babel-template/src/string.js +++ b/packages/babel-template/src/string.js @@ -1,122 +1,23 @@ -import cloneDeep from "lodash/cloneDeep"; -import has from "lodash/has"; -import traverse from "@babel/traverse"; -import * as babylon from "babylon"; -import { codeFrameColumns } from "@babel/code-frame"; +// @flow +import type { Formatter } from "./formatters"; +import { normalizeReplacements, type TemplateOpts } from "./options"; +import parseAndBuildMetadata from "./parse"; +import populatePlaceholders from "./populate"; -const FROM_TEMPLATE = new Set(); +export default function stringTemplate( + formatter: Formatter, + code: string, + opts: TemplateOpts, +): mixed => T { + code = formatter.code(code); -export default function factory(code: string, opts?: Object): Function { - // since we lazy parse the template, we get the current stack so we have the - // original stack to append if it errors when parsing - let stack; - try { - // error stack gets populated in IE only on throw - // (https://msdn.microsoft.com/en-us/library/hh699850(v=vs.94).aspx) - throw new Error(); - } catch (error) { - if (error.stack) { - // error.stack does not exists in IE <= 9 - stack = error.stack - .split("\n") - .slice(2) - .join("\n"); - } - } + let metadata; - opts = Object.assign( - { - allowReturnOutsideFunction: true, - allowSuperOutsideMethod: true, - preserveComments: false, - sourceType: "module", - }, - opts, - ); + return (arg?: mixed) => { + const replacements = normalizeReplacements(arg); - let getAst = function() { - let ast; + if (!metadata) metadata = parseAndBuildMetadata(formatter, code, opts); - try { - ast = babylon.parse(code, opts); - - ast = traverse.removeProperties(ast, { - preserveComments: opts.preserveComments, - }); - } catch (err) { - const loc = err.loc; - if (loc) { - err.loc = null; - err.message += "\n" + codeFrameColumns(code, { start: loc }); - } - err.stack = `${err.stack}\n ==========================\n${stack}`; - throw err; - } - - getAst = function() { - return ast; - }; - - return ast; - }; - - return function(...args) { - return useTemplate(getAst(), args); + return formatter.unwrap(populatePlaceholders(metadata, replacements)); }; } - -function useTemplate(ast, nodes?: Array) { - ast = cloneDeep(ast); - const { program } = ast; - - if (nodes.length) { - traverse.cheap(ast, function(node) { - FROM_TEMPLATE.add(node); - }); - - traverse(ast, templateVisitor, null, nodes); - - FROM_TEMPLATE.clear(); - } - - if (program.body.length > 1) { - return program.body; - } else { - return program.body[0]; - } -} - -const templateVisitor = { - // 360 - noScope: true, - - Identifier(path, args) { - const { node, parentPath } = path; - if (!FROM_TEMPLATE.has(node)) return path.skip(); - - let replacement; - if (has(args[0], node.name)) { - replacement = args[0][node.name]; - } else if (node.name[0] === "$") { - const i = +node.name.slice(1); - if (args[i]) replacement = args[i]; - } - - if (parentPath.isExpressionStatement()) { - path = parentPath; - } - - if (replacement === null) { - path.remove(); - } else if (replacement) { - path.replaceInline(replacement); - path.skip(); - } - }, - - exit({ node }) { - if (!node.loc) { - traverse.clearNode(node); - } - }, -}; diff --git a/packages/babel-template/test/index.js b/packages/babel-template/test/index.js index 1e6ec4bf00..f98f1509cd 100644 --- a/packages/babel-template/test/index.js +++ b/packages/babel-template/test/index.js @@ -1,34 +1,202 @@ import generator from "../../babel-generator"; import template from "../lib"; -import chai from "chai"; +import { expect } from "chai"; +import * as t from "babel-types"; const comments = "// Sum two numbers\nconst add = (a, b) => a + b;"; -describe("templating", function() { +describe("babel-template", function() { it("import statements are allowed by default", function() { - chai - .expect(function() { - template("import foo from 'foo'")({}); - }) - .not.to.throw(); + expect(function() { + template("import foo from 'foo'")({}); + }).not.to.throw(); }); it("with statements are allowed with sourceType: script", function() { - chai - .expect(function() { - template("with({}){}", { sourceType: "script" })({}); - }) - .not.to.throw(); + expect(function() { + template("with({}){}", { sourceType: "script" })({}); + }).not.to.throw(); }); it("should strip comments by default", function() { const code = "const add = (a, b) => a + b;"; const output = template(comments)(); - chai.expect(generator(output).code).to.be.equal(code); + expect(generator(output).code).to.be.equal(code); }); it("should preserve comments with a flag", function() { const output = template(comments, { preserveComments: true })(); - chai.expect(generator(output).code).to.be.equal(comments); + expect(generator(output).code).to.be.equal(comments); + }); + + describe("string-based", () => { + it("should handle replacing values from an object", () => { + const value = t.stringLiteral("some string value"); + const result = template(` + if (SOME_VAR === "") {} + `)({ + SOME_VAR: value, + }); + + expect(result.type).to.equal("IfStatement"); + expect(result.test.type).to.equal("BinaryExpression"); + expect(result.test.left).to.equal(value); + }); + + it("should handle replacing values given an array", () => { + const value = t.stringLiteral("some string value"); + const result = template(` + if ($0 === "") {} + `)([value]); + + expect(result.type).to.equal("IfStatement"); + expect(result.test.type).to.equal("BinaryExpression"); + expect(result.test.left).to.equal(value); + }); + + it("should handle replacing values with null to remove them", () => { + const result = template(` + callee(ARG); + `)({ ARG: null }); + + expect(result.type).to.equal("ExpressionStatement"); + expect(result.expression.type).to.equal("CallExpression"); + expect(result.expression.arguments).to.eql([]); + }); + + it("should handle replacing values that are string content", () => { + const result = template(` + ("ARG"); + `)({ ARG: "some new content" }); + + expect(result.type).to.equal("ExpressionStatement"); + expect(result.expression.type).to.equal("StringLiteral"); + expect(result.expression.value).to.equal("some new content"); + }); + + it("should automatically clone nodes if they are injected twice", () => { + const id = t.identifier("someIdent"); + + const result = template(` + ID; + ID; + `)({ ID: id }); + + expect(result[0].type).to.equal("ExpressionStatement"); + expect(result[0].expression).to.equal(id); + expect(result[1].type).to.equal("ExpressionStatement"); + expect(result[1].expression).not.to.equal(id); + expect(result[1].expression).to.eql(id); + }); + + it("should allow passing in a whitelist of replacement names", () => { + const id = t.identifier("someIdent"); + const result = template( + ` + some_id; + `, + { placeholderWhitelist: new Set(["some_id"]) }, + )({ some_id: id }); + + expect(result.type).to.equal("ExpressionStatement"); + expect(result.expression).to.equal(id); + }); + + it("should allow passing in a RegExp to match replacement patterns", () => { + const id = t.identifier("someIdent"); + const result = template( + ` + ID; + ANOTHER_ID; + `, + { placeholderPattern: /^ID$/ }, + )({ ID: id }); + + expect(result[0].type).to.equal("ExpressionStatement"); + expect(result[0].expression).to.equal(id); + expect(result[1].type).to.equal("ExpressionStatement"); + expect(result[1].expression.type).to.equal("Identifier"); + expect(result[1].expression.name).to.equal("ANOTHER_ID"); + }); + + it("should throw if unknown replacements are provided", () => { + expect(() => { + template(` + ID; + `)({ ID: t.identifier("someIdent"), ANOTHER_ID: null }); + }).to.throw(Error, 'Unknown substitution "ANOTHER_ID" given'); + }); + + it("should throw if placeholders are not given explicit values", () => { + expect(() => { + template(` + ID; + ANOTHER_ID; + `)({ ID: t.identifier("someIdent") }); + }).to.throw(Error, 'No substitution given for "ANOTHER_ID"'); + }); + + it("should return the AST directly when using .ast", () => { + const result = template.ast(` + if ("some string value" === "") {} + `); + + expect(result.type).to.equal("IfStatement"); + expect(result.test.type).to.equal("BinaryExpression"); + expect(result.test.left.type).to.equal("StringLiteral"); + expect(result.test.left.value).to.equal("some string value"); + }); + }); + + describe("literal-based", () => { + it("should handle replacing values from an object", () => { + const value = t.stringLiteral("some string value"); + const result = template` + if (${value} === "") {} + `(); + + expect(result.type).to.equal("IfStatement"); + expect(result.test.type).to.equal("BinaryExpression"); + expect(result.test.left).to.equal(value); + }); + + it("should handle replacing values with null to remove them", () => { + const result = template` + callee(${null}); + `(); + + expect(result.type).to.equal("ExpressionStatement"); + expect(result.expression.type).to.equal("CallExpression"); + expect(result.expression.arguments).to.eql([]); + }); + + it("should handle replacing values that are string content", () => { + const result = template` + ("${"some new content"}"); + `(); + + expect(result.type).to.equal("ExpressionStatement"); + expect(result.expression.type).to.equal("StringLiteral"); + expect(result.expression.value).to.equal("some new content"); + }); + + it("should allow setting options by passing an object", () => { + const result = template({ sourceType: "script" })` + with({}){} + `(); + + expect(result.type).to.equal("WithStatement"); + }); + + it("should return the AST directly when using .ast", () => { + const value = t.stringLiteral("some string value"); + const result = template.ast` + if (${value} === "") {} + `; + + expect(result.type).to.equal("IfStatement"); + expect(result.test.type).to.equal("BinaryExpression"); + expect(result.test.left).to.equal(value); + }); }); }); diff --git a/packages/babel-template/test/template.js b/packages/babel-template/test/template.js deleted file mode 100644 index 95dcb9760e..0000000000 --- a/packages/babel-template/test/template.js +++ /dev/null @@ -1,183 +0,0 @@ -import generator from "../../babel-generator"; -import * as t from "@babel/types"; -import template from "../lib"; -import chai from "chai"; -const expect = chai.expect; - -describe("tagged templating", () => { - it("basic support", () => { - const tpl = template`("stringLiteral")`; - const result = tpl(); - - expect(result).to.be.ok; - expect(t.isStringLiteral(result.expression)).to.be.true; - }); - - describe("numeric interpolation", () => { - it("single replacement", () => { - const tpl = template`+${0}`; - const node = t.numericLiteral(123); - const result = tpl(node); - - expect(result).to.be.ok; - expect(t.isUnaryExpression(result.expression)).to.be.true; - expect(result.expression.argument).to.equal(node); - }); - - it("duplicate replacement", () => { - const tpl = template`${0} + ${0}`; - const node = t.numericLiteral(123); - const result = tpl(node); - - expect(result).to.be.ok; - expect(t.isBinaryExpression(result.expression)).to.be.true; - expect(result.expression.left).to.equal(node); - expect(result.expression.right).to.equal(result.expression.left); - }); - - it("multiple replacement", () => { - const tpl = template`${0}.${1}(${2})`; - const object = t.identifier("foo"); - const property = t.identifier("bar"); - const argument = t.numericLiteral(123); - const result = tpl(object, property, argument); - - expect(result).to.be.ok; - expect(t.isCallExpression(result.expression)).to.be.true; - - const { callee, arguments: args } = result.expression; - expect(t.isMemberExpression(callee)).to.be.true; - expect(callee.object).to.equal(object); - expect(callee.property).to.equal(property); - - expect(args).to.deep.equal([argument]); - }); - }); - - describe("string interpolation", () => { - it("has expected internal representation", () => { - const tpl = template`${"foo"}(${"b a r"})`; - expect(generator(tpl()).code).to.equal( - "$BABEL_TEMPLATE$$foo($BABEL_TEMPLATE$$bAR);", - ); - }); - - it("simple replacement", () => { - const tpl = template`${"foo"}(${"b a r"})`; - const arg = { - foo: t.identifier("baz"), - "b a r": t.numericLiteral(123), - }; - - const result = tpl(arg); - - expect(result).to.be.ok; - expect(t.isCallExpression(result.expression)).to.be.true; - - const { callee, arguments: args } = result.expression; - - expect(callee).to.equal(arg.foo); - expect(args).to.deep.equal([arg["b a r"]]); - }); - - it("does not conflict with similar identifiers", () => { - const tpl = template`foo + ${"foo"}`; - const arg = { - foo: t.identifier("foo"), - }; - - const result = tpl(arg); - - expect(result).to.be.ok; - expect(t.isBinaryExpression(result.expression)).to.be.true; - - const { left, right } = result.expression; - expect(left).to.not.equal(right); - expect(t.isIdentifier(left, { name: "foo" })).to.be.true; - - expect(right).to.equal(arg.foo); - }); - - it("does not conflict when t.toIdentifier conflicts", () => { - const tpl = template`${"fOO"} + ${"f o o"}`; - const arg = { - fOO: t.numericLiteral(123), - "f o o": t.numericLiteral(321), - }; - - const result = tpl(arg); - - expect(result).to.be.ok; - expect(t.isBinaryExpression(result.expression)).to.be.true; - - const { left, right } = result.expression; - expect(left).to.not.equal(right); - - expect(left).to.equal(arg.fOO); - expect(right).to.equal(arg["f o o"]); - }); - }); - - describe("mixed interpolation", () => { - it("throws when 0 is used", () => { - expect(() => template`${0} - ${"foo"}`).to.throw( - "Template cannot have a '0' replacement and a named replacement at the same time", - ); - }); - - it("works", () => { - const tpl = template`${1}.${"prop"}`; - const arg = { - prop: t.identifier("prop"), - }; - - const result = tpl(arg, t.thisExpression()); - - expect(result).to.be.ok; - expect(t.isMemberExpression(result.expression)).to.be.true; - - const { object, property } = result.expression; - - expect(t.isThisExpression(object)).to.be.true; - expect(property).to.equal(arg.prop); - }); - }); - - describe("Node interpolation", () => { - it("works", () => { - const node = t.identifier("foo"); - const tpl = template`${node}`; - - const result = tpl(); - - expect(result).to.be.ok; - expect(result.expression).to.equal(node); - }); - }); - - describe("options", () => { - it("works", () => { - const remove = template({ preserveComments: false })`// comment\nid;`; - const preserve = template({ preserveComments: true })`// comment\nid;`; - - const removeResult = remove(); - const preserveResult = preserve(); - - expect(removeResult); - expect(preserveResult).to.be.ok; - - // it exists, it just resets to undefined - expect(removeResult.leadingComments).to.be.undefined; - - expect(Array.isArray(preserveResult.leadingComments)).to.be.true; - expect(preserveResult.leadingComments[0]).to.have.property( - "type", - "CommentLine", - ); - expect(preserveResult.leadingComments[0]).to.have.property( - "value", - " comment", - ); - }); - }); -});