ember-source
Version:
A JavaScript framework for creating ambitious web applications
646 lines (619 loc) • 17.2 kB
JavaScript
import '../@ember/debug/index.js';
import calculateLocationDisplay from '../@ember/template-compiler/lib/system/calculate-location-display.js';
import { isPath, trackLocals, isStringLiteral } from '../@ember/template-compiler/lib/plugins/utils.js';
import { assert } from '../@ember/debug/lib/assert.js';
const Char = {
NBSP: 0xa0,
QUOT: 0x22,
LT: 0x3c,
GT: 0x3e,
AMP: 0x26
};
// \x26 is ampersand, \xa0 is non-breaking space
const ATTR_VALUE_REGEX_TEST = /["\x26\xa0]/u;
const ATTR_VALUE_REGEX_REPLACE = new RegExp(ATTR_VALUE_REGEX_TEST.source, 'gu');
const TEXT_REGEX_TEST = /[&<>\xa0]/u;
const TEXT_REGEX_REPLACE = new RegExp(TEXT_REGEX_TEST.source, 'gu');
function attrValueReplacer(char) {
switch (char.charCodeAt(0)) {
case Char.NBSP:
return ' ';
case Char.QUOT:
return '"';
case Char.AMP:
return '&';
default:
return char;
}
}
function textReplacer(char) {
switch (char.charCodeAt(0)) {
case Char.NBSP:
return ' ';
case Char.AMP:
return '&';
case Char.LT:
return '<';
case Char.GT:
return '>';
default:
return char;
}
}
function escapeAttrValue(attrValue) {
if (ATTR_VALUE_REGEX_TEST.test(attrValue)) {
return attrValue.replace(ATTR_VALUE_REGEX_REPLACE, attrValueReplacer);
}
return attrValue;
}
function escapeText(text) {
if (TEXT_REGEX_TEST.test(text)) {
return text.replace(TEXT_REGEX_REPLACE, textReplacer);
}
return text;
}
function sortByLoc(a, b) {
// If either is invisible, don't try to order them
if (a.loc.isInvisible || b.loc.isInvisible) {
return 0;
}
if (a.loc.startPosition.line < b.loc.startPosition.line) {
return -1;
}
if (a.loc.startPosition.line === b.loc.startPosition.line && a.loc.startPosition.column < b.loc.startPosition.column) {
return -1;
}
if (a.loc.startPosition.line === b.loc.startPosition.line && a.loc.startPosition.column === b.loc.startPosition.column) {
return 0;
}
return 1;
}
const voidMap = new Set(['area', 'base', 'br', 'col', 'command', 'embed', 'hr', 'img', 'input', 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
const NON_WHITESPACE = /^\S/u;
/**
* Examples when true:
* - link
* - liNK
*
* Examples when false:
* - Link (component)
*/
function isVoidTag(tag) {
return voidMap.has(tag.toLowerCase()) && tag[0]?.toLowerCase() === tag[0];
}
class Printer {
buffer = '';
options;
constructor(options) {
this.options = options;
}
/*
This is used by _all_ methods on this Printer class that add to `this.buffer`,
it allows consumers of the printer to use alternate string representations for
a given node.
The primary use case for this are things like source -> source codemod utilities.
For example, ember-template-recast attempts to always preserve the original string
formatting in each AST node if no modifications are made to it.
*/
handledByOverride(node, ensureLeadingWhitespace = false) {
if (this.options.override !== undefined) {
let result = this.options.override(node, this.options);
if (typeof result === 'string') {
if (ensureLeadingWhitespace && NON_WHITESPACE.test(result)) {
result = ` ${result}`;
}
this.buffer += result;
return true;
}
}
return false;
}
Node(node) {
switch (node.type) {
case 'MustacheStatement':
case 'BlockStatement':
case 'MustacheCommentStatement':
case 'CommentStatement':
case 'TextNode':
case 'ElementNode':
case 'AttrNode':
case 'Block':
case 'Template':
return this.TopLevelStatement(node);
case 'StringLiteral':
case 'BooleanLiteral':
case 'NumberLiteral':
case 'UndefinedLiteral':
case 'NullLiteral':
case 'PathExpression':
case 'SubExpression':
return this.Expression(node);
case 'ConcatStatement':
// should have an AttrNode parent
return this.ConcatStatement(node);
case 'Hash':
return this.Hash(node);
case 'HashPair':
return this.HashPair(node);
case 'ElementModifierStatement':
return this.ElementModifierStatement(node);
}
}
Expression(expression) {
switch (expression.type) {
case 'StringLiteral':
case 'BooleanLiteral':
case 'NumberLiteral':
case 'UndefinedLiteral':
case 'NullLiteral':
return this.Literal(expression);
case 'PathExpression':
return this.PathExpression(expression);
case 'SubExpression':
return this.SubExpression(expression);
}
}
Literal(literal) {
switch (literal.type) {
case 'StringLiteral':
return this.StringLiteral(literal);
case 'BooleanLiteral':
return this.BooleanLiteral(literal);
case 'NumberLiteral':
return this.NumberLiteral(literal);
case 'UndefinedLiteral':
return this.UndefinedLiteral(literal);
case 'NullLiteral':
return this.NullLiteral(literal);
}
}
TopLevelStatement(statement) {
switch (statement.type) {
case 'MustacheStatement':
return this.MustacheStatement(statement);
case 'BlockStatement':
return this.BlockStatement(statement);
case 'MustacheCommentStatement':
return this.MustacheCommentStatement(statement);
case 'CommentStatement':
return this.CommentStatement(statement);
case 'TextNode':
return this.TextNode(statement);
case 'ElementNode':
return this.ElementNode(statement);
case 'Block':
return this.Block(statement);
case 'Template':
return this.Template(statement);
case 'AttrNode':
// should have element
return this.AttrNode(statement);
}
}
Template(template) {
this.TopLevelStatements(template.body);
}
Block(block) {
/*
When processing a template like:
```hbs
{{#if whatever}}
whatever
{{else if somethingElse}}
something else
{{else}}
fallback
{{/if}}
```
The AST still _effectively_ looks like:
```hbs
{{#if whatever}}
whatever
{{else}}{{#if somethingElse}}
something else
{{else}}
fallback
{{/if}}{{/if}}
```
The only way we can tell if that is the case is by checking for
`block.chained`, but unfortunately when the actual statements are
processed the `block.body[0]` node (which will always be a
`BlockStatement`) has no clue that its ancestor `Block` node was
chained.
This "forwards" the `chained` setting so that we can check
it later when processing the `BlockStatement`.
*/
if (block.chained) {
let firstChild = block.body[0];
firstChild.chained = true;
}
if (this.handledByOverride(block)) {
return;
}
this.TopLevelStatements(block.body);
}
TopLevelStatements(statements) {
statements.forEach(statement => this.TopLevelStatement(statement));
}
ElementNode(el) {
if (this.handledByOverride(el)) {
return;
}
this.OpenElementNode(el);
this.TopLevelStatements(el.children);
this.CloseElementNode(el);
}
OpenElementNode(el) {
this.buffer += `<${el.tag}`;
const parts = [...el.attributes, ...el.modifiers, ...el.comments].sort(sortByLoc);
for (const part of parts) {
this.buffer += ' ';
switch (part.type) {
case 'AttrNode':
this.AttrNode(part);
break;
case 'ElementModifierStatement':
this.ElementModifierStatement(part);
break;
case 'MustacheCommentStatement':
this.MustacheCommentStatement(part);
break;
}
}
if (el.blockParams.length) {
this.BlockParams(el.blockParams);
}
if (el.selfClosing) {
this.buffer += ' /';
}
this.buffer += '>';
}
CloseElementNode(el) {
if (el.selfClosing || isVoidTag(el.tag)) {
return;
}
this.buffer += `</${el.tag}>`;
}
AttrNode(attr) {
if (this.handledByOverride(attr)) {
return;
}
let {
name,
value
} = attr;
this.buffer += name;
const isAttribute = !name.startsWith('@');
const shouldElideValue = isAttribute && value.type == 'TextNode' && value.chars.length === 0;
if (!shouldElideValue) {
this.buffer += '=';
this.AttrNodeValue(value);
}
}
AttrNodeValue(value) {
if (value.type === 'TextNode') {
let quote = '"';
if (this.options.entityEncoding === 'raw') {
if (value.chars.includes('"') && !value.chars.includes("'")) {
quote = "'";
}
}
this.buffer += quote;
this.TextNode(value, quote);
this.buffer += quote;
} else {
this.Node(value);
}
}
TextNode(text, isInAttr) {
if (this.handledByOverride(text)) {
return;
}
if (this.options.entityEncoding === 'raw') {
if (isInAttr && text.chars.includes(isInAttr)) {
this.buffer += escapeAttrValue(text.chars);
} else {
this.buffer += text.chars;
}
} else if (isInAttr) {
this.buffer += escapeAttrValue(text.chars);
} else {
this.buffer += escapeText(text.chars);
}
}
MustacheStatement(mustache) {
if (this.handledByOverride(mustache)) {
return;
}
this.buffer += mustache.trusting ? '{{{' : '{{';
if (mustache.strip.open) {
this.buffer += '~';
}
this.Expression(mustache.path);
this.Params(mustache.params);
this.Hash(mustache.hash);
if (mustache.strip.close) {
this.buffer += '~';
}
this.buffer += mustache.trusting ? '}}}' : '}}';
}
BlockStatement(block) {
if (this.handledByOverride(block)) {
return;
}
if (block.chained) {
this.buffer += block.inverseStrip.open ? '{{~' : '{{';
this.buffer += 'else ';
} else {
this.buffer += block.openStrip.open ? '{{~#' : '{{#';
}
this.Expression(block.path);
this.Params(block.params);
this.Hash(block.hash);
if (block.program.blockParams.length) {
this.BlockParams(block.program.blockParams);
}
if (block.chained) {
this.buffer += block.inverseStrip.close ? '~}}' : '}}';
} else {
this.buffer += block.openStrip.close ? '~}}' : '}}';
}
this.Block(block.program);
if (block.inverse) {
if (!block.inverse.chained) {
this.buffer += block.inverseStrip.open ? '{{~' : '{{';
this.buffer += 'else';
this.buffer += block.inverseStrip.close ? '~}}' : '}}';
}
this.Block(block.inverse);
}
if (!block.chained) {
this.buffer += block.closeStrip.open ? '{{~/' : '{{/';
this.Expression(block.path);
this.buffer += block.closeStrip.close ? '~}}' : '}}';
}
}
BlockParams(blockParams) {
this.buffer += ` as |${blockParams.join(' ')}|`;
}
ConcatStatement(concat) {
if (this.handledByOverride(concat)) {
return;
}
this.buffer += '"';
concat.parts.forEach(part => {
if (part.type === 'TextNode') {
this.TextNode(part, '"');
} else {
this.Node(part);
}
});
this.buffer += '"';
}
MustacheCommentStatement(comment) {
if (this.handledByOverride(comment)) {
return;
}
this.buffer += `{{!--${comment.value}--}}`;
}
ElementModifierStatement(mod) {
if (this.handledByOverride(mod)) {
return;
}
this.buffer += '{{';
this.Expression(mod.path);
this.Params(mod.params);
this.Hash(mod.hash);
this.buffer += '}}';
}
CommentStatement(comment) {
if (this.handledByOverride(comment)) {
return;
}
this.buffer += `<!--${comment.value}-->`;
}
PathExpression(path) {
if (this.handledByOverride(path)) {
return;
}
this.buffer += path.original;
}
SubExpression(sexp) {
if (this.handledByOverride(sexp)) {
return;
}
this.buffer += '(';
this.Expression(sexp.path);
this.Params(sexp.params);
this.Hash(sexp.hash);
this.buffer += ')';
}
Params(params) {
// TODO: implement a top level Params AST node (just like the Hash object)
// so that this can also be overridden
if (params.length) {
params.forEach(param => {
this.buffer += ' ';
this.Expression(param);
});
}
}
Hash(hash) {
if (this.handledByOverride(hash, true)) {
return;
}
hash.pairs.forEach(pair => {
this.buffer += ' ';
this.HashPair(pair);
});
}
HashPair(pair) {
if (this.handledByOverride(pair)) {
return;
}
this.buffer += pair.key;
this.buffer += '=';
this.Node(pair.value);
}
StringLiteral(str) {
if (this.handledByOverride(str)) {
return;
}
this.buffer += JSON.stringify(str.value);
}
BooleanLiteral(bool) {
if (this.handledByOverride(bool)) {
return;
}
this.buffer += String(bool.value);
}
NumberLiteral(number) {
if (this.handledByOverride(number)) {
return;
}
this.buffer += String(number.value);
}
UndefinedLiteral(node) {
if (this.handledByOverride(node)) {
return;
}
this.buffer += 'undefined';
}
NullLiteral(node) {
if (this.handledByOverride(node)) {
return;
}
this.buffer += 'null';
}
print(node) {
let {
options
} = this;
if (options.override) {
let result = options.override(node, options);
if (result !== undefined) {
return result;
}
}
this.buffer = '';
this.Node(node);
return this.buffer;
}
}
function build(ast, options = {
entityEncoding: 'transformed'
}) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- JS users
if (!ast) {
return '';
}
let printer = new Printer(options);
return printer.print(ast);
}
/**
@module ember
*/
/**
A Glimmer2 AST transformation that replaces all instances of
```handlebars
{{helper "..." ...}}
```
with
```handlebars
{{helper (-resolve "helper:...") ...}}
```
and
```handlebars
{{helper ... ...}}
```
with
```handlebars
{{helper (-disallow-dynamic-resolution ...) ...}}
```
and
```handlebars
{{modifier "..." ...}}
```
with
```handlebars
{{modifier (-resolve "modifier:...") ...}}
```
and
```handlebars
{{modifier ... ...}}
```
with
```handlebars
{{modifier (-disallow-dynamic-resolution ...) ...}}
```
@private
@class TransformResolutions
*/
const TARGETS = Object.freeze(['helper', 'modifier']);
function transformResolutions(env) {
let {
builders: b
} = env.syntax;
let moduleName = env.meta?.moduleName;
let {
hasLocal,
node: tracker
} = trackLocals(env);
let seen;
return {
name: 'transform-resolutions',
visitor: {
Template: {
enter() {
seen = new Set();
},
exit() {
seen = undefined;
}
},
Block: tracker,
ElementNode: {
keys: {
children: tracker
}
},
MustacheStatement(node) {
(!(seen) && assert('[BUG] seen set should be available', seen));
if (seen.has(node)) {
return;
}
if (isPath(node.path) && !isLocalVariable(node.path, hasLocal) && TARGETS.indexOf(node.path.original) !== -1) {
let result = b.mustache(node.path, transformParams(b, node.params, node.path.original, moduleName, node.loc), node.hash, node.trusting, node.loc, node.strip);
// Avoid double/infinite-processing
seen.add(result);
return result;
}
},
SubExpression(node) {
(!(seen) && assert('[BUG] seen set should be available', seen));
if (seen.has(node)) {
return;
}
if (isPath(node.path) && !isLocalVariable(node.path, hasLocal) && TARGETS.indexOf(node.path.original) !== -1) {
let result = b.sexpr(node.path, transformParams(b, node.params, node.path.original, moduleName, node.loc), node.hash, node.loc);
// Avoid double/infinite-processing
seen.add(result);
return result;
}
}
}
};
}
function isLocalVariable(node, hasLocal) {
return !(node.head.type === 'ThisHead') && node.tail.length === 1 && hasLocal(node.head.original);
}
function transformParams(b, params, type, moduleName, loc) {
let [first, ...rest] = params;
(!(first) && assert(`The ${type} keyword requires at least one positional arguments ${calculateLocationDisplay(moduleName, loc)}`, first));
if (isStringLiteral(first)) {
return [b.sexpr(b.path('-resolve', first.loc), [b.string(`${type}:${first.value}`)], undefined, first.loc), ...rest];
} else {
return [b.sexpr(b.path('-disallow-dynamic-resolution', first.loc), [first], b.hash([b.pair('type', b.string(type), first.loc), b.pair('loc', b.string(calculateLocationDisplay(moduleName, loc)), first.loc), b.pair('original', b.string(build(first)))]), first.loc), ...rest];
}
}
export { Printer as P, build as b, isVoidTag as i, transformResolutions as t, voidMap as v };