@barefootjs/jinja 0.18.4 → 0.18.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapter/expr/array-method.d.ts.map +1 -1
- package/dist/adapter/expr/emitters.d.ts +2 -2
- package/dist/adapter/expr/emitters.d.ts.map +1 -1
- package/dist/adapter/index.js +120 -20
- package/dist/adapter/jinja-adapter.d.ts +49 -0
- package/dist/adapter/jinja-adapter.d.ts.map +1 -1
- package/dist/adapter/lib/constants.d.ts.map +1 -1
- package/dist/adapter/lib/static-value.d.ts +15 -0
- package/dist/adapter/lib/static-value.d.ts.map +1 -0
- package/dist/build.js +120 -20
- package/dist/conformance-pins.d.ts.map +1 -1
- package/dist/index.js +122 -37
- package/dist/render-divergences.d.ts.map +1 -1
- package/package.json +3 -3
- package/python/barefootjs/runtime.py +64 -6
- package/python/tests/test_helper_vectors.py +6 -0
- package/python/tests/test_template_primitives.py +45 -0
- package/src/__tests__/jinja-adapter-unit.test.ts +141 -0
- package/src/adapter/expr/array-method.ts +19 -0
- package/src/adapter/expr/emitters.ts +19 -6
- package/src/adapter/jinja-adapter.ts +164 -13
- package/src/adapter/lib/constants.ts +3 -0
- package/src/adapter/lib/static-value.ts +41 -0
- package/src/conformance-pins.ts +24 -30
- package/src/render-divergences.ts +4 -16
- package/src/test-render.ts +13 -142
|
@@ -383,8 +383,149 @@ export function C(props: { count: number }) {
|
|
|
383
383
|
})
|
|
384
384
|
})
|
|
385
385
|
|
|
386
|
+
describe('JinjaAdapter - named-slot capture identifier safety (#2168 jsx-element-prop)', () => {
|
|
387
|
+
// A JSX-valued prop under a hyphenated name (`data-slot`, a valid JSX
|
|
388
|
+
// attribute name) must not leak into the `{% set %}` capture variable's
|
|
389
|
+
// identifier — Jinja variable names can't contain `-`. The capture
|
|
390
|
+
// identifier is purely counter-based (never derived from the prop name);
|
|
391
|
+
// the hash KEY passed to `render_child` still carries the real name,
|
|
392
|
+
// quoted via `jinjaHashKey`.
|
|
393
|
+
test('a hyphenated prop name does not appear in the capture variable', () => {
|
|
394
|
+
const { template } = compileAndGenerate(`
|
|
395
|
+
function Card(props) { return null }
|
|
396
|
+
export function Parent() {
|
|
397
|
+
return <Card data-slot={<strong>Title</strong>}>text</Card>
|
|
398
|
+
}
|
|
399
|
+
`)
|
|
400
|
+
expect(template).toContain('{% set bf_prop_0 %}')
|
|
401
|
+
expect(template).toContain("'data-slot': bf_prop_0")
|
|
402
|
+
expect(template).not.toContain('data-slot %}')
|
|
403
|
+
expect(template).not.toContain('data-slot_')
|
|
404
|
+
})
|
|
405
|
+
})
|
|
406
|
+
|
|
386
407
|
// #2038 nested-callback-predicate loudness is pinned at the shared
|
|
387
408
|
// conformance layer (workstream C): `filter-nested-callback-predicate` /
|
|
388
409
|
// `filter-nested-find-predicate` (BF101 via `expectedDiagnostics`) and
|
|
389
410
|
// `filter-nested-callback-predicate-client` (the `/* @client */` suppression
|
|
390
411
|
// twin, which must render clean).
|
|
412
|
+
|
|
413
|
+
// #2221: `_resolveLiteralConst` is a flat name lookup against
|
|
414
|
+
// `ir.metadata.localConstants` with no notion of AST scope — it used to
|
|
415
|
+
// substitute an outer const's literal value even at an occurrence that is
|
|
416
|
+
// actually an enclosing loop callback's own (shadowing) parameter, so every
|
|
417
|
+
// iteration rendered the same hard-coded literal. Guarded with the same
|
|
418
|
+
// coarse `collectLoopBoundNames` exclusion as #2212: any name a loop binds
|
|
419
|
+
// anywhere in the component never inlines, falling back to the bare
|
|
420
|
+
// identifier. SSR-only tests for the same #2222 reason as the #2212
|
|
421
|
+
// describe below.
|
|
422
|
+
describe('JinjaAdapter - const inlining vs loop-param shadowing (#2221)', () => {
|
|
423
|
+
test('a loop param shadowing an outer literal const emits the identifier, not the const value', () => {
|
|
424
|
+
const { template } = compileAndGenerate(`
|
|
425
|
+
function Widget() {
|
|
426
|
+
const label: string = 'x'
|
|
427
|
+
return <ul>{[2, 5].map((label) => <li key={label}>{1 + label}</li>)}</ul>
|
|
428
|
+
}
|
|
429
|
+
`)
|
|
430
|
+
// The loop body must reference the per-iteration loop var...
|
|
431
|
+
expect(template).toContain('1 + label')
|
|
432
|
+
// ...never the outer const's hard-coded value.
|
|
433
|
+
expect(template).not.toContain("1 + 'x'")
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
test('a numeric const shadowed by a loop param emits the identifier too', () => {
|
|
437
|
+
const { template } = compileAndGenerate(`
|
|
438
|
+
function Widget() {
|
|
439
|
+
const count = 7
|
|
440
|
+
return <ul>{[2, 5].map((count) => <li key={count}>{1 + count}</li>)}</ul>
|
|
441
|
+
}
|
|
442
|
+
`)
|
|
443
|
+
expect(template).toContain('1 + count')
|
|
444
|
+
expect(template).not.toContain('1 + 7')
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
test('a literal const NOT shadowed by any loop still inlines (#1897 pin)', () => {
|
|
448
|
+
const { template } = compileAndGenerate(`
|
|
449
|
+
function Widget({ values }: { values: number[] }) {
|
|
450
|
+
const totalPages = 5
|
|
451
|
+
return <div>
|
|
452
|
+
<p>Page 1 of {1 + totalPages}</p>
|
|
453
|
+
<ul>{values.map((v) => <li key={v}>{v}</li>)}</ul>
|
|
454
|
+
</div>
|
|
455
|
+
}
|
|
456
|
+
`)
|
|
457
|
+
expect(template).toContain('1 + 5')
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
// The accepted coarse-exclusion trade-off (same as #2212): a name that is
|
|
461
|
+
// loop-bound ANYWHERE in the component never inlines, even at a genuinely
|
|
462
|
+
// non-shadowed occurrence outside the loop — the bare identifier is
|
|
463
|
+
// emitted instead of the value.
|
|
464
|
+
test('a const referenced outside the loop whose name is loop-bound elsewhere falls back to the identifier (accepted trade-off)', () => {
|
|
465
|
+
const { template } = compileAndGenerate(`
|
|
466
|
+
function Widget({ values }: { values: number[] }) {
|
|
467
|
+
const label: string = 'x'
|
|
468
|
+
return <div>
|
|
469
|
+
<p>{1 + label}</p>
|
|
470
|
+
<ul>{values.map((label) => <li key={label}>{2 + label}</li>)}</ul>
|
|
471
|
+
</div>
|
|
472
|
+
}
|
|
473
|
+
`)
|
|
474
|
+
expect(template).not.toContain("1 + 'x'")
|
|
475
|
+
expect(template).toContain('2 + label')
|
|
476
|
+
})
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
// #2237: `_resolveStaticRecordLiteral` (`IDENT.key` on a module-scope
|
|
480
|
+
// object-literal const, e.g. `variantClasses.ghost` — #1896/#1897) is a
|
|
481
|
+
// flat name lookup on `objectName` with no notion of AST scope, the
|
|
482
|
+
// record-literal sibling of #2221's `_resolveLiteralConst` bug. It used to
|
|
483
|
+
// substitute the outer const's member value even at an occurrence that is
|
|
484
|
+
// actually an enclosing loop callback's own (shadowing) parameter, so every
|
|
485
|
+
// iteration rendered the same hard-coded literal instead of the per-item
|
|
486
|
+
// value. Guarded with the same coarse `staticLoopSourceBoundNames`
|
|
487
|
+
// exclusion as #2221: any name a loop binds anywhere in the component
|
|
488
|
+
// never inlines, falling back to the bare `cfg['x']` member expression.
|
|
489
|
+
describe('JinjaAdapter - record-literal member lookup vs loop-param shadowing (#2237)', () => {
|
|
490
|
+
test('a loop param shadowing an outer module object const emits the member access, not the outer literal', () => {
|
|
491
|
+
const { template } = compileAndGenerate(`
|
|
492
|
+
const cfg = { x: 'outer-lit' }
|
|
493
|
+
function Widget({ rows }: { rows: { x: string }[] }) {
|
|
494
|
+
return <ul>{rows.map((cfg) => <li key={cfg.x}>{cfg.x}</li>)}</ul>
|
|
495
|
+
}
|
|
496
|
+
`)
|
|
497
|
+
// The loop body must reference the per-iteration member access...
|
|
498
|
+
expect(template).toContain("bf.string(cfg['x'])")
|
|
499
|
+
// ...never the outer const's hard-coded value.
|
|
500
|
+
expect(template).not.toContain("bf.string('outer-lit')")
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
test('a module object const NOT shadowed by any loop still inlines (variantClasses.ghost shape, #1896/#1897 pin)', () => {
|
|
504
|
+
const { template } = compileAndGenerate(`
|
|
505
|
+
const variantClasses = { solid: 'bg-solid', ghost: 'bg-ghost' }
|
|
506
|
+
function Widget({ variant }: { variant: 'solid' | 'ghost' }) {
|
|
507
|
+
return <div>{variantClasses.ghost}</div>
|
|
508
|
+
}
|
|
509
|
+
`)
|
|
510
|
+
expect(template).toContain("bf.string('bg-ghost')")
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
// The accepted coarse-exclusion trade-off (same as #2221/#2212): an
|
|
514
|
+
// object name that is loop-bound ANYWHERE in the component never
|
|
515
|
+
// inlines its member lookups, even at a genuinely non-shadowed
|
|
516
|
+
// occurrence outside the loop — the bare member expression is emitted
|
|
517
|
+
// instead of the value.
|
|
518
|
+
test('a record member referenced outside the loop whose object name is loop-bound elsewhere falls back to the member expression (accepted trade-off)', () => {
|
|
519
|
+
const { template } = compileAndGenerate(`
|
|
520
|
+
const cfg = { x: 'outer-lit' }
|
|
521
|
+
function Widget({ rows }: { rows: { x: string }[] }) {
|
|
522
|
+
return <div>
|
|
523
|
+
<p>{cfg.x}</p>
|
|
524
|
+
<ul>{rows.map((cfg) => <li key={cfg.x}>{cfg.x}</li>)}</ul>
|
|
525
|
+
</div>
|
|
526
|
+
}
|
|
527
|
+
`)
|
|
528
|
+
expect(template).not.toContain("bf.string('outer-lit')")
|
|
529
|
+
expect(template).toContain("bf.string(cfg['x'])")
|
|
530
|
+
})
|
|
531
|
+
})
|
|
@@ -91,6 +91,15 @@ export function renderArrayMethod(
|
|
|
91
91
|
const recv = emit(object)
|
|
92
92
|
return `bf.trim(${recv})`
|
|
93
93
|
}
|
|
94
|
+
case 'trimStart':
|
|
95
|
+
case 'trimEnd': {
|
|
96
|
+
// `.trimStart()` / `.trimEnd()` — the one-sided siblings of
|
|
97
|
+
// `.trim()` (#2183 follow-up). Dedicated `bf.trim_start` /
|
|
98
|
+
// `bf.trim_end` helpers, not `bf.trim` with a flag.
|
|
99
|
+
const fn = method === 'trimStart' ? 'trim_start' : 'trim_end'
|
|
100
|
+
const recv = emit(object)
|
|
101
|
+
return `bf.${fn}(${recv})`
|
|
102
|
+
}
|
|
94
103
|
case 'toFixed': {
|
|
95
104
|
// `.toFixed(digits?)` — `bf.to_fixed` mirrors JS rounding +
|
|
96
105
|
// zero-padding (default 0 digits). #1897.
|
|
@@ -126,6 +135,16 @@ export function renderArrayMethod(
|
|
|
126
135
|
const newS = emit(args[1])
|
|
127
136
|
return `bf.replace(${recv}, ${oldS}, ${newS})`
|
|
128
137
|
}
|
|
138
|
+
case 'replaceAll': {
|
|
139
|
+
// `.replaceAll(old, new)` — string-pattern form, EVERY occurrence,
|
|
140
|
+
// via the dedicated `bf.replace_all` helper (not `bf.replace` with
|
|
141
|
+
// a flag) — the regex-pattern form is refused upstream at the
|
|
142
|
+
// parser, same as `.replace`. See #2182.
|
|
143
|
+
const recv = emit(object)
|
|
144
|
+
const oldS = emit(args[0])
|
|
145
|
+
const newS = emit(args[1])
|
|
146
|
+
return `bf.replace_all(${recv}, ${oldS}, ${newS})`
|
|
147
|
+
}
|
|
129
148
|
case 'repeat': {
|
|
130
149
|
const recv = emit(object)
|
|
131
150
|
const count = args.length === 0 ? '0' : emit(args[0])
|
|
@@ -141,7 +141,7 @@ export class JinjaFilterEmitter implements ParsedExprEmitter {
|
|
|
141
141
|
return String(value)
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
member(object: ParsedExpr, property: string, _computed: boolean, emit: (e: ParsedExpr) => string): string {
|
|
144
|
+
member(object: ParsedExpr, property: string, _computed: boolean, _optional: boolean, emit: (e: ParsedExpr) => string): string {
|
|
145
145
|
// `.length` — route through `bf.length` (handles both array element
|
|
146
146
|
// count and string char count, JS-compatibly). Jinja's builtin
|
|
147
147
|
// `|length` filter also faults trying to match JS semantics for every
|
|
@@ -149,8 +149,16 @@ export class JinjaFilterEmitter implements ParsedExprEmitter {
|
|
|
149
149
|
if (property === 'length') {
|
|
150
150
|
return `bf.length(${emit(object)})`
|
|
151
151
|
}
|
|
152
|
-
//
|
|
153
|
-
|
|
152
|
+
// Bracket/item access, NOT `.` attribute access: Jinja's default
|
|
153
|
+
// `getattr` semantics try a Python ATTRIBUTE first, falling back to a
|
|
154
|
+
// dict key only if no such attribute exists — so `group.items` (a
|
|
155
|
+
// dict key from the JS object) instead resolves to the built-in bound
|
|
156
|
+
// method `dict.items`, raising "not iterable" downstream instead of
|
|
157
|
+
// returning the key's value. `[...]` compiles through Jinja's
|
|
158
|
+
// `getitem`, which tries the KEY first, sidestepping any built-in
|
|
159
|
+
// dict method name (items/keys/values/get/pop/update/...) that would
|
|
160
|
+
// otherwise shadow a same-named JS object field.
|
|
161
|
+
return `${emit(object)}['${escapeJinjaSingleQuoted(property)}']`
|
|
154
162
|
}
|
|
155
163
|
|
|
156
164
|
indexAccess(object: ParsedExpr, index: ParsedExpr, emit: (e: ParsedExpr) => string): string {
|
|
@@ -313,7 +321,7 @@ export class JinjaTopLevelEmitter implements ParsedExprEmitter {
|
|
|
313
321
|
return String(value)
|
|
314
322
|
}
|
|
315
323
|
|
|
316
|
-
member(object: ParsedExpr, property: string, _computed: boolean, emit: (e: ParsedExpr) => string): string {
|
|
324
|
+
member(object: ParsedExpr, property: string, _computed: boolean, _optional: boolean, emit: (e: ParsedExpr) => string): string {
|
|
317
325
|
// `props.x` flattens to the bare context var the SSR caller binds each
|
|
318
326
|
// prop to (props arrive as individual top-level context entries, not a
|
|
319
327
|
// nested `props` dict).
|
|
@@ -331,8 +339,13 @@ export class JinjaTopLevelEmitter implements ParsedExprEmitter {
|
|
|
331
339
|
const obj = emit(object)
|
|
332
340
|
// `.length` → `bf.length` (array count or string char count, JS-compat).
|
|
333
341
|
if (property === 'length') return `bf.length(${obj})`
|
|
334
|
-
//
|
|
335
|
-
|
|
342
|
+
// Bracket/item access, NOT `.` attribute access — see the sibling
|
|
343
|
+
// `JinjaFilterEmitter.member()` for why: Jinja's `.` tries a Python
|
|
344
|
+
// ATTRIBUTE first, so a dict key that happens to share a name with a
|
|
345
|
+
// built-in dict method (`items`, `keys`, `values`, `get`, ...) resolves
|
|
346
|
+
// to the bound method instead of the value. `[...]` (Jinja `getitem`)
|
|
347
|
+
// tries the key first.
|
|
348
|
+
return `${obj}['${escapeJinjaSingleQuoted(property)}']`
|
|
336
349
|
}
|
|
337
350
|
|
|
338
351
|
indexAccess(object: ParsedExpr, index: ParsedExpr, emit: (e: ParsedExpr) => string): string {
|
|
@@ -140,10 +140,16 @@ import {
|
|
|
140
140
|
queryHrefArgs,
|
|
141
141
|
isValidHelperId,
|
|
142
142
|
sortComparatorFromArrow,
|
|
143
|
+
isDangerousInnerHtmlAttr,
|
|
144
|
+
resolveDangerousInnerHtml,
|
|
145
|
+
dangerousInnerHtmlMetacharViolation,
|
|
146
|
+
dangerousInnerHtmlDiagnostic,
|
|
147
|
+
resolveStaticLoopSource,
|
|
148
|
+
collectLoopBoundNames,
|
|
143
149
|
} from '@barefootjs/jsx'
|
|
144
150
|
import { isAriaBooleanAttr, isBooleanResultExpr, isExplicitStringCall } from './boolean-result.ts'
|
|
145
151
|
import type { ParsedExpr, LoweringMatcher } from '@barefootjs/jsx'
|
|
146
|
-
import { BF_SLOT, BF_COND, BF_REGION } from '@barefootjs/shared'
|
|
152
|
+
import { BF_SLOT, BF_COND, BF_REGION, escapeHtml } from '@barefootjs/shared'
|
|
147
153
|
|
|
148
154
|
import type { JinjaRenderCtx } from './lib/types.ts'
|
|
149
155
|
import { JINJA_PRIMITIVE_EMIT_MAP } from './lib/constants.ts'
|
|
@@ -158,6 +164,7 @@ import {
|
|
|
158
164
|
collectRootScopeNodes,
|
|
159
165
|
} from './lib/ir-scope.ts'
|
|
160
166
|
import { renderSortMethod, renderSortEval } from './expr/array-method.ts'
|
|
167
|
+
import { staticValueToJinja } from './lib/static-value.ts'
|
|
161
168
|
import { JinjaFilterEmitter, JinjaTopLevelEmitter, truthyTest } from './expr/emitters.ts'
|
|
162
169
|
import type { JinjaEmitContext, JinjaSpreadContext, JinjaMemoContext } from './emit-context.ts'
|
|
163
170
|
import {
|
|
@@ -206,6 +213,14 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
|
|
|
206
213
|
private options: Required<JinjaAdapterOptions>
|
|
207
214
|
private errors: CompilerError[] = []
|
|
208
215
|
private inLoop: boolean = false
|
|
216
|
+
/**
|
|
217
|
+
* `IRLoop.depth` of the loop currently being rendered (save/restore
|
|
218
|
+
* around `renderChildren(loop.children)`, mirroring `inLoop` above).
|
|
219
|
+
* `renderAttributes` reads this to derive the `key` → `data-key`/
|
|
220
|
+
* `data-key-N` suffix — the depth is IR-computed (jsx-to-ir.ts), not
|
|
221
|
+
* re-derived here (#2168 nested-loop-outer-binding).
|
|
222
|
+
*/
|
|
223
|
+
private currentLoopKeyDepth = 0
|
|
209
224
|
/**
|
|
210
225
|
* SolidJS-style props identifier (`function(props: P)`) and the
|
|
211
226
|
* analyzer-extracted prop names. Stashed at `generate()` entry so the
|
|
@@ -258,6 +273,17 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
|
|
|
258
273
|
*/
|
|
259
274
|
private localConstants: IRMetadata['localConstants'] = []
|
|
260
275
|
|
|
276
|
+
/**
|
|
277
|
+
* Every name a `.map()`/`.filter()` loop callback binds as its item/index
|
|
278
|
+
* parameter anywhere in the component (#2208 fable review). A static
|
|
279
|
+
* loop-SOURCE name (e.g. a function-scope `const items = [...]`) must
|
|
280
|
+
* never resolve through `resolveStaticLoopSource` at a use site where a
|
|
281
|
+
* DIFFERENT, enclosing loop's own callback param shadows it — same
|
|
282
|
+
* shadowing hazard, and same coarse-but-safe mitigation, as #2212's
|
|
283
|
+
* `collectLoopBoundNames` use in `collectStringValueNames`.
|
|
284
|
+
*/
|
|
285
|
+
private staticLoopSourceBoundNames: Set<string> = new Set()
|
|
286
|
+
|
|
261
287
|
/**
|
|
262
288
|
* Optional, no-default props that are `None` when the caller omits them.
|
|
263
289
|
* Their bare-reference attribute emission is guarded with a Jinja
|
|
@@ -290,6 +316,7 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
|
|
|
290
316
|
// ("True"/"False") (#1897, pagination's data-active).
|
|
291
317
|
this.booleanTypedProps = collectBooleanTypedProps(ir)
|
|
292
318
|
this.localConstants = ir.metadata.localConstants ?? []
|
|
319
|
+
this.staticLoopSourceBoundNames = collectLoopBoundNames(ir)
|
|
293
320
|
this.nullableOptionalProps = collectNullableOptionalProps(ir)
|
|
294
321
|
this.stringValueNames = collectStringValueNames(ir)
|
|
295
322
|
this.moduleStringConsts = collectModuleStringConsts(ir.metadata.localConstants)
|
|
@@ -401,7 +428,9 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
|
|
|
401
428
|
}
|
|
402
429
|
|
|
403
430
|
emitText(node: IRText): string {
|
|
404
|
-
|
|
431
|
+
// IRText carries the entity-DECODED value (Phase 1 decodes JSX
|
|
432
|
+
// character references); re-escape for direct HTML emission.
|
|
433
|
+
return escapeHtml(node.value)
|
|
405
434
|
}
|
|
406
435
|
|
|
407
436
|
emitExpression(node: IRExpression): string {
|
|
@@ -510,7 +539,8 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
|
|
|
510
539
|
renderElement(element: IRElement): string {
|
|
511
540
|
const tag = element.tag
|
|
512
541
|
const attrs = this.renderAttributes(element)
|
|
513
|
-
const
|
|
542
|
+
const dangerousHtml = this.renderDangerousInnerHtml(element)
|
|
543
|
+
const children = dangerousHtml !== null ? dangerousHtml : this.renderChildren(element.children)
|
|
514
544
|
|
|
515
545
|
let hydrationAttrs = ''
|
|
516
546
|
if (element.needsScope) {
|
|
@@ -545,6 +575,28 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
|
|
|
545
575
|
return `<${tag}${attrs}${hydrationAttrs}>${children}</${tag}>`
|
|
546
576
|
}
|
|
547
577
|
|
|
578
|
+
/**
|
|
579
|
+
* `dangerouslySetInnerHTML={{ __html: '...' }}` (#2207) — see the Blade
|
|
580
|
+
* adapter's identical helper for the full rationale. `null` means the
|
|
581
|
+
* attribute is absent (caller falls through to normal `renderChildren`);
|
|
582
|
+
* a non-`null` string (possibly `''`) replaces the children outright.
|
|
583
|
+
*/
|
|
584
|
+
private renderDangerousInnerHtml(element: IRElement): string | null {
|
|
585
|
+
const resolution = resolveDangerousInnerHtml(element)
|
|
586
|
+
if (!resolution) return null
|
|
587
|
+
if (resolution.kind === 'dynamic') {
|
|
588
|
+
this.errors.push(dangerousInnerHtmlDiagnostic(resolution.expr, resolution.loc))
|
|
589
|
+
return ''
|
|
590
|
+
}
|
|
591
|
+
const violation = dangerousInnerHtmlMetacharViolation(resolution.html, this.name)
|
|
592
|
+
if (violation) {
|
|
593
|
+
const attr = element.attrs.find(isDangerousInnerHtmlAttr)!
|
|
594
|
+
this.errors.push(dangerousInnerHtmlDiagnostic(`{ __html: ${JSON.stringify(resolution.html)} }`, attr.loc, violation))
|
|
595
|
+
return ''
|
|
596
|
+
}
|
|
597
|
+
return resolution.html
|
|
598
|
+
}
|
|
599
|
+
|
|
548
600
|
// ===========================================================================
|
|
549
601
|
// Expression Rendering
|
|
550
602
|
// ===========================================================================
|
|
@@ -558,8 +610,12 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
|
|
|
558
610
|
}
|
|
559
611
|
|
|
560
612
|
// Text-position interpolation of a possibly-non-string value — see the
|
|
561
|
-
// file header, divergence 2.
|
|
562
|
-
|
|
613
|
+
// file header, divergence 2. Thread the IR-carried `.parsed` tree
|
|
614
|
+
// through (mirrors go-template's `convertExpressionToGo(expr.expr,
|
|
615
|
+
// classify, expr.parsed)`) so a resolved bare-identifier
|
|
616
|
+
// `.map`/`.filter`/… callback (`resolveCallbackMethodFunctionReferences`,
|
|
617
|
+
// #2206) isn't lost to a fresh, unresolved re-parse of the raw string.
|
|
618
|
+
const jinjaExpr = `bf.string(${this.convertExpressionToJinja(expr.expr, expr.parsed)})`
|
|
563
619
|
|
|
564
620
|
if (expr.slotId) {
|
|
565
621
|
return `{{ bf.text_start("${expr.slotId}") | safe }}{{ ${jinjaExpr} }}{{ bf.text_end() | safe }}`
|
|
@@ -706,8 +762,27 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
|
|
|
706
762
|
// test corpus only because the widened destructure gate (#2087 Phase
|
|
707
763
|
// A/B) no longer refuses this fixture's `([emoji, users]) => ...`
|
|
708
764
|
// param first.
|
|
765
|
+
// #2208: a loop source that is a fully-static array literal — either
|
|
766
|
+
// inline (`[{ label: 'Alpha' }, ...].map(...)`) or a bare identifier
|
|
767
|
+
// bound to a FUNCTION-scope local const whose initializer has no
|
|
768
|
+
// prop/signal/function-call dependency — inlines as a native Jinja
|
|
769
|
+
// list/dict literal below, the same way a module-scope const's value
|
|
770
|
+
// is already seeded. A runtime-computed local (#2069, e.g.
|
|
771
|
+
// `Object.entries(props.tags).filter(...)`) still refuses below.
|
|
772
|
+
// `isNameShadowed` guards a DIFFERENT, enclosing loop's own callback
|
|
773
|
+
// param shadowing this identifier (fable review) — never resolve the
|
|
774
|
+
// static const in that case. `rawArray` then falls through to the
|
|
775
|
+
// bare identifier expression below, same as before #2208 — which
|
|
776
|
+
// still trips the pre-existing BF101 gate for an unresolvable local
|
|
777
|
+
// const reference (a loud, conservative refusal, not a silent wrong
|
|
778
|
+
// value).
|
|
779
|
+
const staticItems = resolveStaticLoopSource(loop.arrayParsed, this.localConstants, {
|
|
780
|
+
isNameShadowed: name => this.staticLoopSourceBoundNames.has(name),
|
|
781
|
+
})
|
|
782
|
+
const staticArray = staticItems !== null ? staticValueToJinja(staticItems) : null
|
|
783
|
+
|
|
709
784
|
const arrayName = loop.array.trim()
|
|
710
|
-
if (/^[A-Za-z_$][\w$]*$/.test(arrayName)) {
|
|
785
|
+
if (staticArray === null && /^[A-Za-z_$][\w$]*$/.test(arrayName)) {
|
|
711
786
|
const arrayConst = (this.localConstants ?? []).find(c => c.name === arrayName)
|
|
712
787
|
if (arrayConst && !arrayConst.isModule && this._resolveLiteralConst(arrayName) === null) {
|
|
713
788
|
this.errors.push({
|
|
@@ -723,7 +798,7 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
|
|
|
723
798
|
}
|
|
724
799
|
}
|
|
725
800
|
|
|
726
|
-
const rawArray = this.convertExpressionToJinja(loop.array)
|
|
801
|
+
const rawArray = staticArray ?? this.convertExpressionToJinja(loop.array)
|
|
727
802
|
// Apply sort if present: wrap the loop array in the shared `bf.sort`
|
|
728
803
|
// helper, binding the sorted result to a per-iteration local so the
|
|
729
804
|
// helper runs once.
|
|
@@ -777,7 +852,11 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
|
|
|
777
852
|
// `{...rest}` → `bf.spread_attrs(rest)` emit path both see only the
|
|
778
853
|
// keys NOT already destructured.
|
|
779
854
|
const indexLocalLines: string[] = []
|
|
780
|
-
if (loop.
|
|
855
|
+
if (loop.objectIteration) {
|
|
856
|
+
// `key`/`value` bind directly in the for-header (see `array` below)
|
|
857
|
+
// via Jinja's own dict iteration — no derived `loop.index0` local
|
|
858
|
+
// needed, unlike the array `iterationShape` cases.
|
|
859
|
+
} else if (loop.iterationShape === 'keys') {
|
|
781
860
|
indexLocalLines.push(`{% set ${jinjaIdent(param)} = loop.index0 %}`)
|
|
782
861
|
} else if (loop.index) {
|
|
783
862
|
indexLocalLines.push(`{% set ${jinjaIdent(loop.index)} = loop.index0 %}`)
|
|
@@ -802,10 +881,13 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
|
|
|
802
881
|
|
|
803
882
|
const prevInLoop = this.inLoop
|
|
804
883
|
this.inLoop = true
|
|
884
|
+
const prevLoopKeyDepth = this.currentLoopKeyDepth
|
|
885
|
+
this.currentLoopKeyDepth = loop.depth
|
|
805
886
|
// Re-render children now that inLoop is set (so nested components use the
|
|
806
887
|
// loop-child naming convention). renderedChildren above was computed with
|
|
807
888
|
// the previous flag; recompute under the loop flag.
|
|
808
889
|
const childrenUnderLoop = this.renderChildren(loop.children)
|
|
890
|
+
this.currentLoopKeyDepth = prevLoopKeyDepth
|
|
809
891
|
this.inLoop = prevInLoop
|
|
810
892
|
void renderedChildren
|
|
811
893
|
|
|
@@ -822,7 +904,19 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
|
|
|
822
904
|
// Scoped per-call-site marker so sibling `.map()`s under the same parent
|
|
823
905
|
// each get their own reconciliation range.
|
|
824
906
|
lines.push(`{{ bf.comment("loop:${loop.markerId}") | safe }}`)
|
|
825
|
-
|
|
907
|
+
// `objectIteration` (#2168 object-entries-map): Python `dict` preserves
|
|
908
|
+
// JS `Object.entries()`'s insertion-order semantics natively, so this
|
|
909
|
+
// lowers straight to Jinja's own dict-iteration forms — no runtime
|
|
910
|
+
// helper needed. `.items()` binds `index` (the KEY) alongside `param`;
|
|
911
|
+
// `.keys()`/`.values()` bind `param` alone.
|
|
912
|
+
const forHeader = loop.objectIteration === 'entries'
|
|
913
|
+
? `{% for ${jinjaIdent(loop.index ?? param)}, ${jinjaIdent(param)} in ${array}.items() %}`
|
|
914
|
+
: loop.objectIteration === 'keys'
|
|
915
|
+
? `{% for ${jinjaIdent(param)} in ${array}.keys() %}`
|
|
916
|
+
: loop.objectIteration === 'values'
|
|
917
|
+
? `{% for ${jinjaIdent(param)} in ${array}.values() %}`
|
|
918
|
+
: `{% for ${jinjaIdent(loopVar)} in ${array} %}`
|
|
919
|
+
lines.push(forHeader)
|
|
826
920
|
for (const il of indexLocalLines) lines.push(il)
|
|
827
921
|
|
|
828
922
|
// Handle filter().map() pattern by wrapping children in if-condition
|
|
@@ -974,11 +1068,33 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
|
|
|
974
1068
|
type Segment = { kind: 'entries'; parts: string[] } | { kind: 'spread'; expr: string }
|
|
975
1069
|
const segments: Segment[] = [{ kind: 'entries', parts: [] }]
|
|
976
1070
|
const currentEntries = () => this.componentPropSegmentEntries(segments)
|
|
1071
|
+
// Named JSX-valued props OTHER than the reserved `children`
|
|
1072
|
+
// (`header={<strong>Title</strong>}`, #2168 jsx-element-prop) each get
|
|
1073
|
+
// their own `{% set %}` capture, prepended to the final returned
|
|
1074
|
+
// string below — same mechanism as the reserved children capture,
|
|
1075
|
+
// just keyed by the prop's own name instead of `children`.
|
|
1076
|
+
const namedSlotSetBlocks: string[] = []
|
|
977
1077
|
|
|
978
1078
|
for (const p of comp.props) {
|
|
979
1079
|
// Skip callback props (onXxx) and `ref` — both are client-only for
|
|
980
1080
|
// SSR (Hono renders neither; the client JS wires them at hydration).
|
|
981
1081
|
if ((p.name.match(/^on[A-Z]/) || p.name === 'ref') && p.value.kind === 'expression') continue
|
|
1082
|
+
if (p.value.kind === 'jsx-children' && p.name !== 'children') {
|
|
1083
|
+
const prevInLoop = this.inLoop
|
|
1084
|
+
this.inLoop = false
|
|
1085
|
+
const slotBody = this.renderChildren(p.value.children)
|
|
1086
|
+
this.inLoop = prevInLoop
|
|
1087
|
+
// Purely counter-based — NOT derived from `p.name` or `comp.slotId`.
|
|
1088
|
+
// A JSX prop name can contain characters (`data-slot`) that aren't a
|
|
1089
|
+
// valid Jinja `{% set %}` target, and `comp.slotId` alone would
|
|
1090
|
+
// collide across two named-slot props on the same component
|
|
1091
|
+
// invocation (unlike the reserved children slot, there's only ever
|
|
1092
|
+
// one of those per invocation).
|
|
1093
|
+
const captureName = `bf_prop_${this.childrenCaptureCounter++}`
|
|
1094
|
+
namedSlotSetBlocks.push(`{% set ${captureName} %}${slotBody}{% endset %}`)
|
|
1095
|
+
currentEntries().push(`${jinjaHashKey(p.name)}: ${captureName}`)
|
|
1096
|
+
continue
|
|
1097
|
+
}
|
|
982
1098
|
if (p.value.kind === 'spread') {
|
|
983
1099
|
const trimmed = p.value.expr.trim()
|
|
984
1100
|
// SolidJS-style props identifier (`function(props: P)`) has no
|
|
@@ -1042,12 +1158,12 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
|
|
|
1042
1158
|
const captureName = `bf_children_${comp.slotId ?? 'c' + this.childrenCaptureCounter++}`
|
|
1043
1159
|
currentEntries().push(`${jinjaHashKey('children')}: ${captureName}`)
|
|
1044
1160
|
const dict = this.combineComponentPropSegments(segments)
|
|
1045
|
-
return
|
|
1161
|
+
return `${namedSlotSetBlocks.join('')}{% set ${captureName} %}${childrenBody}{% endset %}{{ bf.render_child('${tplName}', ${dict}) | safe }}`
|
|
1046
1162
|
}
|
|
1047
1163
|
|
|
1048
1164
|
const isEmpty = segments.every(s => s.kind === 'entries' && s.parts.length === 0)
|
|
1049
1165
|
const dictEntries = isEmpty ? '' : `, ${this.combineComponentPropSegments(segments)}`
|
|
1050
|
-
return
|
|
1166
|
+
return `${namedSlotSetBlocks.join('')}{{ bf.render_child('${tplName}'${dictEntries}) | safe }}`
|
|
1051
1167
|
}
|
|
1052
1168
|
|
|
1053
1169
|
private childrenCaptureCounter = 0
|
|
@@ -1132,7 +1248,7 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
|
|
|
1132
1248
|
* AttrValue lowering for intrinsic-element attributes (Jinja).
|
|
1133
1249
|
*/
|
|
1134
1250
|
private readonly elementAttrEmitter: AttrValueEmitter = {
|
|
1135
|
-
emitLiteral: (value, name) => `${name}="${value.value}"`,
|
|
1251
|
+
emitLiteral: (value, name) => `${name}="${escapeHtml(value.value)}"`,
|
|
1136
1252
|
emitExpression: (value, name) => {
|
|
1137
1253
|
// `style={{ … }}` object literal → a CSS string with dynamic values
|
|
1138
1254
|
// interpolated, instead of refusing the bare object with BF101 (#1322).
|
|
@@ -1334,10 +1450,19 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
|
|
|
1334
1450
|
// the unsupported-expression lowering is never reached for a deferred
|
|
1335
1451
|
// predicate (no BF101 / BF102). #1966
|
|
1336
1452
|
if (attr.clientOnly) continue
|
|
1453
|
+
// `dangerouslySetInnerHTML` never renders as an HTML attribute — it's
|
|
1454
|
+
// handled by `renderDangerousInnerHtml` instead, which replaces the
|
|
1455
|
+
// element's children. Skip it here so its `{ __html: ... }` object
|
|
1456
|
+
// literal never reaches the generic object-literal BF101 refusal
|
|
1457
|
+
// (which would double-report alongside the purpose-built one).
|
|
1458
|
+
if (isDangerousInnerHtmlAttr(attr)) continue
|
|
1337
1459
|
// Rewrite JSX special-prop names to their HTML-attribute counterparts.
|
|
1338
1460
|
let attrName: string
|
|
1339
1461
|
if (attr.name === 'className') attrName = 'class'
|
|
1340
|
-
else if (attr.name === 'key')
|
|
1462
|
+
else if (attr.name === 'key') {
|
|
1463
|
+
const depth = this.currentLoopKeyDepth
|
|
1464
|
+
attrName = depth > 0 ? `data-key-${depth}` : 'data-key'
|
|
1465
|
+
}
|
|
1341
1466
|
else attrName = attr.name
|
|
1342
1467
|
const lowered = emitAttrValue(attr.value, this.elementAttrEmitter, attrName)
|
|
1343
1468
|
if (lowered) parts.push(lowered)
|
|
@@ -1695,8 +1820,19 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
|
|
|
1695
1820
|
* single-quoted string literal (`const totalPages = 5`, #1897
|
|
1696
1821
|
* pagination) — function-scope consts never reach the per-render
|
|
1697
1822
|
* context, so a bare reference would resolve to Undefined.
|
|
1823
|
+
*
|
|
1824
|
+
* The lookup is a flat name match with no notion of AST scope, so a
|
|
1825
|
+
* name that any loop callback binds as its item/index param never
|
|
1826
|
+
* inlines (#2221) — the occurrence may be the loop's own (shadowing)
|
|
1827
|
+
* binding, and substituting the outer const's value there renders every
|
|
1828
|
+
* iteration with the same hard-coded literal. Coarse (a genuinely
|
|
1829
|
+
* non-shadowed same-named const elsewhere in the component also stops
|
|
1830
|
+
* inlining, falling back to the bare identifier) but safe — the same
|
|
1831
|
+
* trade-off as #2212's `collectLoopBoundNames` use in
|
|
1832
|
+
* `collectStringValueNames`.
|
|
1698
1833
|
*/
|
|
1699
1834
|
private _resolveLiteralConst(name: string): string | null {
|
|
1835
|
+
if (this.staticLoopSourceBoundNames.has(name)) return null
|
|
1700
1836
|
const c = (this.localConstants ?? []).find(lc => lc.name === name)
|
|
1701
1837
|
if (c?.value === undefined) return null
|
|
1702
1838
|
const v = c.value.trim()
|
|
@@ -1706,7 +1842,22 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
|
|
|
1706
1842
|
return null
|
|
1707
1843
|
}
|
|
1708
1844
|
|
|
1845
|
+
/**
|
|
1846
|
+
* Resolve `IDENT.key` where `IDENT` is a module-scope object-literal const
|
|
1847
|
+
* (`variantClasses.ghost`, #1896/#1897) to the looked-up scalar.
|
|
1848
|
+
*
|
|
1849
|
+
* The lookup is a flat name match on `objectName` with no notion of AST
|
|
1850
|
+
* scope, so an enclosing loop callback's own param of the same name
|
|
1851
|
+
* (`.map((cfg) => <li>{cfg.x}</li>)` shadowing a module `const cfg = {…}`)
|
|
1852
|
+
* still resolved to the OUTER const's member value at every iteration
|
|
1853
|
+
* (#2237) — the sibling hazard to #2221's `_resolveLiteralConst`. Same
|
|
1854
|
+
* coarse-but-safe `staticLoopSourceBoundNames` guard: any name a loop
|
|
1855
|
+
* binds anywhere in the component never inlines, falling back to the bare
|
|
1856
|
+
* `cfg['x']` member expression (which a Jinja for-loop binds correctly
|
|
1857
|
+
* at the shadowed occurrences).
|
|
1858
|
+
*/
|
|
1709
1859
|
private _resolveStaticRecordLiteral(objectName: string, key: string): string | null {
|
|
1860
|
+
if (this.staticLoopSourceBoundNames.has(objectName)) return null
|
|
1710
1861
|
const hit = lookupStaticRecordLiteral(objectName, key, this.localConstants)
|
|
1711
1862
|
if (!hit) return null
|
|
1712
1863
|
return hit.kind === 'number'
|
|
@@ -22,6 +22,9 @@ export const JINJA_TEMPLATE_PRIMITIVES: Record<string, PrimitiveSpec> = {
|
|
|
22
22
|
'Math.floor': { arity: 1, emit: (args) => `bf.floor(${args[0]})` },
|
|
23
23
|
'Math.ceil': { arity: 1, emit: (args) => `bf.ceil(${args[0]})` },
|
|
24
24
|
'Math.round': { arity: 1, emit: (args) => `bf.round(${args[0]})` },
|
|
25
|
+
'Math.min': { arity: 2, emit: (args) => `bf.min(${args[0]}, ${args[1]})` },
|
|
26
|
+
'Math.max': { arity: 2, emit: (args) => `bf.max(${args[0]}, ${args[1]})` },
|
|
27
|
+
'Math.abs': { arity: 1, emit: (args) => `bf.abs(${args[0]})` },
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
/**
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serialize a compile-time-evaluated JS value (`@barefootjs/jsx`'s
|
|
3
|
+
* `evaluateStaticLiteral`/`resolveStaticLoopSource`, #2208) into a native
|
|
4
|
+
* Jinja2 literal. Used to inline a fully-static loop source (an inline
|
|
5
|
+
* array literal, or a function-scope local const with a static
|
|
6
|
+
* initializer) directly in a `{% for %}` header, rather than requiring a
|
|
7
|
+
* bound template variable.
|
|
8
|
+
*
|
|
9
|
+
* Returns `null` for a value this adapter can't represent as a literal
|
|
10
|
+
* (e.g. `undefined` reads for a missing object key are still representable
|
|
11
|
+
* as `none`, but anything else falls back to the caller's existing BF101
|
|
12
|
+
* refusal instead of guessing).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { escapeJinjaSingleQuoted, jinjaHashKey } from './jinja-naming.ts'
|
|
16
|
+
|
|
17
|
+
export function staticValueToJinja(value: unknown): string | null {
|
|
18
|
+
if (value === null || value === undefined) return 'none'
|
|
19
|
+
if (typeof value === 'boolean') return value ? 'true' : 'false'
|
|
20
|
+
if (typeof value === 'number') return String(value)
|
|
21
|
+
if (typeof value === 'string') return `'${escapeJinjaSingleQuoted(value)}'`
|
|
22
|
+
if (Array.isArray(value)) {
|
|
23
|
+
const items: string[] = []
|
|
24
|
+
for (const el of value) {
|
|
25
|
+
const serialized = staticValueToJinja(el)
|
|
26
|
+
if (serialized === null) return null
|
|
27
|
+
items.push(serialized)
|
|
28
|
+
}
|
|
29
|
+
return `[${items.join(', ')}]`
|
|
30
|
+
}
|
|
31
|
+
if (typeof value === 'object') {
|
|
32
|
+
const entries: string[] = []
|
|
33
|
+
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
|
|
34
|
+
const serialized = staticValueToJinja(val)
|
|
35
|
+
if (serialized === null) return null
|
|
36
|
+
entries.push(`${jinjaHashKey(key)}: ${serialized}`)
|
|
37
|
+
}
|
|
38
|
+
return `{${entries.join(', ')}}`
|
|
39
|
+
}
|
|
40
|
+
return null
|
|
41
|
+
}
|