Reimplement 'babel-template' with better caching and tagged literal utilities.

This commit is contained in:
Logan Smyth 2017-10-15 15:50:14 -04:00
parent 191624d800
commit cc802c1e00
15 changed files with 1050 additions and 422 deletions

View File

@ -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) {

View File

@ -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,

View File

@ -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");

View File

@ -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<string>`
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

View File

@ -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"

View File

@ -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<T> = {
// Build a new builder, merging the given options with the previous ones.
(opts: PublicOpts): TemplateBuilder<T>,
// 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<string>, ...args: Array<mixed>): (?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<string>, ...args: Array<mixed>): 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<T>(
formatter: Formatter<T>,
defaultOpts?: TemplateOpts,
): TemplateBuilder<T> {
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<Arg, Result>(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;
}
};
}

View File

@ -0,0 +1,74 @@
// @flow
export type Formatter<T> = {
code: string => string,
validate: BabelNodeFile => void,
unwrap: BabelNodeFile => T,
};
function makeStatementFormatter<T>(
fn: (Array<BabelNodeStatement>) => T,
): Formatter<T> {
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> | BabelNodeStatement,
> = makeStatementFormatter(body => {
if (body.length > 1) {
return body;
} else {
return body[0];
}
});
export const statements: Formatter<
Array<BabelNodeStatement>,
> = 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<BabelNodeExpression> = {
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<BabelNodeProgram> = {
code: str => str,
validate: () => {},
unwrap: ast => ast.program,
};

View File

@ -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,
},
);

View File

@ -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<Object>
) {
if (!Array.isArray(partials)) {
// support template({ options })`string`
return templateApply.bind(undefined, partials);
export default function literalTemplate<T>(
formatter: Formatter<T>,
tpl: Array<string>,
opts: TemplateOpts,
): (Array<mixed>) => mixed => T {
const { metadata, names } = buildLiteralData(formatter, tpl, opts);
return (arg: Array<mixed>) => {
const defaultReplacements = arg.reduce((acc, replacement, i) => {
acc[names[i]] = replacement;
return acc;
}, {});
return (arg: mixed) => {
const replacements = normalizeReplacements(arg);
if (replacements) {
Object.keys(replacements).forEach(key => {
if (Object.prototype.hasOwnProperty.call(defaultReplacements, key)) {
throw new Error("Unexpected replacement overlap.");
}
return templateApply(null, partials, ...args);
}
function templateApply(
opts: Object | null,
partials: string[],
...args: Array<Object>
) {
if (partials.some(str => str.includes("$BABEL_TEMPLATE$"))) {
throw new Error("Template contains illegal substring $BABEL_TEMPLATE$");
});
}
if (partials.length == 1) {
return factory(partials[0], opts);
}
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",
return formatter.unwrap(
populatePlaceholders(
metadata,
replacements
? Object.assign(replacements, defaultReplacements)
: defaultReplacements,
),
);
}
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<Object>) => {
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];
}
}
args[0] = converted;
}
return func(...args);
};
};
}
function buildLiteralData<T>(
formatter: Formatter<T>,
tpl: Array<string>,
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<string>,
prefix: string,
): { names: Array<string>, 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 };
}

View File

@ -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<string>,
/**
* 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<string> | 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<mixed>;
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",
);
}

View File

@ -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<Placeholder>,
placeholderNames: Set<string>,
};
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<T>(
formatter: Formatter<T>,
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<Placeholder>,
placeholderNames: Set<string>,
placeholderWhitelist: Set<string> | 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;
}
}

View File

@ -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<BabelNode> = (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;
}
}

View File

@ -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<T>(
formatter: Formatter<T>,
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<Object>) {
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);
}
},
};

View File

@ -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() {
expect(function() {
template("import foo from 'foo'")({});
})
.not.to.throw();
}).not.to.throw();
});
it("with statements are allowed with sourceType: script", function() {
chai
.expect(function() {
expect(function() {
template("with({}){}", { sourceType: "script" })({});
})
.not.to.throw();
}).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);
});
});
});

View File

@ -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",
);
});
});
});