diff --git a/packages/@ember/template-compiler/index.ts b/packages/@ember/template-compiler/index.ts new file mode 100644 index 00000000000..0423e08d429 --- /dev/null +++ b/packages/@ember/template-compiler/index.ts @@ -0,0 +1,2 @@ +export { template } from './lib/template'; +export type { EmberPrecompileOptions } from './lib/types'; diff --git a/packages/@ember/template-compiler/lib/compile-options.ts b/packages/@ember/template-compiler/lib/compile-options.ts new file mode 100644 index 00000000000..e565ab83988 --- /dev/null +++ b/packages/@ember/template-compiler/lib/compile-options.ts @@ -0,0 +1,115 @@ +import { assert } from '@ember/debug'; +import { + RESOLUTION_MODE_TRANSFORMS, + STRICT_MODE_KEYWORDS, + STRICT_MODE_TRANSFORMS, +} from './plugins/index'; +import type { EmberPrecompileOptions, PluginFunc } from './types'; +import COMPONENT_NAME_SIMPLE_DASHERIZE_CACHE from './dasherize-component-name'; + +let USER_PLUGINS: PluginFunc[] = []; + +function malformedComponentLookup(string: string) { + return string.indexOf('::') === -1 && string.indexOf(':') > -1; +} + +function buildCompileOptions(_options: EmberPrecompileOptions): EmberPrecompileOptions { + let moduleName = _options.moduleName; + + let options: EmberPrecompileOptions & Partial = { + meta: {}, + isProduction: false, + plugins: { ast: [] }, + ..._options, + moduleName, + customizeComponentName(tagname: string): string { + assert( + `You tried to invoke a component named <${tagname} /> in "${ + moduleName ?? '[NO MODULE]' + }", but that is not a valid name for a component. Did you mean to use the "::" syntax for nested components?`, + !malformedComponentLookup(tagname) + ); + + return COMPONENT_NAME_SIMPLE_DASHERIZE_CACHE.get(tagname); + }, + }; + + if ('eval' in options) { + const localScopeEvaluator = options.eval as (value: string) => unknown; + const globalScopeEvaluator = (value: string) => new Function(`return ${value};`)(); + + options.lexicalScope = (variable: string) => { + if (inScope(variable, localScopeEvaluator)) { + return !inScope(variable, globalScopeEvaluator); + } + + return false; + }; + + delete options.eval; + } + + if ('locals' in options && !options.locals) { + // Glimmer's precompile options declare `locals` like: + // locals?: string[] + // but many in-use versions of babel-plugin-htmlbars-inline-precompile will + // set locals to `null`. This used to work but only because glimmer was + // ignoring locals for non-strict templates, and now it supports that case. + delete options.locals; + } + + // move `moduleName` into `meta` property + if (options.moduleName) { + let meta = options.meta; + assert('has meta', meta); // We just set it + meta.moduleName = options.moduleName; + } + + if (options.strictMode) { + options.keywords = STRICT_MODE_KEYWORDS; + } + + return options; +} + +function transformsFor(options: EmberPrecompileOptions): readonly PluginFunc[] { + return options.strictMode ? STRICT_MODE_TRANSFORMS : RESOLUTION_MODE_TRANSFORMS; +} + +export default function compileOptions( + _options: Partial = {} +): EmberPrecompileOptions { + let options = buildCompileOptions(_options); + let builtInPlugins = transformsFor(options); + + if (!_options.plugins) { + options.plugins = { ast: [...USER_PLUGINS, ...builtInPlugins] }; + } else { + let potententialPugins = [...USER_PLUGINS, ...builtInPlugins]; + assert('expected plugins', options.plugins); + let pluginsToAdd = potententialPugins.filter((plugin) => { + assert('expected plugins', options.plugins); + return options.plugins.ast.indexOf(plugin) === -1; + }); + options.plugins.ast = [...options.plugins.ast, ...pluginsToAdd]; + } + + return options; +} + +type Evaluator = (value: string) => unknown; + +function inScope(variable: string, evaluator: Evaluator): boolean { + try { + return evaluator(`typeof ${variable} !== "undefined"`) === true; + } catch (e) { + // This occurs when attempting to evaluate a reserved word using eval (`eval('typeof let')`). + // If the variable is a reserved word, it's definitely not in scope, so return false. + if (e && e instanceof SyntaxError) { + return false; + } + + // If it's another kind of error, don't swallow it. + throw e; + } +} diff --git a/packages/@ember/template-compiler/lib/dasherize-component-name.ts b/packages/@ember/template-compiler/lib/dasherize-component-name.ts new file mode 100644 index 00000000000..52bdda1329b --- /dev/null +++ b/packages/@ember/template-compiler/lib/dasherize-component-name.ts @@ -0,0 +1,22 @@ +import { Cache } from '@ember/-internals/utils'; + +/* + This diverges from `Ember.String.dasherize` so that`` can resolve to `x-foo`. + `Ember.String.dasherize` would resolve it to `xfoo`.. +*/ +const SIMPLE_DASHERIZE_REGEXP = /[A-Z]|::/g; +const ALPHA = /[A-Za-z0-9]/; + +export default new Cache(1000, (key) => + key.replace(SIMPLE_DASHERIZE_REGEXP, (char, index) => { + if (char === '::') { + return '/'; + } + + if (index === 0 || !ALPHA.test(key[index - 1]!)) { + return char.toLowerCase(); + } + + return `-${char.toLowerCase()}`; + }) +); diff --git a/packages/@ember/template-compiler/lib/plugins/assert-against-attrs.ts b/packages/@ember/template-compiler/lib/plugins/assert-against-attrs.ts new file mode 100644 index 00000000000..f2984a8c3ae --- /dev/null +++ b/packages/@ember/template-compiler/lib/plugins/assert-against-attrs.ts @@ -0,0 +1,119 @@ +import { assert, deprecate } from '@ember/debug'; +import type { AST, ASTPlugin } from '@glimmer/syntax'; +import calculateLocationDisplay from '../system/calculate-location-display'; +import type { EmberASTPluginEnvironment } from '../types'; + +/** + @module ember +*/ + +/** + A Glimmer2 AST transformation that asserts against + + ```handlebars + {{attrs.foo.bar}} + ``` + + ...as well as `{{#if attrs.foo}}`, `{{deeply (nested attrs.foobar.baz)}}`. + + @private + @class AssertAgainstAttrs +*/ + +export default function assertAgainstAttrs(env: EmberASTPluginEnvironment): ASTPlugin { + let { builders: b } = env.syntax; + let moduleName = env.meta?.moduleName; + + let stack: string[][] = [[]]; + + function updateBlockParamsStack(blockParams: string[]) { + let parent = stack[stack.length - 1]; + assert('has parent', parent); + stack.push(parent.concat(blockParams)); + } + + return { + name: 'assert-against-attrs', + + visitor: { + Template: { + enter(node: AST.Template) { + updateBlockParamsStack(node.blockParams); + }, + exit() { + stack.pop(); + }, + }, + + Block: { + enter(node: AST.Block) { + updateBlockParamsStack(node.blockParams); + }, + exit() { + stack.pop(); + }, + }, + + ElementNode: { + enter(node: AST.ElementNode) { + updateBlockParamsStack(node.blockParams); + }, + exit() { + stack.pop(); + }, + }, + + PathExpression(node: AST.PathExpression): AST.Node | void { + if (isAttrs(node, stack[stack.length - 1]!)) { + assert( + `Using {{attrs}} to reference named arguments is not supported. {{${ + node.original + }}} should be updated to {{@${node.original.slice(6)}}}. ${calculateLocationDisplay( + moduleName, + node.loc + )}` + ); + } else if (isThisDotAttrs(node)) { + // When removing this, ensure `{{this.attrs.foo}}` is left as-is, without triggering + // any assertions/deprecations. It's perfectly legal to reference `{{this.attrs.foo}}` + // in the template since it is a real property on the backing class – it will give you + // a `MutableCell` wrapper object, but maybe that's what you want. And in any case, + // there is no compelling to special case that property access. + deprecate( + `Using {{this.attrs}} to reference named arguments has been deprecated. {{${ + node.original + }}} should be updated to {{@${node.original.slice(11)}}}. ${calculateLocationDisplay( + moduleName, + node.loc + )}`, + false, + { + id: 'attrs-arg-access', + url: 'https://deprecations.emberjs.com/v3.x/#toc_attrs-arg-access', + until: '6.0.0', + for: 'ember-source', + since: { + available: '3.26.0', + enabled: '3.26.0', + }, + } + ); + + return b.path(`@${node.original.slice(11)}`, node.loc); + } + }, + }, + }; +} + +function isAttrs(node: AST.PathExpression, symbols: string[]) { + return ( + node.head.type === 'VarHead' && + node.head.name === 'attrs' && + symbols.indexOf(node.head.name) === -1 + ); +} + +function isThisDotAttrs(node: AST.PathExpression) { + return node.head.type === 'ThisHead' && node.tail[0] === 'attrs'; +} diff --git a/packages/@ember/template-compiler/lib/plugins/assert-against-named-outlets.ts b/packages/@ember/template-compiler/lib/plugins/assert-against-named-outlets.ts new file mode 100644 index 00000000000..c680f3b7259 --- /dev/null +++ b/packages/@ember/template-compiler/lib/plugins/assert-against-named-outlets.ts @@ -0,0 +1,37 @@ +import { assert } from '@ember/debug'; +import type { AST, ASTPlugin } from '@glimmer/syntax'; +import calculateLocationDisplay from '../system/calculate-location-display'; +import type { EmberASTPluginEnvironment } from '../types'; + +/** + @module ember +*/ + +/** + Prevents usage of named outlets, a legacy concept in Ember removed in 4.0. + + @private + @class AssertAgainstNamedOutlets +*/ +export default function assertAgainstNamedOutlets(env: EmberASTPluginEnvironment): ASTPlugin { + let moduleName = env.meta?.moduleName; + + return { + name: 'assert-against-named-outlets', + + visitor: { + MustacheStatement(node: AST.MustacheStatement) { + if ( + node.path.type === 'PathExpression' && + node.path.original === 'outlet' && + node.params[0] + ) { + let sourceInformation = calculateLocationDisplay(moduleName, node.loc); + assert( + `Named outlets were removed in Ember 4.0. See https://deprecations.emberjs.com/v3.x#toc_route-render-template for guidance on alternative APIs for named outlet use cases. ${sourceInformation}` + ); + } + }, + }, + }; +} diff --git a/packages/@ember/template-compiler/lib/plugins/assert-input-helper-without-block.ts b/packages/@ember/template-compiler/lib/plugins/assert-input-helper-without-block.ts new file mode 100644 index 00000000000..f16e76056c4 --- /dev/null +++ b/packages/@ember/template-compiler/lib/plugins/assert-input-helper-without-block.ts @@ -0,0 +1,27 @@ +import { assert } from '@ember/debug'; +import type { AST, ASTPlugin } from '@glimmer/syntax'; +import calculateLocationDisplay from '../system/calculate-location-display'; +import type { EmberASTPluginEnvironment } from '../types'; +import { isPath } from './utils'; + +export default function errorOnInputWithContent(env: EmberASTPluginEnvironment): ASTPlugin { + let moduleName = env.meta?.moduleName; + + return { + name: 'assert-input-helper-without-block', + + visitor: { + BlockStatement(node: AST.BlockStatement) { + if (isPath(node.path) && node.path.original === 'input') { + assert(assertMessage(moduleName, node)); + } + }, + }, + }; +} + +function assertMessage(moduleName: string | undefined, node: AST.BlockStatement): string { + let sourceInformation = calculateLocationDisplay(moduleName, node.loc); + + return `The {{input}} helper cannot be used in block form. ${sourceInformation}`; +} diff --git a/packages/@ember/template-compiler/lib/plugins/assert-reserved-named-arguments.ts b/packages/@ember/template-compiler/lib/plugins/assert-reserved-named-arguments.ts new file mode 100644 index 00000000000..b769ba64b1b --- /dev/null +++ b/packages/@ember/template-compiler/lib/plugins/assert-reserved-named-arguments.ts @@ -0,0 +1,47 @@ +import { assert } from '@ember/debug'; +import type { AST, ASTPlugin } from '@glimmer/syntax'; +import calculateLocationDisplay from '../system/calculate-location-display'; +import type { EmberASTPluginEnvironment } from '../types'; + +export default function assertReservedNamedArguments(env: EmberASTPluginEnvironment): ASTPlugin { + let moduleName = env.meta?.moduleName; + + return { + name: 'assert-reserved-named-arguments', + + visitor: { + // In general, we don't assert on the invocation side to avoid creating migration + // hazards (e.g. using angle bracket to invoke a classic component that uses + // `this.someReservedName`. However, we want to avoid leaking special internal + // things, such as `__ARGS__`, so those would need to be asserted on both sides. + + AttrNode({ name, loc }: AST.AttrNode) { + if (name === '@__ARGS__') { + assert(`${assertMessage(name)} ${calculateLocationDisplay(moduleName, loc)}`); + } + }, + + HashPair({ key, loc }: AST.HashPair) { + if (key === '__ARGS__') { + assert(`${assertMessage(key)} ${calculateLocationDisplay(moduleName, loc)}`); + } + }, + + PathExpression({ original, loc }: AST.PathExpression) { + if (isReserved(original)) { + assert(`${assertMessage(original)} ${calculateLocationDisplay(moduleName, loc)}`); + } + }, + }, + }; +} + +const RESERVED = ['@arguments', '@args', '@block', '@else']; + +function isReserved(name: string): boolean { + return RESERVED.indexOf(name) !== -1 || Boolean(name.match(/^@[^a-z]/)); +} + +function assertMessage(name: string): string { + return `'${name}' is reserved.`; +} diff --git a/packages/@ember/template-compiler/lib/plugins/index.ts b/packages/@ember/template-compiler/lib/plugins/index.ts new file mode 100644 index 00000000000..6666ba891e7 --- /dev/null +++ b/packages/@ember/template-compiler/lib/plugins/index.ts @@ -0,0 +1,54 @@ +import AssertAgainstAttrs from './assert-against-attrs'; +import AssertAgainstNamedOutlets from './assert-against-named-outlets'; +import AssertInputHelperWithoutBlock from './assert-input-helper-without-block'; +import AssertReservedNamedArguments from './assert-reserved-named-arguments'; +import TransformActionSyntax from './transform-action-syntax'; +import TransformEachInIntoEach from './transform-each-in-into-each'; +import TransformEachTrackArray from './transform-each-track-array'; +import TransformInElement from './transform-in-element'; +import TransformQuotedBindingsIntoJustBindings from './transform-quoted-bindings-into-just-bindings'; +import TransformResolutions from './transform-resolutions'; +import TransformWrapMountAndOutlet from './transform-wrap-mount-and-outlet'; + +// order of plugins is important +export const RESOLUTION_MODE_TRANSFORMS = Object.freeze([ + TransformQuotedBindingsIntoJustBindings, + AssertReservedNamedArguments, + TransformActionSyntax, + AssertAgainstAttrs, + TransformEachInIntoEach, + AssertInputHelperWithoutBlock, + TransformInElement, + TransformEachTrackArray, + AssertAgainstNamedOutlets, + TransformWrapMountAndOutlet, + TransformResolutions, +]); + +export const STRICT_MODE_TRANSFORMS = Object.freeze([ + TransformQuotedBindingsIntoJustBindings, + AssertReservedNamedArguments, + TransformActionSyntax, + TransformEachInIntoEach, + TransformInElement, + TransformEachTrackArray, + AssertAgainstNamedOutlets, + TransformWrapMountAndOutlet, +]); + +export const STRICT_MODE_KEYWORDS = Object.freeze([ + 'action', + 'mut', + 'readonly', + 'unbound', + + // TransformEachInIntoEach + '-each-in', + // TransformInElement + '-in-el-null', + // TransformEachTrackArray + '-track-array', + // TransformWrapMountAndOutlet + '-mount', + '-outlet', +]); diff --git a/packages/@ember/template-compiler/lib/plugins/transform-action-syntax.ts b/packages/@ember/template-compiler/lib/plugins/transform-action-syntax.ts new file mode 100644 index 00000000000..2ee6fdd4bc4 --- /dev/null +++ b/packages/@ember/template-compiler/lib/plugins/transform-action-syntax.ts @@ -0,0 +1,67 @@ +import type { AST, ASTPlugin } from '@glimmer/syntax'; +import type { Builders, EmberASTPluginEnvironment } from '../types'; +import { isPath } from './utils'; + +/** + @module ember +*/ + +/** + A Glimmer2 AST transformation that replaces all instances of + + ```handlebars +