Skip to content

Commit bce381b

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 bce381b

File tree

17 files changed

+637
-1
lines changed

17 files changed

+637
-1
lines changed

packages/@ember/helper/index.ts

Lines changed: 100 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,102 @@ export interface GetHelper extends Opaque<'helper:get'> {}
470474
export const fn = glimmerFn as FnHelper;
471475
export interface FnHelper extends Opaque<'helper:fn'> {}
472476

477+
/**
478+
* The `{{gt}}` helper returns `true` if the first argument is greater than
479+
* the second argument.
480+
*
481+
* ```js
482+
* import { gt } from '@ember/helper';
483+
*
484+
* <template>
485+
* {{if (gt @score 100) "High score!" "Keep trying"}}
486+
* </template>
487+
* ```
488+
*
489+
* In strict-mode (gjs/gts) templates, `gt` is available as a keyword and
490+
* does not need to be imported.
491+
*
492+
* @method gt
493+
* @param {number} left
494+
* @param {number} right
495+
* @return {boolean}
496+
* @public
497+
*/
498+
export const gt = glimmerGt as unknown as GtHelper;
499+
export interface GtHelper extends Opaque<'helper:gt'> {}
500+
501+
/**
502+
* The `{{gte}}` helper returns `true` if the first argument is greater than
503+
* or equal to the second argument.
504+
*
505+
* ```js
506+
* import { gte } from '@ember/helper';
507+
*
508+
* <template>
509+
* {{if (gte @age 18) "Adult" "Minor"}}
510+
* </template>
511+
* ```
512+
*
513+
* In strict-mode (gjs/gts) templates, `gte` is available as a keyword and
514+
* does not need to be imported.
515+
*
516+
* @method gte
517+
* @param {number} left
518+
* @param {number} right
519+
* @return {boolean}
520+
* @public
521+
*/
522+
export const gte = glimmerGte as unknown as GteHelper;
523+
export interface GteHelper extends Opaque<'helper:gte'> {}
524+
525+
/**
526+
* The `{{lt}}` helper returns `true` if the first argument is less than
527+
* the second argument.
528+
*
529+
* ```js
530+
* import { lt } from '@ember/helper';
531+
*
532+
* <template>
533+
* {{if (lt @temperature 0) "Freezing" "Above zero"}}
534+
* </template>
535+
* ```
536+
*
537+
* In strict-mode (gjs/gts) templates, `lt` is available as a keyword and
538+
* does not need to be imported.
539+
*
540+
* @method lt
541+
* @param {number} left
542+
* @param {number} right
543+
* @return {boolean}
544+
* @public
545+
*/
546+
export const lt = glimmerLt as unknown as LtHelper;
547+
export interface LtHelper extends Opaque<'helper:lt'> {}
548+
549+
/**
550+
* The `{{lte}}` helper returns `true` if the first argument is less than
551+
* or equal to the second argument.
552+
*
553+
* ```js
554+
* import { lte } from '@ember/helper';
555+
*
556+
* <template>
557+
* {{if (lte @count 0) "Empty" "Has items"}}
558+
* </template>
559+
* ```
560+
*
561+
* In strict-mode (gjs/gts) templates, `lte` is available as a keyword and
562+
* does not need to be imported.
563+
*
564+
* @method lte
565+
* @param {number} left
566+
* @param {number} right
567+
* @return {boolean}
568+
* @public
569+
*/
570+
export const lte = glimmerLte as unknown as LteHelper;
571+
export interface LteHelper extends Opaque<'helper:lte'> {}
572+
473573
/**
474574
* The `element` helper lets you dynamically set the tag name of an element.
475575
*

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: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { DEBUG } from '@glimmer/env';
2+
import { jitSuite, RenderTest, test } from '@glimmer-workspace/integration-tests';
3+
4+
import { template } from '@ember/template-compiler';
5+
import { gt } from '@ember/helper';
6+
7+
class KeywordGt extends RenderTest {
8+
static suiteName = 'keyword helper: gt';
9+
10+
@test
11+
'returns true when first arg is greater'() {
12+
const compiled = template('{{if (gt a b) "yes" "no"}}', {
13+
strictMode: true,
14+
scope: () => ({ gt, a: 3, b: 2 }),
15+
});
16+
this.renderComponent(compiled);
17+
this.assertHTML('yes');
18+
}
19+
20+
@test
21+
'returns false when first arg is equal'() {
22+
const compiled = template('{{if (gt a b) "yes" "no"}}', {
23+
strictMode: true,
24+
scope: () => ({ gt, a: 2, b: 2 }),
25+
});
26+
this.renderComponent(compiled);
27+
this.assertHTML('no');
28+
}
29+
30+
@test
31+
'returns false when first arg is less'() {
32+
const compiled = template('{{if (gt a b) "yes" "no"}}', {
33+
strictMode: true,
34+
scope: () => ({ gt, a: 1, b: 2 }),
35+
});
36+
this.renderComponent(compiled);
37+
this.assertHTML('no');
38+
}
39+
@test({ skip: !DEBUG })
40+
'throws if not called with exactly two arguments'(assert: Assert) {
41+
const compiled = template('{{gt a}}', {
42+
strictMode: true,
43+
scope: () => ({ gt, a: 1 }),
44+
});
45+
46+
assert.throws(() => {
47+
this.renderComponent(compiled);
48+
}, /`gt` expects exactly two arguments/);
49+
}
50+
}
51+
52+
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);

0 commit comments

Comments
 (0)