@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.
- package/README.md +66 -0
- package/dist/adapter/analysis/component-tree.d.ts +26 -0
- package/dist/adapter/analysis/component-tree.d.ts.map +1 -0
- package/dist/adapter/boolean-result.d.ts +84 -0
- package/dist/adapter/boolean-result.d.ts.map +1 -0
- package/dist/adapter/emit-context.d.ts +106 -0
- package/dist/adapter/emit-context.d.ts.map +1 -0
- package/dist/adapter/expr/array-method.d.ts +75 -0
- package/dist/adapter/expr/array-method.d.ts.map +1 -0
- package/dist/adapter/expr/emitters.d.ts +143 -0
- package/dist/adapter/expr/emitters.d.ts.map +1 -0
- package/dist/adapter/expr/operand.d.ts +25 -0
- package/dist/adapter/expr/operand.d.ts.map +1 -0
- package/dist/adapter/index.d.ts +6 -0
- package/dist/adapter/index.d.ts.map +1 -0
- package/dist/adapter/index.js +189097 -0
- package/dist/adapter/jinja-adapter.d.ts +394 -0
- package/dist/adapter/jinja-adapter.d.ts.map +1 -0
- package/dist/adapter/lib/constants.d.ts +21 -0
- package/dist/adapter/lib/constants.d.ts.map +1 -0
- package/dist/adapter/lib/ir-scope.d.ts +50 -0
- package/dist/adapter/lib/ir-scope.d.ts.map +1 -0
- package/dist/adapter/lib/jinja-naming.d.ts +77 -0
- package/dist/adapter/lib/jinja-naming.d.ts.map +1 -0
- package/dist/adapter/lib/types.d.ts +27 -0
- package/dist/adapter/lib/types.d.ts.map +1 -0
- package/dist/adapter/memo/seed.d.ts +81 -0
- package/dist/adapter/memo/seed.d.ts.map +1 -0
- package/dist/adapter/props/prop-classes.d.ts +33 -0
- package/dist/adapter/props/prop-classes.d.ts.map +1 -0
- package/dist/adapter/spread/spread-codegen.d.ts +61 -0
- package/dist/adapter/spread/spread-codegen.d.ts.map +1 -0
- package/dist/adapter/value/parsed-literal.d.ts +28 -0
- package/dist/adapter/value/parsed-literal.d.ts.map +1 -0
- package/dist/build.d.ts +28 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +189117 -0
- package/dist/conformance-pins.d.ts +12 -0
- package/dist/conformance-pins.d.ts.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +189118 -0
- package/package.json +66 -0
- package/python/VERSION +1 -0
- package/python/barefootjs/__init__.py +57 -0
- package/python/barefootjs/backend_jinja.py +125 -0
- package/python/barefootjs/evaluator.py +787 -0
- package/python/barefootjs/runtime.py +1334 -0
- package/python/barefootjs/search_params.py +89 -0
- package/python/pyproject.toml +32 -0
- package/python/tests/__init__.py +0 -0
- package/python/tests/test_eval_vectors.py +88 -0
- package/python/tests/test_evaluator.py +406 -0
- package/python/tests/test_helper_vectors.py +288 -0
- package/python/tests/test_omit.py +62 -0
- package/python/tests/test_props_attr.py +54 -0
- package/python/tests/test_query.py +41 -0
- package/python/tests/test_render.py +102 -0
- package/python/tests/test_render_child.py +96 -0
- package/python/tests/test_search_params.py +50 -0
- package/python/tests/test_spread_attrs.py +86 -0
- package/python/tests/test_template_primitives.py +347 -0
- package/python/tests/vector-divergences.json +42 -0
- package/src/__tests__/jinja-adapter-unit.test.ts +390 -0
- package/src/__tests__/jinja-adapter.test.ts +53 -0
- package/src/__tests__/jinja-counter.test.ts +62 -0
- package/src/__tests__/jinja-query-href.test.ts +99 -0
- package/src/__tests__/jinja-spread-attrs.test.ts +225 -0
- package/src/adapter/analysis/component-tree.ts +119 -0
- package/src/adapter/boolean-result.ts +176 -0
- package/src/adapter/emit-context.ts +118 -0
- package/src/adapter/expr/array-method.ts +346 -0
- package/src/adapter/expr/emitters.ts +608 -0
- package/src/adapter/expr/operand.ts +35 -0
- package/src/adapter/index.ts +6 -0
- package/src/adapter/jinja-adapter.ts +1747 -0
- package/src/adapter/lib/constants.ts +33 -0
- package/src/adapter/lib/ir-scope.ts +95 -0
- package/src/adapter/lib/jinja-naming.ts +114 -0
- package/src/adapter/lib/types.ts +30 -0
- package/src/adapter/memo/seed.ts +132 -0
- package/src/adapter/props/prop-classes.ts +65 -0
- package/src/adapter/spread/spread-codegen.ts +166 -0
- package/src/adapter/value/parsed-literal.ts +76 -0
- package/src/build.ts +37 -0
- package/src/conformance-pins.ts +101 -0
- package/src/index.ts +9 -0
- package/src/test-render.ts +714 -0
|
@@ -0,0 +1,1747 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BarefootJS Jinja2 Template Adapter
|
|
3
|
+
*
|
|
4
|
+
* Generates Jinja2 template files (.jinja) from BarefootJS IR.
|
|
5
|
+
*
|
|
6
|
+
* Near-mechanical port of the Text::Xslate (Kolon) adapter
|
|
7
|
+
* (packages/adapter-xslate/src/adapter/xslate-adapter.ts), itself a
|
|
8
|
+
* near-mechanical port of the Mojolicious EP adapter, from Kolon syntax to
|
|
9
|
+
* Jinja2 syntax. The expression-lowering PIPELINE (JS → scalar values /
|
|
10
|
+
* `bf.helper(...)` calls) is shared in spirit with the Xslate adapter; the
|
|
11
|
+
* surrounding template syntax differs:
|
|
12
|
+
*
|
|
13
|
+
* Kolon `<: EXPR :>` → Jinja `{{ EXPR }}` (HTML-escaped)
|
|
14
|
+
* Kolon `<: EXPR | mark_raw :>` → Jinja `{{ EXPR | safe }}` (raw)
|
|
15
|
+
* Kolon `$bf.method(args)` → Jinja `bf.method(args)`
|
|
16
|
+
* Kolon `$name` → Jinja `name`
|
|
17
|
+
* Kolon `: if (C) { A : } else { B : }` → Jinja `{% if C %}A{% else %}B{% endif %}` (`elsif` → `{% elif %}`)
|
|
18
|
+
* Kolon `: for $arr -> $item { … : }` → Jinja `{% for item in arr %}…{% endfor %}`
|
|
19
|
+
* Kolon `: my $x = e;` → Jinja `{% set x = e %}`
|
|
20
|
+
* Kolon `{ k => v }` hashref → Jinja `{'k': v}` dict literal (ALWAYS quoted key — see `lib/jinja-naming.ts`)
|
|
21
|
+
* Kolon `~` concat → Jinja `~` concat
|
|
22
|
+
* Kolon `//` defined-or → `(l if (l is defined and l is not none) else r)` inline (Jinja has no `//`; the `is defined` guard also treats a context var that was never seeded — Jinja's `ChainableUndefined` — as nullish, matching JS `??`'s null-OR-undefined check — see `expr/emitters.ts`'s `logical` for the full rationale)
|
|
23
|
+
* Kolon macro children capture → Jinja `{% set NAME %}…{% endset %}` set-block (see "Children capture" below)
|
|
24
|
+
*
|
|
25
|
+
* The render `jinja2.Environment` this adapter's output assumes:
|
|
26
|
+
* `autoescape=True`, `undefined=ChainableUndefined`, `FileSystemLoader`,
|
|
27
|
+
* PLUS **`trim_blocks=True, lstrip_blocks=True`** — required because this
|
|
28
|
+
* adapter places `{% … %}` control tags on their own source line (mirroring
|
|
29
|
+
* Kolon's line-statement `:` mode, which consumes its own line for free);
|
|
30
|
+
* without `trim_blocks`/`lstrip_blocks` every such line would leak a stray
|
|
31
|
+
* newline/indentation into the rendered HTML. Templates are named
|
|
32
|
+
* `<snake_case_component>.jinja` (same convention as Xslate's `.tx` files).
|
|
33
|
+
*
|
|
34
|
+
* Divergences beyond the syntax table above (all uniform, not per-fixture —
|
|
35
|
+
* see the individual definition sites for the full rationale):
|
|
36
|
+
*
|
|
37
|
+
* 1. **JS truthiness** (`boolean-result.ts`, `expr/emitters.ts`'s
|
|
38
|
+
* `truthyTest`, this file's `convertConditionToJinja`). Python's `[]` /
|
|
39
|
+
* `{}` are falsy; JS's are truthy. Perl doesn't have this problem (a
|
|
40
|
+
* Perl reference is always true), so Kolon needed no truthy-routing
|
|
41
|
+
* layer. Every condition-TEST position (an `{% if %}` / `{% elif %}`
|
|
42
|
+
* test, a ternary test, the left operand of `&&`/`||`) routes through
|
|
43
|
+
* `bf.truthy(...)` unless it is structurally already boolean-shaped.
|
|
44
|
+
* 2. **Stringification** (`bf.string`, applied at every text/attribute
|
|
45
|
+
* interpolation position). Perl's default scalar stringification is
|
|
46
|
+
* close enough to JS's `String(x)` that Kolon only special-cases
|
|
47
|
+
* explicit `String()` calls and boolean-typed values (routed to
|
|
48
|
+
* `bf.bool_str`). Python's default `str()` diverges further —
|
|
49
|
+
* `str(True)` == `"True"`, `str(1.0)` == `"1.0"`, `str(None)` ==
|
|
50
|
+
* `"None"`, and Jinja's `~` concatenation operator calls `str()` on
|
|
51
|
+
* each operand internally — so this port explicitly routes EVERY
|
|
52
|
+
* text/attribute-position value (not already boolean-routed) through
|
|
53
|
+
* `bf.string(...)` before it reaches Jinja's own escaping/concat
|
|
54
|
+
* machinery. Verified empirically (`'a' ~ true ~ none` → `"aTrueNone"`
|
|
55
|
+
* under plain Jinja) — the reason this wrapping is mandatory, not
|
|
56
|
+
* cosmetic.
|
|
57
|
+
* 3. **No Jinja lambda** (`expr/emitters.ts` header, divergence 2). Kolon's
|
|
58
|
+
* `-> $x { … }` lambda — the Xslate top-level emitter's fallback when a
|
|
59
|
+
* predicate callback can't be serialized to the runtime evaluator's
|
|
60
|
+
* JSON form — has no Jinja equivalent. This adapter uses ONE mechanism
|
|
61
|
+
* for every higher-order callback (the evaluator-JSON `*_eval`
|
|
62
|
+
* payload); an unserializable predicate surfaces `BF101` instead of a
|
|
63
|
+
* lambda fallback. `.sort`'s non-lambda STRUCTURED fallback
|
|
64
|
+
* (`bf.sort` with a `{keys: […]}` descriptor) is unaffected and ports
|
|
65
|
+
* unchanged.
|
|
66
|
+
* 4. **Children/fallback capture via `{% set %}...{% endset %}`, never a
|
|
67
|
+
* macro.** Every Kolon macro-capture site in the ported adapter
|
|
68
|
+
* (`renderComponent`'s children forward, `renderAsync`'s fallback) is
|
|
69
|
+
* invoked immediately, in place, with zero arguments — never reused
|
|
70
|
+
* elsewhere or invoked lazily with different arguments. Jinja's
|
|
71
|
+
* set-block (`{% set NAME %}…{% endset %}`) captures exactly that
|
|
72
|
+
* shape as a `Markup` string (under `autoescape=True`) with no macro
|
|
73
|
+
* indirection needed; the captured name is then referenced bare
|
|
74
|
+
* (`NAME`, not `NAME()`) everywhere the Kolon port called `NAME()`.
|
|
75
|
+
* 5. **Reserved-word identifier mangling** (`lib/jinja-naming.ts`). Every
|
|
76
|
+
* bare Jinja variable reference / `{% set %}` target is passed through
|
|
77
|
+
* `jinjaIdent()`; the Python runtime must apply the IDENTICAL mangling
|
|
78
|
+
* when it builds the per-render context dict (so a prop literally
|
|
79
|
+
* named e.g. `class` is threaded through as context key `'class_'` on
|
|
80
|
+
* both sides). Dict-LITERAL keys are a separate, unconditional
|
|
81
|
+
* concern — see `jinjaHashKey`'s docstring for why they are always
|
|
82
|
+
* quoted (unlike Kolon's bareword-key sugar).
|
|
83
|
+
* 6. **In-template signal/memo self-reference seeding is NOT skipped**
|
|
84
|
+
* (`memo/seed.ts`'s file header) — Jinja's `{% set x = x + 1 %}` safely
|
|
85
|
+
* resolves the right-hand `x` from the enclosing scope (verified
|
|
86
|
+
* empirically), unlike Kolon's `my`-shadowing hazard, so a same-name
|
|
87
|
+
* prop-derived signal/memo IS seeded in-template here (Xslate skips
|
|
88
|
+
* it). Strictly more correct, not merely a port artifact.
|
|
89
|
+
*/
|
|
90
|
+
|
|
91
|
+
import type {
|
|
92
|
+
ComponentIR,
|
|
93
|
+
IRNode,
|
|
94
|
+
IRElement,
|
|
95
|
+
IRText,
|
|
96
|
+
IRExpression,
|
|
97
|
+
IRConditional,
|
|
98
|
+
IRLoop,
|
|
99
|
+
IRComponent,
|
|
100
|
+
IRFragment,
|
|
101
|
+
IRSlot,
|
|
102
|
+
IRIfStatement,
|
|
103
|
+
IRProvider,
|
|
104
|
+
IRAsync,
|
|
105
|
+
IRProp,
|
|
106
|
+
IRTemplatePart,
|
|
107
|
+
CompilerError,
|
|
108
|
+
TypeInfo,
|
|
109
|
+
TemplatePrimitiveRegistry,
|
|
110
|
+
IRMetadata,
|
|
111
|
+
} from '@barefootjs/jsx'
|
|
112
|
+
import {
|
|
113
|
+
BaseAdapter,
|
|
114
|
+
type AdapterOutput,
|
|
115
|
+
type AdapterGenerateOptions,
|
|
116
|
+
type TemplateSections,
|
|
117
|
+
type IRNodeEmitter,
|
|
118
|
+
type EmitIRNode,
|
|
119
|
+
type AttrValueEmitter,
|
|
120
|
+
isBooleanAttr,
|
|
121
|
+
parseExpression,
|
|
122
|
+
stringifyParsedExpr,
|
|
123
|
+
exprToString,
|
|
124
|
+
parseProviderObjectLiteral,
|
|
125
|
+
parseStyleObjectEntries,
|
|
126
|
+
isSupported,
|
|
127
|
+
emitParsedExpr,
|
|
128
|
+
emitIRNode,
|
|
129
|
+
emitAttrValue,
|
|
130
|
+
augmentInheritedPropAccesses,
|
|
131
|
+
parseRecordIndexAccess,
|
|
132
|
+
collectModuleStringConsts,
|
|
133
|
+
extractArrowBodyExpression,
|
|
134
|
+
collectContextConsumers,
|
|
135
|
+
isLowerableLoopDestructure,
|
|
136
|
+
type ContextConsumer,
|
|
137
|
+
lookupStaticRecordLiteral,
|
|
138
|
+
searchParamsLocalNames,
|
|
139
|
+
prepareLoweringMatchers,
|
|
140
|
+
queryHrefArgs,
|
|
141
|
+
isValidHelperId,
|
|
142
|
+
sortComparatorFromArrow,
|
|
143
|
+
} from '@barefootjs/jsx'
|
|
144
|
+
import { isAriaBooleanAttr, isBooleanResultExpr, isExplicitStringCall } from './boolean-result.ts'
|
|
145
|
+
import type { ParsedExpr, LoweringMatcher } from '@barefootjs/jsx'
|
|
146
|
+
import { BF_SLOT, BF_COND, BF_REGION } from '@barefootjs/shared'
|
|
147
|
+
|
|
148
|
+
import type { JinjaRenderCtx } from './lib/types.ts'
|
|
149
|
+
import { JINJA_PRIMITIVE_EMIT_MAP } from './lib/constants.ts'
|
|
150
|
+
import {
|
|
151
|
+
jinjaHashKey,
|
|
152
|
+
jinjaIdent,
|
|
153
|
+
escapeJinjaSingleQuoted,
|
|
154
|
+
jinjaAccessorFromSegments,
|
|
155
|
+
} from './lib/jinja-naming.ts'
|
|
156
|
+
import {
|
|
157
|
+
resolveJsxChildrenProp,
|
|
158
|
+
collectRootScopeNodes,
|
|
159
|
+
} from './lib/ir-scope.ts'
|
|
160
|
+
import { renderSortMethod, renderSortEval } from './expr/array-method.ts'
|
|
161
|
+
import { JinjaFilterEmitter, JinjaTopLevelEmitter, truthyTest } from './expr/emitters.ts'
|
|
162
|
+
import type { JinjaEmitContext, JinjaSpreadContext, JinjaMemoContext } from './emit-context.ts'
|
|
163
|
+
import {
|
|
164
|
+
hasClientInteractivity,
|
|
165
|
+
collectImportedLoopChildComponentErrors,
|
|
166
|
+
} from './analysis/component-tree.ts'
|
|
167
|
+
import {
|
|
168
|
+
conditionalSpreadToJinja,
|
|
169
|
+
objectLiteralExprToJinjaDict,
|
|
170
|
+
} from './spread/spread-codegen.ts'
|
|
171
|
+
import {
|
|
172
|
+
generateContextConsumerSeed,
|
|
173
|
+
generateDerivedMemoSeed,
|
|
174
|
+
} from './memo/seed.ts'
|
|
175
|
+
import {
|
|
176
|
+
collectBooleanTypedProps,
|
|
177
|
+
collectNullableOptionalProps,
|
|
178
|
+
collectStringValueNames,
|
|
179
|
+
} from './props/prop-classes.ts'
|
|
180
|
+
|
|
181
|
+
export type { JinjaAdapterOptions } from './lib/types.ts'
|
|
182
|
+
import type { JinjaAdapterOptions } from './lib/types.ts'
|
|
183
|
+
|
|
184
|
+
export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRenderCtx> {
|
|
185
|
+
name = 'jinja'
|
|
186
|
+
extension = '.jinja'
|
|
187
|
+
templatesPerComponent = true
|
|
188
|
+
// Template-string target with no component layer: `bf build` emits a static
|
|
189
|
+
// import-map HTML snippet to include into the page <head>.
|
|
190
|
+
importMapInjection = 'html-snippet' as const
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Identifier-path callees the Jinja runtime can render in template scope.
|
|
194
|
+
* The relocate pass consults this map to mark matching calls as
|
|
195
|
+
* template-safe; the SSR template emitter substitutes the JS call with the
|
|
196
|
+
* registered `bf.NAME(...)` helper invocation.
|
|
197
|
+
*/
|
|
198
|
+
templatePrimitives: TemplatePrimitiveRegistry = JINJA_PRIMITIVE_EMIT_MAP
|
|
199
|
+
|
|
200
|
+
private componentName: string = ''
|
|
201
|
+
/** Component root scope element(s) — each carries `data-key` for a keyed loop
|
|
202
|
+
* item (set by the child renderer from the JSX `key` prop). A plain element
|
|
203
|
+
* root is one node; an `if-statement` (early-return) root contributes the
|
|
204
|
+
* top element of every branch. */
|
|
205
|
+
private rootScopeNodes: Set<IRNode> = new Set()
|
|
206
|
+
private options: Required<JinjaAdapterOptions>
|
|
207
|
+
private errors: CompilerError[] = []
|
|
208
|
+
private inLoop: boolean = false
|
|
209
|
+
/**
|
|
210
|
+
* SolidJS-style props identifier (`function(props: P)`) and the
|
|
211
|
+
* analyzer-extracted prop names. Stashed at `generate()` entry so the
|
|
212
|
+
* per-attribute `emitSpread` callback can build a propsObject spread bag as
|
|
213
|
+
* an inline Jinja dict literal without re-walking the IR.
|
|
214
|
+
*/
|
|
215
|
+
private propsObjectName: string | null = null
|
|
216
|
+
private propsParams: { name: string }[] = []
|
|
217
|
+
private booleanTypedProps: Set<string> = new Set()
|
|
218
|
+
/**
|
|
219
|
+
* Names (signal getters + props) whose value is a string. Carried for
|
|
220
|
+
* parity with the Perl-family adapters (Mojo needs it for `eq`/`ne`
|
|
221
|
+
* selection); the Jinja emitters don't consume it — Jinja's `==`/`!=`
|
|
222
|
+
* compare strings and numbers correctly.
|
|
223
|
+
*/
|
|
224
|
+
private stringValueNames: Set<string> = new Set()
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Module-scope pure-string consts (`const x = 'literal'`), keyed by name →
|
|
228
|
+
* unescaped value. A className template literal that references such a const
|
|
229
|
+
* (`className={`${x} ${className}`}`) must inline the literal: the const is
|
|
230
|
+
* module-scope, so it never reaches the per-render context, and a bare
|
|
231
|
+
* reference to `x` would resolve to Undefined.
|
|
232
|
+
*/
|
|
233
|
+
private moduleStringConsts: Map<string, string> = new Map()
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* (#1922) Local binding names the request-scoped `searchParams()` env signal
|
|
237
|
+
* is imported under (handles `import { searchParams as sp }`). When non-empty
|
|
238
|
+
* the emitter lowers a `<binding>().get(k)` call to a real method call on the
|
|
239
|
+
* per-request `searchParams` reader (`searchParams.get('sort')`) instead of
|
|
240
|
+
* the generic dot deref. Set at `generate()` entry from `ir.metadata.imports`;
|
|
241
|
+
* read by the top-level ParsedExpr emitter.
|
|
242
|
+
*/
|
|
243
|
+
private _searchParamsLocals: Set<string> = new Set()
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Call-lowering matchers active for this component (#2057). Bound at
|
|
247
|
+
* `generate()` entry via `prepareLoweringMatchers` and read by the top-level
|
|
248
|
+
* emitter. Covers both userland plugins and the compiler's built-in plugins
|
|
249
|
+
* (e.g. `queryHref` → `bf.query`, #2042) — one uniform path, no per-API branch.
|
|
250
|
+
*/
|
|
251
|
+
private _loweringMatchers: LoweringMatcher[] = []
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Local + module constants from the IR, used by the conditional-spread and
|
|
255
|
+
* `Record<staticKeys, scalar>[propKey]` lowering paths (#textarea / #checkbox).
|
|
256
|
+
* Stashed at `generate()` entry so `emitSpread` can resolve a bare local
|
|
257
|
+
* const (`const sizeAttrs = size ? {…} : {}`) to its initializer text.
|
|
258
|
+
*/
|
|
259
|
+
private localConstants: IRMetadata['localConstants'] = []
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Optional, no-default props that are `None` when the caller omits them.
|
|
263
|
+
* Their bare-reference attribute emission is guarded with a Jinja
|
|
264
|
+
* `is defined and is not none` test so the attribute DROPS rather than
|
|
265
|
+
* rendering `attr=""` (Hono-style nullish omission, e.g. textarea's
|
|
266
|
+
* `rows`). The filter excludes destructure-defaulted, rest, and
|
|
267
|
+
* concrete-primitive props.
|
|
268
|
+
*/
|
|
269
|
+
private nullableOptionalProps: Set<string> = new Set()
|
|
270
|
+
|
|
271
|
+
constructor(options: JinjaAdapterOptions = {}) {
|
|
272
|
+
super()
|
|
273
|
+
this.options = {
|
|
274
|
+
clientJsBasePath: options.clientJsBasePath ?? '/static/components/',
|
|
275
|
+
barefootJsPath: options.barefootJsPath ?? '/static/components/barefoot.js',
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
generate(ir: ComponentIR, options?: AdapterGenerateOptions): AdapterOutput {
|
|
280
|
+
this.componentName = ir.metadata.componentName
|
|
281
|
+
this.propsObjectName = ir.metadata.propsObjectName ?? null
|
|
282
|
+
// (#checkbox) Enumerate the props-object pattern's inherited attribute
|
|
283
|
+
// accesses (`props.className`/`id`/`disabled`) into propsParams via the
|
|
284
|
+
// shared helper, before deriving `nullableOptionalProps` below.
|
|
285
|
+
augmentInheritedPropAccesses(ir)
|
|
286
|
+
this.propsParams = ir.metadata.propsParams.map(p => ({ name: p.name }))
|
|
287
|
+
// Props whose declared TS type is boolean — a bare binding of one
|
|
288
|
+
// (`data-active={props.isActive}`) must stringify as JS
|
|
289
|
+
// `String(boolean)` ("true"/"false"), not Python's `str(bool)`
|
|
290
|
+
// ("True"/"False") (#1897, pagination's data-active).
|
|
291
|
+
this.booleanTypedProps = collectBooleanTypedProps(ir)
|
|
292
|
+
this.localConstants = ir.metadata.localConstants ?? []
|
|
293
|
+
this.nullableOptionalProps = collectNullableOptionalProps(ir)
|
|
294
|
+
this.stringValueNames = collectStringValueNames(ir)
|
|
295
|
+
this.moduleStringConsts = collectModuleStringConsts(ir.metadata.localConstants)
|
|
296
|
+
this._searchParamsLocals = searchParamsLocalNames(ir.metadata)
|
|
297
|
+
this._loweringMatchers = prepareLoweringMatchers(ir.metadata)
|
|
298
|
+
this.errors = []
|
|
299
|
+
this.childrenCaptureCounter = 0
|
|
300
|
+
|
|
301
|
+
// Mirror of the Xslate adapter's BF103 check: a child component referenced
|
|
302
|
+
// inside a loop body that is imported from a sibling .tsx emits a
|
|
303
|
+
// cross-template `bf.render_child(...)` call that resolves only if the
|
|
304
|
+
// sibling template is registered alongside the parent at render time.
|
|
305
|
+
// Surface it loudly here. Suppressed when the caller guarantees that all
|
|
306
|
+
// sibling templates are registered on the same instance at render time.
|
|
307
|
+
if (!options?.siblingTemplatesRegistered) {
|
|
308
|
+
this.errors.push(...collectImportedLoopChildComponentErrors(ir, this.componentName))
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
this.rootScopeNodes = collectRootScopeNodes(ir.root)
|
|
312
|
+
const templateBody = ir.root.type === 'if-statement'
|
|
313
|
+
? this.renderIfStatement(ir.root as IRIfStatement)
|
|
314
|
+
: this.renderNode(ir.root)
|
|
315
|
+
|
|
316
|
+
// Generate script registration
|
|
317
|
+
const scriptReg = options?.skipScriptRegistration
|
|
318
|
+
? ''
|
|
319
|
+
: this.generateScriptRegistrations(ir, options?.scriptBaseName)
|
|
320
|
+
|
|
321
|
+
// SSR context consumers (`const x = useContext(Ctx)`): seed each local
|
|
322
|
+
// from the active provider value (or the `createContext` default). The
|
|
323
|
+
// provider side pushes the value via `emitProvider`. (#1297)
|
|
324
|
+
const ctxSeed = generateContextConsumerSeed(ir)
|
|
325
|
+
|
|
326
|
+
// Prop/signal-derived memos with a `null` static SSR default (e.g.
|
|
327
|
+
// `createMemo(() => props.value * 10)`) are computed in-template from the
|
|
328
|
+
// already-seeded prop/signal vars — mirroring Go's generated child
|
|
329
|
+
// constructor. (#1297)
|
|
330
|
+
const memoSeed = generateDerivedMemoSeed(this.memoCtx, ir)
|
|
331
|
+
|
|
332
|
+
const template = `${scriptReg}${ctxSeed}${memoSeed}${templateBody}\n`
|
|
333
|
+
|
|
334
|
+
// Merge collected errors into IR errors
|
|
335
|
+
if (this.errors.length > 0) {
|
|
336
|
+
ir.errors.push(...this.errors)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Jinja templates have no JS-style imports / types / default-export
|
|
340
|
+
// sections. The `templatesPerComponent` mode emits one file per component
|
|
341
|
+
// using the raw `template` value; sections are populated for contract
|
|
342
|
+
// uniformity so the compiler never has to string-parse the template.
|
|
343
|
+
const sections: TemplateSections = {
|
|
344
|
+
imports: '',
|
|
345
|
+
types: '',
|
|
346
|
+
component: template,
|
|
347
|
+
defaultExport: '',
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return {
|
|
351
|
+
template,
|
|
352
|
+
sections,
|
|
353
|
+
extension: this.extension,
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ===========================================================================
|
|
358
|
+
// Script Registration
|
|
359
|
+
// ===========================================================================
|
|
360
|
+
|
|
361
|
+
private generateScriptRegistrations(ir: ComponentIR, scriptBaseName?: string): string {
|
|
362
|
+
const hasInteractivity = hasClientInteractivity(ir)
|
|
363
|
+
if (!hasInteractivity) return ''
|
|
364
|
+
|
|
365
|
+
const name = scriptBaseName ?? ir.metadata.componentName
|
|
366
|
+
const runtimePath = this.options.barefootJsPath
|
|
367
|
+
const clientJsPath = `${this.options.clientJsBasePath}${name}.client.js`
|
|
368
|
+
|
|
369
|
+
// Unlike Kolon's `:` line marker (which PRINTS a bare statement's value,
|
|
370
|
+
// forcing a throwaway `my` bind so `register_script`'s return value
|
|
371
|
+
// doesn't leak into the HTML), Jinja's `{% set %}` statement tag never
|
|
372
|
+
// prints anything regardless — no throwaway-bind trick is needed here.
|
|
373
|
+
// Distinct names are kept anyway for direct traceability with the Kolon
|
|
374
|
+
// port (Jinja has no restriction on re-`{% set %}`ing the same name).
|
|
375
|
+
const lines: string[] = []
|
|
376
|
+
lines.push(`{% set _bf_reg0 = bf.register_script('${runtimePath}') %}`)
|
|
377
|
+
lines.push(`{% set _bf_reg1 = bf.register_script('${clientJsPath}') %}`)
|
|
378
|
+
lines.push('')
|
|
379
|
+
return lines.join('\n')
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ===========================================================================
|
|
383
|
+
// Node Rendering
|
|
384
|
+
// ===========================================================================
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Public entry point for node rendering. Delegates to the shared
|
|
388
|
+
* `IRNodeEmitter` dispatcher; per-kind logic lives in the `IRNodeEmitter`
|
|
389
|
+
* methods below.
|
|
390
|
+
*/
|
|
391
|
+
renderNode(node: IRNode): string {
|
|
392
|
+
return emitIRNode<JinjaRenderCtx>(node, this, {} as JinjaRenderCtx)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ===========================================================================
|
|
396
|
+
// IRNodeEmitter implementation (Jinja2)
|
|
397
|
+
// ===========================================================================
|
|
398
|
+
|
|
399
|
+
emitElement(node: IRElement, _ctx: JinjaRenderCtx, _emit: EmitIRNode<JinjaRenderCtx>): string {
|
|
400
|
+
return this.renderElement(node)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
emitText(node: IRText): string {
|
|
404
|
+
return node.value
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
emitExpression(node: IRExpression): string {
|
|
408
|
+
return this.renderExpression(node)
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
emitConditional(node: IRConditional, _ctx: JinjaRenderCtx, _emit: EmitIRNode<JinjaRenderCtx>): string {
|
|
412
|
+
return this.renderConditional(node)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
emitLoop(node: IRLoop, _ctx: JinjaRenderCtx, _emit: EmitIRNode<JinjaRenderCtx>): string {
|
|
416
|
+
return this.renderLoop(node)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
emitComponent(node: IRComponent, _ctx: JinjaRenderCtx, _emit: EmitIRNode<JinjaRenderCtx>): string {
|
|
420
|
+
return this.renderComponent(node)
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
emitFragment(node: IRFragment, _ctx: JinjaRenderCtx, _emit: EmitIRNode<JinjaRenderCtx>): string {
|
|
424
|
+
return this.renderFragment(node)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
emitSlot(node: IRSlot): string {
|
|
428
|
+
return this.renderSlot(node)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
emitIfStatement(node: IRIfStatement, _ctx: JinjaRenderCtx, _emit: EmitIRNode<JinjaRenderCtx>): string {
|
|
432
|
+
return this.renderIfStatement(node)
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
emitProvider(node: IRProvider, _ctx: JinjaRenderCtx, _emit: EmitIRNode<JinjaRenderCtx>): string {
|
|
436
|
+
// SSR context propagation (#1297): bracket the children with a
|
|
437
|
+
// provide/revoke pair on the shared controller-stash context stack so a
|
|
438
|
+
// descendant `useContext` consumer reads the value during the same
|
|
439
|
+
// render. Both helpers return '' (empty), so the inline `{{ … }}`
|
|
440
|
+
// expression form discards their output cleanly — no extra whitespace,
|
|
441
|
+
// no line-statement needed inside the element body.
|
|
442
|
+
const value = this.providerValueJinja(node.valueProp)
|
|
443
|
+
const children = this.renderChildren(node.children)
|
|
444
|
+
const name = node.contextName
|
|
445
|
+
return (
|
|
446
|
+
`{{ bf.provide_context('${name}', ${value}) }}` +
|
|
447
|
+
children +
|
|
448
|
+
`{{ bf.revoke_context('${name}') }}`
|
|
449
|
+
)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Lower a `<Ctx.Provider value>` value prop to a Jinja expression. */
|
|
453
|
+
private providerValueJinja(valueProp: IRProvider['valueProp']): string {
|
|
454
|
+
const v = valueProp.value
|
|
455
|
+
if (v.kind === 'literal') {
|
|
456
|
+
if (typeof v.value === 'string') {
|
|
457
|
+
return `'${escapeJinjaSingleQuoted(v.value)}'`
|
|
458
|
+
}
|
|
459
|
+
if (typeof v.value === 'boolean') return v.value ? 'true' : 'false'
|
|
460
|
+
return String(v.value)
|
|
461
|
+
}
|
|
462
|
+
if (v.kind === 'expression') {
|
|
463
|
+
const dict = this.providerObjectLiteralJinja(v.expr)
|
|
464
|
+
if (dict !== null) return dict
|
|
465
|
+
return this.convertExpressionToJinja(v.expr)
|
|
466
|
+
}
|
|
467
|
+
if (v.kind === 'template') return this.convertTemplateLiteralPartsToJinja(v.parts)
|
|
468
|
+
// Out-of-shape value (spread / jsx-children) — none; consumer defaults.
|
|
469
|
+
return 'none'
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Lower an object-literal provider value (`value={{ open: () => props.open
|
|
474
|
+
* ?? false, onOpenChange: … }}`) to a Jinja dict literal (#1897). The
|
|
475
|
+
* SSR lowering is a per-member snapshot of what a consumer would READ
|
|
476
|
+
* during the same render:
|
|
477
|
+
*
|
|
478
|
+
* - zero-param expression-body arrows are getters — lower the body (the
|
|
479
|
+
* value is fixed for the render, so the call-time indirection drops out)
|
|
480
|
+
* - `on[A-Z]`-named members and function-shaped values are client-only
|
|
481
|
+
* behavior SSR never invokes — lower to `none`
|
|
482
|
+
* - anything else lowers through the normal expression pipeline (so an
|
|
483
|
+
* unsupported getter body still refuses loudly with BF101)
|
|
484
|
+
*
|
|
485
|
+
* Keys keep their JS names verbatim so a consumer-side `ctx.open` access
|
|
486
|
+
* maps onto the same dict key. Returns `null` when the expression is not a
|
|
487
|
+
* plain object literal (spread / computed key) — the caller falls back to
|
|
488
|
+
* the whole-expression path, which refuses those shapes with BF101.
|
|
489
|
+
*/
|
|
490
|
+
private providerObjectLiteralJinja(expr: string): string | null {
|
|
491
|
+
const members = parseProviderObjectLiteral(expr.trim())
|
|
492
|
+
if (members === null) return null
|
|
493
|
+
const entries = members.map(m => {
|
|
494
|
+
const key = jinjaHashKey(m.name)
|
|
495
|
+
if (m.kind === 'function' || /^on[A-Z]/.test(m.name)) return `${key}: none`
|
|
496
|
+
const src = m.kind === 'getter' ? m.body : m.expr
|
|
497
|
+
return `${key}: ${this.convertExpressionToJinja(src)}`
|
|
498
|
+
})
|
|
499
|
+
return `{${entries.join(', ')}}`
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
emitAsync(node: IRAsync, _ctx: JinjaRenderCtx, _emit: EmitIRNode<JinjaRenderCtx>): string {
|
|
503
|
+
return this.renderAsync(node)
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// ===========================================================================
|
|
507
|
+
// Element Rendering
|
|
508
|
+
// ===========================================================================
|
|
509
|
+
|
|
510
|
+
renderElement(element: IRElement): string {
|
|
511
|
+
const tag = element.tag
|
|
512
|
+
const attrs = this.renderAttributes(element)
|
|
513
|
+
const children = this.renderChildren(element.children)
|
|
514
|
+
|
|
515
|
+
let hydrationAttrs = ''
|
|
516
|
+
if (element.needsScope) {
|
|
517
|
+
hydrationAttrs += ` ${this.renderScopeMarker('')}`
|
|
518
|
+
}
|
|
519
|
+
// A root scope element carries `data-key` for a keyed loop item (set on the
|
|
520
|
+
// bf instance by the child renderer from the JSX `key` prop); non-keyed
|
|
521
|
+
// renders add nothing. Mirrors Hono stamping data-key on each loop item's
|
|
522
|
+
// root, including early-return (if-statement) roots. (#1297)
|
|
523
|
+
if (this.rootScopeNodes.has(element) && element.needsScope) {
|
|
524
|
+
hydrationAttrs += ` {{ bf.data_key_attr() | safe }}`
|
|
525
|
+
}
|
|
526
|
+
if (element.slotId) {
|
|
527
|
+
hydrationAttrs += ` ${this.renderSlotMarker(element.slotId)}`
|
|
528
|
+
}
|
|
529
|
+
// Page-lifecycle boundary lowered from `<Region>` (spec/router.md). The id
|
|
530
|
+
// is a deterministic static string (`<file scope>:<index>`), so it emits as
|
|
531
|
+
// a plain literal attribute — no Jinja template tag.
|
|
532
|
+
if (element.regionId) {
|
|
533
|
+
hydrationAttrs += ` ${BF_REGION}="${element.regionId}"`
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const voidElements = [
|
|
537
|
+
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
|
|
538
|
+
'link', 'meta', 'param', 'source', 'track', 'wbr',
|
|
539
|
+
]
|
|
540
|
+
|
|
541
|
+
if (voidElements.includes(tag.toLowerCase())) {
|
|
542
|
+
return `<${tag}${attrs}${hydrationAttrs}>`
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
return `<${tag}${attrs}${hydrationAttrs}>${children}</${tag}>`
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// ===========================================================================
|
|
549
|
+
// Expression Rendering
|
|
550
|
+
// ===========================================================================
|
|
551
|
+
|
|
552
|
+
renderExpression(expr: IRExpression): string {
|
|
553
|
+
if (expr.clientOnly) {
|
|
554
|
+
if (expr.slotId) {
|
|
555
|
+
return `{{ bf.comment("client:${expr.slotId}") | safe }}`
|
|
556
|
+
}
|
|
557
|
+
return ''
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Text-position interpolation of a possibly-non-string value — see the
|
|
561
|
+
// file header, divergence 2.
|
|
562
|
+
const jinjaExpr = `bf.string(${this.convertExpressionToJinja(expr.expr)})`
|
|
563
|
+
|
|
564
|
+
if (expr.slotId) {
|
|
565
|
+
return `{{ bf.text_start("${expr.slotId}") | safe }}{{ ${jinjaExpr} }}{{ bf.text_end() | safe }}`
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
return `{{ ${jinjaExpr} }}`
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// ===========================================================================
|
|
572
|
+
// Conditional Rendering
|
|
573
|
+
// ===========================================================================
|
|
574
|
+
|
|
575
|
+
renderConditional(cond: IRConditional): string {
|
|
576
|
+
if (cond.clientOnly && cond.slotId) {
|
|
577
|
+
return `{{ bf.comment("cond-start:${cond.slotId}") | safe }}{{ bf.comment("cond-end:${cond.slotId}") | safe }}`
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const condition = this.convertConditionToJinja(cond.condition)
|
|
581
|
+
const whenTrue = this.renderNode(cond.whenTrue)
|
|
582
|
+
const whenFalse = this.renderNodeOrNull(cond.whenFalse)
|
|
583
|
+
|
|
584
|
+
// When slotId is present, add bf-c marker.
|
|
585
|
+
// Use comment markers for fragments (multiple sibling elements), attribute
|
|
586
|
+
// for single elements.
|
|
587
|
+
const isFragmentBranch = cond.whenTrue.type === 'fragment' || cond.whenFalse.type === 'fragment'
|
|
588
|
+
const useCommentMarkers = cond.slotId && isFragmentBranch
|
|
589
|
+
|
|
590
|
+
let markedTrue = whenTrue
|
|
591
|
+
let markedFalse = whenFalse
|
|
592
|
+
if (cond.slotId && !useCommentMarkers) {
|
|
593
|
+
markedTrue = this.addCondMarkerToFirstElement(whenTrue, cond.slotId)
|
|
594
|
+
markedFalse = whenFalse ? this.addCondMarkerToFirstElement(whenFalse, cond.slotId) : whenFalse
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
let result: string
|
|
598
|
+
if (useCommentMarkers) {
|
|
599
|
+
// Fragment branches: use comment markers
|
|
600
|
+
const inner = whenFalse
|
|
601
|
+
? `\n{% if ${condition} %}\n${whenTrue}\n{% else %}\n${whenFalse}\n{% endif %}\n`
|
|
602
|
+
: `\n{% if ${condition} %}\n${whenTrue}\n{% endif %}\n`
|
|
603
|
+
result = `{{ bf.comment("cond-start:${cond.slotId}") | safe }}${inner}{{ bf.comment("cond-end:${cond.slotId}") | safe }}`
|
|
604
|
+
} else if (markedFalse) {
|
|
605
|
+
result = `\n{% if ${condition} %}\n${markedTrue}\n{% else %}\n${markedFalse}\n{% endif %}\n`
|
|
606
|
+
} else if (cond.slotId) {
|
|
607
|
+
// Conditional with no else: wrap with comment markers for client hydration
|
|
608
|
+
result = `{{ bf.comment("cond-start:${cond.slotId}") | safe }}\n{% if ${condition} %}\n${whenTrue}\n{% endif %}\n{{ bf.comment("cond-end:${cond.slotId}") | safe }}`
|
|
609
|
+
} else {
|
|
610
|
+
result = `\n{% if ${condition} %}\n${whenTrue}\n{% endif %}\n`
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
return result
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
private renderNodeOrNull(node: IRNode): string | null {
|
|
617
|
+
if (node.type === 'expression' && (node.expr === 'null' || node.expr === 'undefined')) {
|
|
618
|
+
return null
|
|
619
|
+
}
|
|
620
|
+
return this.renderNode(node)
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Add bf-c attribute to the first HTML element in a branch.
|
|
625
|
+
* If no element found, wrap with comment markers.
|
|
626
|
+
*/
|
|
627
|
+
private addCondMarkerToFirstElement(content: string, condId: string): string {
|
|
628
|
+
// Match first HTML open tag
|
|
629
|
+
const match = content.match(/^(<\w+)([\s>])/)
|
|
630
|
+
if (match) {
|
|
631
|
+
return content.replace(/^(<\w+)([\s>])/, `$1 ${BF_COND}="${condId}"$2`)
|
|
632
|
+
}
|
|
633
|
+
// Fall back to comment markers for non-element content
|
|
634
|
+
return `{{ bf.comment("cond-start:${condId}") | safe }}${content}{{ bf.comment("cond-end:${condId}") | safe }}`
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// ===========================================================================
|
|
638
|
+
// Loop Rendering
|
|
639
|
+
// ===========================================================================
|
|
640
|
+
|
|
641
|
+
renderLoop(loop: IRLoop): string {
|
|
642
|
+
// clientOnly loops must not render items at SSR time, but must still emit
|
|
643
|
+
// the `loop:`/`/loop:` boundary marker pair (Hono and Go parity) so the
|
|
644
|
+
// client runtime's mapArray() can locate the insertion anchor when
|
|
645
|
+
// hydrating the array. Without the markers, mapArray() resolves
|
|
646
|
+
// anchor = null and appends after sibling markers (#872). The marker id
|
|
647
|
+
// disambiguates sibling `.map()` calls under the same parent (#1087).
|
|
648
|
+
if (loop.clientOnly) {
|
|
649
|
+
return `{{ bf.comment("loop:${loop.markerId}") | safe }}{{ bf.comment("/loop:${loop.markerId}") | safe }}`
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Jinja's `{% for item in list %}` binds a single loop variable — it
|
|
653
|
+
// can't natively unpack a tuple the way a Python `for` statement can, so
|
|
654
|
+
// a `.map()` destructure param never lowers to a bare Jinja for-target.
|
|
655
|
+
// Instead, `isLowerableLoopDestructure` (#2087) admits any shape whose
|
|
656
|
+
// bindings resolve to a per-adapter accessor without needing the JS/CSR
|
|
657
|
+
// runtime: fixed bindings at any field/index depth (`{ id }`, `[k, v]`,
|
|
658
|
+
// `{ user: { name } }`), array-rest (`[first, ...tail]`), and
|
|
659
|
+
// object-rest whose every use is a member read (`rest.flag`) or a
|
|
660
|
+
// `{...rest}` spread onto an intrinsic element. Each admitted binding
|
|
661
|
+
// becomes a `{% set %}` local off the per-item var (a native accessor
|
|
662
|
+
// for fixed bindings, `bf.slice`/`bf.omit` for rest), so the body's
|
|
663
|
+
// `id` / `v` / `name` / `rest.flag` all resolve. Still refused (BF104):
|
|
664
|
+
// a bare-value rest use (`String(rest)`, `{rest}` as text, `{...fn(rest)}`),
|
|
665
|
+
// a rest spread onto a component/provider, `.filter().map(destructure)`,
|
|
666
|
+
// and `__bf_`-prefixed binding names (would collide with the synthetic
|
|
667
|
+
// per-item var).
|
|
668
|
+
const destructure = !!(loop.paramBindings && loop.paramBindings.length > 0)
|
|
669
|
+
const supportableDestructure = destructure && isLowerableLoopDestructure(loop)
|
|
670
|
+
if (destructure && !supportableDestructure) {
|
|
671
|
+
this.errors.push({
|
|
672
|
+
code: 'BF104',
|
|
673
|
+
severity: 'error',
|
|
674
|
+
message: `Loop callback uses a destructure pattern (\`${loop.param}\`) that the Jinja adapter cannot lower to a native accessor — Jinja \`for item in list\` binds a single loop variable and this shape needs the actual residual value materialized (e.g. a bare object-rest use, or a rest spread onto a component).`,
|
|
675
|
+
loc: loop.loc ?? { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
|
|
676
|
+
suggestion: {
|
|
677
|
+
message:
|
|
678
|
+
`Options:\n` +
|
|
679
|
+
` 1. Rename the parameter to a single name and access tuple/object elements directly in the body (e.g. \`entry => entry[0]\` instead of \`([k, v]) => ...\`).\n` +
|
|
680
|
+
` 2. If using object rest, only read individual fields off it (\`rest.flag\`) or spread it onto an intrinsic element (\`{...rest}\`) — not as a bare value.\n` +
|
|
681
|
+
` 3. Mark the loop position as @client-only so the destructure runs in JS on the client.\n` +
|
|
682
|
+
` 4. Move the loop into a primitive that the adapter registers explicitly.`,
|
|
683
|
+
},
|
|
684
|
+
})
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// A `.map()` loop whose array is a bare identifier bound to a
|
|
688
|
+
// FUNCTION-scope local const with a non-statically-evaluable initializer
|
|
689
|
+
// that reads props/signals (e.g. `const entries =
|
|
690
|
+
// Object.entries(props.x ?? {}).filter(...)`) can't render correctly.
|
|
691
|
+
// Module-scope consts (`isModule`, e.g. `const payments = [...]` at the
|
|
692
|
+
// top of the file) are a DIFFERENT, already-working case — the shared
|
|
693
|
+
// `ssr-defaults.ts` statically evaluates those and seeds them straight
|
|
694
|
+
// into the render context, so a bare `payments` reference resolves for
|
|
695
|
+
// free (data-table demo). Function-scope locals get no such seeding
|
|
696
|
+
// (`ssr-defaults.ts`: "component-scope locals can depend on
|
|
697
|
+
// signals/props and are evaluated lazily elsewhere") — and this
|
|
698
|
+
// adapter's only "elsewhere" is inlining a const's value at its use
|
|
699
|
+
// site (`_resolveLiteralConst`'s numeric/single-quoted-string fast
|
|
700
|
+
// path, or a static-record-literal lookup), never binding one as a
|
|
701
|
+
// `{% set %}` template local. Left unchecked, `{% for item in entries
|
|
702
|
+
// %}` over an unbound name would silently iterate zero times (Jinja's
|
|
703
|
+
// `ChainableUndefined` tolerates it rather than raising) instead of
|
|
704
|
+
// failing loudly. Pre-existing, general limitation, orthogonal to
|
|
705
|
+
// #2087's destructure-binding work — newly reachable in this adapter's
|
|
706
|
+
// test corpus only because the widened destructure gate (#2087 Phase
|
|
707
|
+
// A/B) no longer refuses this fixture's `([emoji, users]) => ...`
|
|
708
|
+
// param first.
|
|
709
|
+
const arrayName = loop.array.trim()
|
|
710
|
+
if (/^[A-Za-z_$][\w$]*$/.test(arrayName)) {
|
|
711
|
+
const arrayConst = (this.localConstants ?? []).find(c => c.name === arrayName)
|
|
712
|
+
if (arrayConst && !arrayConst.isModule && this._resolveLiteralConst(arrayName) === null) {
|
|
713
|
+
this.errors.push({
|
|
714
|
+
code: 'BF101',
|
|
715
|
+
severity: 'error',
|
|
716
|
+
message: `Loop array \`${arrayName}\` is a local computed value (\`${arrayConst.value}\`) that the Jinja adapter cannot bind as a template variable — only numeric/string-literal locals inline at their use site.`,
|
|
717
|
+
loc: loop.loc ?? { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
|
|
718
|
+
suggestion: {
|
|
719
|
+
message:
|
|
720
|
+
'Pre-compute the array server-side and pass it as a prop, or mark the loop position as @client-only so it runs in JS on the client.',
|
|
721
|
+
},
|
|
722
|
+
})
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const rawArray = this.convertExpressionToJinja(loop.array)
|
|
727
|
+
// Apply sort if present: wrap the loop array in the shared `bf.sort`
|
|
728
|
+
// helper, binding the sorted result to a per-iteration local so the
|
|
729
|
+
// helper runs once.
|
|
730
|
+
let array = rawArray
|
|
731
|
+
if (loop.sortComparator) {
|
|
732
|
+
// Evaluator-first (#2018 P3): serialize the comparator arrow body + emit
|
|
733
|
+
// `bf.sort_eval`; fall back to the structured `bf.sort` for a
|
|
734
|
+
// comparator the evaluator can't model (e.g. `localeCompare`). The
|
|
735
|
+
// comparator now arrives as an `IRLoopSort` carrying the generic
|
|
736
|
+
// `arrow` + its params.
|
|
737
|
+
const sort = loop.sortComparator
|
|
738
|
+
const sortEmit = (e: ParsedExpr) => this.convertExpressionToJinja('', e)
|
|
739
|
+
const arrow = sort.arrow
|
|
740
|
+
const params =
|
|
741
|
+
arrow.kind === 'arrow' ? arrow.params : [sort.paramA, sort.paramB]
|
|
742
|
+
const structured = sortComparatorFromArrow(arrow)
|
|
743
|
+
array =
|
|
744
|
+
renderSortEval(rawArray, arrow.kind === 'arrow' ? arrow.body : arrow, params, sortEmit) ??
|
|
745
|
+
(structured !== null ? renderSortMethod(rawArray, structured) : rawArray)
|
|
746
|
+
}
|
|
747
|
+
const param = loop.param
|
|
748
|
+
// Jinja's `{% for item in array %}` binds the item directly. The index,
|
|
749
|
+
// when needed (`.keys().map(k => ...)` or an explicit `index` param),
|
|
750
|
+
// comes from Jinja's own loop object (`loop.index0`, 0-based) — no
|
|
751
|
+
// Kolon-style `$~loopvar.index` indirection needed.
|
|
752
|
+
const renderedChildren = this.renderChildren(loop.children)
|
|
753
|
+
|
|
754
|
+
// For `keys`-shape iterations the callback param IS the index. We iterate
|
|
755
|
+
// the array but bind the loop var to a throwaway and expose the index as
|
|
756
|
+
// the param name via Jinja's built-in `loop.index0`.
|
|
757
|
+
const loopVar = loop.iterationShape === 'keys'
|
|
758
|
+
? '__bf_item'
|
|
759
|
+
: supportableDestructure ? '__bf_item' : param
|
|
760
|
+
|
|
761
|
+
// Index alias: when an explicit `index` param is present (`.map((x, i) =>
|
|
762
|
+
// ...)`) or the iteration is `keys`-shaped, expose it via a `{% set %}`
|
|
763
|
+
// local bound to Jinja's `loop.index0`. A supported destructure param
|
|
764
|
+
// adds one `{% set %}` local per binding, built from the binding's
|
|
765
|
+
// structured `segments` path (#2087 Phase B — never string-parse
|
|
766
|
+
// `b.path`):
|
|
767
|
+
//
|
|
768
|
+
// - fixed binding: a native accessor walking `segments` off the
|
|
769
|
+
// per-item var (`.field` / `[index]`, any depth).
|
|
770
|
+
// - array-rest binding: `bf.slice(<parent accessor>, from, none)` —
|
|
771
|
+
// the exact JS slice, so every read of the binding (including
|
|
772
|
+
// `.length` via the shared member emitter's `bf.length` routing)
|
|
773
|
+
// matches JS semantics with no adapter-side special-casing.
|
|
774
|
+
// - object-rest binding: `bf.omit(<parent accessor>, [<excluded
|
|
775
|
+
// sibling keys>])` — a TRUE residual dict (not an alias to the
|
|
776
|
+
// whole item), so `rest.flag` member reads and the existing
|
|
777
|
+
// `{...rest}` → `bf.spread_attrs(rest)` emit path both see only the
|
|
778
|
+
// keys NOT already destructured.
|
|
779
|
+
const indexLocalLines: string[] = []
|
|
780
|
+
if (loop.iterationShape === 'keys') {
|
|
781
|
+
indexLocalLines.push(`{% set ${jinjaIdent(param)} = loop.index0 %}`)
|
|
782
|
+
} else if (loop.index) {
|
|
783
|
+
indexLocalLines.push(`{% set ${jinjaIdent(loop.index)} = loop.index0 %}`)
|
|
784
|
+
}
|
|
785
|
+
if (supportableDestructure) {
|
|
786
|
+
for (const b of loop.paramBindings ?? []) {
|
|
787
|
+
const parentAccessor = jinjaAccessorFromSegments(jinjaIdent(loopVar), b.segments ?? [])
|
|
788
|
+
if (!b.rest) {
|
|
789
|
+
indexLocalLines.push(`{% set ${jinjaIdent(b.name)} = ${parentAccessor} %}`)
|
|
790
|
+
} else if (b.rest.kind === 'array') {
|
|
791
|
+
indexLocalLines.push(
|
|
792
|
+
`{% set ${jinjaIdent(b.name)} = bf.slice(${parentAccessor}, ${b.rest.from}, none) %}`,
|
|
793
|
+
)
|
|
794
|
+
} else {
|
|
795
|
+
const excludeKeys = b.rest.exclude.map(k => jinjaHashKey(k.key)).join(', ')
|
|
796
|
+
indexLocalLines.push(
|
|
797
|
+
`{% set ${jinjaIdent(b.name)} = bf.omit(${parentAccessor}, [${excludeKeys}]) %}`,
|
|
798
|
+
)
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
const prevInLoop = this.inLoop
|
|
804
|
+
this.inLoop = true
|
|
805
|
+
// Re-render children now that inLoop is set (so nested components use the
|
|
806
|
+
// loop-child naming convention). renderedChildren above was computed with
|
|
807
|
+
// the previous flag; recompute under the loop flag.
|
|
808
|
+
const childrenUnderLoop = this.renderChildren(loop.children)
|
|
809
|
+
this.inLoop = prevInLoop
|
|
810
|
+
void renderedChildren
|
|
811
|
+
|
|
812
|
+
// Whole-item conditional: prepend an always-present `<!--bf-loop-i:KEY-->`
|
|
813
|
+
// anchor before each item's (possibly empty) conditional content so the
|
|
814
|
+
// client's `mapArrayAnchored` can hydrate every SSR-rendered item by its
|
|
815
|
+
// anchor.
|
|
816
|
+
const bodyChildren =
|
|
817
|
+
loop.bodyIsItemConditional && loop.key
|
|
818
|
+
? `{{ bf.comment("loop-i:" ~ bf.string(${this.convertExpressionToJinja(loop.key)})) | safe }}\n${childrenUnderLoop}`
|
|
819
|
+
: childrenUnderLoop
|
|
820
|
+
|
|
821
|
+
const lines: string[] = []
|
|
822
|
+
// Scoped per-call-site marker so sibling `.map()`s under the same parent
|
|
823
|
+
// each get their own reconciliation range.
|
|
824
|
+
lines.push(`{{ bf.comment("loop:${loop.markerId}") | safe }}`)
|
|
825
|
+
lines.push(`{% for ${jinjaIdent(loopVar)} in ${array} %}`)
|
|
826
|
+
for (const il of indexLocalLines) lines.push(il)
|
|
827
|
+
|
|
828
|
+
// Handle filter().map() pattern by wrapping children in if-condition
|
|
829
|
+
if (loop.filterPredicate) {
|
|
830
|
+
let filterCond: string
|
|
831
|
+
if (loop.filterPredicate.predicate) {
|
|
832
|
+
filterCond = this.renderJinjaFilterExpr(
|
|
833
|
+
loop.filterPredicate.predicate,
|
|
834
|
+
loop.filterPredicate.param
|
|
835
|
+
)
|
|
836
|
+
// See the file header, divergence 1: the loop-hoist filter test is a
|
|
837
|
+
// condition position too.
|
|
838
|
+
filterCond = truthyTest(loop.filterPredicate.predicate, filterCond)
|
|
839
|
+
} else {
|
|
840
|
+
filterCond = 'true'
|
|
841
|
+
}
|
|
842
|
+
// Map filter param to loop param (e.g., t → todo). Word-boundary
|
|
843
|
+
// rename over the RENDERED text — same mechanism Kolon uses (there
|
|
844
|
+
// scoped to `$`-sigiled tokens; here scoped by plain word boundaries,
|
|
845
|
+
// since Jinja identifiers have no sigil). Bounded, pre-existing risk:
|
|
846
|
+
// see `lib/ir-scope.ts`'s file header for the general sigil-less
|
|
847
|
+
// text-scan caveat.
|
|
848
|
+
if (loop.filterPredicate.param !== param) {
|
|
849
|
+
filterCond = filterCond.replace(
|
|
850
|
+
new RegExp(`\\b${loop.filterPredicate.param}\\b`, 'g'),
|
|
851
|
+
jinjaIdent(param)
|
|
852
|
+
)
|
|
853
|
+
}
|
|
854
|
+
lines.push(`{% if ${filterCond} %}`)
|
|
855
|
+
lines.push(bodyChildren)
|
|
856
|
+
lines.push(`{% endif %}`)
|
|
857
|
+
} else {
|
|
858
|
+
lines.push(bodyChildren)
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
lines.push(`{% endfor %}`)
|
|
862
|
+
lines.push(`{{ bf.comment("/loop:${loop.markerId}") | safe }}`)
|
|
863
|
+
|
|
864
|
+
return lines.join('\n')
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// ===========================================================================
|
|
868
|
+
// Component Rendering
|
|
869
|
+
// ===========================================================================
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* AttrValue lowering for component invocation props (Jinja dict-entry
|
|
873
|
+
* form). Jinja CANNOT splat a dict into positional args, so every prop is
|
|
874
|
+
* emitted as a `'key': value` entry that the caller collects into ONE dict
|
|
875
|
+
* literal passed to `bf.render_child(name, { ... })`.
|
|
876
|
+
*
|
|
877
|
+
* `jsx-children` returns empty — children are captured via a Jinja
|
|
878
|
+
* set-block below, not threaded through the dict entry list.
|
|
879
|
+
*/
|
|
880
|
+
private readonly componentPropEmitter: AttrValueEmitter = {
|
|
881
|
+
emitLiteral: (value, name) => `${jinjaHashKey(name)}: '${escapeJinjaSingleQuoted(value.value)}'`,
|
|
882
|
+
emitExpression: (value, name) => {
|
|
883
|
+
if (value.parts) {
|
|
884
|
+
return `${jinjaHashKey(name)}: ${this.convertTemplateLiteralPartsToJinja(value.parts)}`
|
|
885
|
+
}
|
|
886
|
+
// Inline object-literal child prop (carousel's `opts={{ align: 'start' }}`):
|
|
887
|
+
// lower to a Jinja dict so the child can serialize it (`data-opts`),
|
|
888
|
+
// instead of refusing the bare object with BF101. (#1971) Read the
|
|
889
|
+
// IR-carried structured `ParsedExpr` tree (#2018) instead of
|
|
890
|
+
// re-parsing `value.expr`; the lowering returns null for any
|
|
891
|
+
// non-object-literal shape, so the common non-object case falls
|
|
892
|
+
// straight through to the bare-expression path below.
|
|
893
|
+
if (value.parsed) {
|
|
894
|
+
const dict = objectLiteralExprToJinjaDict(this.spreadCtx, value.parsed)
|
|
895
|
+
if (dict !== null) return `${jinjaHashKey(name)}: ${dict}`
|
|
896
|
+
}
|
|
897
|
+
return `${jinjaHashKey(name)}: ${this.convertExpressionToJinja(value.expr)}`
|
|
898
|
+
},
|
|
899
|
+
emitSpread: (value) => {
|
|
900
|
+
// Jinja dicts can't be splatted into the entry list the way `**`
|
|
901
|
+
// flattens Python kwargs into a call literal. `renderComponent`
|
|
902
|
+
// handles EVERY spread shape itself (both the enumerated propsObject
|
|
903
|
+
// case and the general nested `dict(base, **spread)` fold — see its
|
|
904
|
+
// own docstring), so this callback is never reached for `kind:
|
|
905
|
+
// 'spread'` props; it only exists to satisfy the `AttrValueEmitter`
|
|
906
|
+
// interface.
|
|
907
|
+
return this.convertExpressionToJinja(value.expr)
|
|
908
|
+
},
|
|
909
|
+
emitTemplate: (value, name) =>
|
|
910
|
+
`${jinjaHashKey(name)}: ${this.convertTemplateLiteralPartsToJinja(value.parts)}`,
|
|
911
|
+
emitBooleanAttr: (_value, name) => `${jinjaHashKey(name)}: true`,
|
|
912
|
+
emitBooleanShorthand: (_value, name) => `${jinjaHashKey(name)}: true`,
|
|
913
|
+
// JSX children flow through the Jinja set-block capture below; they're
|
|
914
|
+
// not part of the dict entry list.
|
|
915
|
+
emitJsxChildren: () => '',
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* A `renderComponent` props dict, built as an ORDERED sequence of
|
|
920
|
+
* segments so `{...before, ...spread, after: 1}` JSX spread semantics
|
|
921
|
+
* (later entries win) survive the trip through Jinja, which has no
|
|
922
|
+
* dict-splat syntax for anything past a SINGLE `**` per `dict(...)`
|
|
923
|
+
* call. Each `'entries'` segment is a literal Jinja dict `{'k': v, ...}`;
|
|
924
|
+
* each `'spread'` segment is an arbitrary expression lowered from a
|
|
925
|
+
* `{...expr}` prop. `combineComponentPropSegments` folds the sequence
|
|
926
|
+
* into ONE expression via nested `dict(base, **top)` calls (later
|
|
927
|
+
* segment wins on key conflict, matching `Object.assign`/JSX order).
|
|
928
|
+
*/
|
|
929
|
+
private componentPropSegmentEntries(
|
|
930
|
+
segments: Array<{ kind: 'entries'; parts: string[] } | { kind: 'spread'; expr: string }>,
|
|
931
|
+
): string[] {
|
|
932
|
+
const last = segments[segments.length - 1]
|
|
933
|
+
if (last && last.kind === 'entries') return last.parts
|
|
934
|
+
const seg = { kind: 'entries' as const, parts: [] as string[] }
|
|
935
|
+
segments.push(seg)
|
|
936
|
+
return seg.parts
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* Fold ordered prop segments into a single Jinja expression via nested
|
|
941
|
+
* `dict(base, **top)` calls — CPython's `dict()` builtin raises
|
|
942
|
+
* `TemplateSyntaxError` on MORE THAN ONE `**` per call, so a flat
|
|
943
|
+
* `dict(**a, **b, **c)` is not an option; each successive segment wraps
|
|
944
|
+
* the accumulator as the positional `base` with the new segment
|
|
945
|
+
* `**`-unpacked on top, later argument wins on key conflict — exactly
|
|
946
|
+
* like `{...a, ...b}`. A spread segment's expression is wrapped
|
|
947
|
+
* `(EXPR or {})` before unpacking: Jinja's `ChainableUndefined` lets a
|
|
948
|
+
* missing bag (e.g. `children.props` when `children` was never passed)
|
|
949
|
+
* chain through member access without raising, but `**`-unpacking an
|
|
950
|
+
* `Undefined`/`None` value still fails, so the `or {}` guard normalises
|
|
951
|
+
* it to an empty dict first (verified against real jinja2 3.1.6).
|
|
952
|
+
* Empty `'entries'` segments are dropped so a leading/trailing spread
|
|
953
|
+
* doesn't drag in a needless `dict({}, **...)`. Returns `'{}'` when
|
|
954
|
+
* every segment is empty (no props at all).
|
|
955
|
+
*/
|
|
956
|
+
private combineComponentPropSegments(
|
|
957
|
+
segments: ReadonlyArray<{ kind: 'entries'; parts: string[] } | { kind: 'spread'; expr: string }>,
|
|
958
|
+
): string {
|
|
959
|
+
let acc: string | null = null
|
|
960
|
+
for (const seg of segments) {
|
|
961
|
+
if (seg.kind === 'entries') {
|
|
962
|
+
if (seg.parts.length === 0) continue
|
|
963
|
+
const text = `{${seg.parts.join(', ')}}`
|
|
964
|
+
acc = acc === null ? text : `dict(${acc}, **${text})`
|
|
965
|
+
} else {
|
|
966
|
+
const text = `(${seg.expr} or {})`
|
|
967
|
+
acc = acc === null ? text : `dict(${acc}, **${text})`
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
return acc ?? '{}'
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
renderComponent(comp: IRComponent): string {
|
|
974
|
+
type Segment = { kind: 'entries'; parts: string[] } | { kind: 'spread'; expr: string }
|
|
975
|
+
const segments: Segment[] = [{ kind: 'entries', parts: [] }]
|
|
976
|
+
const currentEntries = () => this.componentPropSegmentEntries(segments)
|
|
977
|
+
|
|
978
|
+
for (const p of comp.props) {
|
|
979
|
+
// Skip callback props (onXxx) and `ref` — both are client-only for
|
|
980
|
+
// SSR (Hono renders neither; the client JS wires them at hydration).
|
|
981
|
+
if ((p.name.match(/^on[A-Z]/) || p.name === 'ref') && p.value.kind === 'expression') continue
|
|
982
|
+
if (p.value.kind === 'spread') {
|
|
983
|
+
const trimmed = p.value.expr.trim()
|
|
984
|
+
// SolidJS-style props identifier (`function(props: P)`) has no
|
|
985
|
+
// matching runtime dict in Jinja scope — props arrive as a flat
|
|
986
|
+
// set of top-level template vars, so enumerate the
|
|
987
|
+
// analyzer-extracted props params into dict entries instead of
|
|
988
|
+
// treating it as a runtime spread expression.
|
|
989
|
+
if (this.propsObjectName && this.propsObjectName === trimmed) {
|
|
990
|
+
for (const pp of this.propsParams) {
|
|
991
|
+
currentEntries().push(`${jinjaHashKey(pp.name)}: ${jinjaIdent(pp.name)}`)
|
|
992
|
+
}
|
|
993
|
+
continue
|
|
994
|
+
}
|
|
995
|
+
// Every other spread shape (a destructure rest-bag `props`, a
|
|
996
|
+
// member-access bag like `children.props`, an intrinsic-element
|
|
997
|
+
// spread helper's own operand, …) — Jinja dict literals can't
|
|
998
|
+
// splat a runtime dict into named entries at a call site, but a
|
|
999
|
+
// nested `dict(base, **top)` call can fold it into the
|
|
1000
|
+
// accumulated dict at the right ordinal position (CPython's
|
|
1001
|
+
// `dict()` rejects more than one `**` per call, so the fold
|
|
1002
|
+
// nests rather than flattens — see `combineComponentPropSegments`).
|
|
1003
|
+
// No compile-time filtering of onXxx/ref keys out of the runtime
|
|
1004
|
+
// bag (the render contract tolerates them, same as the other
|
|
1005
|
+
// spread-lowering adapters).
|
|
1006
|
+
segments.push({ kind: 'spread', expr: this.convertExpressionToJinja(p.value.expr) })
|
|
1007
|
+
continue
|
|
1008
|
+
}
|
|
1009
|
+
const lowered = emitAttrValue(p.value, this.componentPropEmitter, p.name)
|
|
1010
|
+
if (lowered) currentEntries().push(lowered)
|
|
1011
|
+
}
|
|
1012
|
+
// Pass slot ID so the child renderer can set correct scope ID for
|
|
1013
|
+
// hydration. Skip for loop children — they use ComponentName_random.
|
|
1014
|
+
// Appended to whatever the trailing entries segment is so a spread's
|
|
1015
|
+
// own `_bf_slot`/`children` keys (if any) never win over these
|
|
1016
|
+
// compiler-controlled entries.
|
|
1017
|
+
if (comp.slotId && !this.inLoop) {
|
|
1018
|
+
currentEntries().push(`${jinjaHashKey('_bf_slot')}: '${comp.slotId}'`)
|
|
1019
|
+
}
|
|
1020
|
+
const tplName = this.toTemplateName(comp.name)
|
|
1021
|
+
|
|
1022
|
+
// Resolve the effective children: a nested `<Box>…</Box>` populates
|
|
1023
|
+
// `comp.children`; an attribute-form `<Box children={<jsx/>} />` lands in
|
|
1024
|
+
// a `jsx-children` AttrValue on the corresponding prop.
|
|
1025
|
+
const effectiveChildren: IRNode[] = comp.children.length > 0
|
|
1026
|
+
? comp.children
|
|
1027
|
+
: resolveJsxChildrenProp(comp.props)
|
|
1028
|
+
|
|
1029
|
+
if (effectiveChildren.length > 0) {
|
|
1030
|
+
// Forward JSX children via a Jinja set-block. The block body is
|
|
1031
|
+
// evaluated in the parent's template scope (signals, conditionals) and
|
|
1032
|
+
// produces the children HTML as a captured `Markup` string; the
|
|
1033
|
+
// captured name is passed as the `children` entry of the
|
|
1034
|
+
// render_child dict. `render_child` materializes it through the
|
|
1035
|
+
// backend before handing it to the child. See the file header,
|
|
1036
|
+
// divergence 4, for why a set-block (not a macro) is the uniform
|
|
1037
|
+
// mechanism here.
|
|
1038
|
+
const prevInLoop = this.inLoop
|
|
1039
|
+
this.inLoop = false
|
|
1040
|
+
const childrenBody = this.renderChildren(effectiveChildren)
|
|
1041
|
+
this.inLoop = prevInLoop
|
|
1042
|
+
const captureName = `bf_children_${comp.slotId ?? 'c' + this.childrenCaptureCounter++}`
|
|
1043
|
+
currentEntries().push(`${jinjaHashKey('children')}: ${captureName}`)
|
|
1044
|
+
const dict = this.combineComponentPropSegments(segments)
|
|
1045
|
+
return `{% set ${captureName} %}${childrenBody}{% endset %}{{ bf.render_child('${tplName}', ${dict}) | safe }}`
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
const isEmpty = segments.every(s => s.kind === 'entries' && s.parts.length === 0)
|
|
1049
|
+
const dictEntries = isEmpty ? '' : `, ${this.combineComponentPropSegments(segments)}`
|
|
1050
|
+
return `{{ bf.render_child('${tplName}'${dictEntries}) | safe }}`
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
private childrenCaptureCounter = 0
|
|
1054
|
+
|
|
1055
|
+
/** Uniquifies the `presenceOrUndefined` temp binding (`bf_puN`) so two
|
|
1056
|
+
* presence-folded attrs in one template don't collide. */
|
|
1057
|
+
private presenceVarCounter = 0
|
|
1058
|
+
|
|
1059
|
+
private toTemplateName(componentName: string): string {
|
|
1060
|
+
// Convert PascalCase to snake_case for template naming.
|
|
1061
|
+
return componentName
|
|
1062
|
+
.replace(/([A-Z])/g, '_$1')
|
|
1063
|
+
.toLowerCase()
|
|
1064
|
+
.replace(/^_/, '')
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// ===========================================================================
|
|
1068
|
+
// If-Statement (Conditional Return) Rendering
|
|
1069
|
+
// ===========================================================================
|
|
1070
|
+
|
|
1071
|
+
private renderIfStatement(ifStmt: IRIfStatement): string {
|
|
1072
|
+
const condition = this.convertConditionToJinja(ifStmt.condition)
|
|
1073
|
+
const consequent = ifStmt.consequent.type === 'if-statement'
|
|
1074
|
+
? this.renderIfStatement(ifStmt.consequent as IRIfStatement)
|
|
1075
|
+
: this.renderNode(ifStmt.consequent)
|
|
1076
|
+
let result = `{% if ${condition} %}\n${consequent}\n`
|
|
1077
|
+
|
|
1078
|
+
if (ifStmt.alternate) {
|
|
1079
|
+
if (ifStmt.alternate.type === 'if-statement') {
|
|
1080
|
+
const altResult = this.renderIfStatement(ifStmt.alternate as IRIfStatement)
|
|
1081
|
+
// Replace leading "{% if" with "{% elif"
|
|
1082
|
+
result += altResult.replace(/^\{% if/, '{% elif')
|
|
1083
|
+
} else {
|
|
1084
|
+
const alternate = this.renderNode(ifStmt.alternate)
|
|
1085
|
+
result += `{% else %}\n${alternate}\n`
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
result += `{% endif %}`
|
|
1090
|
+
return result
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// ===========================================================================
|
|
1094
|
+
// Fragment & Slot Rendering
|
|
1095
|
+
// ===========================================================================
|
|
1096
|
+
|
|
1097
|
+
private renderFragment(fragment: IRFragment): string {
|
|
1098
|
+
const children = this.renderChildren(fragment.children)
|
|
1099
|
+
if (fragment.needsScopeComment) {
|
|
1100
|
+
return `{{ bf.scope_comment() | safe }}${children}`
|
|
1101
|
+
}
|
|
1102
|
+
return children
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
private renderSlot(_slot: IRSlot): string {
|
|
1106
|
+
// Captured children arrive under the `children` context key (see
|
|
1107
|
+
// renderComponent's set-block capture + render_child call), so the var
|
|
1108
|
+
// is `children`. The content is already-rendered markup, so emit it
|
|
1109
|
+
// as-is via `| safe` — otherwise Jinja's autoescape would entity-escape
|
|
1110
|
+
// the child tags. (The IR producer doesn't currently emit `slot`
|
|
1111
|
+
// nodes — `{children}` lowers to an expression whose captured value is
|
|
1112
|
+
// already raw — so this is defensive correctness for if/when a slot
|
|
1113
|
+
// node is produced.)
|
|
1114
|
+
return `{{ ${jinjaIdent('children')} | safe }}`
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
override renderAsync(node: IRAsync): string {
|
|
1118
|
+
const fallback = this.renderNode(node.fallback)
|
|
1119
|
+
const children = this.renderChildren(node.children)
|
|
1120
|
+
// Capture the fallback into a Jinja set-block and pass its rendered HTML
|
|
1121
|
+
// to `bf.async_boundary`, which wraps it in a `<div bf-async="aX">`
|
|
1122
|
+
// placeholder. Same shape as `renderComponent`'s children capture.
|
|
1123
|
+
const captureName = `bf_async_fallback_${node.id}`
|
|
1124
|
+
return `{% set ${captureName} %}${fallback}{% endset %}{{ bf.async_boundary('${node.id}', ${captureName}) | safe }}\n${children}`
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// ===========================================================================
|
|
1128
|
+
// Attribute Rendering
|
|
1129
|
+
// ===========================================================================
|
|
1130
|
+
|
|
1131
|
+
/**
|
|
1132
|
+
* AttrValue lowering for intrinsic-element attributes (Jinja).
|
|
1133
|
+
*/
|
|
1134
|
+
private readonly elementAttrEmitter: AttrValueEmitter = {
|
|
1135
|
+
emitLiteral: (value, name) => `${name}="${value.value}"`,
|
|
1136
|
+
emitExpression: (value, name) => {
|
|
1137
|
+
// `style={{ … }}` object literal → a CSS string with dynamic values
|
|
1138
|
+
// interpolated, instead of refusing the bare object with BF101 (#1322).
|
|
1139
|
+
if (name === 'style') {
|
|
1140
|
+
const css = this.tryLowerStyleObject(value.expr)
|
|
1141
|
+
if (css !== null) return `style="${css}"`
|
|
1142
|
+
}
|
|
1143
|
+
// Refuse shapes that the lowering pipeline can't represent in Jinja —
|
|
1144
|
+
// tagged-template-literal call expressions (`cn\`base \${tone()}\``).
|
|
1145
|
+
// Same gate as the Xslate adapter.
|
|
1146
|
+
if (this.refuseUnsupportedAttrExpression(value.expr, name)) {
|
|
1147
|
+
return ''
|
|
1148
|
+
}
|
|
1149
|
+
// Hono-style nullish omission: a bare reference to an optional,
|
|
1150
|
+
// no-default prop (`nullableOptionalProps`) is guarded so the
|
|
1151
|
+
// attribute drops instead of rendering `attr=""`. Narrowly scoped to
|
|
1152
|
+
// bare identifiers — member exprs, calls, and concrete/defaulted
|
|
1153
|
+
// props are unaffected.
|
|
1154
|
+
const bareId = value.expr.trim()
|
|
1155
|
+
// Normalize a props-object access (`props.id`) to its bare prop name
|
|
1156
|
+
// (`id`) so the nullable-optional set — keyed by bare name — matches the
|
|
1157
|
+
// SolidJS props-object pattern, not just destructured params.
|
|
1158
|
+
const normalizedBareId =
|
|
1159
|
+
this.propsObjectName && bareId.startsWith(`${this.propsObjectName}.`)
|
|
1160
|
+
? bareId.slice(this.propsObjectName.length + 1)
|
|
1161
|
+
: bareId
|
|
1162
|
+
if (
|
|
1163
|
+
!isBooleanAttr(name) &&
|
|
1164
|
+
!value.presenceOrUndefined &&
|
|
1165
|
+
/^[A-Za-z_$][\w$]*$/.test(normalizedBareId) &&
|
|
1166
|
+
this.nullableOptionalProps.has(normalizedBareId)
|
|
1167
|
+
) {
|
|
1168
|
+
const jinja = this.convertExpressionToJinja(value.expr)
|
|
1169
|
+
const body = this.shouldBoolStr(value.expr, name)
|
|
1170
|
+
? `${name}="{{ bf.bool_str(${jinja}) }}"`
|
|
1171
|
+
: `${name}="{{ bf.string(${jinja}) }}"`
|
|
1172
|
+
// `jinja` is a bare identifier reference for this narrowly-gated
|
|
1173
|
+
// shape, so it doubles as both the guard test and the display
|
|
1174
|
+
// value — same "is defined and is not none" pair the Kolon port's
|
|
1175
|
+
// `defined` check maps to (see `providerValueJinja`'s header for
|
|
1176
|
+
// why `is not none` alone isn't enough: a var missing from context
|
|
1177
|
+
// entirely reads as Undefined, not `none`).
|
|
1178
|
+
return `\n{% if ${jinja} is defined and ${jinja} is not none %}\n${body}\n{% endif %}\n`
|
|
1179
|
+
}
|
|
1180
|
+
if (isBooleanAttr(name)) {
|
|
1181
|
+
// Boolean attributes: render conditionally (present or absent).
|
|
1182
|
+
const jinja = this.convertExpressionToJinja(value.expr)
|
|
1183
|
+
return `{{ ('${name}' if ${this.wrapConditionExpr(value.expr, jinja)} else '') }}`
|
|
1184
|
+
}
|
|
1185
|
+
if (value.presenceOrUndefined) {
|
|
1186
|
+
// `attr={expr || undefined}` on a NON-boolean attribute: Hono
|
|
1187
|
+
// renders the attr with its stringified value when truthy and
|
|
1188
|
+
// omits it otherwise (`aria-disabled={isDisabled() || undefined}`
|
|
1189
|
+
// → `aria-disabled="true"`), so bare presence would diverge.
|
|
1190
|
+
// Route through `bool_str` when the name/shape witnesses a
|
|
1191
|
+
// boolean value, same as the unconditional path below (#1897).
|
|
1192
|
+
// Bind to a temp first so the expression evaluates once, not in
|
|
1193
|
+
// both the guard and the value.
|
|
1194
|
+
const jinja = this.convertExpressionToJinja(value.expr)
|
|
1195
|
+
const tmp = `bf_pu${this.presenceVarCounter++}`
|
|
1196
|
+
const body = this.shouldBoolStr(value.expr, name)
|
|
1197
|
+
? `${name}="{{ bf.bool_str(${tmp}) }}"`
|
|
1198
|
+
: `${name}="{{ bf.string(${tmp}) }}"`
|
|
1199
|
+
return `\n{% set ${tmp} = ${jinja} %}\n{% if ${this.wrapConditionExpr(value.expr, tmp)} %}\n${body}\n{% endif %}\n`
|
|
1200
|
+
}
|
|
1201
|
+
// `attr={cond ? value : undefined}` OMITS the attribute on the
|
|
1202
|
+
// falsy branch (Hono drops undefined-valued attributes) — wrap the
|
|
1203
|
+
// whole attribute in the condition instead of rendering `attr=""`
|
|
1204
|
+
// (#1897, pagination's `aria-current={props.isActive ? 'page' :
|
|
1205
|
+
// undefined}`). Same parity rule the Go adapter applies.
|
|
1206
|
+
{
|
|
1207
|
+
const m = this.parseUndefinedAlternateTernary(value.expr)
|
|
1208
|
+
if (m) {
|
|
1209
|
+
const cond = this.convertConditionToJinja(m.condition)
|
|
1210
|
+
const val = this.convertExpressionToJinja(m.consequent)
|
|
1211
|
+
return `\n{% if ${cond} %}\n${name}="{{ bf.string(${val}) }}"\n{% endif %}\n`
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
// Boolean-result handling: route boolean-shaped values through
|
|
1215
|
+
// `bf.bool_str` so the wire bytes match JS `String(boolean)`. Every
|
|
1216
|
+
// other value is a text-position interpolation — route through
|
|
1217
|
+
// `bf.string` (see the file header, divergence 2).
|
|
1218
|
+
const jinja = this.convertExpressionToJinja(value.expr)
|
|
1219
|
+
if (this.shouldBoolStr(value.expr, name)) {
|
|
1220
|
+
return `${name}="{{ bf.bool_str(${jinja}) }}"`
|
|
1221
|
+
}
|
|
1222
|
+
return `${name}="{{ bf.string(${jinja}) }}"`
|
|
1223
|
+
},
|
|
1224
|
+
emitBooleanAttr: (_value, name) => name,
|
|
1225
|
+
emitTemplate: (value, name) =>
|
|
1226
|
+
`${name}="{{ ${this.convertTemplateLiteralPartsToJinja(value.parts)} }}"`,
|
|
1227
|
+
// Spread attributes (`<div {...attrs()} />`) lower through the
|
|
1228
|
+
// `bf.spread_attrs` runtime helper, mirroring the Xslate adapter.
|
|
1229
|
+
emitSpread: (value) => {
|
|
1230
|
+
if (this.refuseUnsupportedAttrExpression(value.expr, '...')) {
|
|
1231
|
+
return ''
|
|
1232
|
+
}
|
|
1233
|
+
// SolidJS-style props identifier (`(props: P) { <el {...props}/> }`) has
|
|
1234
|
+
// no matching context dict in Jinja scope — props arrive as a flat set
|
|
1235
|
+
// of top-level context vars. Emit an inline dict literal enumerating
|
|
1236
|
+
// the analyzer-extracted props params.
|
|
1237
|
+
const trimmed = value.expr.trim()
|
|
1238
|
+
if (this.propsObjectName && this.propsObjectName === trimmed) {
|
|
1239
|
+
const entries = this.propsParams.map(p =>
|
|
1240
|
+
`${jinjaHashKey(p.name)}: ${jinjaIdent(p.name)}`,
|
|
1241
|
+
)
|
|
1242
|
+
return `{{ bf.spread_attrs({${entries.join(', ')}}) | safe }}`
|
|
1243
|
+
}
|
|
1244
|
+
// Conditional inline-object spread (#textarea):
|
|
1245
|
+
// `{...(COND ? { 'aria-describedby': describedBy } : {})}`
|
|
1246
|
+
// Emit a Jinja inline ternary of dicts — the falsy `{}` branch OMITS
|
|
1247
|
+
// the key (`spread_attrs` does NOT emit empty-dict entries).
|
|
1248
|
+
// Read the spread's IR-carried `ParsedExpr` tree (#2018) instead of
|
|
1249
|
+
// re-parsing `trimmed`.
|
|
1250
|
+
const ternaryDict = conditionalSpreadToJinja(this.spreadCtx, value.parsed)
|
|
1251
|
+
if (ternaryDict !== null) {
|
|
1252
|
+
return `{{ bf.spread_attrs(${ternaryDict}) | safe }}`
|
|
1253
|
+
}
|
|
1254
|
+
// Function-scope local const holding a conditional inline-object
|
|
1255
|
+
// `const sizeAttrs = size ? {…} : {}` then `{...sizeAttrs}`
|
|
1256
|
+
// (#checkbox / icon). Resolve the bare identifier to its initializer text
|
|
1257
|
+
// and route through the same conditional-spread lowering. Only
|
|
1258
|
+
// function-scope (`!isModule`) consts whose value is NOT itself a bare
|
|
1259
|
+
// identifier (loop guard) are considered.
|
|
1260
|
+
if (/^[A-Za-z_$][\w$]*$/.test(trimmed)) {
|
|
1261
|
+
const localConst = (this.localConstants ?? []).find(
|
|
1262
|
+
c => c.name === trimmed && !c.isModule,
|
|
1263
|
+
)
|
|
1264
|
+
if (localConst?.value !== undefined) {
|
|
1265
|
+
const initTrimmed = localConst.value.trim()
|
|
1266
|
+
if (!/^[A-Za-z_$][\w$]*$/.test(initTrimmed)) {
|
|
1267
|
+
// The local const's initializer text isn't carried as a structured
|
|
1268
|
+
// tree on the spread attr, so parse it once via the shared
|
|
1269
|
+
// `parseExpression` (the analyzer's own entry) — not
|
|
1270
|
+
// `ts.createSourceFile` — mirroring go-template's same local-const
|
|
1271
|
+
// resolution path.
|
|
1272
|
+
const resolved = conditionalSpreadToJinja(
|
|
1273
|
+
this.spreadCtx,
|
|
1274
|
+
parseExpression(initTrimmed),
|
|
1275
|
+
)
|
|
1276
|
+
if (resolved !== null) {
|
|
1277
|
+
return `{{ bf.spread_attrs(${resolved}) | safe }}`
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
const jinjaExpr = this.convertExpressionToJinja(value.expr)
|
|
1283
|
+
return `{{ bf.spread_attrs(${jinjaExpr}) | safe }}`
|
|
1284
|
+
},
|
|
1285
|
+
// Neither variant is legal on intrinsic elements.
|
|
1286
|
+
emitBooleanShorthand: () => '',
|
|
1287
|
+
emitJsxChildren: () => '',
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
/**
|
|
1291
|
+
* Lower a `style={{ … }}` object literal to a CSS string with dynamic values
|
|
1292
|
+
* interpolated as Jinja expressions, e.g. `{ backgroundColor: color }` →
|
|
1293
|
+
* `background-color:{{ bf.string(color) }}`. Returns null when the shape is
|
|
1294
|
+
* unsupported or any value can't be lowered (caller falls through to
|
|
1295
|
+
* BF101). (#1322)
|
|
1296
|
+
*/
|
|
1297
|
+
private tryLowerStyleObject(expr: string): string | null {
|
|
1298
|
+
const entries = parseStyleObjectEntries(expr)
|
|
1299
|
+
if (!entries) return null
|
|
1300
|
+
for (const e of entries) {
|
|
1301
|
+
if (e.kind === 'expr' && !isSupported(parseExpression(e.expr)).supported) return null
|
|
1302
|
+
}
|
|
1303
|
+
// The static CSS key + literal value are inlined into a double-quoted
|
|
1304
|
+
// `style="..."` attribute as raw template text, so HTML-attr escape them
|
|
1305
|
+
// (a value like `'"'` would otherwise break the attribute / inject
|
|
1306
|
+
// markup). The dynamic arm's `{{ … }}` is HTML-escaped by Jinja.
|
|
1307
|
+
return entries
|
|
1308
|
+
.map(e =>
|
|
1309
|
+
e.kind === 'literal'
|
|
1310
|
+
? `${this.escapeAttrText(e.cssKey)}:${this.escapeAttrText(e.value)}`
|
|
1311
|
+
: `${this.escapeAttrText(e.cssKey)}:{{ bf.string(${this.convertExpressionToJinja(e.expr)}) }}`,
|
|
1312
|
+
)
|
|
1313
|
+
.join(';')
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
/** HTML-attribute escape for static text inlined into a `"..."` attribute. */
|
|
1317
|
+
private escapeAttrText(s: string): string {
|
|
1318
|
+
return s
|
|
1319
|
+
.replace(/&/g, '&')
|
|
1320
|
+
.replace(/"/g, '"')
|
|
1321
|
+
.replace(/'/g, ''')
|
|
1322
|
+
.replace(/</g, '<')
|
|
1323
|
+
.replace(/>/g, '>')
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
private renderAttributes(element: IRElement): string {
|
|
1327
|
+
const parts: string[] = []
|
|
1328
|
+
|
|
1329
|
+
for (const attr of element.attrs) {
|
|
1330
|
+
// `/* @client */` attribute bindings are deferred to hydrate: the
|
|
1331
|
+
// client runtime sets/patches the attribute in a mount effect (the
|
|
1332
|
+
// CSR template omits it; ir-to-client-js emits the setAttribute
|
|
1333
|
+
// effect). Skip SSR emission so the server omits the attribute and
|
|
1334
|
+
// the unsupported-expression lowering is never reached for a deferred
|
|
1335
|
+
// predicate (no BF101 / BF102). #1966
|
|
1336
|
+
if (attr.clientOnly) continue
|
|
1337
|
+
// Rewrite JSX special-prop names to their HTML-attribute counterparts.
|
|
1338
|
+
let attrName: string
|
|
1339
|
+
if (attr.name === 'className') attrName = 'class'
|
|
1340
|
+
else if (attr.name === 'key') attrName = 'data-key'
|
|
1341
|
+
else attrName = attr.name
|
|
1342
|
+
const lowered = emitAttrValue(attr.value, this.elementAttrEmitter, attrName)
|
|
1343
|
+
if (lowered) parts.push(lowered)
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
return parts.length > 0 ? ' ' + parts.join(' ') : ''
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
// ===========================================================================
|
|
1350
|
+
// Hydration Markers
|
|
1351
|
+
// ===========================================================================
|
|
1352
|
+
|
|
1353
|
+
renderScopeMarker(_instanceIdExpr: string): string {
|
|
1354
|
+
// bf-s is the addressable scope id. hydration_attrs adds bf-h / bf-m /
|
|
1355
|
+
// bf-r conditionally; props_attr adds bf-p when props are present.
|
|
1356
|
+
return `bf-s="{{ bf.scope_attr() }}" {{ bf.hydration_attrs() | safe }} {{ bf.props_attr() | safe }}`
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
renderSlotMarker(slotId: string): string {
|
|
1360
|
+
return `${BF_SLOT}="${slotId}"`
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
renderCondMarker(condId: string): string {
|
|
1364
|
+
return `${BF_COND}="${condId}"`
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
// ===========================================================================
|
|
1368
|
+
// Filter Predicate Rendering (ParsedExpr → Jinja)
|
|
1369
|
+
// ===========================================================================
|
|
1370
|
+
|
|
1371
|
+
/**
|
|
1372
|
+
* Convert a ParsedExpr AST to a Jinja expression string for filter
|
|
1373
|
+
* predicates. Wraps the shared ParsedExpr dispatcher with a
|
|
1374
|
+
* `JinjaFilterEmitter` carrying the predicate's loop param and any
|
|
1375
|
+
* block-body local var aliases.
|
|
1376
|
+
*/
|
|
1377
|
+
private renderJinjaFilterExpr(
|
|
1378
|
+
expr: ParsedExpr,
|
|
1379
|
+
param: string,
|
|
1380
|
+
localVarMap: Map<string, string> = new Map(),
|
|
1381
|
+
): string {
|
|
1382
|
+
return emitParsedExpr(
|
|
1383
|
+
expr,
|
|
1384
|
+
new JinjaFilterEmitter(
|
|
1385
|
+
param,
|
|
1386
|
+
localVarMap,
|
|
1387
|
+
n => this._isStringValueName(n),
|
|
1388
|
+
// A nested callback method inside the predicate has no Jinja scalar
|
|
1389
|
+
// form — surface BF101 (#2038) instead of silently degrading it to
|
|
1390
|
+
// its receiver.
|
|
1391
|
+
(message, reason) => this._recordExprBF101(message, reason),
|
|
1392
|
+
),
|
|
1393
|
+
)
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
// ===========================================================================
|
|
1397
|
+
// Expression Conversion: JS → Jinja
|
|
1398
|
+
// ===========================================================================
|
|
1399
|
+
|
|
1400
|
+
private convertTemplateLiteralPartsToJinja(literalParts: IRTemplatePart[]): string {
|
|
1401
|
+
const parts: string[] = []
|
|
1402
|
+
for (const part of literalParts) {
|
|
1403
|
+
if (part.type === 'string') {
|
|
1404
|
+
parts.push(this.substituteJsInterpolationsToJinja(part.value))
|
|
1405
|
+
} else if (part.type === 'ternary') {
|
|
1406
|
+
const cond = this.convertConditionToJinja(part.condition)
|
|
1407
|
+
parts.push(
|
|
1408
|
+
`('${escapeJinjaSingleQuoted(part.whenTrue)}' if ${cond} else '${escapeJinjaSingleQuoted(part.whenFalse)}')`,
|
|
1409
|
+
)
|
|
1410
|
+
} else if (part.type === 'lookup') {
|
|
1411
|
+
// `${MAP[KEY]}` against a Record<T, string> literal — emit a Jinja
|
|
1412
|
+
// dict literal with an immediate `.get(key, '')` lookup. `.get`'s
|
|
1413
|
+
// explicit default mirrors the go-template adapter's "empty when no
|
|
1414
|
+
// case matches" semantics directly (Jinja's bare `{…}[missing]`
|
|
1415
|
+
// already stringifies to '' too, verified empirically, but `.get`
|
|
1416
|
+
// is the more direct/idiomatic expression of the same contract).
|
|
1417
|
+
const keyExpr = this.convertExpressionToJinja(part.key)
|
|
1418
|
+
const entries = Object.entries(part.cases)
|
|
1419
|
+
.map(([k, v]) => `${jinjaHashKey(k)}: '${escapeJinjaSingleQuoted(v)}'`)
|
|
1420
|
+
.join(', ')
|
|
1421
|
+
parts.push(`bf.string({${entries}}.get(${keyExpr}, ''))`)
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
// Join with Jinja string concatenation (`~`). Every term is already a
|
|
1425
|
+
// string (literal or `bf.string(...)`-wrapped), so `~`'s own `str()`
|
|
1426
|
+
// coercion is a no-op here.
|
|
1427
|
+
return parts.length === 1 ? parts[0] : parts.join(' ~ ')
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
/**
|
|
1431
|
+
* Translate `${EXPR}` interpolations in a static template-part string into
|
|
1432
|
+
* Jinja variable references and concatenate them with the surrounding
|
|
1433
|
+
* literal text. Each interpolated (non-literal) segment routes through
|
|
1434
|
+
* `bf.string(...)` — see the file header, divergence 2.
|
|
1435
|
+
*/
|
|
1436
|
+
private substituteJsInterpolationsToJinja(s: string): string {
|
|
1437
|
+
const segments: string[] = []
|
|
1438
|
+
const re = /\$\{([^}]+)\}/g
|
|
1439
|
+
let lastIndex = 0
|
|
1440
|
+
let m: RegExpExecArray | null
|
|
1441
|
+
while ((m = re.exec(s)) !== null) {
|
|
1442
|
+
if (m.index > lastIndex) {
|
|
1443
|
+
segments.push(`'${escapeJinjaSingleQuoted(s.slice(lastIndex, m.index))}'`)
|
|
1444
|
+
}
|
|
1445
|
+
segments.push(`bf.string(${this.convertExpressionToJinja(m[1].trim())})`)
|
|
1446
|
+
lastIndex = re.lastIndex
|
|
1447
|
+
}
|
|
1448
|
+
if (lastIndex < s.length) {
|
|
1449
|
+
segments.push(`'${escapeJinjaSingleQuoted(s.slice(lastIndex))}'`)
|
|
1450
|
+
}
|
|
1451
|
+
if (segments.length === 0) return `''`
|
|
1452
|
+
return segments.length === 1 ? segments[0] : `(${segments.join(' ~ ')})`
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
/**
|
|
1456
|
+
* Refuse JS expression shapes that have no idiomatic Jinja representation:
|
|
1457
|
+
* object literals (`style={{...}}`) and tagged-template-literal call
|
|
1458
|
+
* expressions (`cn\`base \${tone()}\``). Records `BF101`. Returns `true`
|
|
1459
|
+
* when the shape was rejected (caller should drop the attribute).
|
|
1460
|
+
*/
|
|
1461
|
+
private refuseUnsupportedAttrExpression(expr: string, attrName: string): boolean {
|
|
1462
|
+
let probe = expr.trim()
|
|
1463
|
+
while (probe.startsWith('(')) probe = probe.slice(1).trimStart()
|
|
1464
|
+
const startsAsObjectLiteral = probe.startsWith('{')
|
|
1465
|
+
const hasTaggedTemplate = /[A-Za-z_$][\w$]*\s*`/.test(probe)
|
|
1466
|
+
if (!startsAsObjectLiteral && !hasTaggedTemplate) return false
|
|
1467
|
+
const parsed = parseExpression(expr.trim())
|
|
1468
|
+
const support = isSupported(parsed)
|
|
1469
|
+
if (parsed.kind !== 'unsupported' && support.supported) return false
|
|
1470
|
+
const reason = support.reason ?? (parsed.kind === 'unsupported' ? parsed.reason : undefined)
|
|
1471
|
+
const reasonLine = reason ? `\n${reason}` : ''
|
|
1472
|
+
this.errors.push({
|
|
1473
|
+
code: 'BF101',
|
|
1474
|
+
severity: 'error',
|
|
1475
|
+
message: `Expression not supported on attribute '${attrName}': ${expr.trim()}${reasonLine}`,
|
|
1476
|
+
loc: { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
|
|
1477
|
+
suggestion: {
|
|
1478
|
+
message: 'The Jinja adapter cannot lower JS object literals or tagged-template-literal expressions into Jinja. Move the expression into a `\'use client\'` component (so hydration computes it), or expand it into discrete attributes whose values are values the adapter can lower.',
|
|
1479
|
+
},
|
|
1480
|
+
})
|
|
1481
|
+
return true
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
/**
|
|
1485
|
+
* Build the EmitContext seam the top-level `ParsedExpr` emitter depends on.
|
|
1486
|
+
* Built as a private object (the adapter does NOT `implements JinjaEmitContext`)
|
|
1487
|
+
* so the wrapped bookkeeping — `_searchParamsLocals`, the const/record
|
|
1488
|
+
* resolvers, BF101 recording, the filter-predicate entry — stays private and
|
|
1489
|
+
* off the exported adapter's public type, matching the Go adapter's
|
|
1490
|
+
* `emitCtx` and the `spreadCtx` / `memoCtx` seams below.
|
|
1491
|
+
*/
|
|
1492
|
+
private get emitCtx(): JinjaEmitContext {
|
|
1493
|
+
return {
|
|
1494
|
+
_searchParamsLocals: this._searchParamsLocals,
|
|
1495
|
+
_resolveModuleStringConst: (name) => this._resolveModuleStringConst(name),
|
|
1496
|
+
_resolveLiteralConst: (name) => this._resolveLiteralConst(name),
|
|
1497
|
+
_resolveStaticRecordLiteral: (o, k) => this._resolveStaticRecordLiteral(o, k),
|
|
1498
|
+
_recordExprBF101: (message, reason) => this._recordExprBF101(message, reason),
|
|
1499
|
+
_renderJinjaFilterExprPublic: (e, p) => this._renderJinjaFilterExprPublic(e, p),
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
/**
|
|
1504
|
+
* Build the narrow context the extracted spread lowering depends on. Passing
|
|
1505
|
+
* a purpose-built object (rather than `this`) keeps the adapter's bookkeeping
|
|
1506
|
+
* members private — they stay internal implementation detail, not part of the
|
|
1507
|
+
* exported class's public surface.
|
|
1508
|
+
*/
|
|
1509
|
+
private get spreadCtx(): JinjaSpreadContext {
|
|
1510
|
+
return {
|
|
1511
|
+
componentName: this.componentName,
|
|
1512
|
+
errors: this.errors,
|
|
1513
|
+
localConstants: this.localConstants,
|
|
1514
|
+
propsParams: this.propsParams,
|
|
1515
|
+
convertExpressionToJinja: (e, preParsed) => this.convertExpressionToJinja(e, preParsed),
|
|
1516
|
+
convertConditionToJinja: (e, preParsed) => this.convertConditionToJinja(e, preParsed),
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
/** Build the narrow context the extracted memo seeding depends on. */
|
|
1521
|
+
private get memoCtx(): JinjaMemoContext {
|
|
1522
|
+
return {
|
|
1523
|
+
convertExpressionToJinja: (e, preParsed) => this.convertExpressionToJinja(e, preParsed),
|
|
1524
|
+
errors: this.errors,
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
private convertExpressionToJinja(expr: string, preParsed?: ParsedExpr): string {
|
|
1529
|
+
// Parse-first lowering — parity with the Xslate adapter's
|
|
1530
|
+
// `convertExpressionToKolon`. Parse the JS expression once, gate it on the
|
|
1531
|
+
// shared `isSupported`, and render every supported shape through the AST
|
|
1532
|
+
// emitter. Unsupported shapes surface as BF101.
|
|
1533
|
+
//
|
|
1534
|
+
// `preParsed` is the IR-carried `ParsedExpr` tree (cf. go-template's
|
|
1535
|
+
// `convertExpressionToGo(jsExpr, out?, preParsed?)`); when present it is
|
|
1536
|
+
// used directly instead of re-parsing `expr`, so spread condition/value
|
|
1537
|
+
// lowering threads the carried tree through without a stringify→re-parse
|
|
1538
|
+
// round-trip. The diagnostic text is then derived from the tree
|
|
1539
|
+
// (`stringifyParsedExpr`) so callers can pass `''` for `expr`.
|
|
1540
|
+
let parsed: ParsedExpr
|
|
1541
|
+
if (preParsed) {
|
|
1542
|
+
parsed = preParsed
|
|
1543
|
+
} else {
|
|
1544
|
+
const trimmed = expr.trim()
|
|
1545
|
+
if (trimmed === '') return "''"
|
|
1546
|
+
parsed = parseExpression(trimmed)
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
// Registered call lowerings (#2057) — including the built-in `queryHref`
|
|
1550
|
+
// plugin (#2042), which lowers `queryHref(base, { … })` to a neutral
|
|
1551
|
+
// `guard-list` on the `query` helper → `bf.query(base, <triples>)`.
|
|
1552
|
+
// Recognised before the support gate because the object-literal arg is
|
|
1553
|
+
// otherwise `unsupported` (BF101). The `query` helper includes a pair iff its
|
|
1554
|
+
// guard is truthy AND its value is a non-empty string (the client's
|
|
1555
|
+
// `if (value)`): a plain `key: v` passes guard `true`, a conditional
|
|
1556
|
+
// `key: cond ? v : undefined` passes the lowered cond. Only the `query`
|
|
1557
|
+
// helper renders to `bf.query`; another guard-list helper must not be
|
|
1558
|
+
// silently mis-rendered as a query.
|
|
1559
|
+
if (parsed.kind === 'call') {
|
|
1560
|
+
for (const matcher of this._loweringMatchers) {
|
|
1561
|
+
const node = matcher(parsed.callee, parsed.args)
|
|
1562
|
+
if (node?.kind === 'guard-list' && node.helper === 'query') {
|
|
1563
|
+
const qArgs = queryHrefArgs(node, n => this.renderParsedExprToJinja(n))
|
|
1564
|
+
return `bf.query(${qArgs.join(', ')})`
|
|
1565
|
+
}
|
|
1566
|
+
// Generic `helper-call` (#2069) — the neutral vocabulary's escape
|
|
1567
|
+
// hatch for a userland `LoweringPlugin` that lowers to a single
|
|
1568
|
+
// runtime-helper invocation. `bf.<helper>(args…)` mirrors the
|
|
1569
|
+
// `query` helper's own naming convention exactly: the framework
|
|
1570
|
+
// renders the call, the plugin author registers `<helper>` as a
|
|
1571
|
+
// Jinja-callable function/filter in their own runtime — same
|
|
1572
|
+
// contract as `bf.query` itself, just not built in.
|
|
1573
|
+
if (node?.kind === 'helper-call' && isValidHelperId(node.helper)) {
|
|
1574
|
+
const argsX = node.args.map(a => this.renderParsedExprToJinja(a))
|
|
1575
|
+
return `bf.${node.helper}(${argsX.join(', ')})`
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
const support = isSupported(parsed)
|
|
1581
|
+
if (!support.supported) {
|
|
1582
|
+
this.errors.push({
|
|
1583
|
+
code: 'BF101',
|
|
1584
|
+
severity: 'error',
|
|
1585
|
+
message: `Expression not supported: ${preParsed ? stringifyParsedExpr(parsed) : expr.trim()}`,
|
|
1586
|
+
loc: { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
|
|
1587
|
+
suggestion: {
|
|
1588
|
+
message: support.reason
|
|
1589
|
+
? `${support.reason}\n\nOptions:\n1. Use /* @client */ for client-side evaluation\n2. Pre-compute the value in the backend`
|
|
1590
|
+
: 'Options:\n1. Use /* @client */ for client-side evaluation\n2. Pre-compute the value in the backend',
|
|
1591
|
+
},
|
|
1592
|
+
})
|
|
1593
|
+
// Safe Jinja empty-string literal — valid in every context the result
|
|
1594
|
+
// might land in.
|
|
1595
|
+
return "''"
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
return this.renderParsedExprToJinja(parsed)
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
/**
|
|
1602
|
+
* Convert a JS condition (an `if` / ternary / loop-filter test) to a Jinja
|
|
1603
|
+
* boolean expression, routing through `bf.truthy(...)` unless the
|
|
1604
|
+
* expression is structurally already boolean-shaped. See the file header,
|
|
1605
|
+
* divergence 1.
|
|
1606
|
+
*/
|
|
1607
|
+
private convertConditionToJinja(expr: string, preParsed?: ParsedExpr): string {
|
|
1608
|
+
const jinja = this.convertExpressionToJinja(expr, preParsed)
|
|
1609
|
+
return this.wrapConditionExpr(expr, jinja, preParsed)
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
/**
|
|
1613
|
+
* Shared helper: given the ORIGINAL JS expression (or its already-parsed
|
|
1614
|
+
* tree) and its ALREADY-RENDERED Jinja text, wrap the rendered text with
|
|
1615
|
+
* `bf.truthy(...)` unless the expression is structurally boolean-shaped.
|
|
1616
|
+
* Split from `convertConditionToJinja` so a caller that already lowered the
|
|
1617
|
+
* expression for another purpose (e.g. the `presenceOrUndefined` temp bind)
|
|
1618
|
+
* doesn't lower it twice.
|
|
1619
|
+
*/
|
|
1620
|
+
private wrapConditionExpr(expr: string, jinja: string, preParsed?: ParsedExpr): string {
|
|
1621
|
+
const isBoolean = preParsed
|
|
1622
|
+
? isBooleanResultExpr(stringifyParsedExpr(preParsed))
|
|
1623
|
+
: isBooleanResultExpr(expr)
|
|
1624
|
+
return isBoolean ? jinja : `bf.truthy(${jinja})`
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
/**
|
|
1628
|
+
* Render a full ParsedExpr tree to Jinja for top-level (non-filter)
|
|
1629
|
+
* expressions where identifiers are signals / template vars.
|
|
1630
|
+
*/
|
|
1631
|
+
private renderParsedExprToJinja(expr: ParsedExpr): string {
|
|
1632
|
+
return emitParsedExpr(expr, new JinjaTopLevelEmitter(this.emitCtx))
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
/** Whether `name` (a signal getter or prop) holds a string value. Carried
|
|
1636
|
+
* for parity with the Perl-family adapters; the Jinja emitters don't
|
|
1637
|
+
* consume it (Jinja's `==`/`!=` compare strings and numbers correctly). */
|
|
1638
|
+
private _isStringValueName(name: string): boolean {
|
|
1639
|
+
return this.stringValueNames.has(name)
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
/**
|
|
1643
|
+
* Parse `cond ? value : undefined` (or `: null`), returning the
|
|
1644
|
+
* condition/consequent source spans, else `null`. Used for the
|
|
1645
|
+
* attribute-omission rule (#1897).
|
|
1646
|
+
*/
|
|
1647
|
+
parseUndefinedAlternateTernary(
|
|
1648
|
+
expr: string,
|
|
1649
|
+
): { condition: string; consequent: string } | null {
|
|
1650
|
+
const parsed = parseExpression(expr.trim())
|
|
1651
|
+
if (parsed?.kind !== 'conditional') return null
|
|
1652
|
+
const alt = parsed.alternate
|
|
1653
|
+
const isUndef =
|
|
1654
|
+
(alt.kind === 'identifier' && (alt.name === 'undefined' || alt.name === 'null')) ||
|
|
1655
|
+
(alt.kind === 'literal' && (alt.value === null || alt.value === undefined))
|
|
1656
|
+
if (!isUndef) return null
|
|
1657
|
+
// Serialise the parsed sub-expressions back to JS source rather than
|
|
1658
|
+
// slicing `expr` text — `indexOf('?')` / `lastIndexOf(':')` would
|
|
1659
|
+
// mis-split when the consequent itself contains `?` / `:` inside a
|
|
1660
|
+
// string or nested ternary (`cond ? 'a:b' : undefined`).
|
|
1661
|
+
return {
|
|
1662
|
+
condition: exprToString(parsed.test),
|
|
1663
|
+
consequent: exprToString(parsed.consequent),
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
isBooleanTypedPropRef(expr: string): boolean {
|
|
1668
|
+
let bare = expr.trim()
|
|
1669
|
+
if (this.propsObjectName && bare.startsWith(`${this.propsObjectName}.`)) {
|
|
1670
|
+
bare = bare.slice(this.propsObjectName.length + 1)
|
|
1671
|
+
}
|
|
1672
|
+
if (!/^[A-Za-z_$][\w$]*$/.test(bare)) return false
|
|
1673
|
+
return this.booleanTypedProps.has(bare)
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
/**
|
|
1677
|
+
* Whether an attribute-value expression should route through
|
|
1678
|
+
* `bf.bool_str` (vs. plain `bf.string`) at its interpolation site.
|
|
1679
|
+
* `isExplicitStringCall` is checked FIRST and short-circuits the other
|
|
1680
|
+
* three: an explicit `String(x)` call already lowers to `bf.string(x)`,
|
|
1681
|
+
* which — unlike Kolon's Perl port — correctly stringifies a real
|
|
1682
|
+
* boolean on its own (see `runtime.js_string`'s bool branch), so
|
|
1683
|
+
* layering `bf.bool_str` on top would run Python truthiness over the
|
|
1684
|
+
* ALREADY-STRINGIFIED text instead of the original boolean. See
|
|
1685
|
+
* `isExplicitStringCall`'s docstring in `boolean-result.ts` for the full
|
|
1686
|
+
* double-wrap failure mode this guards against.
|
|
1687
|
+
*/
|
|
1688
|
+
private shouldBoolStr(expr: string, name: string): boolean {
|
|
1689
|
+
if (isExplicitStringCall(expr)) return false
|
|
1690
|
+
return isBooleanResultExpr(expr) || isAriaBooleanAttr(name) || this.isBooleanTypedPropRef(expr)
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
/**
|
|
1694
|
+
* Inline a const (any scope) whose initializer is a pure numeric or
|
|
1695
|
+
* single-quoted string literal (`const totalPages = 5`, #1897
|
|
1696
|
+
* pagination) — function-scope consts never reach the per-render
|
|
1697
|
+
* context, so a bare reference would resolve to Undefined.
|
|
1698
|
+
*/
|
|
1699
|
+
private _resolveLiteralConst(name: string): string | null {
|
|
1700
|
+
const c = (this.localConstants ?? []).find(lc => lc.name === name)
|
|
1701
|
+
if (c?.value === undefined) return null
|
|
1702
|
+
const v = c.value.trim()
|
|
1703
|
+
if (/^-?\d+(\.\d+)?$/.test(v)) return v
|
|
1704
|
+
const strLit = /^'([^'\\]*)'$/.exec(v) ?? /^"([^"\\]*)"$/.exec(v)
|
|
1705
|
+
if (strLit) return `'${escapeJinjaSingleQuoted(strLit[1])}'`
|
|
1706
|
+
return null
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
private _resolveStaticRecordLiteral(objectName: string, key: string): string | null {
|
|
1710
|
+
const hit = lookupStaticRecordLiteral(objectName, key, this.localConstants)
|
|
1711
|
+
if (!hit) return null
|
|
1712
|
+
return hit.kind === 'number'
|
|
1713
|
+
? hit.text
|
|
1714
|
+
: `'${escapeJinjaSingleQuoted(hit.text)}'`
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
private _resolveModuleStringConst(name: string): string | null {
|
|
1718
|
+
// A loop body may bind a `{% set %}` local that shadows a module const of
|
|
1719
|
+
// the same name; never inline inside one (conservative — drop to the
|
|
1720
|
+
// bare identifier).
|
|
1721
|
+
if (this.inLoop) return null
|
|
1722
|
+
const value = this.moduleStringConsts.get(name)
|
|
1723
|
+
if (value === undefined) return null
|
|
1724
|
+
return `'${escapeJinjaSingleQuoted(value)}'`
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
private _recordExprBF101(message: string, reason?: string): void {
|
|
1728
|
+
this.errors.push({
|
|
1729
|
+
code: 'BF101',
|
|
1730
|
+
severity: 'error',
|
|
1731
|
+
message,
|
|
1732
|
+
loc: { file: this.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
|
|
1733
|
+
suggestion: {
|
|
1734
|
+
message: reason
|
|
1735
|
+
? `${reason}\n\nOptions:\n1. Use /* @client */ for client-side evaluation\n2. Pre-compute the value in the backend`
|
|
1736
|
+
: 'Options:\n1. Use /* @client */ for client-side evaluation\n2. Pre-compute the value in the backend',
|
|
1737
|
+
},
|
|
1738
|
+
})
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
/** Internal hook for higher-order: predicate body re-uses the filter emitter. */
|
|
1742
|
+
private _renderJinjaFilterExprPublic(expr: ParsedExpr, param: string): string {
|
|
1743
|
+
return this.renderJinjaFilterExpr(expr, param)
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
export const jinjaAdapter = new JinjaAdapter()
|