@barefootjs/rust 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 +118 -18
- package/dist/adapter/lib/constants.d.ts.map +1 -1
- package/dist/adapter/lib/static-value.d.ts +13 -0
- package/dist/adapter/lib/static-value.d.ts.map +1 -0
- package/dist/adapter/minijinja-adapter.d.ts +49 -0
- package/dist/adapter/minijinja-adapter.d.ts.map +1 -1
- package/dist/build.js +118 -18
- package/dist/conformance-pins.d.ts.map +1 -1
- package/dist/index.js +120 -35
- package/dist/render-divergences.d.ts.map +1 -1
- package/package.json +3 -3
- package/runtime/src/num.rs +30 -0
- package/runtime/src/runtime.rs +78 -10
- package/runtime/tests/helper_vectors.rs +6 -0
- package/runtime/tests/template_primitives.rs +42 -0
- package/src/__tests__/minijinja-adapter-unit.test.ts +140 -0
- package/src/adapter/expr/array-method.ts +19 -0
- package/src/adapter/expr/emitters.ts +2 -2
- package/src/adapter/lib/constants.ts +3 -0
- package/src/adapter/lib/static-value.ts +39 -0
- package/src/adapter/minijinja-adapter.ts +170 -13
- package/src/conformance-pins.ts +24 -30
- package/src/render-divergences.ts +4 -16
- package/src/test-render.ts +11 -144
|
@@ -385,8 +385,148 @@ export function C(props: { count: number }) {
|
|
|
385
385
|
})
|
|
386
386
|
})
|
|
387
387
|
|
|
388
|
+
describe('MinijinjaAdapter - named-slot capture identifier safety (#2168 jsx-element-prop)', () => {
|
|
389
|
+
// A JSX-valued prop under a hyphenated name (`data-slot`, a valid JSX
|
|
390
|
+
// attribute name) must not leak into the `{% set %}` capture variable's
|
|
391
|
+
// identifier — minijinja variable names can't contain `-`. The capture
|
|
392
|
+
// identifier is purely counter-based (never derived from the prop name);
|
|
393
|
+
// the hash KEY passed to `render_child` still carries the real name,
|
|
394
|
+
// quoted via `minijinjaHashKey`.
|
|
395
|
+
test('a hyphenated prop name does not appear in the capture variable', () => {
|
|
396
|
+
const { template } = compileAndGenerate(`
|
|
397
|
+
function Card(props) { return null }
|
|
398
|
+
export function Parent() {
|
|
399
|
+
return <Card data-slot={<strong>Title</strong>}>text</Card>
|
|
400
|
+
}
|
|
401
|
+
`)
|
|
402
|
+
expect(template).toContain('{% set bf_prop_0 %}')
|
|
403
|
+
expect(template).toContain("'data-slot': bf_prop_0")
|
|
404
|
+
expect(template).not.toContain('data-slot %}')
|
|
405
|
+
expect(template).not.toContain('data-slot_')
|
|
406
|
+
})
|
|
407
|
+
})
|
|
408
|
+
|
|
388
409
|
// #2038 nested-callback-predicate loudness is pinned at the shared
|
|
389
410
|
// conformance layer (workstream C): `filter-nested-callback-predicate` /
|
|
390
411
|
// `filter-nested-find-predicate` (BF101 via `expectedDiagnostics`) and
|
|
391
412
|
// `filter-nested-callback-predicate-client` (the `/* @client */` suppression
|
|
392
413
|
// twin, which must render clean).
|
|
414
|
+
|
|
415
|
+
// #2221: `_resolveLiteralConst` is a flat name lookup against
|
|
416
|
+
// `ir.metadata.localConstants` with no notion of AST scope — it used to
|
|
417
|
+
// substitute an outer const's literal value even at an occurrence that is
|
|
418
|
+
// actually an enclosing loop callback's own (shadowing) parameter, so every
|
|
419
|
+
// iteration rendered the same hard-coded literal. Guarded with the same
|
|
420
|
+
// coarse `collectLoopBoundNames` exclusion as #2212: any name a loop binds
|
|
421
|
+
// anywhere in the component never inlines, falling back to the bare
|
|
422
|
+
// identifier.
|
|
423
|
+
describe('MinijinjaAdapter - const inlining vs loop-param shadowing (#2221)', () => {
|
|
424
|
+
test('a loop param shadowing an outer literal const emits the identifier, not the const value', () => {
|
|
425
|
+
const { template } = compileAndGenerate(`
|
|
426
|
+
function Widget() {
|
|
427
|
+
const label: string = 'x'
|
|
428
|
+
return <ul>{[2, 5].map((label) => <li key={label}>{1 + label}</li>)}</ul>
|
|
429
|
+
}
|
|
430
|
+
`)
|
|
431
|
+
// The loop body must reference the per-iteration loop var...
|
|
432
|
+
expect(template).toContain('1 + label')
|
|
433
|
+
// ...never the outer const's hard-coded value.
|
|
434
|
+
expect(template).not.toContain("1 + 'x'")
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
test('a numeric const shadowed by a loop param emits the identifier too', () => {
|
|
438
|
+
const { template } = compileAndGenerate(`
|
|
439
|
+
function Widget() {
|
|
440
|
+
const count = 7
|
|
441
|
+
return <ul>{[2, 5].map((count) => <li key={count}>{1 + count}</li>)}</ul>
|
|
442
|
+
}
|
|
443
|
+
`)
|
|
444
|
+
expect(template).toContain('1 + count')
|
|
445
|
+
expect(template).not.toContain('1 + 7')
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
test('a literal const NOT shadowed by any loop still inlines (#1897 pin)', () => {
|
|
449
|
+
const { template } = compileAndGenerate(`
|
|
450
|
+
function Widget({ values }: { values: number[] }) {
|
|
451
|
+
const totalPages = 5
|
|
452
|
+
return <div>
|
|
453
|
+
<p>Page 1 of {1 + totalPages}</p>
|
|
454
|
+
<ul>{values.map((v) => <li key={v}>{v}</li>)}</ul>
|
|
455
|
+
</div>
|
|
456
|
+
}
|
|
457
|
+
`)
|
|
458
|
+
expect(template).toContain('1 + 5')
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
// The accepted coarse-exclusion trade-off (same as #2212): a name that is
|
|
462
|
+
// loop-bound ANYWHERE in the component never inlines, even at a genuinely
|
|
463
|
+
// non-shadowed occurrence outside the loop — the bare identifier is
|
|
464
|
+
// emitted instead of the value.
|
|
465
|
+
test('a const referenced outside the loop whose name is loop-bound elsewhere falls back to the identifier (accepted trade-off)', () => {
|
|
466
|
+
const { template } = compileAndGenerate(`
|
|
467
|
+
function Widget({ values }: { values: number[] }) {
|
|
468
|
+
const label: string = 'x'
|
|
469
|
+
return <div>
|
|
470
|
+
<p>{1 + label}</p>
|
|
471
|
+
<ul>{values.map((label) => <li key={label}>{2 + label}</li>)}</ul>
|
|
472
|
+
</div>
|
|
473
|
+
}
|
|
474
|
+
`)
|
|
475
|
+
expect(template).not.toContain("1 + 'x'")
|
|
476
|
+
expect(template).toContain('2 + label')
|
|
477
|
+
})
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
// #2237: `_resolveStaticRecordLiteral` (`IDENT.key` on a module-scope
|
|
481
|
+
// object-literal const, e.g. `variantClasses.ghost` — #1896/#1897) is a
|
|
482
|
+
// flat name lookup on `objectName` with no notion of AST scope, the
|
|
483
|
+
// record-literal sibling of #2221's `_resolveLiteralConst` bug. It used to
|
|
484
|
+
// substitute the outer const's member value even at an occurrence that is
|
|
485
|
+
// actually an enclosing loop callback's own (shadowing) parameter, so every
|
|
486
|
+
// iteration rendered the same hard-coded literal instead of the per-item
|
|
487
|
+
// value. Guarded with the same coarse `staticLoopSourceBoundNames`
|
|
488
|
+
// exclusion as #2221: any name a loop binds anywhere in the component
|
|
489
|
+
// never inlines, falling back to the bare `cfg.x` member expression.
|
|
490
|
+
describe('MinijinjaAdapter - record-literal member lookup vs loop-param shadowing (#2237)', () => {
|
|
491
|
+
test('a loop param shadowing an outer module object const emits the member access, not the outer literal', () => {
|
|
492
|
+
const { template } = compileAndGenerate(`
|
|
493
|
+
const cfg = { x: 'outer-lit' }
|
|
494
|
+
function Widget({ rows }: { rows: { x: string }[] }) {
|
|
495
|
+
return <ul>{rows.map((cfg) => <li key={cfg.x}>{cfg.x}</li>)}</ul>
|
|
496
|
+
}
|
|
497
|
+
`)
|
|
498
|
+
// The loop body must reference the per-iteration member access...
|
|
499
|
+
expect(template).toContain('bf.string(cfg.x)')
|
|
500
|
+
// ...never the outer const's hard-coded value.
|
|
501
|
+
expect(template).not.toContain("bf.string('outer-lit')")
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
test('a module object const NOT shadowed by any loop still inlines (variantClasses.ghost shape, #1896/#1897 pin)', () => {
|
|
505
|
+
const { template } = compileAndGenerate(`
|
|
506
|
+
const variantClasses = { solid: 'bg-solid', ghost: 'bg-ghost' }
|
|
507
|
+
function Widget({ variant }: { variant: 'solid' | 'ghost' }) {
|
|
508
|
+
return <div>{variantClasses.ghost}</div>
|
|
509
|
+
}
|
|
510
|
+
`)
|
|
511
|
+
expect(template).toContain("bf.string('bg-ghost')")
|
|
512
|
+
})
|
|
513
|
+
|
|
514
|
+
// The accepted coarse-exclusion trade-off (same as #2221/#2212): an
|
|
515
|
+
// object name that is loop-bound ANYWHERE in the component never
|
|
516
|
+
// inlines its member lookups, even at a genuinely non-shadowed
|
|
517
|
+
// occurrence outside the loop — the bare member expression is emitted
|
|
518
|
+
// instead of the value.
|
|
519
|
+
test('a record member referenced outside the loop whose object name is loop-bound elsewhere falls back to the member expression (accepted trade-off)', () => {
|
|
520
|
+
const { template } = compileAndGenerate(`
|
|
521
|
+
const cfg = { x: 'outer-lit' }
|
|
522
|
+
function Widget({ rows }: { rows: { x: string }[] }) {
|
|
523
|
+
return <div>
|
|
524
|
+
<p>{cfg.x}</p>
|
|
525
|
+
<ul>{rows.map((cfg) => <li key={cfg.x}>{cfg.x}</li>)}</ul>
|
|
526
|
+
</div>
|
|
527
|
+
}
|
|
528
|
+
`)
|
|
529
|
+
expect(template).not.toContain("bf.string('outer-lit')")
|
|
530
|
+
expect(template).toContain('bf.string(cfg.x)')
|
|
531
|
+
})
|
|
532
|
+
})
|
|
@@ -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`
|
|
141
|
+
// with a flag) — the regex-pattern form is refused upstream at
|
|
142
|
+
// the 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
|
|
@@ -313,7 +313,7 @@ export class JinjaTopLevelEmitter implements ParsedExprEmitter {
|
|
|
313
313
|
return String(value)
|
|
314
314
|
}
|
|
315
315
|
|
|
316
|
-
member(object: ParsedExpr, property: string, _computed: boolean, emit: (e: ParsedExpr) => string): string {
|
|
316
|
+
member(object: ParsedExpr, property: string, _computed: boolean, _optional: boolean, emit: (e: ParsedExpr) => string): string {
|
|
317
317
|
// `props.x` flattens to the bare context var the SSR caller binds each
|
|
318
318
|
// prop to (props arrive as individual top-level context entries, not a
|
|
319
319
|
// nested `props` dict).
|
|
@@ -26,6 +26,9 @@ export const JINJA_TEMPLATE_PRIMITIVES: Record<string, PrimitiveSpec> = {
|
|
|
26
26
|
'Math.floor': { arity: 1, emit: (args) => `bf.floor(${args[0]})` },
|
|
27
27
|
'Math.ceil': { arity: 1, emit: (args) => `bf.ceil(${args[0]})` },
|
|
28
28
|
'Math.round': { arity: 1, emit: (args) => `bf.round(${args[0]})` },
|
|
29
|
+
'Math.min': { arity: 2, emit: (args) => `bf.min(${args[0]}, ${args[1]})` },
|
|
30
|
+
'Math.max': { arity: 2, emit: (args) => `bf.max(${args[0]}, ${args[1]})` },
|
|
31
|
+
'Math.abs': { arity: 1, emit: (args) => `bf.abs(${args[0]})` },
|
|
29
32
|
}
|
|
30
33
|
|
|
31
34
|
/**
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serialize a compile-time-evaluated JS value (`@barefootjs/jsx`'s
|
|
3
|
+
* `evaluateStaticLiteral`/`resolveStaticLoopSource`, #2208) into a native
|
|
4
|
+
* MiniJinja 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
|
+
* the caller falls back to its existing BF101 refusal instead of guessing.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { escapeMinijinjaSingleQuoted, minijinjaHashKey } from './minijinja-naming.ts'
|
|
14
|
+
|
|
15
|
+
export function staticValueToMinijinja(value: unknown): string | null {
|
|
16
|
+
if (value === null || value === undefined) return 'none'
|
|
17
|
+
if (typeof value === 'boolean') return value ? 'true' : 'false'
|
|
18
|
+
if (typeof value === 'number') return String(value)
|
|
19
|
+
if (typeof value === 'string') return `'${escapeMinijinjaSingleQuoted(value)}'`
|
|
20
|
+
if (Array.isArray(value)) {
|
|
21
|
+
const items: string[] = []
|
|
22
|
+
for (const el of value) {
|
|
23
|
+
const serialized = staticValueToMinijinja(el)
|
|
24
|
+
if (serialized === null) return null
|
|
25
|
+
items.push(serialized)
|
|
26
|
+
}
|
|
27
|
+
return `[${items.join(', ')}]`
|
|
28
|
+
}
|
|
29
|
+
if (typeof value === 'object') {
|
|
30
|
+
const entries: string[] = []
|
|
31
|
+
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
|
|
32
|
+
const serialized = staticValueToMinijinja(val)
|
|
33
|
+
if (serialized === null) return null
|
|
34
|
+
entries.push(`${minijinjaHashKey(key)}: ${serialized}`)
|
|
35
|
+
}
|
|
36
|
+
return `{${entries.join(', ')}}`
|
|
37
|
+
}
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
@@ -165,10 +165,16 @@ import {
|
|
|
165
165
|
queryHrefArgs,
|
|
166
166
|
isValidHelperId,
|
|
167
167
|
sortComparatorFromArrow,
|
|
168
|
+
isDangerousInnerHtmlAttr,
|
|
169
|
+
resolveDangerousInnerHtml,
|
|
170
|
+
dangerousInnerHtmlMetacharViolation,
|
|
171
|
+
dangerousInnerHtmlDiagnostic,
|
|
172
|
+
resolveStaticLoopSource,
|
|
173
|
+
collectLoopBoundNames,
|
|
168
174
|
} from '@barefootjs/jsx'
|
|
169
175
|
import { isAriaBooleanAttr, isBooleanResultExpr, isExplicitStringCall } from './boolean-result.ts'
|
|
170
176
|
import type { ParsedExpr, LoweringMatcher, LoopBindingPathSegment } from '@barefootjs/jsx'
|
|
171
|
-
import { BF_SLOT, BF_COND, BF_REGION } from '@barefootjs/shared'
|
|
177
|
+
import { BF_SLOT, BF_COND, BF_REGION, escapeHtml } from '@barefootjs/shared'
|
|
172
178
|
|
|
173
179
|
import type { JinjaRenderCtx } from './lib/types.ts'
|
|
174
180
|
import { JINJA_PRIMITIVE_EMIT_MAP } from './lib/constants.ts'
|
|
@@ -178,6 +184,7 @@ import {
|
|
|
178
184
|
collectRootScopeNodes,
|
|
179
185
|
} from './lib/ir-scope.ts'
|
|
180
186
|
import { renderSortMethod, renderSortEval } from './expr/array-method.ts'
|
|
187
|
+
import { staticValueToMinijinja } from './lib/static-value.ts'
|
|
181
188
|
import { JinjaFilterEmitter, JinjaTopLevelEmitter, truthyTest } from './expr/emitters.ts'
|
|
182
189
|
import type { JinjaEmitContext, JinjaSpreadContext, JinjaMemoContext } from './emit-context.ts'
|
|
183
190
|
import {
|
|
@@ -254,6 +261,14 @@ export class MinijinjaAdapter extends BaseAdapter implements IRNodeEmitter<Jinja
|
|
|
254
261
|
private options: Required<MinijinjaAdapterOptions>
|
|
255
262
|
private errors: CompilerError[] = []
|
|
256
263
|
private inLoop: boolean = false
|
|
264
|
+
/**
|
|
265
|
+
* `IRLoop.depth` of the loop currently being rendered (save/restore
|
|
266
|
+
* around `renderChildren(loop.children)`, mirroring `inLoop` above).
|
|
267
|
+
* `renderAttributes` reads this to derive the `key` → `data-key`/
|
|
268
|
+
* `data-key-N` suffix — the depth is IR-computed (jsx-to-ir.ts), not
|
|
269
|
+
* re-derived here (#2168 nested-loop-outer-binding).
|
|
270
|
+
*/
|
|
271
|
+
private currentLoopKeyDepth = 0
|
|
257
272
|
/**
|
|
258
273
|
* SolidJS-style props identifier (`function(props: P)`) and the
|
|
259
274
|
* analyzer-extracted prop names. Stashed at `generate()` entry so the
|
|
@@ -306,6 +321,17 @@ export class MinijinjaAdapter extends BaseAdapter implements IRNodeEmitter<Jinja
|
|
|
306
321
|
*/
|
|
307
322
|
private localConstants: IRMetadata['localConstants'] = []
|
|
308
323
|
|
|
324
|
+
/**
|
|
325
|
+
* Every name a `.map()`/`.filter()` loop callback binds as its item/index
|
|
326
|
+
* parameter anywhere in the component (#2208 fable review). A static
|
|
327
|
+
* loop-SOURCE name (e.g. a function-scope `const items = [...]`) must
|
|
328
|
+
* never resolve through `resolveStaticLoopSource` at a use site where a
|
|
329
|
+
* DIFFERENT, enclosing loop's own callback param shadows it — same
|
|
330
|
+
* shadowing hazard, and same coarse-but-safe mitigation, as #2212's
|
|
331
|
+
* `collectLoopBoundNames` use in `collectStringValueNames`.
|
|
332
|
+
*/
|
|
333
|
+
private staticLoopSourceBoundNames: Set<string> = new Set()
|
|
334
|
+
|
|
309
335
|
/**
|
|
310
336
|
* Optional, no-default props that are `None` when the caller omits them.
|
|
311
337
|
* Their bare-reference attribute emission is guarded with a Jinja
|
|
@@ -338,6 +364,7 @@ export class MinijinjaAdapter extends BaseAdapter implements IRNodeEmitter<Jinja
|
|
|
338
364
|
// ("True"/"False") (#1897, pagination's data-active).
|
|
339
365
|
this.booleanTypedProps = collectBooleanTypedProps(ir)
|
|
340
366
|
this.localConstants = ir.metadata.localConstants ?? []
|
|
367
|
+
this.staticLoopSourceBoundNames = collectLoopBoundNames(ir)
|
|
341
368
|
this.nullableOptionalProps = collectNullableOptionalProps(ir)
|
|
342
369
|
this.stringValueNames = collectStringValueNames(ir)
|
|
343
370
|
this.moduleStringConsts = collectModuleStringConsts(ir.metadata.localConstants)
|
|
@@ -449,7 +476,9 @@ export class MinijinjaAdapter extends BaseAdapter implements IRNodeEmitter<Jinja
|
|
|
449
476
|
}
|
|
450
477
|
|
|
451
478
|
emitText(node: IRText): string {
|
|
452
|
-
|
|
479
|
+
// IRText carries the entity-DECODED value (Phase 1 decodes JSX
|
|
480
|
+
// character references); re-escape for direct HTML emission.
|
|
481
|
+
return escapeHtml(node.value)
|
|
453
482
|
}
|
|
454
483
|
|
|
455
484
|
emitExpression(node: IRExpression): string {
|
|
@@ -558,7 +587,8 @@ export class MinijinjaAdapter extends BaseAdapter implements IRNodeEmitter<Jinja
|
|
|
558
587
|
renderElement(element: IRElement): string {
|
|
559
588
|
const tag = element.tag
|
|
560
589
|
const attrs = this.renderAttributes(element)
|
|
561
|
-
const
|
|
590
|
+
const dangerousHtml = this.renderDangerousInnerHtml(element)
|
|
591
|
+
const children = dangerousHtml !== null ? dangerousHtml : this.renderChildren(element.children)
|
|
562
592
|
|
|
563
593
|
let hydrationAttrs = ''
|
|
564
594
|
if (element.needsScope) {
|
|
@@ -593,6 +623,28 @@ export class MinijinjaAdapter extends BaseAdapter implements IRNodeEmitter<Jinja
|
|
|
593
623
|
return `<${tag}${attrs}${hydrationAttrs}>${children}</${tag}>`
|
|
594
624
|
}
|
|
595
625
|
|
|
626
|
+
/**
|
|
627
|
+
* `dangerouslySetInnerHTML={{ __html: '...' }}` (#2207) — see the Blade
|
|
628
|
+
* adapter's identical helper for the full rationale. `null` means the
|
|
629
|
+
* attribute is absent (caller falls through to normal `renderChildren`);
|
|
630
|
+
* a non-`null` string (possibly `''`) replaces the children outright.
|
|
631
|
+
*/
|
|
632
|
+
private renderDangerousInnerHtml(element: IRElement): string | null {
|
|
633
|
+
const resolution = resolveDangerousInnerHtml(element)
|
|
634
|
+
if (!resolution) return null
|
|
635
|
+
if (resolution.kind === 'dynamic') {
|
|
636
|
+
this.errors.push(dangerousInnerHtmlDiagnostic(resolution.expr, resolution.loc))
|
|
637
|
+
return ''
|
|
638
|
+
}
|
|
639
|
+
const violation = dangerousInnerHtmlMetacharViolation(resolution.html, this.name)
|
|
640
|
+
if (violation) {
|
|
641
|
+
const attr = element.attrs.find(isDangerousInnerHtmlAttr)!
|
|
642
|
+
this.errors.push(dangerousInnerHtmlDiagnostic(`{ __html: ${JSON.stringify(resolution.html)} }`, attr.loc, violation))
|
|
643
|
+
return ''
|
|
644
|
+
}
|
|
645
|
+
return resolution.html
|
|
646
|
+
}
|
|
647
|
+
|
|
596
648
|
// ===========================================================================
|
|
597
649
|
// Expression Rendering
|
|
598
650
|
// ===========================================================================
|
|
@@ -606,8 +658,12 @@ export class MinijinjaAdapter extends BaseAdapter implements IRNodeEmitter<Jinja
|
|
|
606
658
|
}
|
|
607
659
|
|
|
608
660
|
// Text-position interpolation of a possibly-non-string value — see the
|
|
609
|
-
// file header, divergence 2.
|
|
610
|
-
|
|
661
|
+
// file header, divergence 2. Thread the IR-carried `.parsed` tree
|
|
662
|
+
// through (mirrors go-template's `convertExpressionToGo(expr.expr,
|
|
663
|
+
// classify, expr.parsed)`) so a resolved bare-identifier
|
|
664
|
+
// `.map`/`.filter`/… callback (`resolveCallbackMethodFunctionReferences`,
|
|
665
|
+
// #2206) isn't lost to a fresh, unresolved re-parse of the raw string.
|
|
666
|
+
const jinjaExpr = `bf.string(${this.convertExpressionToJinja(expr.expr, expr.parsed)})`
|
|
611
667
|
|
|
612
668
|
if (expr.slotId) {
|
|
613
669
|
return `{{ bf.text_start("${expr.slotId}") | safe }}{{ ${jinjaExpr} }}{{ bf.text_end() | safe }}`
|
|
@@ -752,8 +808,27 @@ export class MinijinjaAdapter extends BaseAdapter implements IRNodeEmitter<Jinja
|
|
|
752
808
|
// corpus only because the widened destructure gate (#2087 Phase A/B)
|
|
753
809
|
// no longer refuses this fixture's `([emoji, users]) => ...` param
|
|
754
810
|
// first. Mirrors adapter-jinja's identical check.
|
|
811
|
+
// #2208: a loop source that is a fully-static array literal — either
|
|
812
|
+
// inline (`[{ label: 'Alpha' }, ...].map(...)`) or a bare identifier
|
|
813
|
+
// bound to a FUNCTION-scope local const whose initializer has no
|
|
814
|
+
// prop/signal/function-call dependency — inlines as a native MiniJinja
|
|
815
|
+
// list/dict literal below, the same way a module-scope const's value
|
|
816
|
+
// is already seeded. A runtime-computed local (#2069, e.g.
|
|
817
|
+
// `Object.entries(props.tags).filter(...)`) still refuses below.
|
|
818
|
+
// `isNameShadowed` guards a DIFFERENT, enclosing loop's own callback
|
|
819
|
+
// param shadowing this identifier (fable review) — never resolve the
|
|
820
|
+
// static const in that case. `rawArray` then falls through to the
|
|
821
|
+
// bare identifier expression below, same as before #2208 — which
|
|
822
|
+
// still trips the pre-existing BF101 gate for an unresolvable local
|
|
823
|
+
// const reference (a loud, conservative refusal, not a silent wrong
|
|
824
|
+
// value).
|
|
825
|
+
const staticItems = resolveStaticLoopSource(loop.arrayParsed, this.localConstants, {
|
|
826
|
+
isNameShadowed: name => this.staticLoopSourceBoundNames.has(name),
|
|
827
|
+
})
|
|
828
|
+
const staticArray = staticItems !== null ? staticValueToMinijinja(staticItems) : null
|
|
829
|
+
|
|
755
830
|
const arrayName = loop.array.trim()
|
|
756
|
-
if (/^[A-Za-z_$][\w$]*$/.test(arrayName)) {
|
|
831
|
+
if (staticArray === null && /^[A-Za-z_$][\w$]*$/.test(arrayName)) {
|
|
757
832
|
const arrayConst = (this.localConstants ?? []).find(c => c.name === arrayName)
|
|
758
833
|
if (arrayConst && !arrayConst.isModule && this._resolveLiteralConst(arrayName) === null) {
|
|
759
834
|
this.errors.push({
|
|
@@ -769,7 +844,7 @@ export class MinijinjaAdapter extends BaseAdapter implements IRNodeEmitter<Jinja
|
|
|
769
844
|
}
|
|
770
845
|
}
|
|
771
846
|
|
|
772
|
-
const rawArray = this.convertExpressionToJinja(loop.array)
|
|
847
|
+
const rawArray = staticArray ?? this.convertExpressionToJinja(loop.array)
|
|
773
848
|
// Apply sort if present: wrap the loop array in the shared `bf.sort`
|
|
774
849
|
// helper, binding the sorted result to a per-iteration local so the
|
|
775
850
|
// helper runs once.
|
|
@@ -810,7 +885,11 @@ export class MinijinjaAdapter extends BaseAdapter implements IRNodeEmitter<Jinja
|
|
|
810
885
|
// adds one `{% set %}` local per binding (`rest` aliases the item so
|
|
811
886
|
// `rest.flag` resolves).
|
|
812
887
|
const indexLocalLines: string[] = []
|
|
813
|
-
if (loop.
|
|
888
|
+
if (loop.objectIteration) {
|
|
889
|
+
// `key`/`value` bind directly in the for-header (see below) via the
|
|
890
|
+
// `|items` filter — no derived `loop.index0` local needed, unlike
|
|
891
|
+
// the array `iterationShape` cases.
|
|
892
|
+
} else if (loop.iterationShape === 'keys') {
|
|
814
893
|
indexLocalLines.push(`{% set ${minijinjaIdent(param)} = loop.index0 %}`)
|
|
815
894
|
} else if (loop.index) {
|
|
816
895
|
indexLocalLines.push(`{% set ${minijinjaIdent(loop.index)} = loop.index0 %}`)
|
|
@@ -843,10 +922,13 @@ export class MinijinjaAdapter extends BaseAdapter implements IRNodeEmitter<Jinja
|
|
|
843
922
|
|
|
844
923
|
const prevInLoop = this.inLoop
|
|
845
924
|
this.inLoop = true
|
|
925
|
+
const prevLoopKeyDepth = this.currentLoopKeyDepth
|
|
926
|
+
this.currentLoopKeyDepth = loop.depth
|
|
846
927
|
// Re-render children now that inLoop is set (so nested components use the
|
|
847
928
|
// loop-child naming convention). renderedChildren above was computed with
|
|
848
929
|
// the previous flag; recompute under the loop flag.
|
|
849
930
|
const childrenUnderLoop = this.renderChildren(loop.children)
|
|
931
|
+
this.currentLoopKeyDepth = prevLoopKeyDepth
|
|
850
932
|
this.inLoop = prevInLoop
|
|
851
933
|
void renderedChildren
|
|
852
934
|
|
|
@@ -863,7 +945,25 @@ export class MinijinjaAdapter extends BaseAdapter implements IRNodeEmitter<Jinja
|
|
|
863
945
|
// Scoped per-call-site marker so sibling `.map()`s under the same parent
|
|
864
946
|
// each get their own reconciliation range.
|
|
865
947
|
lines.push(`{{ bf.comment("loop:${loop.markerId}") | safe }}`)
|
|
866
|
-
|
|
948
|
+
// `objectIteration` (#2168 object-entries-map): minijinja has no
|
|
949
|
+
// built-in `.items()` OBJECT METHOD (unlike Python's dict) — `|items`
|
|
950
|
+
// is a FILTER, yielding `[key, value]` pairs, which the `for` tag's
|
|
951
|
+
// own tuple-unpack target (`for a, b in ...`, mirroring Jinja2) binds
|
|
952
|
+
// directly. There's no `|keys`/`|values` filter, so `'keys'`/`'values'`
|
|
953
|
+
// reuse the SAME `|items` pairs and bind the unused half to a
|
|
954
|
+
// throwaway name. Order is whatever the underlying `BTreeMap` gives —
|
|
955
|
+
// sorted-by-key, not JS insertion order (a deliberate design choice
|
|
956
|
+
// for a DIFFERENT feature, canonical JSON encoding — see `num.rs`);
|
|
957
|
+
// this happens to satisfy the current fixture, but is a documented
|
|
958
|
+
// known limitation for out-of-alphabetical-order data, same as Go.
|
|
959
|
+
const forHeader = loop.objectIteration === 'entries'
|
|
960
|
+
? `{% for ${minijinjaIdent(loop.index ?? param)}, ${minijinjaIdent(param)} in ${array}|items %}`
|
|
961
|
+
: loop.objectIteration === 'keys'
|
|
962
|
+
? `{% for ${minijinjaIdent(param)}, __bf_v in ${array}|items %}`
|
|
963
|
+
: loop.objectIteration === 'values'
|
|
964
|
+
? `{% for __bf_k, ${minijinjaIdent(param)} in ${array}|items %}`
|
|
965
|
+
: `{% for ${minijinjaIdent(loopVar)} in ${array} %}`
|
|
966
|
+
lines.push(forHeader)
|
|
867
967
|
for (const il of indexLocalLines) lines.push(il)
|
|
868
968
|
|
|
869
969
|
// Handle filter().map() pattern by wrapping children in if-condition
|
|
@@ -1017,11 +1117,33 @@ export class MinijinjaAdapter extends BaseAdapter implements IRNodeEmitter<Jinja
|
|
|
1017
1117
|
type Segment = { kind: 'entries'; parts: string[] } | { kind: 'spread'; expr: string }
|
|
1018
1118
|
const segments: Segment[] = [{ kind: 'entries', parts: [] }]
|
|
1019
1119
|
const currentEntries = () => this.componentPropSegmentEntries(segments)
|
|
1120
|
+
// Named JSX-valued props OTHER than the reserved `children`
|
|
1121
|
+
// (`header={<strong>Title</strong>}`, #2168 jsx-element-prop) each get
|
|
1122
|
+
// their own `{% set %}` capture, prepended to the final returned
|
|
1123
|
+
// string below — same mechanism as the reserved children capture,
|
|
1124
|
+
// just keyed by the prop's own name instead of `children`.
|
|
1125
|
+
const namedSlotSetBlocks: string[] = []
|
|
1020
1126
|
|
|
1021
1127
|
for (const p of comp.props) {
|
|
1022
1128
|
// Skip callback props (onXxx) and `ref` — both are client-only for
|
|
1023
1129
|
// SSR (Hono renders neither; the client JS wires them at hydration).
|
|
1024
1130
|
if ((p.name.match(/^on[A-Z]/) || p.name === 'ref') && p.value.kind === 'expression') continue
|
|
1131
|
+
if (p.value.kind === 'jsx-children' && p.name !== 'children') {
|
|
1132
|
+
const prevInLoop = this.inLoop
|
|
1133
|
+
this.inLoop = false
|
|
1134
|
+
const slotBody = this.renderChildren(p.value.children)
|
|
1135
|
+
this.inLoop = prevInLoop
|
|
1136
|
+
// Purely counter-based — NOT derived from `p.name` or `comp.slotId`.
|
|
1137
|
+
// A JSX prop name can contain characters (`data-slot`) that aren't a
|
|
1138
|
+
// valid minijinja `{% set %}` target, and `comp.slotId` alone would
|
|
1139
|
+
// collide across two named-slot props on the same component
|
|
1140
|
+
// invocation (unlike the reserved children slot, there's only ever
|
|
1141
|
+
// one of those per invocation).
|
|
1142
|
+
const captureName = `bf_prop_${this.childrenCaptureCounter++}`
|
|
1143
|
+
namedSlotSetBlocks.push(`{% set ${captureName} %}${slotBody}{% endset %}`)
|
|
1144
|
+
currentEntries().push(`${minijinjaHashKey(p.name)}: ${captureName}`)
|
|
1145
|
+
continue
|
|
1146
|
+
}
|
|
1025
1147
|
if (p.value.kind === 'spread') {
|
|
1026
1148
|
const trimmed = p.value.expr.trim()
|
|
1027
1149
|
// SolidJS-style props identifier (`function(props: P)`) has no
|
|
@@ -1085,12 +1207,12 @@ export class MinijinjaAdapter extends BaseAdapter implements IRNodeEmitter<Jinja
|
|
|
1085
1207
|
const captureName = `bf_children_${comp.slotId ?? 'c' + this.childrenCaptureCounter++}`
|
|
1086
1208
|
currentEntries().push(`${minijinjaHashKey('children')}: ${captureName}`)
|
|
1087
1209
|
const dict = this.combineComponentPropSegments(segments)
|
|
1088
|
-
return
|
|
1210
|
+
return `${namedSlotSetBlocks.join('')}{% set ${captureName} %}${childrenBody}{% endset %}{{ bf.render_child('${tplName}', ${dict}) | safe }}`
|
|
1089
1211
|
}
|
|
1090
1212
|
|
|
1091
1213
|
const isEmpty = segments.every(s => s.kind === 'entries' && s.parts.length === 0)
|
|
1092
1214
|
const dictEntries = isEmpty ? '' : `, ${this.combineComponentPropSegments(segments)}`
|
|
1093
|
-
return
|
|
1215
|
+
return `${namedSlotSetBlocks.join('')}{{ bf.render_child('${tplName}'${dictEntries}) | safe }}`
|
|
1094
1216
|
}
|
|
1095
1217
|
|
|
1096
1218
|
private childrenCaptureCounter = 0
|
|
@@ -1175,7 +1297,7 @@ export class MinijinjaAdapter extends BaseAdapter implements IRNodeEmitter<Jinja
|
|
|
1175
1297
|
* AttrValue lowering for intrinsic-element attributes (Jinja).
|
|
1176
1298
|
*/
|
|
1177
1299
|
private readonly elementAttrEmitter: AttrValueEmitter = {
|
|
1178
|
-
emitLiteral: (value, name) => `${name}="${value.value}"`,
|
|
1300
|
+
emitLiteral: (value, name) => `${name}="${escapeHtml(value.value)}"`,
|
|
1179
1301
|
emitExpression: (value, name) => {
|
|
1180
1302
|
// `style={{ … }}` object literal → a CSS string with dynamic values
|
|
1181
1303
|
// interpolated, instead of refusing the bare object with BF101 (#1322).
|
|
@@ -1377,10 +1499,19 @@ export class MinijinjaAdapter extends BaseAdapter implements IRNodeEmitter<Jinja
|
|
|
1377
1499
|
// the unsupported-expression lowering is never reached for a deferred
|
|
1378
1500
|
// predicate (no BF101 / BF102). #1966
|
|
1379
1501
|
if (attr.clientOnly) continue
|
|
1502
|
+
// `dangerouslySetInnerHTML` never renders as an HTML attribute — it's
|
|
1503
|
+
// handled by `renderDangerousInnerHtml` instead, which replaces the
|
|
1504
|
+
// element's children. Skip it here so its `{ __html: ... }` object
|
|
1505
|
+
// literal never reaches the generic object-literal BF101 refusal
|
|
1506
|
+
// (which would double-report alongside the purpose-built one).
|
|
1507
|
+
if (isDangerousInnerHtmlAttr(attr)) continue
|
|
1380
1508
|
// Rewrite JSX special-prop names to their HTML-attribute counterparts.
|
|
1381
1509
|
let attrName: string
|
|
1382
1510
|
if (attr.name === 'className') attrName = 'class'
|
|
1383
|
-
else if (attr.name === 'key')
|
|
1511
|
+
else if (attr.name === 'key') {
|
|
1512
|
+
const depth = this.currentLoopKeyDepth
|
|
1513
|
+
attrName = depth > 0 ? `data-key-${depth}` : 'data-key'
|
|
1514
|
+
}
|
|
1384
1515
|
else attrName = attr.name
|
|
1385
1516
|
const lowered = emitAttrValue(attr.value, this.elementAttrEmitter, attrName)
|
|
1386
1517
|
if (lowered) parts.push(lowered)
|
|
@@ -1744,8 +1875,19 @@ export class MinijinjaAdapter extends BaseAdapter implements IRNodeEmitter<Jinja
|
|
|
1744
1875
|
* single-quoted string literal (`const totalPages = 5`, #1897
|
|
1745
1876
|
* pagination) — function-scope consts never reach the per-render
|
|
1746
1877
|
* context, so a bare reference would resolve to Undefined.
|
|
1878
|
+
*
|
|
1879
|
+
* The lookup is a flat name match with no notion of AST scope, so a
|
|
1880
|
+
* name that any loop callback binds as its item/index param never
|
|
1881
|
+
* inlines (#2221) — the occurrence may be the loop's own (shadowing)
|
|
1882
|
+
* binding, and substituting the outer const's value there renders every
|
|
1883
|
+
* iteration with the same hard-coded literal. Coarse (a genuinely
|
|
1884
|
+
* non-shadowed same-named const elsewhere in the component also stops
|
|
1885
|
+
* inlining, falling back to the bare identifier) but safe — the same
|
|
1886
|
+
* trade-off as #2212's `collectLoopBoundNames` use in
|
|
1887
|
+
* `collectStringValueNames`.
|
|
1747
1888
|
*/
|
|
1748
1889
|
private _resolveLiteralConst(name: string): string | null {
|
|
1890
|
+
if (this.staticLoopSourceBoundNames.has(name)) return null
|
|
1749
1891
|
const c = (this.localConstants ?? []).find(lc => lc.name === name)
|
|
1750
1892
|
if (c?.value === undefined) return null
|
|
1751
1893
|
const v = c.value.trim()
|
|
@@ -1755,7 +1897,22 @@ export class MinijinjaAdapter extends BaseAdapter implements IRNodeEmitter<Jinja
|
|
|
1755
1897
|
return null
|
|
1756
1898
|
}
|
|
1757
1899
|
|
|
1900
|
+
/**
|
|
1901
|
+
* Resolve `IDENT.key` where `IDENT` is a module-scope object-literal const
|
|
1902
|
+
* (`variantClasses.ghost`, #1896/#1897) to the looked-up scalar.
|
|
1903
|
+
*
|
|
1904
|
+
* The lookup is a flat name match on `objectName` with no notion of AST
|
|
1905
|
+
* scope, so an enclosing loop callback's own param of the same name
|
|
1906
|
+
* (`.map((cfg) => <li>{cfg.x}</li>)` shadowing a module `const cfg = {…}`)
|
|
1907
|
+
* still resolved to the OUTER const's member value at every iteration
|
|
1908
|
+
* (#2237) — the sibling hazard to #2221's `_resolveLiteralConst`. Same
|
|
1909
|
+
* coarse-but-safe `staticLoopSourceBoundNames` guard: any name a loop
|
|
1910
|
+
* binds anywhere in the component never inlines, falling back to the bare
|
|
1911
|
+
* `cfg.x` member expression (which a minijinja `for` loop binds correctly
|
|
1912
|
+
* at the shadowed occurrences).
|
|
1913
|
+
*/
|
|
1758
1914
|
private _resolveStaticRecordLiteral(objectName: string, key: string): string | null {
|
|
1915
|
+
if (this.staticLoopSourceBoundNames.has(objectName)) return null
|
|
1759
1916
|
const hit = lookupStaticRecordLiteral(objectName, key, this.localConstants)
|
|
1760
1917
|
if (!hit) return null
|
|
1761
1918
|
return hit.kind === 'number'
|