Skip to content

Commit ae2f0b2

Browse files
NullVoxPopuliclaude
andcommitted
RFC#562 - {{and}}, {{or}}, {{not}} as keywords
Add boolean logic helpers and register them as built-in keywords so they no longer need to be imported in strict-mode (gjs/gts) templates. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1886f8d commit ae2f0b2

13 files changed

Lines changed: 394 additions & 1 deletion

File tree

packages/@ember/helper/index.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import {
1010
concat as glimmerConcat,
1111
get as glimmerGet,
1212
fn as glimmerFn,
13+
and as glimmerAnd,
14+
or as glimmerOr,
15+
not as glimmerNot,
1316
} from '@glimmer/runtime';
1417
import { element as glimmerElement, uniqueId as glimmerUniqueId } from '@ember/-internals/glimmer';
1518
import { type Opaque } from '@ember/-internals/utility-types';
@@ -511,4 +514,57 @@ export interface ElementHelper extends Opaque<'helper:element'> {}
511514
export const uniqueId = glimmerUniqueId;
512515
export type UniqueIdHelper = typeof uniqueId;
513516

517+
/**
518+
* The `{{and}}` helper returns the last truthy value if all arguments are
519+
* truthy, or the first falsy value.
520+
*
521+
* ```js
522+
* import { and } from '@ember/helper';
523+
*
524+
* <template>
525+
* {{if (and @isAdmin @isLoggedIn) "Welcome, admin!" "Access denied"}}
526+
* </template>
527+
* ```
528+
*
529+
* **NOTE:** In strict-mode (gjs/gts) templates, `and` is available as a
530+
* keyword and does not need to be imported.
531+
*/
532+
export const and = glimmerAnd as unknown as AndHelper;
533+
export interface AndHelper extends Opaque<'helper:and'> {}
534+
535+
/**
536+
* The `{{or}}` helper returns the first truthy value, or the last value if
537+
* none are truthy.
538+
*
539+
* ```js
540+
* import { or } from '@ember/helper';
541+
*
542+
* <template>
543+
* {{if (or @hasAccess @isAdmin) "Welcome!" "No access"}}
544+
* </template>
545+
* ```
546+
*
547+
* **NOTE:** In strict-mode (gjs/gts) templates, `or` is available as a
548+
* keyword and does not need to be imported.
549+
*/
550+
export const or = glimmerOr as unknown as OrHelper;
551+
export interface OrHelper extends Opaque<'helper:or'> {}
552+
553+
/**
554+
* The `{{not}}` helper returns the logical negation of its argument.
555+
*
556+
* ```js
557+
* import { not } from '@ember/helper';
558+
*
559+
* <template>
560+
* {{if (not @isDisabled) "Enabled" "Disabled"}}
561+
* </template>
562+
* ```
563+
*
564+
* **NOTE:** In strict-mode (gjs/gts) templates, `not` is available as a
565+
* keyword and does not need to be imported.
566+
*/
567+
export const not = glimmerNot as unknown as NotHelper;
568+
export interface NotHelper extends Opaque<'helper:not'> {}
569+
514570
/* eslint-enable @typescript-eslint/no-empty-object-type */

