@barefootjs/jinja 0.1.0

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.
Files changed (88) hide show
  1. package/README.md +66 -0
  2. package/dist/adapter/analysis/component-tree.d.ts +26 -0
  3. package/dist/adapter/analysis/component-tree.d.ts.map +1 -0
  4. package/dist/adapter/boolean-result.d.ts +84 -0
  5. package/dist/adapter/boolean-result.d.ts.map +1 -0
  6. package/dist/adapter/emit-context.d.ts +106 -0
  7. package/dist/adapter/emit-context.d.ts.map +1 -0
  8. package/dist/adapter/expr/array-method.d.ts +75 -0
  9. package/dist/adapter/expr/array-method.d.ts.map +1 -0
  10. package/dist/adapter/expr/emitters.d.ts +143 -0
  11. package/dist/adapter/expr/emitters.d.ts.map +1 -0
  12. package/dist/adapter/expr/operand.d.ts +25 -0
  13. package/dist/adapter/expr/operand.d.ts.map +1 -0
  14. package/dist/adapter/index.d.ts +6 -0
  15. package/dist/adapter/index.d.ts.map +1 -0
  16. package/dist/adapter/index.js +189097 -0
  17. package/dist/adapter/jinja-adapter.d.ts +394 -0
  18. package/dist/adapter/jinja-adapter.d.ts.map +1 -0
  19. package/dist/adapter/lib/constants.d.ts +21 -0
  20. package/dist/adapter/lib/constants.d.ts.map +1 -0
  21. package/dist/adapter/lib/ir-scope.d.ts +50 -0
  22. package/dist/adapter/lib/ir-scope.d.ts.map +1 -0
  23. package/dist/adapter/lib/jinja-naming.d.ts +77 -0
  24. package/dist/adapter/lib/jinja-naming.d.ts.map +1 -0
  25. package/dist/adapter/lib/types.d.ts +27 -0
  26. package/dist/adapter/lib/types.d.ts.map +1 -0
  27. package/dist/adapter/memo/seed.d.ts +81 -0
  28. package/dist/adapter/memo/seed.d.ts.map +1 -0
  29. package/dist/adapter/props/prop-classes.d.ts +33 -0
  30. package/dist/adapter/props/prop-classes.d.ts.map +1 -0
  31. package/dist/adapter/spread/spread-codegen.d.ts +61 -0
  32. package/dist/adapter/spread/spread-codegen.d.ts.map +1 -0
  33. package/dist/adapter/value/parsed-literal.d.ts +28 -0
  34. package/dist/adapter/value/parsed-literal.d.ts.map +1 -0
  35. package/dist/build.d.ts +28 -0
  36. package/dist/build.d.ts.map +1 -0
  37. package/dist/build.js +189117 -0
  38. package/dist/conformance-pins.d.ts +12 -0
  39. package/dist/conformance-pins.d.ts.map +1 -0
  40. package/dist/index.d.ts +9 -0
  41. package/dist/index.d.ts.map +1 -0
  42. package/dist/index.js +189118 -0
  43. package/package.json +66 -0
  44. package/python/VERSION +1 -0
  45. package/python/barefootjs/__init__.py +57 -0
  46. package/python/barefootjs/backend_jinja.py +125 -0
  47. package/python/barefootjs/evaluator.py +787 -0
  48. package/python/barefootjs/runtime.py +1334 -0
  49. package/python/barefootjs/search_params.py +89 -0
  50. package/python/pyproject.toml +32 -0
  51. package/python/tests/__init__.py +0 -0
  52. package/python/tests/test_eval_vectors.py +88 -0
  53. package/python/tests/test_evaluator.py +406 -0
  54. package/python/tests/test_helper_vectors.py +288 -0
  55. package/python/tests/test_omit.py +62 -0
  56. package/python/tests/test_props_attr.py +54 -0
  57. package/python/tests/test_query.py +41 -0
  58. package/python/tests/test_render.py +102 -0
  59. package/python/tests/test_render_child.py +96 -0
  60. package/python/tests/test_search_params.py +50 -0
  61. package/python/tests/test_spread_attrs.py +86 -0
  62. package/python/tests/test_template_primitives.py +347 -0
  63. package/python/tests/vector-divergences.json +42 -0
  64. package/src/__tests__/jinja-adapter-unit.test.ts +390 -0
  65. package/src/__tests__/jinja-adapter.test.ts +53 -0
  66. package/src/__tests__/jinja-counter.test.ts +62 -0
  67. package/src/__tests__/jinja-query-href.test.ts +99 -0
  68. package/src/__tests__/jinja-spread-attrs.test.ts +225 -0
  69. package/src/adapter/analysis/component-tree.ts +119 -0
  70. package/src/adapter/boolean-result.ts +176 -0
  71. package/src/adapter/emit-context.ts +118 -0
  72. package/src/adapter/expr/array-method.ts +346 -0
  73. package/src/adapter/expr/emitters.ts +608 -0
  74. package/src/adapter/expr/operand.ts +35 -0
  75. package/src/adapter/index.ts +6 -0
  76. package/src/adapter/jinja-adapter.ts +1747 -0
  77. package/src/adapter/lib/constants.ts +33 -0
  78. package/src/adapter/lib/ir-scope.ts +95 -0
  79. package/src/adapter/lib/jinja-naming.ts +114 -0
  80. package/src/adapter/lib/types.ts +30 -0
  81. package/src/adapter/memo/seed.ts +132 -0
  82. package/src/adapter/props/prop-classes.ts +65 -0
  83. package/src/adapter/spread/spread-codegen.ts +166 -0
  84. package/src/adapter/value/parsed-literal.ts +76 -0
  85. package/src/build.ts +37 -0
  86. package/src/conformance-pins.ts +101 -0
  87. package/src/index.ts +9 -0
  88. package/src/test-render.ts +714 -0
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Compile-time constant tables for the Jinja2 template adapter.
3
+ *
4
+ * Extracted the way `packages/adapter-xslate/src/adapter/lib/constants.ts` is.
5
+ */
6
+
7
+ import type { PrimitiveSpec } from './types.ts'
8
+
9
+ /**
10
+ * Single source of truth for the Jinja adapter's template-primitive
11
+ * surface. Each entry pairs the expected arity with the emit function.
12
+ *
13
+ * The emit fn returns a Jinja expression (no surrounding `{{ }}`) suitable
14
+ * for embedding inside an interpolation — `bf.json(val)`, `bf.floor(val)`,
15
+ * etc. The same primitive names as the Xslate adapter, but invoked as
16
+ * `bf.NAME(args)` (bare, no `$` sigil) instead of `$bf.NAME(args)`.
17
+ */
18
+ export const JINJA_TEMPLATE_PRIMITIVES: Record<string, PrimitiveSpec> = {
19
+ 'JSON.stringify': { arity: 1, emit: (args) => `bf.json(${args[0]})` },
20
+ 'String': { arity: 1, emit: (args) => `bf.string(${args[0]})` },
21
+ 'Number': { arity: 1, emit: (args) => `bf.number(${args[0]})` },
22
+ 'Math.floor': { arity: 1, emit: (args) => `bf.floor(${args[0]})` },
23
+ 'Math.ceil': { arity: 1, emit: (args) => `bf.ceil(${args[0]})` },
24
+ 'Math.round': { arity: 1, emit: (args) => `bf.round(${args[0]})` },
25
+ }
26
+
27
+ /**
28
+ * Module-scope `templatePrimitives` map derived once from the spec record.
29
+ */
30
+ export const JINJA_PRIMITIVE_EMIT_MAP: Record<string, (args: string[]) => string> =
31
+ Object.fromEntries(
32
+ Object.entries(JINJA_TEMPLATE_PRIMITIVES).map(([k, v]) => [k, v.emit])
33
+ )
@@ -0,0 +1,95 @@
1
+ /**
2
+ * IR traversal helpers for the Jinja2 template adapter.
3
+ *
4
+ * Ported from `packages/adapter-xslate/src/adapter/lib/ir-scope.ts`.
5
+ * `resolveJsxChildrenProp` and `collectRootScopeNodes` are byte-identical
6
+ * (adapter-agnostic IR walks).
7
+ *
8
+ * `extractTopLevelIdentifiers` scans the RENDERED template text (rather than
9
+ * re-deriving free vars from the original JS AST) so it stays exactly in
10
+ * sync with whatever the emitter actually produced — but Jinja identifiers
11
+ * have no sigil (unlike Kolon's `$`, which made a trivially safe
12
+ * `\$([A-Za-z_]\w*)` scan possible): a bare word in the rendered text could
13
+ * be a genuine context-var reference, a `bf.` runtime-helper method name, a
14
+ * Jinja grammar keyword emitted by this adapter's own condition/ternary
15
+ * lowering (`if`/`else`/`is`/`not`/`and`/`or`/`none`/`true`/`false`), or
16
+ * content inside a single-quoted string literal. This helper makes the scan
17
+ * sound: it strips quoted string spans first, then matches identifier tokens
18
+ * NOT immediately preceded by a `.` (excluding dotted property/method names
19
+ * — the same exclusion the `$` sigil gave Kolon for free, since Kolon's
20
+ * regex only ever matched right after `$`), then drops the closed set of
21
+ * tokens this adapter's own codegen can emit that aren't context vars (`bf`
22
+ * and the Jinja keywords above). `memo/seed.ts` uses it to detect a
23
+ * constant lowering (no top-level identifier at all) that should keep the
24
+ * static ssr-defaults seed instead of an in-template `{% set %}`; scope
25
+ * AVAILABILITY itself is now the shared `computeSsrSeedPlan`'s job
26
+ * (packages/jsx/src/ssr-seed-plan.ts), not this module's (#2075).
27
+ */
28
+
29
+ import type { IRNode, IRProp, IRIfStatement, IRFragment } from '@barefootjs/jsx'
30
+
31
+ /** Tokens this adapter's own codegen can emit that are never context vars. */
32
+ const NON_VAR_TOKENS = new Set([
33
+ 'bf', 'if', 'else', 'is', 'not', 'and', 'or', 'in', 'none', 'true', 'false',
34
+ // `is defined` — the nullish-coalescing (`??`) lowering's Undefined guard
35
+ // (`expr/emitters.ts`'s `logical`, and the nullable-optional-prop
36
+ // attribute-omission test in `jinja-adapter.ts`) emits this Jinja test
37
+ // keyword; it's never a context var.
38
+ 'defined',
39
+ ])
40
+
41
+ /**
42
+ * Extract the set of "top-level identifier" tokens from a rendered Jinja
43
+ * expression: bare words, excluding quoted-string content, dotted
44
+ * property/method names, and this adapter's own non-var keyword vocabulary.
45
+ * See the file header for why this replaces a direct `\w+` scan.
46
+ */
47
+ export function extractTopLevelIdentifiers(jinjaExpr: string): string[] {
48
+ // Strip single-quoted string literals (this adapter only ever emits
49
+ // single-quoted string literals, backslash-escaped) so their content can't
50
+ // leak into the identifier scan.
51
+ const stripped = jinjaExpr.replace(/'(?:\\.|[^'\\])*'/g, ' ')
52
+ const out: string[] = []
53
+ for (const m of stripped.matchAll(/(?<!\.)\b([A-Za-z_]\w*)\b/g)) {
54
+ if (!NON_VAR_TOKENS.has(m[1])) out.push(m[1])
55
+ }
56
+ return out
57
+ }
58
+
59
+ /**
60
+ * Find the `children` prop's `jsx-children` payload. Narrowed via the
61
+ * AttrValue `kind` discriminator so adapter code stays type-safe if the IR
62
+ * shape evolves.
63
+ */
64
+ export function resolveJsxChildrenProp(props: readonly IRProp[]): IRNode[] {
65
+ const prop = props.find(p => p.name === 'children')
66
+ if (!prop) return []
67
+ if (prop.value.kind !== 'jsx-children') return []
68
+ return prop.value.children
69
+ }
70
+
71
+ /**
72
+ * Collect the component's root scope element node(s) — the elements that
73
+ * become the rendered root and so carry `data-key` for a keyed loop item. A
74
+ * plain element root is itself; an `if-statement` (early-return) root
75
+ * contributes the top element of each branch, since exactly one renders at
76
+ * runtime. (#1297)
77
+ */
78
+ export function collectRootScopeNodes(node: IRNode): Set<IRNode> {
79
+ const out = new Set<IRNode>()
80
+ const visit = (n: IRNode | null): void => {
81
+ if (!n) return
82
+ if (n.type === 'element') { out.add(n); return }
83
+ if (n.type === 'if-statement') {
84
+ const s = n as IRIfStatement
85
+ visit(s.consequent)
86
+ visit(s.alternate)
87
+ return
88
+ }
89
+ if (n.type === 'fragment') {
90
+ for (const c of (n as IRFragment).children) visit(c)
91
+ }
92
+ }
93
+ visit(node)
94
+ return out
95
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Jinja2/Python identifier and dict-key conventions for the Jinja adapter.
3
+ *
4
+ * Pure helpers, ported from the Kolon equivalent
5
+ * (`packages/adapter-xslate/src/adapter/lib/kolon-naming.ts`) but adjusted for
6
+ * two real syntax divergences between Kolon and Jinja2:
7
+ *
8
+ * 1. **Dict-literal keys are always quoted.** Kolon's hashref fat-comma
9
+ * (`key => value`) auto-quotes a bareword key, so `kolonHashKey` only
10
+ * quotes a non-identifier-safe name (`'data-slot'`). A Jinja/Python dict
11
+ * LITERAL has no such bareword-key sugar — `{key: value}` means "look up
12
+ * the *variable* `key` and use its value as the key", not the string
13
+ * `"key"`. `jinjaHashKey` therefore ALWAYS emits a quoted string literal,
14
+ * identifier-safe or not — the one place this port is NOT a bare 1:1
15
+ * syntax substitution, because treating it as one would silently change
16
+ * dict keys into undefined-variable lookups.
17
+ * 2. **Reserved-word identifier mangling.** Verified empirically against
18
+ * Jinja 3.1 that most Python keywords (`class`, `import`, `for`, …) parse
19
+ * fine as bare Jinja variable / loop-target names — Jinja compiles every
20
+ * user template name to an internally-prefixed Python local (`l_0_…`), so
21
+ * the template layer itself mostly sidesteps Python's own reserved-word
22
+ * rules. The genuinely Jinja-reserved bare words are `not` / `and` / `or`
23
+ * / `in` / `is` / `if` / `else` (grammar keywords) plus the three
24
+ * constants `true` / `false` / `none` (rejected specifically as
25
+ * assignment targets). Per the adapter plan, `jinjaIdent` mangles the
26
+ * FULL Python reserved-word list (a conservative superset of what Jinja's
27
+ * grammar strictly requires) with a trailing-underscore suffix, trivial
28
+ * and easy to keep in lock-step with the Python runtime's identical rule
29
+ * (`barefootjs.runtime.jinja_ident`) — the runtime applies the SAME
30
+ * mangling when it builds the per-render context dict, so a prop/signal
31
+ * named e.g. `class` is threaded through as context key `'class_'` on
32
+ * both sides.
33
+ */
34
+
35
+ import type { LoopBindingPathSegment } from '@barefootjs/jsx'
36
+
37
+ /**
38
+ * Escape a string for a Jinja/Python single-quoted literal: backslash first
39
+ * (so it doesn't double-escape the quote we add next), then the quote.
40
+ * Python string-literal escaping treats `\\` / `\'` identically to Perl's,
41
+ * so this is byte-identical to `escapeKolonSingleQuoted`.
42
+ */
43
+ export function escapeJinjaSingleQuoted(s: string): string {
44
+ return s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
45
+ }
46
+
47
+ /**
48
+ * Quote a dict-literal KEY for Jinja. UNLIKE Kolon's `kolonHashKey`, this
49
+ * always quotes — see the file header for why a bareword key would silently
50
+ * become a variable lookup instead of a string key.
51
+ */
52
+ export function jinjaHashKey(name: string): string {
53
+ return `'${escapeJinjaSingleQuoted(name)}'`
54
+ }
55
+
56
+ /**
57
+ * Python reserved words (the plan's list) that must not appear as a bare
58
+ * Jinja identifier — see the file header. Kept as the single source of
59
+ * truth; the Python runtime's `jinja_ident` mirrors this exact set.
60
+ */
61
+ const RESERVED_WORDS = new Set([
62
+ 'if', 'else', 'for', 'in', 'is', 'not', 'and', 'or', 'none', 'true', 'false',
63
+ 'import', 'from', 'class', 'def', 'pass', 'del', 'return', 'lambda', 'global',
64
+ 'with', 'as', 'raise', 'try', 'except', 'finally', 'while', 'break',
65
+ 'continue', 'elif', 'yield', 'assert', 'nonlocal',
66
+ ])
67
+
68
+ /**
69
+ * Mangle a JS identifier (prop name, signal getter, loop param, …) into a
70
+ * Jinja-safe variable name: reserved words get a trailing `_` suffix,
71
+ * everything else passes through unchanged. Applied at every point the
72
+ * adapter emits a bare Jinja variable reference or `{% set %}` target —
73
+ * mirrors Kolon's `$name` sigil giving those references collision immunity
74
+ * for free; Jinja has no sigil, so the adapter mangles instead.
75
+ */
76
+ export function jinjaIdent(name: string): string {
77
+ return RESERVED_WORDS.has(name) ? `${name}_` : name
78
+ }
79
+
80
+ /**
81
+ * Build a native Jinja accessor expression from a `.map()` destructure
82
+ * binding's structured {@link LoopBindingPathSegment} path (#2087 Phase B) —
83
+ * the per-adapter counterpart to the JS accessor suffix carried in
84
+ * `LoopParamBinding.path`. Walks `segments` (never string-parses `path` —
85
+ * repo rule) appending, per step:
86
+ *
87
+ * - `{ kind: 'index', index }` → `[N]` (a Python list index — same postfix
88
+ * Jinja uses for dict-key lookup, so this is unambiguous either way).
89
+ * - `{ kind: 'field', key, isIdent: true }` → `.key` (bare attribute
90
+ * access — Jinja resolves it against a dict transparently).
91
+ * - `{ kind: 'field', key, isIdent: false }` → `[<quoted key>]` (e.g. a
92
+ * `'data-priority'` sibling key can't be a bare Jinja attribute, same
93
+ * reasoning as `jinjaHashKey` on dict-literal keys).
94
+ *
95
+ * `base` is the already-`jinjaIdent`-mangled loop variable (or a parent
96
+ * accessor expression when called recursively); an empty `segments` array
97
+ * (a rest binding sitting at the loop root) returns `base` unchanged.
98
+ */
99
+ export function jinjaAccessorFromSegments(
100
+ base: string,
101
+ segments: readonly LoopBindingPathSegment[],
102
+ ): string {
103
+ let acc = base
104
+ for (const seg of segments) {
105
+ if (seg.kind === 'index') {
106
+ acc += `[${seg.index}]`
107
+ } else if (seg.isIdent) {
108
+ acc += `.${seg.key}`
109
+ } else {
110
+ acc += `[${jinjaHashKey(seg.key)}]`
111
+ }
112
+ }
113
+ return acc
114
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Shared type aliases for the Jinja2 template adapter.
3
+ *
4
+ * Extracted the way `packages/adapter-xslate/src/adapter/lib/types.ts` is:
5
+ * pure type declarations — no runtime behaviour — so the extracted emit
6
+ * modules and the main adapter share one definition rather than
7
+ * re-declaring the render context / options shape.
8
+ */
9
+
10
+ /** A template-primitive spec: expected call arity + the emit fn. */
11
+ export interface PrimitiveSpec {
12
+ arity: number
13
+ emit: (args: string[]) => string
14
+ }
15
+
16
+ /**
17
+ * Jinja adapter's IRNode render context. Like the Xslate adapter, Jinja's
18
+ * lowering doesn't consume any render-position flags, so the Ctx is empty.
19
+ * Kept as a named alias so future flags can extend it without changing the
20
+ * `IRNodeEmitter` interface.
21
+ */
22
+ export type JinjaRenderCtx = Record<string, never>
23
+
24
+ export interface JinjaAdapterOptions {
25
+ /** Base path for client JS files (default: '/static/components/') */
26
+ clientJsBasePath?: string
27
+
28
+ /** Path to barefoot.js runtime (default: '/static/components/barefoot.js') */
29
+ barefootJsPath?: string
30
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * In-template memo / context seeding for the Jinja template adapter.
3
+ *
4
+ * Ported from `packages/adapter-xslate/src/adapter/memo/seed.ts`. Free
5
+ * functions taking a `JinjaMemoContext` (built by the adapter's `memoCtx`
6
+ * getter) so the cluster depends only on the recursive expression entry, not
7
+ * the whole adapter class. These emit the `{% set x = ... %}` statements
8
+ * that let the body's bare `x` resolve to a derived signal/memo value or an
9
+ * active context value at SSR time.
10
+ *
11
+ * One deliberate behavioural IMPROVEMENT over the Kolon port: Kolon's `my`
12
+ * declares a NEW lexical, so `: my $x = … $x …;` reads the not-yet-assigned
13
+ * lexical on its own right-hand side — broken. Xslate therefore skips
14
+ * in-template seeding for a same-name signal/memo (a `refsSelf` guard in its
15
+ * `generateDerivedMemoSeed`). Jinja's `{% set %}` has no such shadowing
16
+ * hazard — `{% set x = x + 1 %}` resolves the right-hand `x` against the
17
+ * value ALREADY bound in the enclosing scope (verified empirically against
18
+ * Jinja 3.1: `env.from_string("{% set x = x + 1 %}{{ x }}").render(x=5)` →
19
+ * `"6"`, not an error or a stale `5`). This adapter therefore seeds a
20
+ * same-name signal/memo too, which is strictly more correct — the seed then
21
+ * re-derives from the already-bound prop instead of leaving the render var
22
+ * on its static (possibly per-callsite-wrong) default. No self-ref guard is
23
+ * ported for this reason.
24
+ */
25
+
26
+ import {
27
+ type ComponentIR,
28
+ type ContextConsumer,
29
+ collectContextConsumers,
30
+ computeSsrSeedPlan,
31
+ materializeGetterCalls,
32
+ } from '@barefootjs/jsx'
33
+
34
+ import type { JinjaMemoContext } from '../emit-context.ts'
35
+ import { extractTopLevelIdentifiers } from '../lib/ir-scope.ts'
36
+ import { jinjaIdent, escapeJinjaSingleQuoted } from '../lib/jinja-naming.ts'
37
+
38
+ /** Jinja literal for a context-consumer's `createContext` default. */
39
+ export function contextDefaultJinja(c: ContextConsumer): string {
40
+ const d = c.defaultValue
41
+ if (d === null || d === undefined) return 'none'
42
+ if (typeof d === 'string') return `'${escapeJinjaSingleQuoted(d)}'`
43
+ if (typeof d === 'boolean') return d ? 'true' : 'false'
44
+ return String(d)
45
+ }
46
+
47
+ /**
48
+ * Emit one `{% set <local> = bf.use_context(...) %}` statement per context
49
+ * consumer so the body's bare `<local>` resolves to the active provider
50
+ * value (or the `createContext` default). (#1297)
51
+ */
52
+ export function generateContextConsumerSeed(ir: ComponentIR): string {
53
+ const consumers = collectContextConsumers(ir.metadata)
54
+ if (consumers.length === 0) return ''
55
+ return (
56
+ consumers
57
+ .map(
58
+ c =>
59
+ `{% set ${jinjaIdent(c.localName)} = bf.use_context('${c.contextName}', ${contextDefaultJinja(c)}) %}`,
60
+ )
61
+ .join('\n') + '\n'
62
+ )
63
+ }
64
+
65
+ /**
66
+ * Emit `{% set <name> = <jinja> %}` statements for every `derived` step of
67
+ * the backend-neutral SSR seed plan — the scope/availability/ordering
68
+ * analysis lives in `computeSsrSeedPlan` (packages/jsx/src/ssr-seed-plan.ts);
69
+ * this only lowers each step's expression to Jinja and applies the
70
+ * backend-specific emit guards: skip an empty lowering, and skip a lowering
71
+ * that references no top-level identifier at all (a constant init/body —
72
+ * e.g. a `derived` step with empty `frees` — keeps the existing static
73
+ * ssr-defaults seed instead). Unlike the Kolon port there is no self-ref
74
+ * guard — see the file header. `env-reader` and `opaque` steps emit nothing
75
+ * (the runtime supplies the reader, or the adapter's ssr-defaults path
76
+ * already covers it). (#1297, #2075)
77
+ *
78
+ * `convertExpressionToJinja` can still fail DEEPER than the plan's
79
+ * structural `isSupported` pre-check — e.g. a `.filter(predicate)` whose
80
+ * predicate isn't evaluator-serializable has NO lambda fallback on this
81
+ * adapter (divergence 3, `jinja-adapter.ts`'s file header: unlike Kolon/Perl,
82
+ * Jinja has no lambda-expression form), so the lowering records a HARD
83
+ * BF101 as a side effect rather than degrading. That's correct for every
84
+ * OTHER `convertExpressionToJinja` call site (which commit to using the
85
+ * lowered text), but wrong here: this is a SPECULATIVE "recompute the memo
86
+ * in-template, else keep the static ssrDefault seed" attempt, so a deeper
87
+ * refusal must degrade silently, not fail the whole component compile.
88
+ * Snapshot the diagnostic list and roll back any errors appended during each
89
+ * step's attempt before moving on.
90
+ *
91
+ * A second, related Jinja-only gap divergence 3 opens up: a predicate
92
+ * referencing a SIBLING getter (`props.items.filter((p) => !tag() || …)`,
93
+ * `tag` a sibling memo) contains a zero-arg CALL node (`tag()`), which the
94
+ * evaluator's pure-expression surface refuses (`toEvalNode`'s `call` arm
95
+ * only allows a builtin callee, e.g. `Math.floor`) — with no lambda
96
+ * fallback to fall back to, the whole predicate would refuse. Kolon/Perl
97
+ * never hit this: their lambda form closes over the sibling's ALREADY-SEEDED
98
+ * lexical directly, without going through the evaluator at all. Go hits the
99
+ * identical gap (it also has no closures at SSR-constructor time) and fixes
100
+ * it the same way its `memo/memo-compute.ts` (`matchFilterArmMemo`) does:
101
+ * `materializeGetterCalls` rewrites a getter call into a bare identifier
102
+ * BEFORE serialization, so the evaluator captures it as a free-var read from
103
+ * `base_env` instead of an unsupported call node — and that free var then
104
+ * resolves against the sibling's own `{% set %}` line, which (being an
105
+ * EARLIER step per the plan's ordering guarantee) is always already bound.
106
+ * `env-reader` names are excluded from the materializable set — the runtime
107
+ * supplies those via the per-request reader, not a template-var lexical, so
108
+ * a call to one (`searchParams()`) must stay a real call, not a bare var.
109
+ */
110
+ export function generateDerivedMemoSeed(ctx: JinjaMemoContext, ir: ComponentIR): string {
111
+ // Package G attached this to metadata at compile time; the `??` fallback
112
+ // only covers hand-built metadata in older tests that predate the attached
113
+ // plan — same shared function, so there's no divergence from the compiler.
114
+ const plan = ir.metadata.ssrSeedPlan ?? computeSsrSeedPlan(ir.metadata)
115
+ const knownGetterNames = new Set<string>(
116
+ plan.steps.filter(s => s.kind !== 'env-reader').map(s => s.name),
117
+ )
118
+ const lines: string[] = []
119
+ for (const step of plan.steps) {
120
+ if (step.kind !== 'derived') continue
121
+ const materialized = materializeGetterCalls(step.parsed, knownGetterNames)
122
+ const errorsBefore = ctx.errors.length
123
+ const jinja = ctx.convertExpressionToJinja('', materialized)
124
+ if (ctx.errors.length > errorsBefore) {
125
+ ctx.errors.length = errorsBefore
126
+ continue
127
+ }
128
+ if (jinja === '' || extractTopLevelIdentifiers(jinja).length === 0) continue
129
+ lines.push(`{% set ${jinjaIdent(step.name)} = ${jinja} %}`)
130
+ }
131
+ return lines.length > 0 ? lines.join('\n') + '\n' : ''
132
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Prop classification for the Jinja2 template adapter.
3
+ *
4
+ * Ported from `packages/adapter-xslate/src/adapter/props/prop-classes.ts`.
5
+ * Pure functions over `ir.metadata` that derive the per-compile prop/name
6
+ * sets the adapter consults during lowering. No adapter instance state.
7
+ */
8
+
9
+ import type { ComponentIR } from '@barefootjs/jsx'
10
+ import { isStringTypeInfo, isBareStringLiteral } from '../value/parsed-literal.ts'
11
+
12
+ /**
13
+ * Props whose declared TS type is boolean — a bare binding of one
14
+ * (`data-active={props.isActive}`) must stringify as JS `String(boolean)`
15
+ * ("true"/"false"), not Python's `str(bool)` ("True"/"False") (#1897,
16
+ * pagination's data-active).
17
+ */
18
+ export function collectBooleanTypedProps(ir: ComponentIR): Set<string> {
19
+ return new Set(
20
+ ir.metadata.propsParams
21
+ .filter(prop => prop.type?.primitive === 'boolean' || prop.type?.raw === 'boolean')
22
+ .map(prop => prop.name),
23
+ )
24
+ }
25
+
26
+ /**
27
+ * Bare references to optional, no-default, non-primitive props (e.g.
28
+ * textarea's `rows`) are `None` when omitted → guarded with
29
+ * `is defined and is not none` in `emitExpression`. See the
30
+ * `nullableOptionalProps` field docstring in `jinja-adapter.ts`.
31
+ */
32
+ export function collectNullableOptionalProps(ir: ComponentIR): Set<string> {
33
+ return new Set(
34
+ ir.metadata.propsParams
35
+ .filter(
36
+ p =>
37
+ p.defaultValue === undefined &&
38
+ !p.isRest &&
39
+ p.type?.kind !== 'primitive',
40
+ )
41
+ .map(p => p.name),
42
+ )
43
+ }
44
+
45
+ /**
46
+ * String-typed signals and props. A signal is string-typed when its inferred
47
+ * type is `string` (or, defensively, when its initial value is a bare string
48
+ * literal); a prop when its annotated type is `string`. In the Mojo adapter
49
+ * this drives `eq`/`ne` selection for string equality; neither the Kolon nor
50
+ * the Jinja emitters consume the distinction (both languages' `==`/`!=`
51
+ * compare strings and numbers correctly), so this set is carried for parity
52
+ * with the Perl-family adapters, not because Jinja needs it today.
53
+ */
54
+ export function collectStringValueNames(ir: ComponentIR): Set<string> {
55
+ const names = new Set<string>()
56
+ for (const s of ir.metadata.signals) {
57
+ if (isStringTypeInfo(s.type) || isBareStringLiteral(s.initialValue)) {
58
+ names.add(s.getter)
59
+ }
60
+ }
61
+ for (const p of ir.metadata.propsParams) {
62
+ if (isStringTypeInfo(p.type)) names.add(p.name)
63
+ }
64
+ return names
65
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Object-literal / conditional-spread → Jinja dict lowering for the Jinja
3
+ * template adapter.
4
+ *
5
+ * Ported from `packages/adapter-xslate/src/adapter/spread/spread-codegen.ts`.
6
+ * Free functions taking a `JinjaSpreadContext` (built by the adapter's
7
+ * `spreadCtx` getter) so the cluster depends on the narrow seam — the
8
+ * recursive expression entry plus per-compile bookkeeping — rather than the
9
+ * whole adapter class.
10
+ *
11
+ * The conditional-spread / object-literal entries read the IR-carried
12
+ * structured `ParsedExpr` tree (#2018) instead of re-parsing the source with
13
+ * `ts.createSourceFile`. The condition and scalar values are threaded
14
+ * straight into `ctx.convertExpressionToJinja` as its `preParsed` argument,
15
+ * so no stringify→re-parse round-trip occurs. The `ts.factory` rebuild in
16
+ * `recordIndexAccessToJinja` only reconstructs the `IDENT[KEY]` node the
17
+ * shared `parseRecordIndexAccess` parser accepts; no source-text re-parse.
18
+ */
19
+
20
+ import ts from 'typescript'
21
+ import { parseRecordIndexAccess, stringifyParsedExpr } from '@barefootjs/jsx'
22
+ import type { ParsedExpr } from '@barefootjs/jsx'
23
+
24
+ import type { JinjaSpreadContext } from '../emit-context.ts'
25
+ import { escapeJinjaSingleQuoted, jinjaHashKey, jinjaIdent } from '../lib/jinja-naming.ts'
26
+
27
+ /**
28
+ * Lower a conditional inline-object spread
29
+ * `COND ? { 'aria-describedby': describedBy } : {}`
30
+ * to a Jinja inline ternary of dicts
31
+ * `({'aria-describedby': describedBy} if bf.truthy(describedBy) else {})`.
32
+ * Reads the IR-carried structured `ParsedExpr` tree; both branches must be
33
+ * object literals; the condition + values route through
34
+ * `convertExpressionToJinja`. Returns `null` for any other shape so the
35
+ * caller falls back to its normal lowering.
36
+ */
37
+ export function conditionalSpreadToJinja(
38
+ ctx: JinjaSpreadContext,
39
+ expr: ParsedExpr | undefined,
40
+ ): string | null {
41
+ if (!expr || expr.kind !== 'conditional') return null
42
+ const whenTrue = expr.consequent
43
+ const whenFalse = expr.alternate
44
+ if (whenTrue.kind !== 'object-literal' || whenFalse.kind !== 'object-literal') {
45
+ return null
46
+ }
47
+ // Thread the condition's carried `ParsedExpr` tree straight through as
48
+ // `preParsed` (#2018) — no stringify→re-parse round-trip.
49
+ const condJinja = ctx.convertConditionToJinja('', expr.test)
50
+ const trueJinja = objectLiteralToJinjaDict(ctx, whenTrue)
51
+ const falseJinja = objectLiteralToJinjaDict(ctx, whenFalse)
52
+ if (trueJinja === null || falseJinja === null) return null
53
+ return `(${trueJinja} if ${condJinja} else ${falseJinja})`
54
+ }
55
+
56
+ /**
57
+ * (#1971) Lower a bare object-literal expression (`{ align: 'start' }`),
58
+ * carried as the IR's structured `ParsedExpr` tree, to a Jinja dict via
59
+ * `objectLiteralToJinjaDict`, or null when it isn't a plain object literal.
60
+ * Used for inline object-literal child props (carousel `opts`).
61
+ */
62
+ export function objectLiteralExprToJinjaDict(
63
+ ctx: JinjaSpreadContext,
64
+ expr: ParsedExpr | undefined,
65
+ ): string | null {
66
+ if (!expr || expr.kind !== 'object-literal') return null
67
+ return objectLiteralToJinjaDict(ctx, expr)
68
+ }
69
+
70
+ /**
71
+ * Convert a static object literal into a Jinja dict string for a
72
+ * conditional spread. Only static string/identifier keys are allowed; values
73
+ * resolve via `convertExpressionToJinja` (or the `Record[propKey]` index
74
+ * lowering). Returns `null` for any computed/spread/dynamic key. Empty
75
+ * object → `{}`.
76
+ */
77
+ export function objectLiteralToJinjaDict(
78
+ ctx: JinjaSpreadContext,
79
+ obj: Extract<ParsedExpr, { kind: 'object-literal' }>,
80
+ ): string | null {
81
+ const entries: string[] = []
82
+ for (const prop of obj.properties) {
83
+ // Shorthand `{ a }` was a `ShorthandPropertyAssignment` (not a
84
+ // `PropertyAssignment`), so the former parser rejected it — keep refusing.
85
+ if (prop.shorthand) return null
86
+ // A numeric key (`{ 1: x }`) was rejected by the former parser (only
87
+ // identifier / string-literal names were accepted); `keyKind`
88
+ // distinguishes it from a same-text string `'1'` key.
89
+ if (prop.keyKind === 'numeric') return null
90
+ const key = prop.key
91
+ const val = prop.value
92
+ const indexed = recordIndexAccessToJinja(ctx, val)
93
+ if (
94
+ indexed === null &&
95
+ val.kind === 'index-access' &&
96
+ !isLiteralIndex(val.index)
97
+ ) {
98
+ // Variable-index record access (`sizeMap[size]`) the static-inline
99
+ // path couldn't resolve (non-scalar value / non-const receiver).
100
+ // Record BF101 and bail so the spread surfaces the out-of-shape
101
+ // diagnostic, matching the Kolon port.
102
+ ctx.errors.push({
103
+ code: 'BF101',
104
+ severity: 'error',
105
+ message: `Spread object value '${stringifyParsedExpr(val)}' indexes a record map whose values aren't scalar literals — it can't lower to an inline Jinja dict.`,
106
+ loc: { file: ctx.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
107
+ suggestion: {
108
+ message: 'Index a record whose values are number/string literals, or move the spread into a \'use client\' component so hydration computes it.',
109
+ },
110
+ })
111
+ return null
112
+ }
113
+ const valJinja =
114
+ indexed !== null
115
+ ? indexed
116
+ // Thread the carried `val` tree straight through as `preParsed`
117
+ // (#2018) — no stringify→re-parse round-trip.
118
+ : ctx.convertExpressionToJinja('', val)
119
+ entries.push(`${jinjaHashKey(key)}: ${valJinja}`)
120
+ }
121
+ return entries.length === 0 ? '{}' : `{${entries.join(', ')}}`
122
+ }
123
+
124
+ /** True when a parsed index is a numeric or string literal (`arr[0]`, `m['k']`). */
125
+ function isLiteralIndex(index: ParsedExpr): boolean {
126
+ return (
127
+ index.kind === 'literal' &&
128
+ (index.literalType === 'number' || index.literalType === 'string')
129
+ )
130
+ }
131
+
132
+ /**
133
+ * Lower a spread-object VALUE of the form `IDENT[KEY]` (CheckIcon's
134
+ * `sizeMap[size]`) to an inline indexed Jinja dict
135
+ * `{'sm': 16, 'md': 20, ...}[size]`.
136
+ * Reuses the shared structural parse (`parseRecordIndexAccess`) — rebuilding
137
+ * the `IDENT[KEY]` node from the carried tree via `ts.factory` rather than
138
+ * re-parsing source text; this wrapper only does the single-quote escaping +
139
+ * bracket-index emit. Jinja indexes a dict literal with the SAME bracket
140
+ * syntax `{…}[key]` a JS object index would use — no Kolon-style divergence
141
+ * to steer around here.
142
+ */
143
+ export function recordIndexAccessToJinja(ctx: JinjaSpreadContext, val: ParsedExpr): string | null {
144
+ // The only shape `parseRecordIndexAccess` accepts is `IDENT[KEY]` with
145
+ // identifier object and index, so rebuild exactly that node from the
146
+ // carried tree via `ts.factory` — no source-text re-parse.
147
+ if (
148
+ val.kind !== 'index-access' ||
149
+ val.object.kind !== 'identifier' ||
150
+ val.index.kind !== 'identifier'
151
+ ) {
152
+ return null
153
+ }
154
+ const tsVal = ts.factory.createElementAccessExpression(
155
+ ts.factory.createIdentifier(val.object.name),
156
+ ts.factory.createIdentifier(val.index.name),
157
+ )
158
+ const parsed = parseRecordIndexAccess(tsVal, ctx.localConstants ?? [], ctx.propsParams)
159
+ if (!parsed) return null
160
+ const entries = parsed.entries.map(e => {
161
+ const mapVal =
162
+ e.value.kind === 'number' ? e.value.text : `'${escapeJinjaSingleQuoted(e.value.text)}'`
163
+ return `${jinjaHashKey(e.key)}: ${mapVal}`
164
+ })
165
+ return `{${entries.join(', ')}}[${jinjaIdent(parsed.indexPropName)}]`
166
+ }