diff --git a/packages/@ember/helper/index.ts b/packages/@ember/helper/index.ts
index 400979a4103..835cc8af9a1 100644
--- a/packages/@ember/helper/index.ts
+++ b/packages/@ember/helper/index.ts
@@ -10,6 +10,15 @@ import {
concat as glimmerConcat,
get as glimmerGet,
fn as glimmerFn,
+ and as glimmerAnd,
+ or as glimmerOr,
+ not as glimmerNot,
+ eq as glimmerEq,
+ neq as glimmerNeq,
+ lt as glimmerLt,
+ lte as glimmerLte,
+ gt as glimmerGt,
+ gte as glimmerGte,
} from '@glimmer/runtime';
import { uniqueId as glimmerUniqueId } from '@ember/-internals/glimmer';
import { type Opaque } from '@ember/-internals/utility-types';
@@ -492,3 +501,120 @@ export const uniqueId = glimmerUniqueId;
export type UniqueIdHelper = typeof uniqueId;
/* eslint-enable @typescript-eslint/no-empty-object-type */
+
+/**
+ * `{{and}}` returns the first falsy value or the last value if all are truthy.
+ *
+ * ```js
+ * import { and } from '@ember/helper';
+ *
+ *
+ * {{if (and this.isActive this.isVerified) "Ready" "Not ready"}}
+ *
+ * ```
+ */
+export const and = glimmerAnd;
+
+/**
+ * `{{or}}` returns the first truthy value or the last value if all are falsy.
+ *
+ * ```js
+ * import { or } from '@ember/helper';
+ *
+ *
+ * {{if (or this.isAdmin this.isModerator) "Has access" "No access"}}
+ *
+ * ```
+ */
+export const or = glimmerOr;
+
+/**
+ * `{{not}}` returns the boolean negation of its argument.
+ *
+ * ```js
+ * import { not } from '@ember/helper';
+ *
+ *
+ * {{if (not this.isActive) "Inactive" "Active"}}
+ *
+ * ```
+ */
+export const not = glimmerNot;
+
+/**
+ * `{{eq}}` checks strict equality (`===`) between two values.
+ *
+ * ```js
+ * import { eq } from '@ember/helper';
+ *
+ *
+ * {{if (eq this.status "active") "Active" "Inactive"}}
+ *
+ * ```
+ */
+export const eq = glimmerEq;
+
+/**
+ * `{{neq}}` checks strict inequality (`!==`) between two values.
+ *
+ * ```js
+ * import { neq } from '@ember/helper';
+ *
+ *
+ * {{if (neq this.status "active") "Not active" "Active"}}
+ *
+ * ```
+ */
+export const neq = glimmerNeq;
+
+/**
+ * `{{lt}}` checks if the first value is less than the second.
+ *
+ * ```js
+ * import { lt } from '@ember/helper';
+ *
+ *
+ * {{if (lt this.count 10) "Under 10" "10 or more"}}
+ *
+ * ```
+ */
+export const lt = glimmerLt;
+
+/**
+ * `{{lte}}` checks if the first value is less than or equal to the second.
+ *
+ * ```js
+ * import { lte } from '@ember/helper';
+ *
+ *
+ * {{if (lte this.count 10) "10 or under" "Over 10"}}
+ *
+ * ```
+ */
+export const lte = glimmerLte;
+
+/**
+ * `{{gt}}` checks if the first value is greater than the second.
+ *
+ * ```js
+ * import { gt } from '@ember/helper';
+ *
+ *
+ * {{if (gt this.count 10) "Over 10" "10 or under"}}
+ *
+ * ```
+ */
+export const gt = glimmerGt;
+
+/**
+ * `{{gte}}` checks if the first value is greater than or equal to the second.
+ *
+ * ```js
+ * import { gte } from '@ember/helper';
+ *
+ *
+ * {{if (gte this.count 10) "10 or more" "Under 10"}}
+ *
+ * ```
+ */
+export const gte = glimmerGte;
diff --git a/packages/@ember/template-compiler/lib/compile-options.ts b/packages/@ember/template-compiler/lib/compile-options.ts
index 00dbf842eb9..56f4c00ec02 100644
--- a/packages/@ember/template-compiler/lib/compile-options.ts
+++ b/packages/@ember/template-compiler/lib/compile-options.ts
@@ -1,3 +1,5 @@
+import { on } from '@ember/modifier';
+import { fn, hash, array, and, or, not, eq, neq, lt, lte, gt, gte } from '@ember/helper';
import { assert } from '@ember/debug';
import {
RESOLUTION_MODE_TRANSFORMS,
@@ -14,11 +16,30 @@ function malformedComponentLookup(string: string) {
return string.indexOf('::') === -1 && string.indexOf(':') > -1;
}
+const RUNTIME_KEYWORDS_NAME = '__ember_keywords__';
+export const keywords = {
+ on,
+ fn,
+ hash,
+ array,
+ and,
+ or,
+ not,
+ eq,
+ neq,
+ lt,
+ lte,
+ gt,
+ gte,
+};
+
+// Not worth adding a type
+(globalThis as any)[RUNTIME_KEYWORDS_NAME] = keywords;
+
function buildCompileOptions(_options: EmberPrecompileOptions): EmberPrecompileOptions {
let moduleName = _options.moduleName;
- let options: EmberPrecompileOptions & Partial = {
- meta: {},
+ let options = {
isProduction: false,
plugins: { ast: [] },
..._options,
@@ -35,8 +56,25 @@ function buildCompileOptions(_options: EmberPrecompileOptions): EmberPrecompileO
},
};
- if ('eval' in options) {
- const localScopeEvaluator = options.eval as (value: string) => unknown;
+ options.meta ||= {};
+ options.meta.emberRuntime ||= {
+ /**
+ * NOTE: when stepping through lexicalScope, or other callbacks here,
+ * we first detect the keywords as "not in scope",
+ * and that is what we want, so that we can import them.
+ */
+ lookupKeyword(name: string): string {
+ assert(
+ `${name} is not a known keyword. Available keywords: ${Object.keys(keywords).join(', ')}`,
+ name in keywords
+ );
+
+ return `globalThis.${RUNTIME_KEYWORDS_NAME}.${name}`;
+ },
+ };
+
+ if ('eval' in options && options.eval) {
+ const localScopeEvaluator = options.eval;
const globalScopeEvaluator = (value: string) => new Function(`return ${value};`)();
options.lexicalScope = (variable: string) => {
@@ -57,9 +95,7 @@ function buildCompileOptions(_options: EmberPrecompileOptions): EmberPrecompileO
if ('scope' in options) {
const scope = (options.scope as () => Record)();
- options.lexicalScope = (variable: string) => {
- return variable in scope;
- };
+ options.lexicalScope = (variable: string) => variable in scope || variable in keywords;
delete options.scope;
}
diff --git a/packages/@ember/template-compiler/lib/plugins/auto-import-builtins.ts b/packages/@ember/template-compiler/lib/plugins/auto-import-builtins.ts
new file mode 100644
index 00000000000..2e21e2abfa5
--- /dev/null
+++ b/packages/@ember/template-compiler/lib/plugins/auto-import-builtins.ts
@@ -0,0 +1,83 @@
+import type { AST, ASTPlugin } from '@glimmer/syntax';
+import type { EmberASTPluginEnvironment } from '../types';
+import { isPath, trackLocals } from './utils';
+
+/**
+ @module ember
+*/
+
+/**
+ A Glimmer2 AST transformation that makes importable keywords work
+
+ @private
+ @class AutoImportBuiltins
+*/
+
+const MODIFIER_KEYWORDS: Record = {
+ on: '@ember/modifier',
+};
+
+const HELPER_KEYWORDS: Record = {
+ fn: '@ember/helper',
+ hash: '@ember/helper',
+ array: '@ember/helper',
+ and: '@ember/helper',
+ or: '@ember/helper',
+ not: '@ember/helper',
+ eq: '@ember/helper',
+ neq: '@ember/helper',
+ lt: '@ember/helper',
+ lte: '@ember/helper',
+ gt: '@ember/helper',
+ gte: '@ember/helper',
+};
+
+export default function autoImportBuiltins(env: EmberASTPluginEnvironment): ASTPlugin {
+ let { hasLocal, visitor } = trackLocals(env);
+
+ function rewrite(
+ node: AST.ElementModifierStatement | AST.MustacheStatement | AST.SubExpression,
+ modulePath: string,
+ name: string
+ ) {
+ if (env.meta?.jsutils) {
+ (node.path as AST.PathExpression).original = env.meta.jsutils.bindImport(
+ modulePath,
+ name,
+ node,
+ { name }
+ );
+ } else if (env.meta?.emberRuntime) {
+ (node.path as AST.PathExpression).original = env.meta.emberRuntime.lookupKeyword(name);
+ }
+ }
+
+ return {
+ name: 'auto-import-built-ins',
+
+ visitor: {
+ ...visitor,
+ ElementModifierStatement(node: AST.ElementModifierStatement) {
+ if (!isPath(node.path) || hasLocal(node.path.original)) return;
+ let modulePath = MODIFIER_KEYWORDS[node.path.original];
+ if (modulePath) {
+ rewrite(node, modulePath, node.path.original);
+ }
+ },
+ MustacheStatement(node: AST.MustacheStatement) {
+ if (!isPath(node.path) || hasLocal(node.path.original)) return;
+ let modulePath = HELPER_KEYWORDS[node.path.original];
+ if (modulePath) {
+ rewrite(node, modulePath, node.path.original);
+ }
+ },
+ SubExpression(node: AST.SubExpression) {
+ if (!isPath(node.path) || hasLocal(node.path.original)) return;
+ let modulePath = HELPER_KEYWORDS[node.path.original];
+ if (modulePath) {
+ rewrite(node, modulePath, node.path.original);
+ }
+ },
+ },
+ };
+}
diff --git a/packages/@ember/template-compiler/lib/plugins/index.ts b/packages/@ember/template-compiler/lib/plugins/index.ts
index 8eea3b646bf..ace6b99f647 100644
--- a/packages/@ember/template-compiler/lib/plugins/index.ts
+++ b/packages/@ember/template-compiler/lib/plugins/index.ts
@@ -9,8 +9,10 @@ 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';
+import AutoImportBuiltins from './auto-import-builtins';
export const INTERNAL_PLUGINS = {
+ AutoImportBuiltins,
AssertAgainstAttrs,
AssertAgainstNamedOutlets,
AssertInputHelperWithoutBlock,
@@ -40,6 +42,7 @@ export const RESOLUTION_MODE_TRANSFORMS = Object.freeze([
]);
export const STRICT_MODE_TRANSFORMS = Object.freeze([
+ AutoImportBuiltins,
TransformQuotedBindingsIntoJustBindings,
AssertReservedNamedArguments,
TransformActionSyntax,
diff --git a/packages/@ember/template-compiler/lib/template.ts b/packages/@ember/template-compiler/lib/template.ts
index bd2247eb386..ac6f1dee03b 100644
--- a/packages/@ember/template-compiler/lib/template.ts
+++ b/packages/@ember/template-compiler/lib/template.ts
@@ -3,7 +3,7 @@ import { precompile as glimmerPrecompile } from '@glimmer/compiler';
import type { SerializedTemplateWithLazyBlock } from '@glimmer/interfaces';
import { setComponentTemplate } from '@glimmer/manager';
import { templateFactory } from '@glimmer/opcode-compiler';
-import compileOptions from './compile-options';
+import compileOptions, { keywords } from './compile-options';
import type { EmberPrecompileOptions } from './types';
type ComponentClass = abstract new (...args: any[]) => object;
@@ -237,14 +237,16 @@ export function template(
templateString: string,
providedOptions?: BaseTemplateOptions | BaseClassTemplateOptions
): object {
- const options: EmberPrecompileOptions = { strictMode: true, ...providedOptions };
- const evaluate = buildEvaluator(options);
+ const options = { strictMode: true, ...providedOptions };
+ const evaluate = buildEvaluator(options);
const normalizedOptions = compileOptions(options);
const component = normalizedOptions.component ?? templateOnly();
const source = glimmerPrecompile(templateString, normalizedOptions);
- const template = templateFactory(evaluate(`(${source})`) as SerializedTemplateWithLazyBlock);
+ const wire = evaluate(`(${source})`) as SerializedTemplateWithLazyBlock;
+
+ const template = templateFactory(wire);
setComponentTemplate(template, component);
@@ -252,23 +254,31 @@ export function template(
}
const evaluator = (source: string) => {
- return new Function(`return ${source}`)();
+ return new Function(`return ${source}`)();
};
-function buildEvaluator(options: Partial | undefined) {
- if (options === undefined) {
- return evaluator;
- }
-
+/**
+ * Builds the source wireformat JSON block
+ *
+ * @param options
+ * @returns
+ */
+function buildEvaluator(options: Partial) {
if (options.eval) {
return options.eval;
} else {
- const scope = options.scope?.();
+ /**
+ * This is ran before the template is compiled,
+ * so we cannot use any information gathered during template compilation.
+ */
+ let scope = options.scope?.();
if (!scope) {
return evaluator;
}
+ scope = Object.assign({}, keywords, scope);
+
return (source: string) => {
let hasThis = Object.prototype.hasOwnProperty.call(scope, 'this');
let thisValue = hasThis ? (scope as { this?: unknown }).this : undefined;
diff --git a/packages/@ember/template-compiler/lib/types.ts b/packages/@ember/template-compiler/lib/types.ts
index 2242663eace..10309ac8ab9 100644
--- a/packages/@ember/template-compiler/lib/types.ts
+++ b/packages/@ember/template-compiler/lib/types.ts
@@ -1,4 +1,5 @@
import type {
+ ASTPluginBuilder,
ASTPluginEnvironment,
builders,
PrecompileOptions,
@@ -13,9 +14,7 @@ export type Builders = typeof builders;
* typing. Here export the interface subclass with no modification.
*/
-export type PluginFunc = NonNullable<
- NonNullable['ast']
->[number];
+export type PluginFunc = ASTPluginBuilder;
export type LexicalScope = NonNullable;
@@ -23,11 +22,31 @@ interface Plugins {
ast: PluginFunc[];
}
-export interface EmberPrecompileOptions extends PrecompileOptions {
+export interface EmberPrecompileOptions extends Omit {
isProduction?: boolean;
moduleName?: string;
plugins?: Plugins;
lexicalScope?: LexicalScope;
+ meta?: {
+ /**
+ * Exists for historical reasons, should not be in new code, as
+ * the module name does not correspond to anything meaningful at runtime.
+ */
+ moduleName?: string | undefined;
+
+ /**
+ * Not available at runtime
+ */
+ jsutils?: { bindImport: (...args: unknown[]) => string };
+
+ /**
+ * Utils unique to the runtime compiler
+ */
+ emberRuntime?: {
+ lookupKeyword(name: string): string;
+ };
+ };
+
/**
* This supports template blocks defined in class bodies.
*
diff --git a/packages/@ember/template-compiler/package.json b/packages/@ember/template-compiler/package.json
index 7a10006fbad..5f4fc58cfd7 100644
--- a/packages/@ember/template-compiler/package.json
+++ b/packages/@ember/template-compiler/package.json
@@ -12,6 +12,8 @@
"@ember/-internals": "workspace:*",
"@ember/component": "workspace:*",
"@ember/debug": "workspace:*",
+ "@ember/modifier": "workspace:*",
+ "@ember/helper": "workspace:*",
"@glimmer/compiler": "workspace:*",
"@glimmer/env": "workspace:*",
"@glimmer/interfaces": "workspace:*",
diff --git a/packages/@glimmer-workspace/integration-tests/test/keywords/array-test.ts b/packages/@glimmer-workspace/integration-tests/test/keywords/array-test.ts
new file mode 100644
index 00000000000..adeb24a9a08
--- /dev/null
+++ b/packages/@glimmer-workspace/integration-tests/test/keywords/array-test.ts
@@ -0,0 +1,84 @@
+import { castToBrowser } from '@glimmer/debug-util';
+import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests';
+
+import { template } from '@ember/template-compiler/runtime';
+import { array } from '@ember/helper';
+
+class KeywordArray extends RenderTest {
+ static suiteName = 'keyword helper: array';
+
+ @test
+ 'it works with explicit scope'(assert: Assert) {
+ let handleClick = (items: unknown[]) => {
+ assert.step(`count:${items.length}`);
+ };
+
+ const compiled = template(
+ '',
+ {
+ strictMode: true,
+ scope: () => ({
+ handleClick,
+ array,
+ }),
+ }
+ );
+
+ this.renderComponent(compiled);
+
+ castToBrowser(this.element, 'div').querySelector('button')!.click();
+ assert.verifySteps(['count:3']);
+ }
+
+ @test
+ 'it works as a keyword (no import needed)'(assert: Assert) {
+ let handleClick = (items: unknown[]) => {
+ assert.step(`count:${items.length}`);
+ };
+
+ const compiled = template(
+ '',
+ {
+ strictMode: true,
+ scope: () => ({
+ handleClick,
+ }),
+ }
+ );
+
+ this.renderComponent(compiled);
+
+ castToBrowser(this.element, 'div').querySelector('button')!.click();
+ assert.verifySteps(['count:3']);
+ }
+
+ @test
+ 'it works with the runtime compiler'(assert: Assert) {
+ let handleClick = (items: unknown[]) => {
+ assert.step(`count:${items.length}`);
+ };
+
+ hide(handleClick);
+
+ const compiled = template(
+ '',
+ {
+ strictMode: true,
+ eval() {
+ return eval(arguments[0]);
+ },
+ }
+ );
+
+ this.renderComponent(compiled);
+
+ castToBrowser(this.element, 'div').querySelector('button')!.click();
+ assert.verifySteps(['count:3']);
+ }
+}
+
+jitSuite(KeywordArray);
+
+const hide = (variable: unknown) => {
+ new Function(`return (${JSON.stringify(variable)});`);
+};
diff --git a/packages/@glimmer-workspace/integration-tests/test/keywords/comparison-test.ts b/packages/@glimmer-workspace/integration-tests/test/keywords/comparison-test.ts
new file mode 100644
index 00000000000..572a0f6bfca
--- /dev/null
+++ b/packages/@glimmer-workspace/integration-tests/test/keywords/comparison-test.ts
@@ -0,0 +1,176 @@
+import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests';
+
+import { template } from '@ember/template-compiler/runtime';
+
+class KeywordLt extends RenderTest {
+ static suiteName = 'keyword helper: lt';
+
+ @test
+ 'returns true when a < b'() {
+ let a = 1;
+ let b = 2;
+
+ const compiled = template('{{if (lt a b) "yes" "no"}}', {
+ strictMode: true,
+ scope: () => ({ a, b }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('yes');
+ }
+
+ @test
+ 'returns false when a >= b'() {
+ let a = 2;
+ let b = 2;
+
+ const compiled = template('{{if (lt a b) "yes" "no"}}', {
+ strictMode: true,
+ scope: () => ({ a, b }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('no');
+ }
+
+ @test
+ 'it works with the runtime compiler'() {
+ let a = 5;
+ let b = 10;
+
+ hide(a, b);
+
+ const compiled = template('{{if (lt a b) "yes" "no"}}', {
+ strictMode: true,
+ eval() {
+ return eval(arguments[0]);
+ },
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('yes');
+ }
+}
+
+class KeywordLte extends RenderTest {
+ static suiteName = 'keyword helper: lte';
+
+ @test
+ 'returns true when a <= b'() {
+ let a = 2;
+ let b = 2;
+
+ const compiled = template('{{if (lte a b) "yes" "no"}}', {
+ strictMode: true,
+ scope: () => ({ a, b }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('yes');
+ }
+
+ @test
+ 'returns false when a > b'() {
+ let a = 3;
+ let b = 2;
+
+ const compiled = template('{{if (lte a b) "yes" "no"}}', {
+ strictMode: true,
+ scope: () => ({ a, b }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('no');
+ }
+}
+
+class KeywordGt extends RenderTest {
+ static suiteName = 'keyword helper: gt';
+
+ @test
+ 'returns true when a > b'() {
+ let a = 3;
+ let b = 2;
+
+ const compiled = template('{{if (gt a b) "yes" "no"}}', {
+ strictMode: true,
+ scope: () => ({ a, b }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('yes');
+ }
+
+ @test
+ 'returns false when a <= b'() {
+ let a = 2;
+ let b = 2;
+
+ const compiled = template('{{if (gt a b) "yes" "no"}}', {
+ strictMode: true,
+ scope: () => ({ a, b }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('no');
+ }
+}
+
+class KeywordGte extends RenderTest {
+ static suiteName = 'keyword helper: gte';
+
+ @test
+ 'returns true when a >= b'() {
+ let a = 2;
+ let b = 2;
+
+ const compiled = template('{{if (gte a b) "yes" "no"}}', {
+ strictMode: true,
+ scope: () => ({ a, b }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('yes');
+ }
+
+ @test
+ 'returns false when a < b'() {
+ let a = 1;
+ let b = 2;
+
+ const compiled = template('{{if (gte a b) "yes" "no"}}', {
+ strictMode: true,
+ scope: () => ({ a, b }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('no');
+ }
+
+ @test
+ 'it works with the runtime compiler'() {
+ let a = 10;
+ let b = 5;
+
+ hide(a, b);
+
+ const compiled = template('{{if (gte a b) "yes" "no"}}', {
+ strictMode: true,
+ eval() {
+ return eval(arguments[0]);
+ },
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('yes');
+ }
+}
+
+jitSuite(KeywordLt);
+jitSuite(KeywordLte);
+jitSuite(KeywordGt);
+jitSuite(KeywordGte);
+
+const hide = (...variables: unknown[]) => {
+ new Function(`return (${JSON.stringify(variables)});`);
+};
diff --git a/packages/@glimmer-workspace/integration-tests/test/keywords/eq-neq-test.ts b/packages/@glimmer-workspace/integration-tests/test/keywords/eq-neq-test.ts
new file mode 100644
index 00000000000..3ea3653989b
--- /dev/null
+++ b/packages/@glimmer-workspace/integration-tests/test/keywords/eq-neq-test.ts
@@ -0,0 +1,98 @@
+import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests';
+
+import { template } from '@ember/template-compiler/runtime';
+
+class KeywordEq extends RenderTest {
+ static suiteName = 'keyword helper: eq';
+
+ @test
+ 'it returns true for equal values'() {
+ const compiled = template('{{if (eq "a" "a") "yes" "no"}}', {
+ strictMode: true,
+ scope: () => ({}),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('yes');
+ }
+
+ @test
+ 'it returns false for non-equal values'() {
+ const compiled = template('{{if (eq "a" "b") "yes" "no"}}', {
+ strictMode: true,
+ scope: () => ({}),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('no');
+ }
+
+ @test
+ 'it uses strict equality'() {
+ let a = 1;
+ let b = '1';
+
+ const compiled = template('{{if (eq a b) "yes" "no"}}', {
+ strictMode: true,
+ scope: () => ({ a, b }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('no');
+ }
+
+ @test
+ 'it works with the runtime compiler'() {
+ const compiled = template('{{if (eq "hello" "hello") "match" "no match"}}', {
+ strictMode: true,
+ eval() {
+ return eval(arguments[0]);
+ },
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('match');
+ }
+}
+
+class KeywordNeq extends RenderTest {
+ static suiteName = 'keyword helper: neq';
+
+ @test
+ 'it returns true for non-equal values'() {
+ const compiled = template('{{if (neq "a" "b") "yes" "no"}}', {
+ strictMode: true,
+ scope: () => ({}),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('yes');
+ }
+
+ @test
+ 'it returns false for equal values'() {
+ const compiled = template('{{if (neq "a" "a") "yes" "no"}}', {
+ strictMode: true,
+ scope: () => ({}),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('no');
+ }
+
+ @test
+ 'it works with the runtime compiler'() {
+ const compiled = template('{{if (neq "a" "b") "different" "same"}}', {
+ strictMode: true,
+ eval() {
+ return eval(arguments[0]);
+ },
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('different');
+ }
+}
+
+jitSuite(KeywordEq);
+jitSuite(KeywordNeq);
diff --git a/packages/@glimmer-workspace/integration-tests/test/keywords/fn-test.ts b/packages/@glimmer-workspace/integration-tests/test/keywords/fn-test.ts
new file mode 100644
index 00000000000..e0190070769
--- /dev/null
+++ b/packages/@glimmer-workspace/integration-tests/test/keywords/fn-test.ts
@@ -0,0 +1,100 @@
+import { castToBrowser } from '@glimmer/debug-util';
+import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests';
+import { setHelperManager, helperCapabilities } from '@glimmer/manager';
+
+import { template } from '@ember/template-compiler/runtime';
+import { fn } from '@ember/helper';
+
+class KeywordFn extends RenderTest {
+ static suiteName = 'keyword helper: fn';
+
+ @test
+ 'it works with explicit scope'(assert: Assert) {
+ let handleClick = (msg: string) => {
+ assert.step(msg);
+ };
+
+ const compiled = template('', {
+ strictMode: true,
+ scope: () => ({
+ handleClick,
+ fn,
+ }),
+ });
+
+ this.renderComponent(compiled);
+
+ castToBrowser(this.element, 'div').querySelector('button')!.click();
+ assert.verifySteps(['hello']);
+ }
+
+ @test
+ 'it works as a keyword (no import needed)'(assert: Assert) {
+ let handleClick = (msg: string) => {
+ assert.step(msg);
+ };
+
+ const compiled = template('', {
+ strictMode: true,
+ scope: () => ({
+ handleClick,
+ }),
+ });
+
+ this.renderComponent(compiled);
+
+ castToBrowser(this.element, 'div').querySelector('button')!.click();
+ assert.verifySteps(['hello']);
+ }
+
+ @test
+ 'it works with the runtime compiler'(assert: Assert) {
+ let handleClick = (msg: string) => {
+ assert.step(msg);
+ };
+
+ hide(handleClick);
+
+ const compiled = template('', {
+ strictMode: true,
+ eval() {
+ return eval(arguments[0]);
+ },
+ });
+
+ this.renderComponent(compiled);
+
+ castToBrowser(this.element, 'div').querySelector('button')!.click();
+ assert.verifySteps(['hello']);
+ }
+
+ @test
+ 'can be shadowed'() {
+ let fn = setHelperManager(
+ () => ({
+ capabilities: helperCapabilities('3.23', { hasValue: true }),
+ createHelper() {
+ return {};
+ },
+ getValue() {
+ return 'shadowed';
+ },
+ }),
+ {}
+ );
+
+ const compiled = template('{{fn "anything"}}', {
+ strictMode: true,
+ scope: () => ({ fn }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('shadowed');
+ }
+}
+
+jitSuite(KeywordFn);
+
+const hide = (variable: unknown) => {
+ new Function(`return (${JSON.stringify(variable)});`);
+};
diff --git a/packages/@glimmer-workspace/integration-tests/test/keywords/hash-test.ts b/packages/@glimmer-workspace/integration-tests/test/keywords/hash-test.ts
new file mode 100644
index 00000000000..a3f41fabb11
--- /dev/null
+++ b/packages/@glimmer-workspace/integration-tests/test/keywords/hash-test.ts
@@ -0,0 +1,47 @@
+import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests';
+
+import { template } from '@ember/template-compiler/runtime';
+import { hash } from '@ember/helper';
+
+class KeywordHash extends RenderTest {
+ static suiteName = 'keyword helper: hash';
+
+ @test
+ 'it works with explicit scope'() {
+ const compiled = template('{{#let (hash name="Ember") as |obj|}}{{obj.name}}{{/let}}', {
+ strictMode: true,
+ scope: () => ({
+ hash,
+ }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('Ember');
+ }
+
+ @test
+ 'it works as a keyword (no import needed)'() {
+ const compiled = template('{{#let (hash name="Ember") as |obj|}}{{obj.name}}{{/let}}', {
+ strictMode: true,
+ scope: () => ({}),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('Ember');
+ }
+
+ @test
+ 'it works with the runtime compiler'() {
+ const compiled = template('{{#let (hash name="Ember") as |obj|}}{{obj.name}}{{/let}}', {
+ strictMode: true,
+ eval() {
+ return eval(arguments[0]);
+ },
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('Ember');
+ }
+}
+
+jitSuite(KeywordHash);
diff --git a/packages/@glimmer-workspace/integration-tests/test/keywords/logical-test.ts b/packages/@glimmer-workspace/integration-tests/test/keywords/logical-test.ts
new file mode 100644
index 00000000000..3665d8aff5b
--- /dev/null
+++ b/packages/@glimmer-workspace/integration-tests/test/keywords/logical-test.ts
@@ -0,0 +1,136 @@
+import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests';
+
+import { template } from '@ember/template-compiler/runtime';
+
+class KeywordAnd extends RenderTest {
+ static suiteName = 'keyword helper: and';
+
+ @test
+ 'returns last value when all truthy'() {
+ let a = 'first';
+ let b = 'second';
+
+ const compiled = template('{{and a b}}', {
+ strictMode: true,
+ scope: () => ({ a, b }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('second');
+ }
+
+ @test
+ 'returns first falsy value'() {
+ let a = '';
+ let b = 'second';
+
+ const compiled = template('{{if (and a b) "yes" "no"}}', {
+ strictMode: true,
+ scope: () => ({ a, b }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('no');
+ }
+
+ @test
+ 'it works with the runtime compiler'() {
+ const compiled = template('{{if (and true true) "yes" "no"}}', {
+ strictMode: true,
+ eval() {
+ return eval(arguments[0]);
+ },
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('yes');
+ }
+}
+
+class KeywordOr extends RenderTest {
+ static suiteName = 'keyword helper: or';
+
+ @test
+ 'returns first truthy value'() {
+ let a = '';
+ let b = 'second';
+
+ const compiled = template('{{or a b}}', {
+ strictMode: true,
+ scope: () => ({ a, b }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('second');
+ }
+
+ @test
+ 'returns last value when all falsy'() {
+ let a = '';
+ let b = 0;
+
+ const compiled = template('{{if (or a b) "yes" "no"}}', {
+ strictMode: true,
+ scope: () => ({ a, b }),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('no');
+ }
+
+ @test
+ 'it works with the runtime compiler'() {
+ const compiled = template('{{if (or false true) "yes" "no"}}', {
+ strictMode: true,
+ eval() {
+ return eval(arguments[0]);
+ },
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('yes');
+ }
+}
+
+class KeywordNot extends RenderTest {
+ static suiteName = 'keyword helper: not';
+
+ @test
+ 'negates truthy to false'() {
+ const compiled = template('{{if (not true) "yes" "no"}}', {
+ strictMode: true,
+ scope: () => ({}),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('no');
+ }
+
+ @test
+ 'negates falsy to true'() {
+ const compiled = template('{{if (not false) "yes" "no"}}', {
+ strictMode: true,
+ scope: () => ({}),
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('yes');
+ }
+
+ @test
+ 'it works with the runtime compiler'() {
+ const compiled = template('{{if (not false) "yes" "no"}}', {
+ strictMode: true,
+ eval() {
+ return eval(arguments[0]);
+ },
+ });
+
+ this.renderComponent(compiled);
+ this.assertHTML('yes');
+ }
+}
+
+jitSuite(KeywordAnd);
+jitSuite(KeywordOr);
+jitSuite(KeywordNot);
diff --git a/packages/@glimmer-workspace/integration-tests/test/keywords/on-runtime-test.ts b/packages/@glimmer-workspace/integration-tests/test/keywords/on-runtime-test.ts
new file mode 100644
index 00000000000..f4fc52c6405
--- /dev/null
+++ b/packages/@glimmer-workspace/integration-tests/test/keywords/on-runtime-test.ts
@@ -0,0 +1,64 @@
+import { castToBrowser } from '@glimmer/debug-util';
+import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests';
+
+import { template } from '@ember/template-compiler/runtime';
+
+class KeywordOn extends RenderTest {
+ static suiteName = 'keyword modifier: on (runtime)';
+
+ @test
+ 'explicit scope'(assert: Assert) {
+ let handleClick = () => {
+ assert.step('success');
+ };
+
+ const compiled = template('', {
+ strictMode: true,
+ scope: () => ({
+ handleClick,
+ }),
+ });
+
+ this.renderComponent(compiled);
+
+ castToBrowser(this.element, 'div').querySelector('button')!.click();
+ assert.verifySteps(['success']);
+ }
+
+ @test
+ 'implicit scope'(assert: Assert) {
+ let handleClick = () => {
+ assert.step('success');
+ };
+
+ hide(handleClick);
+
+ const compiled = template('', {
+ strictMode: true,
+ eval() {
+ return eval(arguments[0]);
+ },
+ });
+
+ this.renderComponent(compiled);
+
+ castToBrowser(this.element, 'div').querySelector('button')!.click();
+ assert.verifySteps(['success']);
+ }
+}
+
+jitSuite(KeywordOn);
+
+/**
+ * This function is used to hide a variable from the transpiler, so that it
+ * doesn't get removed as "unused". It does not actually do anything with the
+ * variable, it just makes it be part of an expression that the transpiler
+ * won't remove.
+ *
+ * It's a bit of a hack, but it's necessary for testing.
+ *
+ * @param variable The variable to hide.
+ */
+const hide = (variable: unknown) => {
+ new Function(`return (${JSON.stringify(variable)});`);
+};
diff --git a/packages/@glimmer-workspace/integration-tests/test/keywords/on-test.ts b/packages/@glimmer-workspace/integration-tests/test/keywords/on-test.ts
new file mode 100644
index 00000000000..c29cdd4f963
--- /dev/null
+++ b/packages/@glimmer-workspace/integration-tests/test/keywords/on-test.ts
@@ -0,0 +1,99 @@
+import { castToBrowser } from '@glimmer/debug-util';
+import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests';
+import { setModifierManager, modifierCapabilities } from '@glimmer/manager';
+
+import { template } from '@ember/template-compiler/runtime';
+import { on } from '@ember/modifier';
+
+class KeywordOn extends RenderTest {
+ static suiteName = 'keyword modifier: on';
+
+ /**
+ * We require the babel compiler to emit keywords, so this is actually no different than normal usage
+ * prior to RFC 997.
+ *
+ * We are required to have the compiler that emits this low-level format to detect if on is in scope and then
+ * _not_ add the `on` modifier from `@ember/modifier` import.
+ */
+ @test
+ 'it works'(assert: Assert) {
+ let handleClick = () => {
+ assert.step('success');
+ };
+
+ const compiled = template('', {
+ strictMode: true,
+ scope: () => ({
+ handleClick,
+ on,
+ }),
+ });
+
+ this.renderComponent(compiled);
+
+ castToBrowser(this.element, 'div').querySelector('button')!.click();
+ assert.verifySteps(['success']);
+ }
+
+ @test
+ 'it works with the runtime compiler'(assert: Assert) {
+ let handleClick = () => {
+ assert.step('success');
+ };
+
+ hide(handleClick);
+
+ const compiled = template('', {
+ strictMode: true,
+ eval() {
+ return eval(arguments[0]);
+ },
+ });
+
+ this.renderComponent(compiled);
+
+ castToBrowser(this.element, 'div').querySelector('button')!.click();
+ assert.verifySteps(['success']);
+ }
+
+ @test
+ 'can be shadowed'(assert: Assert) {
+ let on = setModifierManager(() => {
+ return {
+ capabilities: modifierCapabilities('3.22'),
+ createModifier() {
+ assert.step('shadowed:success');
+ },
+ installModifier() {},
+ updateModifier() {},
+ destroyModifier() {},
+ };
+ }, {});
+
+ const compiled = template('', {
+ strictMode: true,
+ scope: () => ({ on }),
+ });
+
+ this.renderComponent(compiled);
+
+ castToBrowser(this.element, 'div').querySelector('button')!.click();
+ assert.verifySteps(['shadowed:success']);
+ }
+}
+
+jitSuite(KeywordOn);
+
+/**
+ * This function is used to hide a variable from the transpiler, so that it
+ * doesn't get removed as "unused". It does not actually do anything with the
+ * variable, it just makes it be part of an expression that the transpiler
+ * won't remove.
+ *
+ * It's a bit of a hack, but it's necessary for testing.
+ *
+ * @param variable The variable to hide.
+ */
+const hide = (variable: unknown) => {
+ new Function(`return (${JSON.stringify(variable)});`);
+};
diff --git a/packages/@glimmer-workspace/integration-tests/test/package.json b/packages/@glimmer-workspace/integration-tests/test/package.json
index 7fda1b061b7..3db79747f72 100644
--- a/packages/@glimmer-workspace/integration-tests/test/package.json
+++ b/packages/@glimmer-workspace/integration-tests/test/package.json
@@ -20,6 +20,9 @@
"@glimmer/syntax": "workspace:*",
"@glimmer/util": "workspace:*",
"@glimmer/validator": "workspace:*",
- "@glimmer/wire-format": "workspace:*"
+ "@glimmer/wire-format": "workspace:*",
+ "@ember/helper": "workspace:*",
+ "@ember/modifier": "workspace:*",
+ "@ember/template-compiler": "workspace:*"
}
}
diff --git a/packages/@glimmer/runtime/index.ts b/packages/@glimmer/runtime/index.ts
index 9ec4eb2b603..57d2204d884 100644
--- a/packages/@glimmer/runtime/index.ts
+++ b/packages/@glimmer/runtime/index.ts
@@ -31,12 +31,21 @@ export {
inTransaction,
runtimeOptions,
} from './lib/environment';
+export { and } from './lib/helpers/and';
export { array } from './lib/helpers/array';
export { concat } from './lib/helpers/concat';
+export { eq } from './lib/helpers/eq';
export { fn } from './lib/helpers/fn';
export { get } from './lib/helpers/get';
+export { gt } from './lib/helpers/gt';
+export { gte } from './lib/helpers/gte';
export { hash } from './lib/helpers/hash';
export { invokeHelper } from './lib/helpers/invoke';
+export { lt } from './lib/helpers/lt';
+export { lte } from './lib/helpers/lte';
+export { neq } from './lib/helpers/neq';
+export { not } from './lib/helpers/not';
+export { or } from './lib/helpers/or';
export { on } from './lib/modifiers/on';
export { renderComponent, renderMain, renderSync } from './lib/render';
export { DynamicScopeImpl, ScopeImpl } from './lib/scope';
diff --git a/packages/@glimmer/runtime/lib/helpers/and.ts b/packages/@glimmer/runtime/lib/helpers/and.ts
new file mode 100644
index 00000000000..5426d975946
--- /dev/null
+++ b/packages/@glimmer/runtime/lib/helpers/and.ts
@@ -0,0 +1,35 @@
+import type { CapturedArguments } from '@glimmer/interfaces';
+import type { Reference } from '@glimmer/reference';
+import { createComputeRef, valueForRef } from '@glimmer/reference';
+
+import { internalHelper } from './internal-helper';
+
+/**
+ Use the `{{and}}` helper to perform logical AND across multiple values.
+
+ ```handlebars
+ {{if (and this.isActive this.isVerified) "Ready" "Not ready"}}
+ ```
+
+ Returns the first falsy value (short-circuits), or the last value if all are truthy.
+
+ @method and
+ @param {Any} ...values
+ @return {Any}
+ @public
+*/
+export const and = internalHelper(({ positional }: CapturedArguments): Reference => {
+ return createComputeRef(
+ () => {
+ let last: unknown;
+ for (let i = 0; i < positional.length; i++) {
+ let ref = positional[i];
+ last = ref ? valueForRef(ref) : undefined;
+ if (!last) return last;
+ }
+ return last;
+ },
+ null,
+ 'and'
+ );
+});
diff --git a/packages/@glimmer/runtime/lib/helpers/eq.ts b/packages/@glimmer/runtime/lib/helpers/eq.ts
new file mode 100644
index 00000000000..b6e7e11a525
--- /dev/null
+++ b/packages/@glimmer/runtime/lib/helpers/eq.ts
@@ -0,0 +1,32 @@
+import type { CapturedArguments } from '@glimmer/interfaces';
+import type { Reference } from '@glimmer/reference';
+import { createComputeRef, valueForRef } from '@glimmer/reference';
+
+import { internalHelper } from './internal-helper';
+
+/**
+ Use the `{{eq}}` helper to check strict equality between two values.
+
+ ```handlebars
+ {{if (eq this.status "active") "Yes" "No"}}
+ ```
+
+ Equivalent to `a === b` in JavaScript.
+
+ @method eq
+ @param {Any} a
+ @param {Any} b
+ @return {Boolean}
+ @public
+*/
+export const eq = internalHelper(({ positional }: CapturedArguments): Reference => {
+ return createComputeRef(
+ () => {
+ let a = positional[0] ? valueForRef(positional[0]) : undefined;
+ let b = positional[1] ? valueForRef(positional[1]) : undefined;
+ return a === b;
+ },
+ null,
+ 'eq'
+ );
+});
diff --git a/packages/@glimmer/runtime/lib/helpers/gt.ts b/packages/@glimmer/runtime/lib/helpers/gt.ts
new file mode 100644
index 00000000000..2e43b1289eb
--- /dev/null
+++ b/packages/@glimmer/runtime/lib/helpers/gt.ts
@@ -0,0 +1,32 @@
+import type { CapturedArguments } from '@glimmer/interfaces';
+import type { Reference } from '@glimmer/reference';
+import { createComputeRef, valueForRef } from '@glimmer/reference';
+
+import { internalHelper } from './internal-helper';
+
+/**
+ Use the `{{gt}}` helper to check if one value is greater than another.
+
+ ```handlebars
+ {{if (gt this.count 10) "Over 10" "10 or under"}}
+ ```
+
+ Equivalent to `a > b` in JavaScript.
+
+ @method gt
+ @param {Any} a
+ @param {Any} b
+ @return {Boolean}
+ @public
+*/
+export const gt = internalHelper(({ positional }: CapturedArguments): Reference => {
+ return createComputeRef(
+ () => {
+ let a = positional[0] ? valueForRef(positional[0]) : undefined;
+ let b = positional[1] ? valueForRef(positional[1]) : undefined;
+ return (a as number) > (b as number);
+ },
+ null,
+ 'gt'
+ );
+});
diff --git a/packages/@glimmer/runtime/lib/helpers/gte.ts b/packages/@glimmer/runtime/lib/helpers/gte.ts
new file mode 100644
index 00000000000..e2442a90295
--- /dev/null
+++ b/packages/@glimmer/runtime/lib/helpers/gte.ts
@@ -0,0 +1,32 @@
+import type { CapturedArguments } from '@glimmer/interfaces';
+import type { Reference } from '@glimmer/reference';
+import { createComputeRef, valueForRef } from '@glimmer/reference';
+
+import { internalHelper } from './internal-helper';
+
+/**
+ Use the `{{gte}}` helper to check if one value is greater than or equal to another.
+
+ ```handlebars
+ {{if (gte this.count 10) "10 or more" "Under 10"}}
+ ```
+
+ Equivalent to `a >= b` in JavaScript.
+
+ @method gte
+ @param {Any} a
+ @param {Any} b
+ @return {Boolean}
+ @public
+*/
+export const gte = internalHelper(({ positional }: CapturedArguments): Reference => {
+ return createComputeRef(
+ () => {
+ let a = positional[0] ? valueForRef(positional[0]) : undefined;
+ let b = positional[1] ? valueForRef(positional[1]) : undefined;
+ return (a as number) >= (b as number);
+ },
+ null,
+ 'gte'
+ );
+});
diff --git a/packages/@glimmer/runtime/lib/helpers/lt.ts b/packages/@glimmer/runtime/lib/helpers/lt.ts
new file mode 100644
index 00000000000..13fc23f44bb
--- /dev/null
+++ b/packages/@glimmer/runtime/lib/helpers/lt.ts
@@ -0,0 +1,32 @@
+import type { CapturedArguments } from '@glimmer/interfaces';
+import type { Reference } from '@glimmer/reference';
+import { createComputeRef, valueForRef } from '@glimmer/reference';
+
+import { internalHelper } from './internal-helper';
+
+/**
+ Use the `{{lt}}` helper to check if one value is less than another.
+
+ ```handlebars
+ {{if (lt this.count 10) "Under 10" "10 or more"}}
+ ```
+
+ Equivalent to `a < b` in JavaScript.
+
+ @method lt
+ @param {Any} a
+ @param {Any} b
+ @return {Boolean}
+ @public
+*/
+export const lt = internalHelper(({ positional }: CapturedArguments): Reference => {
+ return createComputeRef(
+ () => {
+ let a = positional[0] ? valueForRef(positional[0]) : undefined;
+ let b = positional[1] ? valueForRef(positional[1]) : undefined;
+ return (a as number) < (b as number);
+ },
+ null,
+ 'lt'
+ );
+});
diff --git a/packages/@glimmer/runtime/lib/helpers/lte.ts b/packages/@glimmer/runtime/lib/helpers/lte.ts
new file mode 100644
index 00000000000..54592b38c91
--- /dev/null
+++ b/packages/@glimmer/runtime/lib/helpers/lte.ts
@@ -0,0 +1,32 @@
+import type { CapturedArguments } from '@glimmer/interfaces';
+import type { Reference } from '@glimmer/reference';
+import { createComputeRef, valueForRef } from '@glimmer/reference';
+
+import { internalHelper } from './internal-helper';
+
+/**
+ Use the `{{lte}}` helper to check if one value is less than or equal to another.
+
+ ```handlebars
+ {{if (lte this.count 10) "10 or under" "Over 10"}}
+ ```
+
+ Equivalent to `a <= b` in JavaScript.
+
+ @method lte
+ @param {Any} a
+ @param {Any} b
+ @return {Boolean}
+ @public
+*/
+export const lte = internalHelper(({ positional }: CapturedArguments): Reference => {
+ return createComputeRef(
+ () => {
+ let a = positional[0] ? valueForRef(positional[0]) : undefined;
+ let b = positional[1] ? valueForRef(positional[1]) : undefined;
+ return (a as number) <= (b as number);
+ },
+ null,
+ 'lte'
+ );
+});
diff --git a/packages/@glimmer/runtime/lib/helpers/neq.ts b/packages/@glimmer/runtime/lib/helpers/neq.ts
new file mode 100644
index 00000000000..77036081fde
--- /dev/null
+++ b/packages/@glimmer/runtime/lib/helpers/neq.ts
@@ -0,0 +1,32 @@
+import type { CapturedArguments } from '@glimmer/interfaces';
+import type { Reference } from '@glimmer/reference';
+import { createComputeRef, valueForRef } from '@glimmer/reference';
+
+import { internalHelper } from './internal-helper';
+
+/**
+ Use the `{{neq}}` helper to check strict inequality between two values.
+
+ ```handlebars
+ {{if (neq this.status "active") "Not active" "Active"}}
+ ```
+
+ Equivalent to `a !== b` in JavaScript.
+
+ @method neq
+ @param {Any} a
+ @param {Any} b
+ @return {Boolean}
+ @public
+*/
+export const neq = internalHelper(({ positional }: CapturedArguments): Reference => {
+ return createComputeRef(
+ () => {
+ let a = positional[0] ? valueForRef(positional[0]) : undefined;
+ let b = positional[1] ? valueForRef(positional[1]) : undefined;
+ return a !== b;
+ },
+ null,
+ 'neq'
+ );
+});
diff --git a/packages/@glimmer/runtime/lib/helpers/not.ts b/packages/@glimmer/runtime/lib/helpers/not.ts
new file mode 100644
index 00000000000..0a421932fcc
--- /dev/null
+++ b/packages/@glimmer/runtime/lib/helpers/not.ts
@@ -0,0 +1,30 @@
+import type { CapturedArguments } from '@glimmer/interfaces';
+import type { Reference } from '@glimmer/reference';
+import { createComputeRef, valueForRef } from '@glimmer/reference';
+
+import { internalHelper } from './internal-helper';
+
+/**
+ Use the `{{not}}` helper to negate a value using Handlebars truthiness.
+
+ ```handlebars
+ {{if (not this.isActive) "Inactive" "Active"}}
+ ```
+
+ Returns `true` for falsy values, `false` for truthy values.
+
+ @method not
+ @param {Any} value
+ @return {Boolean}
+ @public
+*/
+export const not = internalHelper(({ positional }: CapturedArguments): Reference => {
+ return createComputeRef(
+ () => {
+ let value = positional[0] ? valueForRef(positional[0]) : undefined;
+ return !value;
+ },
+ null,
+ 'not'
+ );
+});
diff --git a/packages/@glimmer/runtime/lib/helpers/or.ts b/packages/@glimmer/runtime/lib/helpers/or.ts
new file mode 100644
index 00000000000..7804f0226ae
--- /dev/null
+++ b/packages/@glimmer/runtime/lib/helpers/or.ts
@@ -0,0 +1,35 @@
+import type { CapturedArguments } from '@glimmer/interfaces';
+import type { Reference } from '@glimmer/reference';
+import { createComputeRef, valueForRef } from '@glimmer/reference';
+
+import { internalHelper } from './internal-helper';
+
+/**
+ Use the `{{or}}` helper to perform logical OR across multiple values.
+
+ ```handlebars
+ {{if (or this.isAdmin this.isModerator) "Has access" "No access"}}
+ ```
+
+ Returns the first truthy value (short-circuits), or the last value if all are falsy.
+
+ @method or
+ @param {Any} ...values
+ @return {Any}
+ @public
+*/
+export const or = internalHelper(({ positional }: CapturedArguments): Reference => {
+ return createComputeRef(
+ () => {
+ let last: unknown;
+ for (let i = 0; i < positional.length; i++) {
+ let ref = positional[i];
+ last = ref ? valueForRef(ref) : undefined;
+ if (last) return last;
+ }
+ return last;
+ },
+ null,
+ 'or'
+ );
+});
diff --git a/packages/@glimmer/syntax/lib/v2/normalize.ts b/packages/@glimmer/syntax/lib/v2/normalize.ts
index 369f081975e..c858f3ce506 100644
--- a/packages/@glimmer/syntax/lib/v2/normalize.ts
+++ b/packages/@glimmer/syntax/lib/v2/normalize.ts
@@ -2,10 +2,7 @@ import type { PresentArray } from '@glimmer/interfaces';
import { asPresentArray, isPresentArray, localAssert } from '@glimmer/debug-util';
import { assign } from '@glimmer/util';
-import type {
- PrecompileOptions,
- PrecompileOptionsWithLexicalScope,
-} from '../parser/tokenizer-event-handlers';
+import type { PrecompileOptions } from '../parser/tokenizer-event-handlers';
import type { SourceLocation } from '../source/location';
import type { Source } from '../source/source';
import type { SourceSpan } from '../source/span';
@@ -35,7 +32,7 @@ import {
export function normalize(
source: Source,
- options: PrecompileOptionsWithLexicalScope = { lexicalScope: () => false }
+ options: PrecompileOptions & { lexicalScope?: (variable: string) => boolean }
): [ast: ASTv2.Template, locals: string[]] {
let ast = preprocess(source, options);
@@ -45,10 +42,12 @@ export function normalize(
locals: ast.blockParams,
keywords: options.keywords ?? [],
};
+ let localsSet = new Set(normalizeOptions.locals);
+ let lexicalScope = options.lexicalScope ?? ((name: string) => localsSet.has(name));
let top = SymbolTable.top(normalizeOptions.locals, normalizeOptions.keywords, {
customizeComponentName: options.customizeComponentName ?? ((name) => name),
- lexicalScope: options.lexicalScope,
+ lexicalScope,
});
let block = new BlockContext(source, normalizeOptions, top);
let normalizer = new StatementNormalizer(block);
diff --git a/packages/ember-template-compiler/lib/plugins/auto-import-builtins.ts b/packages/ember-template-compiler/lib/plugins/auto-import-builtins.ts
new file mode 100644
index 00000000000..b89c297c799
--- /dev/null
+++ b/packages/ember-template-compiler/lib/plugins/auto-import-builtins.ts
@@ -0,0 +1,4 @@
+import { INTERNAL_PLUGINS } from '@ember/template-compiler/-internal-primitives';
+import type { ASTPluginBuilder } from '@glimmer/syntax';
+
+export default INTERNAL_PLUGINS.AutoImportBuiltins as ASTPluginBuilder;
diff --git a/packages/ember-template-compiler/lib/plugins/index.ts b/packages/ember-template-compiler/lib/plugins/index.ts
index ad24f63e151..a40843130ba 100644
--- a/packages/ember-template-compiler/lib/plugins/index.ts
+++ b/packages/ember-template-compiler/lib/plugins/index.ts
@@ -10,6 +10,7 @@ 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';
+import AutoImportBuiltins from './auto-import-builtins';
export const INTERNAL_PLUGINS: Record = {
AssertAgainstAttrs,
@@ -41,6 +42,7 @@ export const RESOLUTION_MODE_TRANSFORMS: readonly ASTPluginBuilder[] = Object.fr
]);
export const STRICT_MODE_TRANSFORMS: readonly ASTPluginBuilder[] = Object.freeze([
+ AutoImportBuiltins,
TransformQuotedBindingsIntoJustBindings,
AssertReservedNamedArguments,
TransformActionSyntax,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6df73016386..b926a44f131 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1151,6 +1151,12 @@ importers:
'@ember/debug':
specifier: workspace:*
version: link:../debug
+ '@ember/helper':
+ specifier: workspace:*
+ version: link:../helper
+ '@ember/modifier':
+ specifier: workspace:*
+ version: link:../modifier
'@glimmer/compiler':
specifier: workspace:*
version: link:../../@glimmer/compiler
@@ -1407,6 +1413,15 @@ importers:
packages/@glimmer-workspace/integration-tests/test:
dependencies:
+ '@ember/helper':
+ specifier: workspace:*
+ version: link:../../../@ember/helper
+ '@ember/modifier':
+ specifier: workspace:*
+ version: link:../../../@ember/modifier
+ '@ember/template-compiler':
+ specifier: workspace:*
+ version: link:../../../@ember/template-compiler
'@glimmer-workspace/integration-tests':
specifier: workspace:*
version: link:..
@@ -2810,6 +2825,9 @@ importers:
ember-load-initializers:
specifier: ^2.1.2
version: 2.1.2(@babel/core@7.29.0)
+ ember-modifier:
+ specifier: ^4.2.2
+ version: 4.3.0(@babel/core@7.29.0)
ember-page-title:
specifier: ^8.2.3
version: 8.2.4(ember-source@)
diff --git a/smoke-tests/app-template/package.json b/smoke-tests/app-template/package.json
index 8600e5e8205..fc39cc59420 100644
--- a/smoke-tests/app-template/package.json
+++ b/smoke-tests/app-template/package.json
@@ -42,6 +42,7 @@
"ember-cli-terser": "^4.0.2",
"ember-data": "~5.8.1",
"ember-load-initializers": "^2.1.2",
+ "ember-modifier": "^4.2.2",
"ember-page-title": "^8.2.3",
"ember-qunit": "^8.0.2",
"ember-resolver": "^11.0.1",
diff --git a/smoke-tests/scenarios/basic-test.ts b/smoke-tests/scenarios/basic-test.ts
index b58d060f79a..4a96f88e1a7 100644
--- a/smoke-tests/scenarios/basic-test.ts
+++ b/smoke-tests/scenarios/basic-test.ts
@@ -258,6 +258,166 @@ function basicTest(scenarios: Scenarios, appName: string) {
});
});
`,
+ 'on-as-keyword-test.gjs': `
+ import { module, test } from 'qunit';
+ import { setupRenderingTest } from 'ember-qunit';
+ import { render, click } from '@ember/test-helpers';
+
+ import Component from '@glimmer/component';
+ import { tracked } from '@glimmer/tracking';
+
+ class Demo extends Component {
+ @tracked message = 'hello';
+ louder = () => this.message = this.message + '!';
+
+
+
+
+ }
+
+ module('{{on}} as keyword', function(hooks) {
+ setupRenderingTest(hooks);
+
+ test('it works', async function(assert) {
+ await render(Demo);
+ assert.dom('button').hasText('hello');
+ await click('button');
+ assert.dom('button').hasText('hello!');
+ });
+ });
+ `,
+ 'helper-keywords-test.gjs': `
+ import { module, test } from 'qunit';
+ import { setupRenderingTest } from 'ember-qunit';
+ import { render } from '@ember/test-helpers';
+
+ import Component from '@glimmer/component';
+ import { tracked } from '@glimmer/tracking';
+
+ class FnDemo extends Component {
+ greet = (name) => 'Hello, ' + name + '!';
+
+
+ {{#let (fn this.greet "World") as |sayHello|}}
+ {{sayHello}}
+ {{/let}}
+
+ }
+
+ class HashDemo extends Component {
+
+ {{#let (hash name="Ember" version="6") as |obj|}}
+ {{obj.name}} {{obj.version}}
+ {{/let}}
+
+ }
+
+ class ArrayDemo extends Component {
+
+ {{#each (array "a" "b" "c") as |item|}}
+ {{item}}
+ {{/each}}
+
+ }
+
+ class EqDemo extends Component {
+ @tracked status = 'active';
+
+
+ {{if (eq this.status "active") "yes" "no"}}
+
+ }
+
+ class LogicalDemo extends Component {
+
+ {{if (and true true) "yes" "no"}}
+ {{if (or false true) "yes" "no"}}
+ {{if (not false) "yes" "no"}}
+
+ }
+
+ class ComparisonDemo extends Component {
+
+ {{if (lt 1 2) "yes" "no"}}
+ {{if (gt 2 1) "yes" "no"}}
+
+ }
+
+ module('helper keywords', function(hooks) {
+ setupRenderingTest(hooks);
+
+ test('fn works as keyword', async function(assert) {
+ await render(FnDemo);
+ assert.dom('#fn-result').hasText('Hello, World!');
+ });
+
+ test('hash works as keyword', async function(assert) {
+ await render(HashDemo);
+ assert.dom('#hash-result').hasText('Ember 6');
+ });
+
+ test('array works as keyword', async function(assert) {
+ await render(ArrayDemo);
+ assert.dom('.array-item').exists({ count: 3 });
+ });
+
+ test('eq works as keyword', async function(assert) {
+ await render(EqDemo);
+ assert.dom('#eq-result').hasText('yes');
+ });
+
+ test('logical operators work as keywords', async function(assert) {
+ await render(LogicalDemo);
+ assert.dom('#and-result').hasText('yes');
+ assert.dom('#or-result').hasText('yes');
+ assert.dom('#not-result').hasText('yes');
+ });
+
+ test('comparison operators work as keywords', async function(assert) {
+ await render(ComparisonDemo);
+ assert.dom('#lt-result').hasText('yes');
+ assert.dom('#gt-result').hasText('yes');
+ });
+ });
+ `,
+ 'on-as-keyword-but-its-shadowed-test.gjs': `
+ import QUnit, { module, test } from 'qunit';
+ import { setupRenderingTest } from 'ember-qunit';
+ import { render, click } from '@ember/test-helpers';
+
+ import Component from '@glimmer/component';
+ import { tracked } from '@glimmer/tracking';
+ import { modifier as eModifier } from 'ember-modifier';
+
+ module('{{on}} as keyword (but it is shadowed)', function(hooks) {
+ setupRenderingTest(hooks);
+
+ test('it works', async function(assert) {
+ // shadows keyword!
+ const on = eModifier(() => {
+ assert.step('shadowed:on:create');
+ });
+
+ class Demo extends Component {
+ @tracked message = 'hello';
+ louder = () => this.message = this.message + '!';
+
+
+
+
+ }
+
+ await render(Demo);
+ assert.verifySteps(['shadowed:on:create']);
+
+ assert.dom('button').hasText('hello');
+ await click('button');
+ assert.dom('button').hasText('hello', 'not changed because this on modifier does not add event listeners');
+
+ assert.verifySteps([]);
+ });
+ });
+ `,
},
},
});
diff --git a/tests/docs/expected.js b/tests/docs/expected.js
index 57caa8fb9a3..4fadc311856 100644
--- a/tests/docs/expected.js
+++ b/tests/docs/expected.js
@@ -172,6 +172,7 @@ module.exports = {
'domReady',
'each-in',
'each',
+ 'eq',
'eachComputedProperty',
'element',
'elementId',
@@ -339,6 +340,7 @@ module.exports = {
'name',
'nearestOfType',
'nearestWithProperty',
+ 'neq',
'next',
'none',
'normalize',