packages/@ember/template-compiler/lib/compile-options.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { fn } from '@ember/helper';
1+
import { and, fn, not, or } from '@ember/helper';
22
import { on } from '@ember/modifier';
33
import { assert } from '@ember/debug';
44
import {
@@ -25,8 +25,11 @@ function malformedComponentLookup(string: string) {
2525
export const RUNTIME_KEYWORDS_NAME = '__ember_keywords__';
2626

2727
export const keywords: Record<string, unknown> = {
28+
and,
2829
fn,
30+
not,
2931
on,
32+
or,
3033
};
3134

3235
function buildCompileOptions(_options: EmberPrecompileOptions): EmberPrecompileOptions {

packages/@ember/template-compiler/lib/plugins/auto-import-builtins.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,29 @@ export default function autoImportBuiltins(env: EmberASTPluginEnvironment): ASTP
3030
if (isFn(node, hasLocal)) {
3131
rewriteKeyword(env, node, 'fn', '@ember/helper');
3232
}
33+
if (isAnd(node, hasLocal)) {
34+
rewriteKeyword(env, node, 'and', '@ember/helper');
35+
}
36+
if (isOr(node, hasLocal)) {
37+
rewriteKeyword(env, node, 'or', '@ember/helper');
38+
}
39+
if (isNot(node, hasLocal)) {
40+
rewriteKeyword(env, node, 'not', '@ember/helper');
41+
}
3342
},
3443
MustacheStatement(node: AST.MustacheStatement) {
3544
if (isFn(node, hasLocal)) {
3645
rewriteKeyword(env, node, 'fn', '@ember/helper');
3746
}
47+
if (isAnd(node, hasLocal)) {
48+
rewriteKeyword(env, node, 'and', '@ember/helper');
49+
}
50+
if (isOr(node, hasLocal)) {
51+
rewriteKeyword(env, node, 'or', '@ember/helper');
52+
}
53+
if (isNot(node, hasLocal)) {
54+
rewriteKeyword(env, node, 'not', '@ember/helper');
55+
}
3856
},
3957
},
4058
};
@@ -68,3 +86,24 @@ function isFn(
6886
): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } {
6987
return isPath(node.path) && node.path.original === 'fn' && !hasLocal('fn');
7088
}
89+
90+
function isAnd(
91+
node: AST.MustacheStatement | AST.SubExpression,
92+
hasLocal: (k: string) => boolean
93+
): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } {
94+
return isPath(node.path) && node.path.original === 'and' && !hasLocal('and');
95+
}
96+
97+
function isOr(
98+
node: AST.MustacheStatement | AST.SubExpression,
99+
hasLocal: (k: string) => boolean
100+
): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } {
101+
return isPath(node.path) && node.path.original === 'or' && !hasLocal('or');
102+
}
103+
104+
function isNot(
105+
node: AST.MustacheStatement | AST.SubExpression,
106+
hasLocal: (k: string) => boolean
107+
): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } {
108+
return isPath(node.path) && node.path.original === 'not' && !hasLocal('not');
109+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests';
2+
3+
import { template } from '@ember/template-compiler/runtime';
4+
5+
class KeywordAndRuntime extends RenderTest {
6+
static suiteName = 'keyword helper: and (runtime)';
7+
8+
@test
9+
'explicit scope without import'() {
10+
const compiled = template('{{if (and a b) "yes" "no"}}', {
11+
strictMode: true,
12+
scope: () => ({ a: true, b: true }),
13+
});
14+
15+
this.renderComponent(compiled);
16+
this.assertHTML('yes');
17+
}
18+
19+
@test
20+
'implicit scope (eval)'() {
21+
let a = true;
22+
let b = 'hello';
23+
24+
hide(a);
25+
hide(b);
26+
27+
const compiled = template('{{if (and a b) "yes" "no"}}', {
28+
strictMode: true,
29+
eval() {
30+
return eval(arguments[0]);
31+
},
32+
});
33+
34+
this.renderComponent(compiled);
35+
this.assertHTML('yes');
36+
}
37+
38+
@test
39+
'returns falsy when one arg is falsy'() {
40+
const compiled = template('{{if (and a b) "yes" "no"}}', {
41+
strictMode: true,
42+
scope: () => ({ a: true, b: 0 }),
43+
});
44+
45+
this.renderComponent(compiled);
46+
this.assertHTML('no');
47+
}
48+
}
49+
50+
jitSuite(KeywordAndRuntime);
51+
52+
const hide = (variable: unknown) => {
53+
new Function(`return (${JSON.stringify(variable)});`);
54+
};
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests';
2+
3+
import { template } from '@ember/template-compiler/runtime';
4+
import { and } from '@ember/helper';
5+
6+
class KeywordAnd extends RenderTest {
7+
static suiteName = 'keyword helper: and';
8+
9+
@test
10+
'returns true when all are truthy'() {
11+
const compiled = template('{{if (and a b) "yes" "no"}}', {
12+
strictMode: true,
13+
scope: () => ({ and, a: 1, b: 'hello' }),
14+
});
15+
16+
this.renderComponent(compiled);
17+
this.assertHTML('yes');
18+
}
19+
20+
@test
21+
'returns false when one is falsy'() {
22+
const compiled = template('{{if (and a b) "yes" "no"}}', {
23+
strictMode: true,
24+
scope: () => ({ and, a: 0, b: 'hello' }),
25+
});
26+
27+
this.renderComponent(compiled);
28+
this.assertHTML('no');
29+
}
30+
31+
@test
32+
'works as a MustacheStatement'() {
33+
const compiled = template('{{and a b}}', {
34+
strictMode: true,
35+
scope: () => ({ and, a: true, b: true }),
36+
});
37+
38+
this.renderComponent(compiled);
39+
this.assertHTML('true');
40+
}
41+
}
42+
43+
jitSuite(KeywordAnd);
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests';
2+
3+
import { template } from '@ember/template-compiler/runtime';
4+
5+
class KeywordNotRuntime extends RenderTest {
6+
static suiteName = 'keyword helper: not (runtime)';
7+
8+
@test
9+
'explicit scope without import'() {
10+
const compiled = template('{{if (not a) "yes" "no"}}', {
11+
strictMode: true,
12+
scope: () => ({ a: false }),
13+
});
14+
15+
this.renderComponent(compiled);
16+
this.assertHTML('yes');
17+
}
18+
19+
@test
20+
'implicit scope (eval)'() {
21+
let a = false;
22+
23+
hide(a);
24+
25+
const compiled = template('{{if (not a) "yes" "no"}}', {
26+
strictMode: true,
27+
eval() {
28+
return eval(arguments[0]);
29+
},
30+
});
31+
32+
this.renderComponent(compiled);
33+
this.assertHTML('yes');
34+
}
35+
36+
@test
37+
'returns no for truthy'() {
38+
const compiled = template('{{if (not a) "yes" "no"}}', {
39+
strictMode: true,
40+
scope: () => ({ a: 'hello' }),
41+
});
42+
43+
this.renderComponent(compiled);
44+
this.assertHTML('no');
45+
}
46+
}
47+
48+
jitSuite(KeywordNotRuntime);
49+
50+
const hide = (variable: unknown) => {
51+
new Function(`return (${JSON.stringify(variable)});`);
52+
};
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests';
2+
3+
import { template } from '@ember/template-compiler/runtime';
4+
import { not } from '@ember/helper';
5+
6+
class KeywordNot extends RenderTest {
7+
static suiteName = 'keyword helper: not';
8+
9+
@test
10+
'returns true for falsy value'() {
11+
const compiled = template('{{if (not a) "yes" "no"}}', {
12+
strictMode: true,
13+
scope: () => ({ not, a: false }),
14+
});
15+
16+
this.renderComponent(compiled);
17+
this.assertHTML('yes');
18+
}
19+
20+
@test
21+
'returns false for truthy value'() {
22+
const compiled = template('{{if (not a) "yes" "no"}}', {
23+
strictMode: true,
24+
scope: () => ({ not, a: true }),
25+
});
26+
27+
this.renderComponent(compiled);
28+
this.assertHTML('no');
29+
}
30+
31+
@test
32+
'works with MustacheStatement'() {
33+
const compiled = template('{{not a}}', {
34+
strictMode: true,
35+
scope: () => ({ not, a: false }),
36+
});
37+
38+
this.renderComponent(compiled);
39+
this.assertHTML('true');
40+
}
41+
}
42+
43+
jitSuite(KeywordNot);
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests';
2+
3+
import { template } from '@ember/template-compiler/runtime';
4+
5+
class KeywordOrRuntime extends RenderTest {
6+
static suiteName = 'keyword helper: or (runtime)';
7+
8+
@test
9+
'explicit scope without import'() {
10+
const compiled = template('{{if (or a b) "yes" "no"}}', {
11+
strictMode: true,
12+
scope: () => ({ a: false, b: true }),
13+
});
14+
15+
this.renderComponent(compiled);
16+
this.assertHTML('yes');
17+
}
18+
19+
@test
20+
'implicit scope (eval)'() {
21+
let a = false;
22+
let b = 'hello';
23+
24+
hide(a);
25+
hide(b);
26+
27+
const compiled = template('{{if (or a b) "yes" "no"}}', {
28+
strictMode: true,
29+
eval() {
30+
return eval(arguments[0]);
31+
},
32+
});
33+
34+
this.renderComponent(compiled);
35+
this.assertHTML('yes');
36+
}
37+
38+
@test
39+
'returns no when all falsy'() {
40+
const compiled = template('{{if (or a b) "yes" "no"}}', {
41+
strictMode: true,
42+
scope: () => ({ a: false, b: 0 }),
43+
});
44+
45+
this.renderComponent(compiled);
46+
this.assertHTML('no');
47+
}
48+
}
49+
50+
jitSuite(KeywordOrRuntime);
51+
52+
const hide = (variable: unknown) => {
53+
new Function(`return (${JSON.stringify(variable)});`);
54+
};

0 commit comments

Comments
 (0)