Skip to content

Commit 92df32a

Browse files
NullVoxPopuliclaude
andcommitted
RFC#561 - {{lt}}, {{lte}}, {{gt}}, {{gte}} as keywords
Add comparison 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 92df32a

File tree

17 files changed

+473
-1
lines changed

17 files changed

+473
-1
lines changed

packages/@ember/helper/index.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ import {
1010
concat as glimmerConcat,
1111
get as glimmerGet,
1212
fn as glimmerFn,
13+
gt as glimmerGt,
14+
gte as glimmerGte,
15+
lt as glimmerLt,
16+
lte as glimmerLte,
1317
} from '@glimmer/runtime';
1418
import { element as glimmerElement, uniqueId as glimmerUniqueId } from '@ember/-internals/glimmer';
1519
import { type Opaque } from '@ember/-internals/utility-types';
@@ -470,6 +474,18 @@ export interface GetHelper extends Opaque<'helper:get'> {}
470474
export const fn = glimmerFn as FnHelper;
471475
export interface FnHelper extends Opaque<'helper:fn'> {}
472476

477+
export const gt = glimmerGt as unknown as GtHelper;
478+
export interface GtHelper extends Opaque<'helper:gt'> {}
479+
480+
export const gte = glimmerGte as unknown as GteHelper;
481+
export interface GteHelper extends Opaque<'helper:gte'> {}
482+
483+
export const lt = glimmerLt as unknown as LtHelper;
484+
export interface LtHelper extends Opaque<'helper:lt'> {}
485+
486+
export const lte = glimmerLte as unknown as LteHelper;
487+
export interface LteHelper extends Opaque<'helper:lte'> {}
488+
473489
/**
474490
* The `element` helper lets you dynamically set the tag name of an element.
475491
*

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { fn } from '@ember/helper';
1+
import { fn, gt, gte, lt, lte } from '@ember/helper';
22
import { on } from '@ember/modifier';
33
import { assert } from '@ember/debug';
44
import {
@@ -26,6 +26,10 @@ export const RUNTIME_KEYWORDS_NAME = '__ember_keywords__';
2626

2727
export const keywords: Record<string, unknown> = {
2828
fn,
29+
gt,
30+
gte,
31+
lt,
32+
lte,
2933
on,
3034
};
3135

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

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,35 @@ export default function autoImportBuiltins(env: EmberASTPluginEnvironment): ASTP
3030
if (isFn(node, hasLocal)) {
3131
rewriteKeyword(env, node, 'fn', '@ember/helper');
3232
}
33+
if (isGt(node, hasLocal)) {
34+
rewriteKeyword(env, node, 'gt', '@ember/helper');
35+
}
36+
if (isGte(node, hasLocal)) {
37+
rewriteKeyword(env, node, 'gte', '@ember/helper');
38+
}
39+
if (isLt(node, hasLocal)) {
40+
rewriteKeyword(env, node, 'lt', '@ember/helper');
41+
}
42+
if (isLte(node, hasLocal)) {
43+
rewriteKeyword(env, node, 'lte', '@ember/helper');
44+
}
3345
},
3446
MustacheStatement(node: AST.MustacheStatement) {
3547
if (isFn(node, hasLocal)) {
3648
rewriteKeyword(env, node, 'fn', '@ember/helper');
3749
}
50+
if (isGt(node, hasLocal)) {
51+
rewriteKeyword(env, node, 'gt', '@ember/helper');
52+
}
53+
if (isGte(node, hasLocal)) {
54+
rewriteKeyword(env, node, 'gte', '@ember/helper');
55+
}
56+
if (isLt(node, hasLocal)) {
57+
rewriteKeyword(env, node, 'lt', '@ember/helper');
58+
}
59+
if (isLte(node, hasLocal)) {
60+
rewriteKeyword(env, node, 'lte', '@ember/helper');
61+
}
3862
},
3963
},
4064
};
@@ -68,3 +92,31 @@ function isFn(
6892
): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } {
6993
return isPath(node.path) && node.path.original === 'fn' && !hasLocal('fn');
7094
}
95+
96+
function isGt(
97+
node: AST.MustacheStatement | AST.SubExpression,
98+
hasLocal: (k: string) => boolean
99+
): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } {
100+
return isPath(node.path) && node.path.original === 'gt' && !hasLocal('gt');
101+
}
102+
103+
function isGte(
104+
node: AST.MustacheStatement | AST.SubExpression,
105+
hasLocal: (k: string) => boolean
106+
): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } {
107+
return isPath(node.path) && node.path.original === 'gte' && !hasLocal('gte');
108+
}
109+
110+
function isLt(
111+
node: AST.MustacheStatement | AST.SubExpression,
112+
hasLocal: (k: string) => boolean
113+
): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } {
114+
return isPath(node.path) && node.path.original === 'lt' && !hasLocal('lt');
115+
}
116+
117+
function isLte(
118+
node: AST.MustacheStatement | AST.SubExpression,
119+
hasLocal: (k: string) => boolean
120+
): node is (AST.MustacheStatement | AST.SubExpression) & { path: AST.PathExpression } {
121+
return isPath(node.path) && node.path.original === 'lte' && !hasLocal('lte');
122+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import {
2+
GlimmerishComponent,
3+
jitSuite,
4+
RenderTest,
5+
test,
6+
} from '@glimmer-workspace/integration-tests';
7+
8+
import { template } from '@ember/template-compiler/runtime';
9+
10+
class KeywordGtRuntime extends RenderTest {
11+
static suiteName = 'keyword helper: gt (runtime)';
12+
13+
@test
14+
'explicit scope'() {
15+
const compiled = template('{{if (gt a b) "yes" "no"}}', {
16+
strictMode: true,
17+
scope: () => ({ a: 3, b: 2 }),
18+
});
19+
this.renderComponent(compiled);
20+
this.assertHTML('yes');
21+
}
22+
23+
@test
24+
'implicit scope (eval)'() {
25+
let a = 3;
26+
let b = 2;
27+
hide(a);
28+
hide(b);
29+
const compiled = template('{{if (gt a b) "yes" "no"}}', {
30+
strictMode: true,
31+
eval() {
32+
return eval(arguments[0]);
33+
},
34+
});
35+
this.renderComponent(compiled);
36+
this.assertHTML('yes');
37+
}
38+
39+
@test
40+
'no eval and no scope'() {
41+
class Foo extends GlimmerishComponent {
42+
a = 3;
43+
b = 2;
44+
static {
45+
template('{{if (gt this.a this.b) "yes" "no"}}', {
46+
strictMode: true,
47+
component: this,
48+
});
49+
}
50+
}
51+
this.renderComponent(Foo);
52+
this.assertHTML('yes');
53+
}
54+
}
55+
56+
jitSuite(KeywordGtRuntime);
57+
58+
const hide = (variable: unknown) => {
59+
new Function(`return (${JSON.stringify(variable)});`);
60+
};
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests';
2+
3+
import { template } from '@ember/template-compiler/runtime';
4+
import { gt } from '@ember/helper';
5+
6+
class KeywordGt extends RenderTest {
7+
static suiteName = 'keyword helper: gt';
8+
9+
@test
10+
'returns true when first arg is greater'() {
11+
const compiled = template('{{if (gt a b) "yes" "no"}}', {
12+
strictMode: true,
13+
scope: () => ({ gt, a: 3, b: 2 }),
14+
});
15+
this.renderComponent(compiled);
16+
this.assertHTML('yes');
17+
}
18+
19+
@test
20+
'returns false when first arg is equal'() {
21+
const compiled = template('{{if (gt a b) "yes" "no"}}', {
22+
strictMode: true,
23+
scope: () => ({ gt, a: 2, b: 2 }),
24+
});
25+
this.renderComponent(compiled);
26+
this.assertHTML('no');
27+
}
28+
29+
@test
30+
'returns false when first arg is less'() {
31+
const compiled = template('{{if (gt a b) "yes" "no"}}', {
32+
strictMode: true,
33+
scope: () => ({ gt, a: 1, b: 2 }),
34+
});
35+
this.renderComponent(compiled);
36+
this.assertHTML('no');
37+
}
38+
}
39+
40+
jitSuite(KeywordGt);
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import {
2+
GlimmerishComponent,
3+
jitSuite,
4+
RenderTest,
5+
test,
6+
} from '@glimmer-workspace/integration-tests';
7+
8+
import { template } from '@ember/template-compiler/runtime';
9+
10+
class KeywordGteRuntime extends RenderTest {
11+
static suiteName = 'keyword helper: gte (runtime)';
12+
13+
@test
14+
'explicit scope'() {
15+
const compiled = template('{{if (gte a b) "yes" "no"}}', {
16+
strictMode: true,
17+
scope: () => ({ a: 2, b: 2 }),
18+
});
19+
this.renderComponent(compiled);
20+
this.assertHTML('yes');
21+
}
22+
23+
@test
24+
'no eval and no scope'() {
25+
class Foo extends GlimmerishComponent {
26+
a = 2;
27+
b = 2;
28+
static {
29+
template('{{if (gte this.a this.b) "yes" "no"}}', {
30+
strictMode: true,
31+
component: this,
32+
});
33+
}
34+
}
35+
this.renderComponent(Foo);
36+
this.assertHTML('yes');
37+
}
38+
}
39+
40+
jitSuite(KeywordGteRuntime);
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests';
2+
3+
import { template } from '@ember/template-compiler/runtime';
4+
import { gte } from '@ember/helper';
5+
6+
class KeywordGte extends RenderTest {
7+
static suiteName = 'keyword helper: gte';
8+
9+
@test
10+
'returns true when first arg is greater'() {
11+
const compiled = template('{{if (gte a b) "yes" "no"}}', {
12+
strictMode: true,
13+
scope: () => ({ gte, a: 3, b: 2 }),
14+
});
15+
this.renderComponent(compiled);
16+
this.assertHTML('yes');
17+
}
18+
19+
@test
20+
'returns true when first arg is equal'() {
21+
const compiled = template('{{if (gte a b) "yes" "no"}}', {
22+
strictMode: true,
23+
scope: () => ({ gte, a: 2, b: 2 }),
24+
});
25+
this.renderComponent(compiled);
26+
this.assertHTML('yes');
27+
}
28+
29+
@test
30+
'returns false when first arg is less'() {
31+
const compiled = template('{{if (gte a b) "yes" "no"}}', {
32+
strictMode: true,
33+
scope: () => ({ gte, a: 1, b: 2 }),
34+
});
35+
this.renderComponent(compiled);
36+
this.assertHTML('no');
37+
}
38+
}
39+
40+
jitSuite(KeywordGte);
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import {
2+
GlimmerishComponent,
3+
jitSuite,
4+
RenderTest,
5+
test,
6+
} from '@glimmer-workspace/integration-tests';
7+
8+
import { template } from '@ember/template-compiler/runtime';
9+
10+
class KeywordLtRuntime extends RenderTest {
11+
static suiteName = 'keyword helper: lt (runtime)';
12+
13+
@test
14+
'explicit scope'() {
15+
const compiled = template('{{if (lt a b) "yes" "no"}}', {
16+
strictMode: true,
17+
scope: () => ({ a: 1, b: 2 }),
18+
});
19+
this.renderComponent(compiled);
20+
this.assertHTML('yes');
21+
}
22+
23+
@test
24+
'implicit scope (eval)'() {
25+
let a = 1;
26+
let b = 2;
27+
hide(a);
28+
hide(b);
29+
const compiled = template('{{if (lt a b) "yes" "no"}}', {
30+
strictMode: true,
31+
eval() {
32+
return eval(arguments[0]);
33+
},
34+
});
35+
this.renderComponent(compiled);
36+
this.assertHTML('yes');
37+
}
38+
39+
@test
40+
'no eval and no scope'() {
41+
class Foo extends GlimmerishComponent {
42+
a = 1;
43+
b = 2;
44+
static {
45+
template('{{if (lt this.a this.b) "yes" "no"}}', {
46+
strictMode: true,
47+
component: this,
48+
});
49+
}
50+
}
51+
this.renderComponent(Foo);
52+
this.assertHTML('yes');
53+
}
54+
}
55+
56+
jitSuite(KeywordLtRuntime);
57+
58+
const hide = (variable: unknown) => {
59+
new Function(`return (${JSON.stringify(variable)});`);
60+
};

0 commit comments

Comments
 (0)