@zerotal/flow 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/LICENSE +21 -0
  3. package/README.md +148 -0
  4. package/package.json +84 -0
  5. package/src/Component.ts +1498 -0
  6. package/src/Form.ts +189 -0
  7. package/src/Layout.ts +47 -0
  8. package/src/augment.ts +94 -0
  9. package/src/client/FlowClientError.ts +12 -0
  10. package/src/client/FlowComponent.ts +96 -0
  11. package/src/client/alpinejs-morph.d.ts +5 -0
  12. package/src/client/bridge.ts +3463 -0
  13. package/src/client/confirm.ts +338 -0
  14. package/src/client/cspAdapter.ts +79 -0
  15. package/src/client/cspEvaluator.ts +494 -0
  16. package/src/client/errorOverlay.ts +124 -0
  17. package/src/client/headless.ts +948 -0
  18. package/src/client/index.csp.ts +47 -0
  19. package/src/client/index.ts +56 -0
  20. package/src/client/optimistic.ts +52 -0
  21. package/src/client/timeline.ts +160 -0
  22. package/src/client/url.ts +51 -0
  23. package/src/commands/MakeFlowCommand.ts +290 -0
  24. package/src/compiler/cache.ts +101 -0
  25. package/src/compiler/directives.ts +107 -0
  26. package/src/compiler/helpers.ts +26 -0
  27. package/src/compiler/index.ts +172 -0
  28. package/src/compiler/transform.ts +2070 -0
  29. package/src/components.ts +1465 -0
  30. package/src/config.ts +52 -0
  31. package/src/decorators.ts +978 -0
  32. package/src/dehydrate.ts +333 -0
  33. package/src/durable.ts +243 -0
  34. package/src/events.ts +118 -0
  35. package/src/frameworkEvents.ts +40 -0
  36. package/src/headless.ts +1147 -0
  37. package/src/index.ts +267 -0
  38. package/src/jsx-dev-runtime.ts +14 -0
  39. package/src/jsx-runtime.ts +1272 -0
  40. package/src/mixins.ts +199 -0
  41. package/src/observability.ts +167 -0
  42. package/src/pagination.ts +300 -0
  43. package/src/presence.ts +81 -0
  44. package/src/provider/FlowProvider.ts +1401 -0
  45. package/src/registry.ts +118 -0
  46. package/src/renderContext.ts +55 -0
  47. package/src/router.ts +401 -0
  48. package/src/shared.ts +226 -0
  49. package/src/store.ts +116 -0
  50. package/src/synths/CarbonIntervalSynth.ts +22 -0
  51. package/src/synths/CarbonSynth.ts +20 -0
  52. package/src/synths/CollectionSynth.ts +62 -0
  53. package/src/synths/DateSynth.ts +24 -0
  54. package/src/synths/ModelSynth.ts +78 -0
  55. package/src/synths/TemporaryUploadedFileSynth.ts +19 -0
  56. package/src/synths/index.ts +148 -0
  57. package/src/testing.ts +361 -0
  58. package/src/types.ts +315 -0
  59. package/src/uploads/FileUploads.ts +94 -0
  60. package/src/uploads/TemporaryUploadedFile.ts +274 -0
  61. package/src/uploads/sign.ts +18 -0
  62. package/src/urlSafety.ts +0 -0
  63. package/src/utils.ts +216 -0
  64. package/src/validation.ts +55 -0
@@ -0,0 +1,2070 @@
1
+ /**
2
+ * Flow JSX compiler — TypeScript AST → compiled render() function string.
3
+ *
4
+ * Takes a .tsx source file and returns the body of a compiled render()
5
+ * that emits HTML via string concatenation instead of going through the
6
+ * jsx() runtime, getter instrumentation, and thunk system.
7
+ *
8
+ * Returns null when the file cannot be fully compiled (no Component subclass,
9
+ * unsupported JSX patterns like maps/ternaries/imported components).
10
+ * Those pages continue to use the standard runtime transparently.
11
+ */
12
+
13
+ import * as ts from "typescript";
14
+ import * as path from "node:path";
15
+ import { pathToFileURL } from "node:url";
16
+ import { ZerotalError } from "@zerotal/core";
17
+ import {
18
+ BOOLEAN_DIRECTIVES,
19
+ STRING_DIRECTIVES,
20
+ ATTR_RENAMES,
21
+ VOID_ELEMENTS,
22
+ } from "./directives.ts";
23
+
24
+ // Component base class names that trigger compilation.
25
+ const BASE_CLASSES = new Set(["Component"]);
26
+
27
+ // Client-only `this.<magic>(…)` helpers (resolved on `$flow`, not the server). A
28
+ // read/call rooted at one compiles to a reactive binding (`:attr` or flow:text)
29
+ // rather than a server-rendered value — `this.<magic>` → `$flow.<magic>`.
30
+ const CLIENT_MAGICS = new Set(["currentUrl", "navigateCurrent"]);
31
+
32
+ // The only bare `this.<name>()` client magics still honoured — the ones that are ALSO real
33
+ // Component methods (`this.refresh()` / `this.dispatch()` …), so the name is owned by the base
34
+ // class and can't collide with a developer's own member. The `this.`→`$flow.` rewrite plus the
35
+ // bridge's alias resolve these to the matching `$flow.$<name>` helper. EVERY other client magic
36
+ // now lives ONLY under `$flow` with its `$`-prefix (`$flow.$set`, `$flow.$store`, `$flow.$parent`,
37
+ // …) — written directly, no rewrite — which leaves the bare names (`set`, `toggle`, `store`,
38
+ // `parent`, `on`, `watch`, …) free for the developer's own props/methods. Keep in sync with
39
+ // `_MAGIC_ALIASES` in client/bridge.ts.
40
+ const CLIENT_CALLBACK_MAGICS = new Set(["refresh", "dispatch", "dispatchTo", "dispatchSelf"]);
41
+
42
+ // The client magics you reach through `$flow`. In JSX you write them BARE for clean ergonomics
43
+ // (`$flow.set(...)`, `$flow.store.ui.dark`, `$flow.appendOptimistic(...)`) and the AOT compiler
44
+ // rewrites each to its canonical `$`-prefixed form (`$flow.$set`, `$flow.$store`, …) so the runtime
45
+ // is unambiguous. Longer names first so the alternation prefers `dispatchTo` over `dispatch`, etc.
46
+ const FLOW_MAGIC_NAMES = [
47
+ "dispatchTo",
48
+ "dispatchSelf",
49
+ "dispatch",
50
+ "appendOptimistic",
51
+ "removeOptimistic",
52
+ "onWhisper",
53
+ "whisper",
54
+ "on",
55
+ "set",
56
+ "get",
57
+ "toggle",
58
+ "call",
59
+ "commit",
60
+ "refresh",
61
+ "watch",
62
+ "parent",
63
+ "store",
64
+ "cancel",
65
+ ];
66
+ const _FLOW_MAGIC_RE = new RegExp(`(\\$flow)\\.(${FLOW_MAGIC_NAMES.join("|")})\\b`, "g");
67
+
68
+ /**
69
+ * Rewrite a client expression for emission. Two passes, in this order:
70
+ * 1. author-written bare `$flow.<magic>` → `$flow.$<magic>` — the AOT step that makes the runtime
71
+ * unambiguous (a framework helper always wears `$`), so the bare names stay the developer's.
72
+ * 2. `this.<member>` → `$flow.<member>` — the developer's reactive prop / action.
73
+ * Order matters: (1) runs on the ORIGINAL text, so a `this.set` (a member named like a magic)
74
+ * becomes `$flow.set` (the dev's reactive read / action), never the `$flow.$set` magic. The rule:
75
+ * `this.name` is always yours; `$flow.name` is always the framework helper.
76
+ */
77
+ function _rewriteClientExpr(src: string): string {
78
+ return src.replace(_FLOW_MAGIC_RE, "$1.$$$2").replace(/\bthis\./g, "$flow.");
79
+ }
80
+
81
+ export interface TransformResult {
82
+ /** The compiled render function body — ready to wrap in `export async function render() { ... }`. */
83
+ renderBody: string;
84
+ /** The Component class name, for attribution in the compiled file header. */
85
+ className: string;
86
+ /**
87
+ * Module-level preamble to emit ABOVE the render function: the page's import
88
+ * declarations (relative specifiers rewritten to absolute file URLs) plus its
89
+ * top-level const/function/type declarations — so the compiled render can see
90
+ * the same module scope (imported helpers, module consts) the original did.
91
+ */
92
+ preamble: string;
93
+ }
94
+
95
+ /**
96
+ * Run only the validation pass for a Flow page source file.
97
+ * Throws FlowValidationError on @expose/@locked violations.
98
+ * Called unconditionally by the compiler orchestrator — even on cache hits —
99
+ * so violations always surface regardless of cached compiled output.
100
+ */
101
+ export function validateFlowFile(source: string, filename: string): void {
102
+ const sf = ts.createSourceFile(
103
+ filename,
104
+ source,
105
+ ts.ScriptTarget.Latest,
106
+ /* setParentNodes */ true,
107
+ ts.ScriptKind.TSX,
108
+ );
109
+ const pageClass = _findPageClass(sf);
110
+ if (!pageClass) return;
111
+ const members = _getMemberInfo(pageClass);
112
+ _validateFlowCallbacks(sf, members, filename);
113
+ }
114
+
115
+ /**
116
+ * Transform a Flow page/component source file.
117
+ * Returns the compiled render function, or null if the file can't be compiled.
118
+ */
119
+ /** When true, the compiler emits only CSP-safe expressions (no arrow wrappers,
120
+ * no template literals in bindings) and errors on anything that can't be. Set
121
+ * per-call by transformFlowFile; read by the emission helpers below. Safe as
122
+ * module state because compilation is synchronous and single-threaded. */
123
+ let _cspSafe = false;
124
+
125
+ /** Throw if an emitted client expression uses syntax the CSP evaluator rejects. */
126
+ function _assertCspExpr(text: string, filename: string): void {
127
+ if (!_cspSafe) return;
128
+ if (/=>/.test(text)) {
129
+ throw new FlowValidationError(
130
+ `[Flow CSP] arrow functions aren't allowed in CSP-safe mode: \`${text}\`.\n` +
131
+ ` Move the logic into an @expose action or a named method.\n File: ${filename}`,
132
+ );
133
+ }
134
+ if (text.includes("`")) {
135
+ throw new FlowValidationError(
136
+ `[Flow CSP] template literals aren't allowed in CSP-safe expressions: \`${text}\`.\n` +
137
+ ` Use string concatenation (a + b) instead.\n File: ${filename}`,
138
+ );
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Compile a page's `render()` to a string-concatenating function body.
144
+ *
145
+ * Returns null when the page can't be compiled and must render through the
146
+ * runtime instead. Pass `report` to learn why: the compiler fills in the first
147
+ * blocker it hit, with the line and column to look at.
148
+ */
149
+ export function transformFlowFile(
150
+ source: string,
151
+ filename: string,
152
+ opts: { cspSafe?: boolean; report?: BlockerReport } = {},
153
+ ): TransformResult | null {
154
+ _cspSafe = opts.cspSafe ?? false;
155
+ const sf = ts.createSourceFile(
156
+ filename,
157
+ source,
158
+ ts.ScriptTarget.Latest,
159
+ /* setParentNodes */ true,
160
+ ts.ScriptKind.TSX,
161
+ );
162
+
163
+ const pageClass = _findPageClass(sf);
164
+ if (!pageClass) return null;
165
+
166
+ const members = _getMemberInfo(pageClass);
167
+ const className = pageClass.name?.text ?? "Unknown";
168
+
169
+ // Validate flow() client callbacks across the whole file before AOT.
170
+ // This must run even when the AOT compiler bails (ternaries, components, etc.).
171
+ _validateFlowCallbacks(sf, members, filename);
172
+
173
+ const renderMethod = _findRenderMethod(pageClass);
174
+ if (!renderMethod) return null;
175
+
176
+ const report: BlockerReport = opts.report ?? {};
177
+ const renderBody = _compileRenderMethod(renderMethod, filename, members, report);
178
+ if (!renderBody) {
179
+ // The page couldn't be statically compiled, so it falls back to the runtime renderer — which
180
+ // EVALUATES JSX expressions on the server. A `$flow` read in a value position (a binding or a
181
+ // text child) would blow up there with a bare `$flow is not defined`, because `$flow` is a
182
+ // client-only global. Fail fast with the reason instead of that cryptic runtime error.
183
+ const flowRef = _findServerEvaluatedFlowRef(renderMethod);
184
+ if (flowRef) {
185
+ const read = _blockerAt(flowRef.node, "", "");
186
+ throw new FlowValidationError(
187
+ `${className} reads \`$flow\` in a ${flowRef.where}, but this page can't be statically compiled.\n` +
188
+ ` ${_at(filename, read.line, read.column)} ← the \`$flow\` read\n` +
189
+ ` Without compilation the page renders through the runtime, which evaluates JSX on the\n` +
190
+ ` server — and \`$flow\` is a client-only global, so it would fail there.\n` +
191
+ describeBlocker(report.blocker, filename) +
192
+ ` Fix either side: drop the \`$flow\` read, or clear the blocker so the page compiles.\n` +
193
+ ` (\`$flow\` inside an onClick/onChange handler is always fine — handlers are never evaluated on the server.)`,
194
+ );
195
+ }
196
+ return null;
197
+ }
198
+
199
+ const preamble = _buildPreamble(sf, filename);
200
+ return { renderBody, className, preamble };
201
+ }
202
+
203
+ /**
204
+ * Find a `$flow` reference that the RUNTIME renderer would evaluate on the server — i.e. one in a
205
+ * value position: a non-event JSX attribute value, or a JSX child expression.
206
+ *
207
+ * `$flow` inside an `on*` handler never counts, at any depth: handlers are serialised to a `flow:*`
208
+ * attribute and never invoked server-side. Depth is the whole difficulty — a `<For>` child is an
209
+ * arrow function whose body is full of handlers, so scanning a child expression wholesale reports
210
+ * a read the developer never wrote, in a position they never used.
211
+ *
212
+ * Returns the offending identifier and a short description of its position, or null.
213
+ */
214
+ function _findServerEvaluatedFlowRef(node: ts.Node): { where: string; node: ts.Node } | null {
215
+ let found: { where: string; node: ts.Node } | null = null;
216
+
217
+ const visit = (n: ts.Node): void => {
218
+ if (found) return;
219
+ // Skip the whole handler subtree rather than just its attribute.
220
+ if (ts.isJsxAttribute(n) && ts.isIdentifier(n.name) && n.name.text.startsWith("on")) return;
221
+
222
+ if (ts.isIdentifier(n) && n.text === "$flow") {
223
+ const p = n.parent;
224
+ // Skip when `$flow` is the member NAME (`obj.$flow`), not the object root.
225
+ if (!(p && ts.isPropertyAccessExpression(p) && p.name === n)) {
226
+ const where = _gelRefPosition(n);
227
+ if (where) found = { where, node: n };
228
+ return;
229
+ }
230
+ }
231
+ ts.forEachChild(n, visit);
232
+ };
233
+
234
+ visit(node);
235
+ return found;
236
+ }
237
+
238
+ /**
239
+ * Classify where a `$flow` identifier sits by walking up to the JSX construct holding it:
240
+ * a named attribute binding, a text child, or a position the server never evaluates (null).
241
+ */
242
+ function _gelRefPosition(id: ts.Node): string | null {
243
+ for (let n: ts.Node | undefined = id; n; n = n.parent) {
244
+ if (ts.isJsxAttribute(n)) {
245
+ const name = ts.isIdentifier(n.name) ? n.name.text : "";
246
+ return name.startsWith("on") ? null : `\`${name}=\` binding`;
247
+ }
248
+ if (ts.isJsxExpression(n) && n.parent && !ts.isJsxAttribute(n.parent)) return "text child";
249
+ }
250
+ return null;
251
+ }
252
+
253
+ /**
254
+ * Component binding props whose `{this.key}` value the runtime resolves to a
255
+ * property NAME (via `_resolveReactiveName`). The AOT bind-injection pass makes
256
+ * that resolution static + robust by emitting the key up front. Maps each attr
257
+ * to the key it takes inside the emitted `__flowBinds` object (the same name the
258
+ * component reads with `_injectedBindKey(props, attr)`).
259
+ */
260
+ const BIND_INJECT_ATTRS = new Set(["show", "bind", "query"]);
261
+
262
+ /**
263
+ * Bind-name injection — the runtime-path counterpart to AOT compilation.
264
+ *
265
+ * The string compiler bails on any function component, so component-heavy pages
266
+ * render through the runtime `jsx()` path, where TSC's jsx transform has already
267
+ * discarded the `show`→`sheetOpen` prop→key mapping. That makes value-based bind
268
+ * resolution fragile (a sibling/child binding clobbers the getter capture, and
269
+ * same-valued props can't be told apart). This pass reprints the page's
270
+ * `render()` with `__flowBinds={{ show: "sheetOpen" }}` added to every component
271
+ * that binds `show`/`bind`/`query` to `this.<key>`, so the component resolves its
272
+ * bound prop statically. The reprint is otherwise identical to the source, so
273
+ * behaviour is unchanged apart from robust binding. Returns a standalone
274
+ * `render()` module (pragma + preamble + function), or null when nothing to inject.
275
+ */
276
+ export function buildBindInjectedRender(source: string, filename: string): string | null {
277
+ const sf = ts.createSourceFile(
278
+ filename,
279
+ source,
280
+ ts.ScriptTarget.Latest,
281
+ /* setParentNodes */ true,
282
+ ts.ScriptKind.TSX,
283
+ );
284
+ const pageClass = _findPageClass(sf);
285
+ if (!pageClass) return null;
286
+ const renderMethod = _findRenderMethod(pageClass);
287
+ if (!renderMethod || !renderMethod.body) return null;
288
+
289
+ let injected = false;
290
+
291
+ const transformer =
292
+ (ctx: ts.TransformationContext) =>
293
+ (root: ts.Node): ts.Node => {
294
+ const { factory } = ctx;
295
+
296
+ const withInjectedAttrs = (attrs: ts.JsxAttributes, tagNode: ts.JsxTagNameExpression) => {
297
+ const tag = _tagName(tagNode);
298
+ if (!tag || !_isComponent(tag)) return attrs;
299
+ // Respect an explicit __flowBinds already present (idempotent).
300
+ if (
301
+ attrs.properties.some(
302
+ (p) => ts.isJsxAttribute(p) && ts.isIdentifier(p.name) && p.name.text === "__flowBinds",
303
+ )
304
+ ) {
305
+ return attrs;
306
+ }
307
+ const binds: ts.PropertyAssignment[] = [];
308
+ for (const p of attrs.properties) {
309
+ if (!ts.isJsxAttribute(p) || !ts.isIdentifier(p.name)) continue;
310
+ if (!BIND_INJECT_ATTRS.has(p.name.text)) continue;
311
+ const init = p.initializer;
312
+ if (!init || !ts.isJsxExpression(init) || !init.expression) continue;
313
+ const e = init.expression;
314
+ if (
315
+ ts.isPropertyAccessExpression(e) &&
316
+ e.expression.kind === ts.SyntaxKind.ThisKeyword &&
317
+ ts.isIdentifier(e.name)
318
+ ) {
319
+ binds.push(
320
+ factory.createPropertyAssignment(
321
+ factory.createIdentifier(p.name.text),
322
+ factory.createStringLiteral(e.name.text),
323
+ ),
324
+ );
325
+ }
326
+ }
327
+ if (binds.length === 0) return attrs;
328
+ injected = true;
329
+ const flowBindsAttr = factory.createJsxAttribute(
330
+ factory.createIdentifier("__flowBinds"),
331
+ factory.createJsxExpression(
332
+ undefined,
333
+ factory.createObjectLiteralExpression(binds, false),
334
+ ),
335
+ );
336
+ return factory.updateJsxAttributes(attrs, [...attrs.properties, flowBindsAttr]);
337
+ };
338
+
339
+ const visit = (node: ts.Node): ts.Node => {
340
+ if (ts.isJsxSelfClosingElement(node)) {
341
+ const next = factory.updateJsxSelfClosingElement(
342
+ node,
343
+ node.tagName,
344
+ node.typeArguments,
345
+ withInjectedAttrs(node.attributes, node.tagName),
346
+ );
347
+ return ts.visitEachChild(next, visit, ctx);
348
+ }
349
+ if (ts.isJsxElement(node)) {
350
+ const opening = factory.updateJsxOpeningElement(
351
+ node.openingElement,
352
+ node.openingElement.tagName,
353
+ node.openingElement.typeArguments,
354
+ withInjectedAttrs(node.openingElement.attributes, node.openingElement.tagName),
355
+ );
356
+ const next = factory.updateJsxElement(node, opening, node.children, node.closingElement);
357
+ return ts.visitEachChild(next, visit, ctx);
358
+ }
359
+ return ts.visitEachChild(node, visit, ctx);
360
+ };
361
+ return ts.visitNode(root, visit);
362
+ };
363
+
364
+ const result = ts.transform(renderMethod.body, [transformer]);
365
+ const newBody = result.transformed[0]!;
366
+ if (!injected) {
367
+ result.dispose();
368
+ return null;
369
+ }
370
+
371
+ const printer = ts.createPrinter({ removeComments: false });
372
+ const bodyText = printer.printNode(ts.EmitHint.Unspecified, newBody, sf);
373
+ result.dispose();
374
+
375
+ const isAsync = !!renderMethod.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword);
376
+ const preamble = _buildPreamble(sf, filename);
377
+ return (
378
+ `/** @jsxImportSource @zerotal/flow */\n` +
379
+ (preamble ? preamble + "\n\n" : "") +
380
+ `export ${isAsync ? "async " : ""}function render() ${bodyText}\n`
381
+ );
382
+ }
383
+
384
+ /**
385
+ * Re-emit the page module's imports + top-level declarations so the compiled
386
+ * render() (which lives in a separate file) resolves the same names. Relative
387
+ * import specifiers are rewritten to absolute file:// URLs (location-independent);
388
+ * package specifiers are kept as-is.
389
+ */
390
+ function _buildPreamble(sf: ts.SourceFile, filename: string): string {
391
+ const dir = path.dirname(filename);
392
+ const out: string[] = [];
393
+ for (const stmt of sf.statements) {
394
+ if (ts.isImportDeclaration(stmt)) {
395
+ if (!ts.isStringLiteral(stmt.moduleSpecifier)) continue;
396
+ const spec = stmt.moduleSpecifier.text;
397
+ const target = spec.startsWith(".") ? pathToFileURL(path.resolve(dir, spec)).href : spec;
398
+ const clause = stmt.importClause ? stmt.importClause.getText() : "";
399
+ out.push(
400
+ clause
401
+ ? `import ${clause} from ${JSON.stringify(target)};`
402
+ : `import ${JSON.stringify(target)};`,
403
+ );
404
+ continue;
405
+ }
406
+ // Carry module-level declarations (consts, helpers, types) verbatim — they may be
407
+ // referenced by the render body. The Component class itself is intentionally skipped.
408
+ if (
409
+ ts.isVariableStatement(stmt) ||
410
+ ts.isFunctionDeclaration(stmt) ||
411
+ ts.isTypeAliasDeclaration(stmt) ||
412
+ ts.isInterfaceDeclaration(stmt) ||
413
+ ts.isEnumDeclaration(stmt)
414
+ ) {
415
+ out.push(stmt.getText());
416
+ }
417
+ }
418
+ return out.join("\n");
419
+ }
420
+
421
+ // ── AST finders ───────────────────────────────────────────────────────────────
422
+
423
+ function _findPageClass(sf: ts.SourceFile): ts.ClassDeclaration | null {
424
+ for (const stmt of sf.statements) {
425
+ if (!ts.isClassDeclaration(stmt)) continue;
426
+ const ext = stmt.heritageClauses?.find((h) => h.token === ts.SyntaxKind.ExtendsKeyword);
427
+ if (!ext) continue;
428
+ for (const t of ext.types) {
429
+ if (ts.isIdentifier(t.expression) && BASE_CLASSES.has(t.expression.text)) {
430
+ return stmt;
431
+ }
432
+ }
433
+ }
434
+ return null;
435
+ }
436
+
437
+ function _findRenderMethod(cls: ts.ClassDeclaration): ts.MethodDeclaration | null {
438
+ for (const m of cls.members) {
439
+ if (ts.isMethodDeclaration(m) && ts.isIdentifier(m.name) && m.name.text === "render") return m;
440
+ }
441
+ return null;
442
+ }
443
+
444
+ // ── Member info extraction ────────────────────────────────────────────────────
445
+
446
+ interface MemberInfo {
447
+ exposed: Set<string>; // @expose only — readable + writable from client
448
+ locked: Set<string>; // @locked only — readable from client, not writable
449
+ computed: Set<string>; // @computed getters — derived, NOT in the snapshot
450
+ readable: Set<string>; // exposed ∪ locked — any prop in the snapshot (client-reactive)
451
+ }
452
+
453
+ /** Collect @expose, @locked, and @computed decorated members from the Component class AST. */
454
+ function _getMemberInfo(cls: ts.ClassDeclaration): MemberInfo {
455
+ const exposed = new Set<string>();
456
+ const locked = new Set<string>();
457
+ const computed = new Set<string>();
458
+ for (const member of cls.members) {
459
+ // @computed sits on a getter (GetAccessorDeclaration); @expose/@locked on fields/methods.
460
+ if (
461
+ !ts.isPropertyDeclaration(member) &&
462
+ !ts.isMethodDeclaration(member) &&
463
+ !ts.isGetAccessorDeclaration(member)
464
+ ) {
465
+ continue;
466
+ }
467
+ if (!ts.isIdentifier(member.name)) continue;
468
+ const name = member.name.text;
469
+ const mods = member.modifiers ?? [];
470
+ const has = (dec: string) =>
471
+ mods.some(
472
+ (m) => ts.isDecorator(m) && ts.isIdentifier(m.expression) && m.expression.text === dec,
473
+ );
474
+ if (has("expose")) exposed.add(name);
475
+ if (has("locked")) locked.add(name);
476
+ if (has("computed")) computed.add(name);
477
+ }
478
+ return { exposed, locked, computed, readable: new Set([...exposed, ...locked]) };
479
+ }
480
+
481
+ // ── this.xxx reference extraction (read vs write) ─────────────────────────────
482
+
483
+ interface ThisRef {
484
+ name: string;
485
+ isWrite: boolean;
486
+ }
487
+
488
+ /**
489
+ * Walk an AST node and collect all direct `this.xxx` references,
490
+ * flagging ones that are the immediate LHS of an assignment as writes.
491
+ */
492
+ // ts.SyntaxKind.FirstAssignment / LastAssignment are @internal in TS 5.x
493
+ // and evaluate to undefined at runtime — enumerate assignment operators explicitly.
494
+ const ASSIGN_OPS = new Set<ts.SyntaxKind>([
495
+ ts.SyntaxKind.EqualsToken,
496
+ ts.SyntaxKind.PlusEqualsToken,
497
+ ts.SyntaxKind.MinusEqualsToken,
498
+ ts.SyntaxKind.AsteriskEqualsToken,
499
+ ts.SyntaxKind.AsteriskAsteriskEqualsToken,
500
+ ts.SyntaxKind.SlashEqualsToken,
501
+ ts.SyntaxKind.PercentEqualsToken,
502
+ ts.SyntaxKind.LessThanLessThanEqualsToken,
503
+ ts.SyntaxKind.GreaterThanGreaterThanEqualsToken,
504
+ ts.SyntaxKind.GreaterThanGreaterThanGreaterThanEqualsToken,
505
+ ts.SyntaxKind.AmpersandEqualsToken,
506
+ ts.SyntaxKind.BarEqualsToken,
507
+ ts.SyntaxKind.BarBarEqualsToken,
508
+ ts.SyntaxKind.AmpersandAmpersandEqualsToken,
509
+ ts.SyntaxKind.QuestionQuestionEqualsToken,
510
+ ts.SyntaxKind.CaretEqualsToken,
511
+ ]);
512
+
513
+ function _extractThisRefs(node: ts.Node): ThisRef[] {
514
+ const refs: ThisRef[] = [];
515
+
516
+ function walk(n: ts.Node): void {
517
+ // Binary assignment: this.x = val / this.x += val / etc.
518
+ if (ts.isBinaryExpression(n) && ASSIGN_OPS.has(n.operatorToken.kind)) {
519
+ if (_isThisProp(n.left)) {
520
+ refs.push({ name: (n.left as ts.PropertyAccessExpression).name.text, isWrite: true });
521
+ } else {
522
+ walk(n.left);
523
+ }
524
+ walk(n.right);
525
+ return;
526
+ }
527
+ // Prefix/postfix ++/--: ++this.x / this.x++
528
+ if (
529
+ (ts.isPrefixUnaryExpression(n) || ts.isPostfixUnaryExpression(n)) &&
530
+ (n.operator === ts.SyntaxKind.PlusPlusToken ||
531
+ n.operator === ts.SyntaxKind.MinusMinusToken) &&
532
+ _isThisProp(n.operand)
533
+ ) {
534
+ refs.push({ name: (n.operand as ts.PropertyAccessExpression).name.text, isWrite: true });
535
+ return;
536
+ }
537
+ if (_isThisProp(n)) {
538
+ refs.push({ name: (n as ts.PropertyAccessExpression).name.text, isWrite: false });
539
+ return;
540
+ }
541
+ ts.forEachChild(n, walk);
542
+ }
543
+
544
+ walk(node);
545
+ return refs;
546
+ }
547
+
548
+ /**
549
+ * If `expr` is `flow(this.prop)` or `flow(this.prop).mod1.mod2`, returns the
550
+ * inner `this.prop` PropertyAccessExpression. Returns null for arrow-function
551
+ * flow() targets, parametric modifiers like `.debounce('300ms')`, or any other
552
+ * non-this-prop form.
553
+ */
554
+ function _extractFlowProp(expr: ts.Expression): ts.PropertyAccessExpression | null {
555
+ // Strip any trailing simple property-access modifiers (.prevent, .stop, .live, …)
556
+ let node: ts.Expression = expr;
557
+ while (ts.isPropertyAccessExpression(node)) {
558
+ node = node.expression;
559
+ }
560
+ // node must now be the bare flow(…) call
561
+ if (
562
+ !ts.isCallExpression(node) ||
563
+ !ts.isIdentifier(node.expression) ||
564
+ node.expression.text !== "flow" ||
565
+ node.arguments.length !== 1
566
+ )
567
+ return null;
568
+ const arg = node.arguments[0]!;
569
+ if (!_isThisProp(arg)) return null;
570
+ return arg as ts.PropertyAccessExpression;
571
+ }
572
+
573
+ /**
574
+ * Collect the simple modifier names from a `flow(this.prop).a.b.c` chain.
575
+ * Returns [] for a bare `flow(this.prop)` call.
576
+ * Only works for simple property-access modifiers; parametric forms like
577
+ * `.debounce('300ms')` (a CallExpression) are not collected — those callers
578
+ * should bail to the runtime.
579
+ */
580
+ function _extractFlowModifiers(expr: ts.Expression): string[] {
581
+ const modifiers: string[] = [];
582
+ let node: ts.Expression = expr;
583
+ while (ts.isPropertyAccessExpression(node)) {
584
+ modifiers.unshift(node.name.text);
585
+ node = node.expression;
586
+ }
587
+ return modifiers;
588
+ }
589
+
590
+ /**
591
+ * Unwrap flow() modifier chains to find the inner arrow function.
592
+ * Handles: flow(arrow), flow(arrow).stop, flow(arrow).debounce('300ms'), etc.
593
+ */
594
+ function _findFlowArrow(expr: ts.Expression): ts.ArrowFunction | null {
595
+ if (ts.isCallExpression(expr)) {
596
+ // flow(arrow) — direct call
597
+ if (
598
+ ts.isIdentifier(expr.expression) &&
599
+ expr.expression.text === "flow" &&
600
+ expr.arguments.length >= 1 &&
601
+ ts.isArrowFunction(expr.arguments[0] as ts.Node)
602
+ ) {
603
+ return expr.arguments[0] as ts.ArrowFunction;
604
+ }
605
+ // flow(arrow).debounce('300ms') — parametric modifier call
606
+ return _findFlowArrow(expr.expression);
607
+ }
608
+ // flow(arrow).stop / .prevent / etc. — property modifier access
609
+ if (ts.isPropertyAccessExpression(expr)) {
610
+ return _findFlowArrow(expr.expression);
611
+ }
612
+ return null;
613
+ }
614
+
615
+ // ── Validation error ──────────────────────────────────────────────────────────
616
+
617
+ /**
618
+ * Thrown by the pre-compilation validation pass.
619
+ * Unlike ordinary compile failures (which bail to runtime), this is FATAL —
620
+ * the orchestrator rethrows it so the server refuses to start with invalid code.
621
+ */
622
+ export class FlowValidationError extends ZerotalError {
623
+ constructor(message: string) {
624
+ super(message, "E_PULSE_VALIDATION", 500);
625
+ this.name = "FlowValidationError";
626
+ }
627
+ }
628
+
629
+ // ── Pre-compilation validation pass ──────────────────────────────────────────
630
+ //
631
+ // Runs BEFORE AOT compilation so it fires even on pages the compiler bails on
632
+ // (ternaries, maps, sub-components, etc.). Scans all JSX attributes for
633
+ // flow(arrowFn) client callbacks and validates their this.xxx references.
634
+
635
+ function _validateArrowRefs(arrow: ts.ArrowFunction, members: MemberInfo, filename: string): void {
636
+ for (const { name: ref, isWrite } of _extractThisRefs(arrow)) {
637
+ // The dual real-method magics (this.refresh/dispatch/…) resolve on $flow at runtime —
638
+ // exempt from the @expose/@locked rules below. Every other client magic now lives on
639
+ // `$flow` (`$flow.$set`, `$flow.$store`, …), which carries no `this.` ref for the validator
640
+ // to see, so it's naturally exempt — and a bare `this.set` is validated as the developer's
641
+ // own member (their method if defined, else a clear error).
642
+ if (CLIENT_CALLBACK_MAGICS.has(ref)) continue;
643
+
644
+ if (isWrite) {
645
+ if (members.locked.has(ref)) {
646
+ throw new FlowValidationError(
647
+ `[Flow] Cannot set @locked property "${ref}" from a client callback.\n` +
648
+ ` Move the update into a server action instead.\n` +
649
+ ` File: ${filename}`,
650
+ );
651
+ }
652
+ if (!members.exposed.has(ref)) {
653
+ throw new FlowValidationError(
654
+ `[Flow] "${ref}" does not match any @expose — only @expose properties can be set from a client callback.\n` +
655
+ ` File: ${filename}`,
656
+ );
657
+ }
658
+ } else {
659
+ if (!members.readable.has(ref)) {
660
+ throw new FlowValidationError(
661
+ `[Flow] "${ref}" does not match any @expose or @locked — it cannot be read in a client callback.\n` +
662
+ ` File: ${filename}`,
663
+ );
664
+ }
665
+ }
666
+ }
667
+ }
668
+
669
+ function _validateFlowCallbacks(sf: ts.SourceFile, members: MemberInfo, filename: string): void {
670
+ function scan(node: ts.Node): void {
671
+ if (
672
+ ts.isJsxAttribute(node) &&
673
+ node.initializer &&
674
+ ts.isJsxExpression(node.initializer) &&
675
+ node.initializer.expression
676
+ ) {
677
+ const expr = node.initializer.expression;
678
+ const attrName = ts.isIdentifier(node.name) ? node.name.text : "";
679
+ // flow(() => …) chains — validate the wrapped arrow.
680
+ const flowArrow = _findFlowArrow(expr);
681
+ if (flowArrow) {
682
+ _validateArrowRefs(flowArrow, members, filename);
683
+ } else if (attrName.startsWith("on") && ts.isArrowFunction(expr)) {
684
+ // Bare client-handler arrow — onClick={(e) => this.x = 0}. Same safety rules:
685
+ // its this.xxx refs must be @expose (writes) / @expose|@locked (reads).
686
+ _validateArrowRefs(expr, members, filename);
687
+ }
688
+ }
689
+ ts.forEachChild(node, scan);
690
+ }
691
+ scan(sf);
692
+ }
693
+
694
+ // ── Renderer ──────────────────────────────────────────────────────────────────
695
+
696
+ /**
697
+ * What stopped a page compiling, and where. Recorded at the first bail — the one
698
+ * that actually caused the fallback; everything after it is fallout.
699
+ */
700
+ export interface CompileBlocker {
701
+ /** What the compiler found, e.g. "`<Demo>` is an imported component". */
702
+ reason: string;
703
+ /** What to change, e.g. "inline its markup". */
704
+ fix: string;
705
+ /** 1-based position of the construct in the source file. */
706
+ line: number;
707
+ column: number;
708
+ }
709
+
710
+ /** Mutable out-parameter: the compiler fills `blocker` in when it falls back. */
711
+ export interface BlockerReport {
712
+ blocker?: CompileBlocker;
713
+ }
714
+
715
+ /** Locate `node` in its source file, 1-based, for a `file:line:column` reference. */
716
+ function _blockerAt(node: ts.Node, reason: string, fix: string): CompileBlocker {
717
+ const sf = node.getSourceFile();
718
+ const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart(sf));
719
+ return { reason, fix, line: line + 1, column: character + 1 };
720
+ }
721
+
722
+ /** `app/pages/lists.tsx:83:10` — relative and slash-normalised, so terminals linkify it. */
723
+ function _at(filename: string, line: number, column: number): string {
724
+ const rel = path.relative(process.cwd(), filename).split("\\").join("/");
725
+ return `${rel || filename}:${line}:${column}`;
726
+ }
727
+
728
+ /** A short single-line excerpt of `node`'s source, so a message can quote what it found. */
729
+ function _snippet(node: ts.Node, max = 40): string {
730
+ const text = node.getText().replace(/\s+/g, " ").trim();
731
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
732
+ }
733
+
734
+ /**
735
+ * The "what stops it compiling" section of a diagnostic: the recorded blocker
736
+ * and where to look. Falls back to the common causes when the compiler bailed
737
+ * somewhere that records nothing.
738
+ */
739
+ export function describeBlocker(blocker: CompileBlocker | undefined, filename: string): string {
740
+ if (!blocker) {
741
+ return (
742
+ ` What stops it compiling: could not be pinpointed. The usual causes are an imported\n` +
743
+ ` component in render(), a computed class={…}, or a numeric attribute like rows={3}.\n`
744
+ );
745
+ }
746
+ return (
747
+ ` What stops it compiling:\n` +
748
+ ` ${_at(filename, blocker.line, blocker.column)} ${blocker.reason}\n` +
749
+ ` → ${blocker.fix}\n`
750
+ );
751
+ }
752
+
753
+ function _compileRenderMethod(
754
+ method: ts.MethodDeclaration,
755
+ filename: string,
756
+ members: MemberInfo,
757
+ report?: BlockerReport,
758
+ ): string | null {
759
+ let canCompile = true;
760
+
761
+ /** Record the first blocker and stop compiling. */
762
+ function bail(reason: string, fix: string, node: ts.Node): null {
763
+ canCompile = false;
764
+ if (report && !report.blocker) report.blocker = _blockerAt(node, reason, fix);
765
+ return null;
766
+ }
767
+
768
+ if (!method.body) return bail("render() has no body", "give it a body that returns JSX", method);
769
+
770
+ // The body must be: [zero or more non-return leading statements] then ONE top-level
771
+ // return of JSX. Any other return anywhere (early returns / branching) → bail to runtime.
772
+ const returns: ts.ReturnStatement[] = [];
773
+ const countReturns = (n: ts.Node): void => {
774
+ if (ts.isReturnStatement(n)) returns.push(n);
775
+ ts.forEachChild(n, countReturns);
776
+ };
777
+ countReturns(method.body);
778
+ if (returns.length !== 1) {
779
+ return bail(
780
+ `render() has ${returns.length} return statements; the compiler needs exactly one`,
781
+ "fold the branching into the returned JSX (a ternary) or into a leading const",
782
+ returns[1] ?? method,
783
+ );
784
+ }
785
+
786
+ const stmts = method.body.statements;
787
+ const returnIdx = stmts.findIndex((s) => ts.isReturnStatement(s));
788
+ if (returnIdx === -1) {
789
+ return bail(
790
+ "render()'s only return is nested inside another block",
791
+ "return the JSX from the top level of render()",
792
+ returns[0] ?? method,
793
+ );
794
+ }
795
+ const retStmt = stmts[returnIdx] as ts.ReturnStatement;
796
+ if (!retStmt.expression) return bail("render() returns nothing", "return JSX", retStmt);
797
+
798
+ // Leading statements (const/let computations etc.) carried verbatim — they run against
799
+ // `this` and the carried module scope before the JSX is emitted.
800
+ const leading = stmts
801
+ .slice(0, returnIdx)
802
+ .map((s) => s.getText())
803
+ .join("\n ");
804
+
805
+ let returnExpr: ts.Expression = retStmt.expression;
806
+ while (ts.isParenthesizedExpression(returnExpr)) returnExpr = returnExpr.expression;
807
+ if (
808
+ !ts.isJsxElement(returnExpr) &&
809
+ !ts.isJsxFragment(returnExpr) &&
810
+ !ts.isJsxSelfClosingElement(returnExpr)
811
+ ) {
812
+ return bail(
813
+ "render() returns something other than a JSX element",
814
+ "return the markup directly instead of a value computed from it",
815
+ returnExpr,
816
+ );
817
+ }
818
+
819
+ // Segment builder
820
+ const parts: string[] = [];
821
+ let staticBuf = "";
822
+
823
+ function emitStatic(s: string): void {
824
+ staticBuf += s;
825
+ }
826
+
827
+ function emitDynamic(expr: string): void {
828
+ if (staticBuf) {
829
+ parts.push(JSON.stringify(staticBuf));
830
+ staticBuf = "";
831
+ }
832
+ parts.push(expr);
833
+ }
834
+
835
+ function flush(): void {
836
+ if (staticBuf) {
837
+ parts.push(JSON.stringify(staticBuf));
838
+ staticBuf = "";
839
+ }
840
+ }
841
+
842
+ /**
843
+ * Run `run` against a fresh sub-buffer and return the JS string-expression it
844
+ * produced (for embedding inside a ternary/&&/map branch), restoring the outer
845
+ * buffer afterward. Returns null if the sub-render bailed.
846
+ */
847
+ function captureExpr(run: () => void): string | null {
848
+ const outerParts = parts.slice();
849
+ const outerBuf = staticBuf;
850
+ parts.length = 0;
851
+ staticBuf = "";
852
+ run();
853
+ let result: string | null = null;
854
+ if (canCompile) {
855
+ if (staticBuf) {
856
+ parts.push(JSON.stringify(staticBuf));
857
+ staticBuf = "";
858
+ }
859
+ result = parts.length ? parts.join(" + ") : '""';
860
+ }
861
+ parts.length = 0;
862
+ for (const p of outerParts) parts.push(p);
863
+ staticBuf = outerBuf;
864
+ return result;
865
+ }
866
+
867
+ /**
868
+ * Compile an expression in *value/child position* (a ternary/&&/map branch, a
869
+ * map body, etc.) to a JS string-expression. Returns null to bail. The condition,
870
+ * array and arrow params are emitted verbatim (they run server-side against `this`
871
+ * + the carried module scope); nested JSX is compiled recursively.
872
+ */
873
+ function _compileValueExpr(raw: ts.Expression): string | null {
874
+ let expr: ts.Expression = raw;
875
+ while (ts.isParenthesizedExpression(expr)) expr = expr.expression;
876
+
877
+ // Client magics are reactive client expressions, so they can't appear in a part that
878
+ // this compiler emits to run on the SERVER (a ternary/&& condition, a .map() source, a
879
+ // template literal, the general escaped-text fallback). They DO work when reached
880
+ // recursively in an attribute value or text child of a nested JSX node (handled there).
881
+ const _assertNoServerMagic = (node: ts.Node, where: string): void => {
882
+ const m = _containsClientMagic(node);
883
+ if (m) {
884
+ throw new FlowValidationError(
885
+ `[Flow] this.${m}(…) (a client-only magic) can't be used in ${where} — that runs on the ` +
886
+ `server. Use it as an attribute value, a class/style expression, or a text child.\n` +
887
+ ` File: ${filename}`,
888
+ );
889
+ }
890
+ };
891
+
892
+ if (ts.isJsxElement(expr) || ts.isJsxFragment(expr) || ts.isJsxSelfClosingElement(expr)) {
893
+ return captureExpr(() => visitChild(expr as unknown as ts.JsxChild));
894
+ }
895
+ if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr))
896
+ return JSON.stringify(_escHtml(expr.text));
897
+ if (ts.isNumericLiteral(expr)) return JSON.stringify(expr.text);
898
+ if (expr.kind === ts.SyntaxKind.NullKeyword) return '""';
899
+ if (ts.isIdentifier(expr) && expr.text === "undefined") return '""';
900
+ if (expr.kind === ts.SyntaxKind.TrueKeyword || expr.kind === ts.SyntaxKind.FalseKeyword)
901
+ return '""';
902
+
903
+ if (_isThisProp(expr)) {
904
+ const prop = (expr as ts.PropertyAccessExpression).name.text;
905
+ if (!members.readable.has(prop)) return null;
906
+ return `__esc(this.${prop})`;
907
+ }
908
+
909
+ // {cond ? A : B}
910
+ if (ts.isConditionalExpression(expr)) {
911
+ _assertNoServerMagic(expr.condition, "a ternary condition");
912
+ const t = _compileValueExpr(expr.whenTrue);
913
+ const f = _compileValueExpr(expr.whenFalse);
914
+ if (t === null || f === null) return null;
915
+ return `(${expr.condition.getText()} ? ${t} : ${f})`;
916
+ }
917
+
918
+ // {cond && A}
919
+ if (
920
+ ts.isBinaryExpression(expr) &&
921
+ expr.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken
922
+ ) {
923
+ _assertNoServerMagic(expr.left, "an && condition");
924
+ const r = _compileValueExpr(expr.right);
925
+ if (r === null) return null;
926
+ return `(${expr.left.getText()} ? ${r} : "")`;
927
+ }
928
+
929
+ // {arr.map((item, i) => <JSX>)} / {arr.map(item => { …; return <JSX>; })}
930
+ if (
931
+ ts.isCallExpression(expr) &&
932
+ ts.isPropertyAccessExpression(expr.expression) &&
933
+ expr.expression.name.text === "map" &&
934
+ expr.arguments.length >= 1 &&
935
+ (ts.isArrowFunction(expr.arguments[0]!) || ts.isFunctionExpression(expr.arguments[0]!))
936
+ ) {
937
+ const fn = expr.arguments[0] as ts.ArrowFunction | ts.FunctionExpression;
938
+ _assertNoServerMagic(expr.expression.expression, "a .map() source");
939
+ const arrText = expr.expression.expression.getText();
940
+ const params = fn.parameters.map((p) => p.getText()).join(", ");
941
+ let bodyExpr: string | null;
942
+ if (ts.isBlock(fn.body)) {
943
+ const last = fn.body.statements[fn.body.statements.length - 1];
944
+ if (!last || !ts.isReturnStatement(last) || !last.expression) return null;
945
+ const compiled = _compileValueExpr(last.expression);
946
+ if (compiled === null) return null;
947
+ const lead = fn.body.statements
948
+ .slice(0, -1)
949
+ .map((s) => s.getText())
950
+ .join("\n");
951
+ bodyExpr = lead ? `(() => { ${lead}\nreturn ${compiled}; })()` : compiled;
952
+ } else {
953
+ bodyExpr = _compileValueExpr(fn.body);
954
+ }
955
+ if (bodyExpr === null) return null;
956
+ return `${arrText}.map((${params}) => ${bodyExpr}).join("")`;
957
+ }
958
+
959
+ // `Hi ${this.name}` — template literal: server-render the escaped string.
960
+ if (ts.isTemplateExpression(expr)) {
961
+ _assertNoServerMagic(expr, "a template literal");
962
+ return `__esc(${expr.getText()})`;
963
+ }
964
+
965
+ // General value expression — identifier (local/param), member/element access,
966
+ // arithmetic/comparison, unary — render as escaped text. Emitted verbatim so it
967
+ // evaluates against `this` + carried module scope + enclosing map params.
968
+ // CallExpressions are excluded: they may return an HtmlNode rather than a string.
969
+ if (
970
+ ts.isIdentifier(expr) ||
971
+ ts.isPropertyAccessExpression(expr) ||
972
+ ts.isElementAccessExpression(expr) ||
973
+ ts.isBinaryExpression(expr) ||
974
+ ts.isPrefixUnaryExpression(expr) ||
975
+ ts.isPostfixUnaryExpression(expr)
976
+ ) {
977
+ _assertNoServerMagic(expr, "this position");
978
+ return `__esc(${expr.getText()})`;
979
+ }
980
+
981
+ return null;
982
+ }
983
+
984
+ /**
985
+ * True if every value in `node` comes from `this.<snapshot prop>` or the `$flow` client
986
+ * runtime (`$flow.$store.*`, `$flow.count`) + literals/operators (no bare locals, calls, or
987
+ * other roots) — i.e. it's safe to evaluate client-side against the reactive proxy.
988
+ */
989
+ function _isSnapshotOnly(node: ts.Node): boolean {
990
+ let ok = true;
991
+ const visit = (n: ts.Node): void => {
992
+ if (!ok) return;
993
+ if (ts.isPropertyAccessExpression(n)) {
994
+ let root: ts.Expression = n.expression;
995
+ while (ts.isPropertyAccessExpression(root)) root = root.expression;
996
+ // `$flow` client runtime root — `$flow.$store.ui.dark` — reactive on the client, allowed.
997
+ if (ts.isIdentifier(root) && root.text === "$flow") return;
998
+ if (root.kind !== ts.SyntaxKind.ThisKeyword) {
999
+ ok = false;
1000
+ return;
1001
+ }
1002
+ let pa: ts.PropertyAccessExpression = n;
1003
+ while (ts.isPropertyAccessExpression(pa.expression)) pa = pa.expression;
1004
+ if (!members.readable.has(pa.name.text)) ok = false;
1005
+ return; // chain is all this.x.y — don't recurse
1006
+ }
1007
+ if (n.kind === ts.SyntaxKind.ThisKeyword) return;
1008
+ if (ts.isIdentifier(n)) {
1009
+ ok = false;
1010
+ return;
1011
+ } // bare local / import
1012
+ if (ts.isCallExpression(n) || ts.isElementAccessExpression(n)) {
1013
+ ok = false;
1014
+ return;
1015
+ }
1016
+ ts.forEachChild(n, visit);
1017
+ };
1018
+ visit(node);
1019
+ return ok;
1020
+ }
1021
+
1022
+ // ── Visitors ─────────────────────────────────────────────────────────────
1023
+
1024
+ function visitChildren(children: readonly ts.JsxChild[]): void {
1025
+ for (const c of children) visitChild(c);
1026
+ }
1027
+
1028
+ function visitChild(node: ts.JsxChild): void {
1029
+ if (!canCompile) return;
1030
+ // A widened handle: the checks below narrow `node` to `never` by the end, and
1031
+ // the final bail still needs somewhere to point.
1032
+ const child: ts.Node = node;
1033
+
1034
+ // ── Whitespace-only text node (between tags) ─────────────────────────
1035
+ if (ts.isJsxText(node)) {
1036
+ if (node.containsOnlyTriviaWhiteSpaces) return;
1037
+ // Normalise: collapse leading/trailing whitespace on lines, newlines → space
1038
+ const raw = node.text;
1039
+ const lines = raw.split("\n");
1040
+ const trimmed = lines
1041
+ .map((l) => l.trim())
1042
+ .filter(Boolean)
1043
+ .join(" ");
1044
+ if (trimmed) emitStatic(_escHtml(trimmed));
1045
+ return;
1046
+ }
1047
+
1048
+ // ── JSX expression: {expr} ───────────────────────────────────────────
1049
+ if (ts.isJsxExpression(node)) {
1050
+ if (!node.expression) return; // {/* comment */}
1051
+ const expr = node.expression;
1052
+
1053
+ // {' '} or {'literal'} — string literal
1054
+ if (ts.isStringLiteral(expr)) {
1055
+ emitStatic(_escHtml(expr.text));
1056
+ return;
1057
+ }
1058
+
1059
+ // {this.propName}
1060
+ if (_isThisProp(expr)) {
1061
+ const prop = (expr as ts.PropertyAccessExpression).name.text;
1062
+ // @computed getters aren't in the snapshot, but a text child renders a STATIC
1063
+ // server-evaluated value (`__esc`), not a reactive binding — so a derived getter
1064
+ // is fine here (it just updates on the next server patch, like any static read).
1065
+ if (!members.readable.has(prop) && !members.computed.has(prop)) {
1066
+ throw new FlowValidationError(
1067
+ `[Flow] {this.${prop}} — "${prop}" does not match any @expose, @locked, or @computed property.\n` +
1068
+ ` File: ${filename}`,
1069
+ );
1070
+ }
1071
+ emitDynamic(`__esc(this.${prop})`);
1072
+ return;
1073
+ }
1074
+
1075
+ // {Number(this.x)} or {String(this.x)} — simple casts
1076
+ if (
1077
+ ts.isCallExpression(expr) &&
1078
+ ts.isIdentifier(expr.expression) &&
1079
+ (expr.expression.text === "Number" || expr.expression.text === "String") &&
1080
+ expr.arguments.length === 1 &&
1081
+ _isThisProp(expr.arguments[0]!)
1082
+ ) {
1083
+ const prop = (expr.arguments[0] as ts.PropertyAccessExpression).name.text;
1084
+ // @computed getters aren't in the snapshot, but a text child renders a STATIC
1085
+ // server-evaluated value (`__esc`), not a reactive binding — so a derived getter
1086
+ // is fine here (it just updates on the next server patch, like any static read).
1087
+ if (!members.readable.has(prop) && !members.computed.has(prop)) {
1088
+ throw new FlowValidationError(
1089
+ `[Flow] {this.${prop}} — "${prop}" does not match any @expose, @locked, or @computed property.\n` +
1090
+ ` File: ${filename}`,
1091
+ );
1092
+ }
1093
+ emitDynamic(`__esc(this.${prop})`);
1094
+ return;
1095
+ }
1096
+
1097
+ // {this.currentUrl(...)} or {this.currentUrl() === "/" ? "a" : "b"} — a client-magic
1098
+ // read in text position (a pure value, no JSX). Emit a reactive flow:text span (a wrapper
1099
+ // so it composes with siblings); the bridge evaluates the $flow expression on hydration.
1100
+ // Magic-bearing expressions that contain JSX fall through to _compileValueExpr, which
1101
+ // throws (it can't be made reactive there).
1102
+ if (_containsClientMagic(expr) && !_containsJsx(expr)) {
1103
+ const src = _clientMagicSrc(expr, members, filename);
1104
+ _assertCspExpr(src, filename);
1105
+ emitStatic(`<span flow:text="${_escAttr(src)}"></span>`);
1106
+ return;
1107
+ }
1108
+
1109
+ // {$flow.$store.ui.dark ? 'On' : 'Off'} — reads the `$flow` client runtime in text
1110
+ // position. Emit an Alpine-native x-text (reactive on the client, no round-trip). Any
1111
+ // mixed this.<prop> refs are rewritten to $flow.<prop> (also Alpine-reactive).
1112
+ if (_referencesGel(expr) && !_containsJsx(expr)) {
1113
+ const src = _clientMagicSrc(expr, members, filename);
1114
+ _assertCspExpr(src, filename);
1115
+ emitStatic(`<span x-text="${_escAttr(src)}"></span>`);
1116
+ return;
1117
+ }
1118
+ // {$flow.$store.x ? <A/> : <B/>} — a client value can't switch between JSX subtrees
1119
+ // (that needs client-side templating Flow doesn't do). Fail with guidance rather than
1120
+ // bake a client-only value into server-rendered HTML.
1121
+ if (_referencesGel(expr)) {
1122
+ throw new FlowValidationError(
1123
+ `[Flow] a \`$flow\` value can't select between JSX elements ({$flow.$store.x ? <A/> : <B/>}).\n` +
1124
+ ` Drive the DOM from it with a class / style / show binding or a text child instead,\n` +
1125
+ ` or switch on an @expose/@locked prop (this.x) for server-rendered branches.\n File: ${filename}`,
1126
+ );
1127
+ }
1128
+
1129
+ // Ternaries, &&, .map(), template literals → recursive sub-expression compile.
1130
+ const compiled = _compileValueExpr(expr);
1131
+ if (compiled !== null) {
1132
+ emitDynamic(compiled);
1133
+ return;
1134
+ }
1135
+
1136
+ // Anything else — can't compile; fall back to the runtime renderer.
1137
+ bail(
1138
+ `\`{${_snippet(expr)}}\` is an expression the compiler can't render statically`,
1139
+ "compute it into a leading const, or move it behind an @expose prop",
1140
+ expr,
1141
+ );
1142
+ return;
1143
+ }
1144
+
1145
+ if (ts.isJsxElement(node)) {
1146
+ visitElement(node);
1147
+ return;
1148
+ }
1149
+ if (ts.isJsxSelfClosingElement(node)) {
1150
+ visitSelfClosing(node);
1151
+ return;
1152
+ }
1153
+ if (ts.isJsxFragment(node)) {
1154
+ visitChildren(node.children);
1155
+ return;
1156
+ }
1157
+
1158
+ bail(
1159
+ `an unsupported ${ts.SyntaxKind[child.kind]} node in the markup`,
1160
+ "rewrite it as plain JSX",
1161
+ child,
1162
+ );
1163
+ }
1164
+
1165
+ /**
1166
+ * Compile `<ErrorMessage for={this.field} class="…" />` to a reactive, self-hiding
1167
+ * `<span flow:error="field" flow:show="errors.field">`. `for` (preferred) or the legacy `name`
1168
+ * accepts `this.field`, `this.errors.field`, or a literal field name. Falls back to runtime
1169
+ * (bail) for anything it can't statically resolve.
1170
+ */
1171
+ function emitErrorMessage(attrs: ts.JsxAttributes): void {
1172
+ let field: string | null = null;
1173
+ let cls = "text-red-400 text-xs";
1174
+ let extra = "";
1175
+
1176
+ for (const attr of attrs.properties) {
1177
+ if (!ts.isJsxAttribute(attr) || !ts.isIdentifier(attr.name)) {
1178
+ bail(
1179
+ "<ErrorMessage> has a spread or computed attribute",
1180
+ "write its attributes out by name",
1181
+ attr,
1182
+ );
1183
+ return;
1184
+ }
1185
+ const name = attr.name.text;
1186
+ const init = attr.initializer;
1187
+
1188
+ if (name === "for" || name === "name") {
1189
+ if (init && ts.isStringLiteral(init)) {
1190
+ field = init.text;
1191
+ continue;
1192
+ }
1193
+ if (init && ts.isJsxExpression(init) && init.expression) {
1194
+ const e = init.expression;
1195
+ // this.errors.<field>
1196
+ if (
1197
+ ts.isPropertyAccessExpression(e) &&
1198
+ ts.isPropertyAccessExpression(e.expression) &&
1199
+ e.expression.expression.kind === ts.SyntaxKind.ThisKeyword &&
1200
+ e.expression.name.text === "errors"
1201
+ ) {
1202
+ field = e.name.text;
1203
+ continue;
1204
+ }
1205
+ // this.<field>
1206
+ if (_isThisProp(e)) {
1207
+ const prop = e.name.text;
1208
+ if (!members.readable.has(prop)) {
1209
+ throw new FlowValidationError(
1210
+ `[Flow] <ErrorMessage for={this.${prop}} /> — "${prop}" is not an @expose or @locked property.\n` +
1211
+ ` File: ${filename}`,
1212
+ );
1213
+ }
1214
+ field = prop;
1215
+ continue;
1216
+ }
1217
+ }
1218
+ bail(
1219
+ `<ErrorMessage ${name}={…}> names a field the compiler can't resolve`,
1220
+ `write ${name}="fieldName", or ${name}={this.fieldName}`,
1221
+ attr,
1222
+ );
1223
+ return;
1224
+ }
1225
+
1226
+ if (name === "class" || name === "className") {
1227
+ if (init && ts.isStringLiteral(init)) {
1228
+ cls = init.text;
1229
+ continue;
1230
+ }
1231
+ // dynamic class → let the runtime component render it
1232
+ bail("<ErrorMessage> has a computed class", "inline the class as a string literal", attr);
1233
+ return;
1234
+ }
1235
+
1236
+ // Forward other plain static attributes (id, data-*, etc.).
1237
+ if (init === undefined) {
1238
+ extra += ` ${name}`;
1239
+ continue;
1240
+ }
1241
+ if (ts.isStringLiteral(init)) {
1242
+ extra += ` ${name}="${_escAttr(init.text)}"`;
1243
+ continue;
1244
+ }
1245
+ bail(
1246
+ `<ErrorMessage ${name}={…}> is not a static value`,
1247
+ `write ${name}="…" as a string literal`,
1248
+ attr,
1249
+ );
1250
+ return;
1251
+ }
1252
+
1253
+ if (!field) {
1254
+ bail(
1255
+ "<ErrorMessage> has no `for` field",
1256
+ 'add for="fieldName" (or for={this.fieldName})',
1257
+ attrs,
1258
+ );
1259
+ return;
1260
+ }
1261
+ emitStatic(
1262
+ `<span flow:error="${field}" flow:show="errors.${field}" class="${_escAttr(cls)}"${extra}></span>`,
1263
+ );
1264
+ }
1265
+
1266
+ function visitElement(node: ts.JsxElement): void {
1267
+ const tag = _tagName(node.openingElement.tagName);
1268
+ if (tag === "ErrorMessage") {
1269
+ emitErrorMessage(node.openingElement.attributes);
1270
+ return;
1271
+ }
1272
+ // <For each={this.items} keyBy="id">{(item) => <JSX>}</For> — a reactive list. Compiles to an
1273
+ // Alpine <template x-for>, so appendOptimistic/removeOptimistic (and any client update to the
1274
+ // array) re-render it instantly, while server patches keep it authoritative.
1275
+ if (tag === "For") {
1276
+ emitStatic(_compileFor(node));
1277
+ return;
1278
+ }
1279
+ // <Link href hover current> — the Flow SPA-nav anchor. Compiles to
1280
+ // <a flow:navigate …> (see _visitAttrs link mode) instead of bailing.
1281
+ if (tag === "Link") {
1282
+ emitStatic("<a");
1283
+ _visitAttrs(node.openingElement.attributes, true);
1284
+ if (!canCompile) return;
1285
+ emitStatic(">");
1286
+ visitChildren(node.children);
1287
+ emitStatic("</a>");
1288
+ return;
1289
+ }
1290
+ if (!tag || _isComponent(tag)) {
1291
+ bail(
1292
+ `\`<${tag ?? "?"}>\` is a component, not an HTML element`,
1293
+ "inline its markup here, or let this page render through the runtime",
1294
+ node.openingElement.tagName,
1295
+ );
1296
+ return;
1297
+ }
1298
+
1299
+ emitStatic(`<${tag}`);
1300
+ const textProp = _visitAttrs(node.openingElement.attributes);
1301
+ if (!canCompile) return;
1302
+ emitStatic(">");
1303
+
1304
+ if (textProp !== null) {
1305
+ // text={this.x} overrides all children
1306
+ emitDynamic(`__esc(this.${textProp})`);
1307
+ } else {
1308
+ visitChildren(node.children);
1309
+ }
1310
+
1311
+ emitStatic(`</${tag}>`);
1312
+ }
1313
+
1314
+ function visitSelfClosing(node: ts.JsxSelfClosingElement): void {
1315
+ const tag = _tagName(node.tagName);
1316
+ if (tag === "ErrorMessage") {
1317
+ emitErrorMessage(node.attributes);
1318
+ return;
1319
+ }
1320
+ // <Link href … /> — SPA-nav anchor with no children. → <a flow:navigate …></a>
1321
+ if (tag === "Link") {
1322
+ emitStatic("<a");
1323
+ _visitAttrs(node.attributes, true);
1324
+ if (!canCompile) return;
1325
+ emitStatic("></a>");
1326
+ return;
1327
+ }
1328
+ if (!tag || _isComponent(tag)) {
1329
+ bail(
1330
+ `\`<${tag ?? "?"} />\` is a component, not an HTML element`,
1331
+ "inline its markup here, or let this page render through the runtime",
1332
+ node.tagName,
1333
+ );
1334
+ return;
1335
+ }
1336
+
1337
+ emitStatic(`<${tag}`);
1338
+ const textProp = _visitAttrs(node.attributes);
1339
+ if (!canCompile) return;
1340
+
1341
+ if (VOID_ELEMENTS.has(tag)) {
1342
+ emitStatic(">");
1343
+ } else {
1344
+ emitStatic(">");
1345
+ if (textProp !== null) emitDynamic(`__esc(this.${textProp})`);
1346
+ emitStatic(`</${tag}>`);
1347
+ }
1348
+ }
1349
+
1350
+ // ── <For> reactive list → Alpine <template x-for> ─────────────────────────────
1351
+
1352
+ function _forError(node: ts.Node, msg: string): never {
1353
+ void node;
1354
+ throw new FlowValidationError(
1355
+ `[Flow] <For>: ${msg}\n` +
1356
+ ` Shape: <For each={this.arrayProp} keyBy="idField">{(item) => <li>…</li>}</For>.\n` +
1357
+ ` Item templates support element structure, static attrs, class/className, reactive attrs,\n` +
1358
+ ` on* arrow handlers, and {item.field} text. For anything else, drop to a raw Alpine\n` +
1359
+ ` <template x-for="item in $flow.arrayProp"> escape hatch.\n File: ${filename}`,
1360
+ );
1361
+ }
1362
+
1363
+ const _rewriteThis = _rewriteClientExpr;
1364
+
1365
+ /** Compile one attribute of a <For> item element to an Alpine directive (x-for scope). */
1366
+ function _compileItemAttr(a: ts.JsxAttributeLike): string {
1367
+ if (!ts.isJsxAttribute(a) || !ts.isIdentifier(a.name)) {
1368
+ _forError(a, "unsupported attribute in a <For> item");
1369
+ }
1370
+ const attr = a as ts.JsxAttribute;
1371
+ const name = attr.name.getText();
1372
+ if (name === "key") return ""; // consumed by the <template> :key
1373
+ const init = attr.initializer;
1374
+ if (init === undefined) return ` ${name}`; // boolean attribute
1375
+ if (ts.isStringLiteral(init)) return ` ${name}="${_escAttr(init.text)}"`;
1376
+ if (!ts.isJsxExpression(init) || !init.expression) return "";
1377
+ const expr = init.expression;
1378
+ const event = name.startsWith("on") && name.length > 2 ? name.slice(2).toLowerCase() : null;
1379
+ if (event) {
1380
+ // Alpine @event — evaluated in the x-for scope (loop var + $flow). Unwrap arrow handlers to
1381
+ // their body (a bare `() => expr` would otherwise never fire).
1382
+ if (ts.isArrowFunction(expr)) {
1383
+ if (ts.isBlock(expr.body)) {
1384
+ _forError(
1385
+ a,
1386
+ "block-body arrow handlers aren't supported in a <For> item — use one expression",
1387
+ );
1388
+ }
1389
+ return ` @${event}="${_escAttr(_rewriteThis(expr.body.getText()))}"`;
1390
+ }
1391
+ return ` @${event}="${_escAttr(_rewriteThis(expr.getText()))}"`;
1392
+ }
1393
+ // class/className → :class; any other value expr → reactive :attr.
1394
+ const alpine = name === "className" ? "class" : name;
1395
+ return ` :${alpine}="${_escAttr(_rewriteThis(expr.getText()))}"`;
1396
+ }
1397
+
1398
+ /** Compile one node of a <For> item template to Alpine-directive HTML. */
1399
+ function _compileItemNode(node: ts.Node): string {
1400
+ if (ts.isJsxText(node)) {
1401
+ return node.text.trim() ? _escHtml(node.text) : "";
1402
+ }
1403
+ if (ts.isJsxExpression(node)) {
1404
+ return node.expression
1405
+ ? `<span x-text="${_escAttr(_rewriteThis(node.expression.getText()))}"></span>`
1406
+ : "";
1407
+ }
1408
+ if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) {
1409
+ const opening = ts.isJsxElement(node) ? node.openingElement : node;
1410
+ const tag = _tagName(opening.tagName);
1411
+ if (!tag || _isComponent(tag)) {
1412
+ _forError(node, `nested component <${tag}> isn't supported in a <For> item`);
1413
+ }
1414
+ let attrsHtml = "";
1415
+ for (const a of opening.attributes.properties) attrsHtml += _compileItemAttr(a);
1416
+
1417
+ if (ts.isJsxSelfClosingElement(node)) {
1418
+ return VOID_ELEMENTS.has(tag) ? `<${tag}${attrsHtml}>` : `<${tag}${attrsHtml}></${tag}>`;
1419
+ }
1420
+ // A single `{item.field}` child → x-text on the element (cleaner than a wrapper span).
1421
+ const meaningful = node.children.filter((c) => !(ts.isJsxText(c) && !c.text.trim()));
1422
+ if (
1423
+ meaningful.length === 1 &&
1424
+ ts.isJsxExpression(meaningful[0]!) &&
1425
+ meaningful[0]!.expression
1426
+ ) {
1427
+ const src = _rewriteThis((meaningful[0] as ts.JsxExpression).expression!.getText());
1428
+ return `<${tag}${attrsHtml} x-text="${_escAttr(src)}"></${tag}>`;
1429
+ }
1430
+ const inner = node.children.map(_compileItemNode).join("");
1431
+ return `<${tag}${attrsHtml}>${inner}</${tag}>`;
1432
+ }
1433
+ return "";
1434
+ }
1435
+
1436
+ function _compileFor(node: ts.JsxElement): string {
1437
+ let eachProp: string | null = null;
1438
+ let keyBy: string | null = null;
1439
+ for (const a of node.openingElement.attributes.properties) {
1440
+ if (!ts.isJsxAttribute(a) || !ts.isIdentifier(a.name))
1441
+ _forError(node, "unsupported <For> attribute");
1442
+ const n = (a as ts.JsxAttribute).name.getText();
1443
+ const init = (a as ts.JsxAttribute).initializer;
1444
+ if (n === "each") {
1445
+ if (init && ts.isJsxExpression(init) && init.expression && _isThisProp(init.expression)) {
1446
+ eachProp = (init.expression as ts.PropertyAccessExpression).name.text;
1447
+ } else _forError(node, "each must be {this.<arrayProp>}");
1448
+ } else if (n === "keyBy") {
1449
+ const lit =
1450
+ init && ts.isStringLiteral(init)
1451
+ ? init.text
1452
+ : init &&
1453
+ ts.isJsxExpression(init) &&
1454
+ init.expression &&
1455
+ ts.isStringLiteral(init.expression)
1456
+ ? init.expression.text
1457
+ : null;
1458
+ if (lit === null) _forError(node, 'keyBy must be a string field name, e.g. keyBy="id"');
1459
+ keyBy = lit;
1460
+ } else {
1461
+ _forError(node, `unsupported <For> attribute "${n}"`);
1462
+ }
1463
+ }
1464
+ if (!eachProp) _forError(node, "needs each={this.<arrayProp>}");
1465
+
1466
+ const exprChild = node.children.find((c) => ts.isJsxExpression(c) && c.expression);
1467
+ const arrow = exprChild && ts.isJsxExpression(exprChild) ? exprChild.expression : undefined;
1468
+ if (!arrow || !ts.isArrowFunction(arrow) || arrow.parameters.length < 1) {
1469
+ _forError(node, "child must be an arrow: {(item) => <JSX>}");
1470
+ }
1471
+ const varName = arrow.parameters[0]!.name.getText();
1472
+ const body = ts.isParenthesizedExpression(arrow.body) ? arrow.body.expression : arrow.body;
1473
+ let itemHtml: string;
1474
+ if (ts.isJsxFragment(body)) itemHtml = body.children.map(_compileItemNode).join("");
1475
+ else if (ts.isJsxElement(body) || ts.isJsxSelfClosingElement(body))
1476
+ itemHtml = _compileItemNode(body);
1477
+ else return _forError(node, "the item arrow must return a JSX element");
1478
+
1479
+ const keyAttr = keyBy ? ` :key="${_escAttr(`${varName}.${keyBy}`)}"` : "";
1480
+ return `<template x-for="${_escAttr(`${varName} in $flow.${eachProp}`)}"${keyAttr}>${itemHtml}</template>`;
1481
+ }
1482
+
1483
+ /**
1484
+ * Process all attributes on an element, emitting static/dynamic parts.
1485
+ * Returns the prop name of a `text={this.x}` attribute (if found),
1486
+ * so the caller can inject the current value as element children.
1487
+ * Returns null when no `text` prop is present.
1488
+ * Sets canCompile=false and returns null early on unsupported patterns.
1489
+ */
1490
+ function _visitAttrs(attrs: ts.JsxAttributes, link = false): string | null {
1491
+ let textProp: string | null = null;
1492
+
1493
+ // <Link> → <a flow:navigate>: the navigate directive is always present; `hover`
1494
+ // and `current` are translated to their flow: directives in the loop below.
1495
+ if (link) emitStatic(" flow:navigate");
1496
+
1497
+ // Sync-timing modifiers for the input binding (sibling boolean attrs: live / blur).
1498
+ const _hasBoolAttr = (n: string) =>
1499
+ attrs.properties.some(
1500
+ (a) =>
1501
+ ts.isJsxAttribute(a) &&
1502
+ ts.isIdentifier(a.name) &&
1503
+ a.name.text === n &&
1504
+ a.initializer === undefined,
1505
+ );
1506
+ const modelMod = _hasBoolAttr("blur") ? ".blur" : _hasBoolAttr("live") ? ".live" : "";
1507
+ // `number` / `trim` input modifiers → data-flow-* markers the bridge reads when coercing the
1508
+ // value (client-side data hygiene, so the server sees a real number / trimmed string).
1509
+ const modelExtra =
1510
+ (_hasBoolAttr("number") ? " data-flow-number" : "") +
1511
+ (_hasBoolAttr("trim") ? " data-flow-trim" : "");
1512
+
1513
+ // The `transition` preset on this element (fade when bare / no string), else null.
1514
+ const _transitionPreset = (): string | null => {
1515
+ for (const a of attrs.properties) {
1516
+ if (!ts.isJsxAttribute(a) || !ts.isIdentifier(a.name) || a.name.text !== "transition")
1517
+ continue;
1518
+ const i = a.initializer;
1519
+ if (i === undefined) return "fade";
1520
+ if (ts.isStringLiteral(i)) return i.text || "fade";
1521
+ if (ts.isJsxExpression(i) && i.expression && ts.isStringLiteral(i.expression))
1522
+ return i.expression.text || "fade";
1523
+ return "fade";
1524
+ }
1525
+ return null;
1526
+ };
1527
+ /** True if this element has a `show=` prop at all. When paired with `transition`, the show is
1528
+ * driven by Alpine `x-show` + Alpine's real `x-transition` engine (see _emitShowTransition),
1529
+ * so the `transition` prop must NOT also emit a bare `flow:transition` (which is morph-enter). */
1530
+ const _hasShowAttr = (): boolean =>
1531
+ attrs.properties.some(
1532
+ (a) => ts.isJsxAttribute(a) && ts.isIdentifier(a.name) && a.name.text === "show",
1533
+ );
1534
+
1535
+ /** Alpine class-based x-transition directives for a `show=` element, reusing the shared
1536
+ * `flow-t-<preset>` CSS (the preset class carries the transition timing AND the hidden state
1537
+ * via `.flow-t-<preset>.flow-t-out`). Alpine owns the enter/leave lifecycle — no custom JS. */
1538
+ const _emitShowTransition = (preset: string): string =>
1539
+ ` x-transition:enter="flow-t-${preset}" x-transition:enter-start="flow-t-out"` +
1540
+ ` x-transition:leave="flow-t-${preset}" x-transition:leave-end="flow-t-out"`;
1541
+
1542
+ for (const attr of attrs.properties) {
1543
+ if (!canCompile) return null;
1544
+
1545
+ // <Link> prop translations (link mode only).
1546
+ if (link && ts.isJsxAttribute(attr) && ts.isIdentifier(attr.name)) {
1547
+ const ln = attr.name.text;
1548
+ if (ln === "hover" && attr.initializer === undefined) {
1549
+ emitStatic(" flow:navigate.hover");
1550
+ continue;
1551
+ }
1552
+ if (ln === "current") {
1553
+ const ci = attr.initializer;
1554
+ // current={false} → opt out of the auto active class; current={true} → default (skip).
1555
+ if (ci && ts.isJsxExpression(ci) && ci.expression?.kind === ts.SyntaxKind.FalseKeyword) {
1556
+ emitStatic(" flow:current.ignore");
1557
+ continue;
1558
+ }
1559
+ if (ci && ts.isJsxExpression(ci) && ci.expression?.kind === ts.SyntaxKind.TrueKeyword) {
1560
+ continue;
1561
+ }
1562
+ // current="active-class" → falls through to STRING_DIRECTIVES (current → flow:current).
1563
+ }
1564
+ }
1565
+
1566
+ // Spread: {...foo} — can't compile
1567
+ if (ts.isJsxSpreadAttribute(attr)) {
1568
+ return bail(
1569
+ `\`{...${_snippet(attr.expression, 20)}}\` spreads attributes`,
1570
+ "write the attributes out by name",
1571
+ attr,
1572
+ );
1573
+ }
1574
+ if (!ts.isJsxAttribute(attr)) {
1575
+ return bail("an attribute the compiler can't read", 'write it as `name="value"`', attr);
1576
+ }
1577
+
1578
+ const name = ts.isIdentifier(attr.name) ? attr.name.text : null;
1579
+ if (!name) {
1580
+ return bail("an attribute with a namespaced name", "use a plain attribute name", attr.name);
1581
+ }
1582
+
1583
+ const init = attr.initializer;
1584
+
1585
+ // `transition` on a `show=` element is animated by Alpine's x-transition (emitted with the
1586
+ // show binding), so skip the flow:transition emission here (that's the morph-enter feature,
1587
+ // for `transition` WITHOUT `show=`).
1588
+ if (name === "transition" && _hasShowAttr()) continue;
1589
+
1590
+ // ── Boolean attributes (no value) ──────────────────────────────
1591
+ if (init === undefined) {
1592
+ // live / blur are input-binding modifiers consumed by value/checked — never emitted.
1593
+ if (name === "live" || name === "blur" || name === "number" || name === "trim") continue;
1594
+ if (name in BOOLEAN_DIRECTIVES) {
1595
+ emitStatic(` ${BOOLEAN_DIRECTIVES[name]}`);
1596
+ } else {
1597
+ emitStatic(` ${name}`);
1598
+ }
1599
+ continue;
1600
+ }
1601
+
1602
+ // ── String literal value ───────────────────────────────────────
1603
+ if (ts.isStringLiteral(init)) {
1604
+ const htmlName = ATTR_RENAMES[name] ?? STRING_DIRECTIVES[name] ?? name;
1605
+ emitStatic(` ${htmlName}="${_escAttr(init.text)}"`);
1606
+ continue;
1607
+ }
1608
+
1609
+ // ── Expression value ──────────────────────────────────────────
1610
+ if (!ts.isJsxExpression(init) || !init.expression) continue;
1611
+ const expr = init.expression;
1612
+
1613
+ // text={this.x} — directive + dynamic child injection
1614
+ if (name === "text") {
1615
+ // text={$flow.store.ui.dark ? "…" : "…"} — a client-reactive value. flow:text reads the
1616
+ // component snapshot, which the store isn't part of, so emit Alpine-native x-text. The
1617
+ // element's literal children stay as the server-rendered fallback until Alpine swaps them.
1618
+ if (_referencesGel(expr) || _containsClientMagic(expr)) {
1619
+ const src = _clientMagicSrc(expr, members, filename);
1620
+ _assertCspExpr(src, filename);
1621
+ emitStatic(` x-text="${_escAttr(src)}"`);
1622
+ continue;
1623
+ }
1624
+ if (!_isThisProp(expr)) {
1625
+ return bail(
1626
+ `\`text={${_snippet(expr, 24)}}\` is not a \`this.<prop>\` read`,
1627
+ "bind text to an @expose or @locked property",
1628
+ attr,
1629
+ );
1630
+ }
1631
+ const prop = (expr as ts.PropertyAccessExpression).name.text;
1632
+ if (!members.readable.has(prop)) {
1633
+ const hint = members.computed.has(prop)
1634
+ ? ` "${prop}" is @computed — it isn't in the snapshot, so it can't be bound reactively. ` +
1635
+ `Use {this.${prop}} for a static value, or make it @expose.`
1636
+ : "";
1637
+ throw new FlowValidationError(
1638
+ `[Flow] text={this.${prop}} — "${prop}" does not match any @expose or @locked property.${hint}\n` +
1639
+ ` File: ${filename}`,
1640
+ );
1641
+ }
1642
+ emitStatic(` flow:text="${prop}"`);
1643
+ textProp = prop;
1644
+ continue;
1645
+ }
1646
+
1647
+ // error={this.errors.field} — validation message binding (flow:error + flow:show).
1648
+ if (
1649
+ name === "error" &&
1650
+ ts.isPropertyAccessExpression(expr) &&
1651
+ ts.isPropertyAccessExpression(expr.expression) &&
1652
+ expr.expression.expression.kind === ts.SyntaxKind.ThisKeyword &&
1653
+ expr.expression.name.text === "errors"
1654
+ ) {
1655
+ const field = expr.name.text;
1656
+ emitStatic(` flow:error="${field}" flow:show="errors.${field}"`);
1657
+ // SSR initial message is injected as a text child at render; keep it simple
1658
+ // (the field's first message is rendered client-side reactively via flow:show).
1659
+ continue;
1660
+ }
1661
+
1662
+ // onClick={this.method} / onClick={(e) => this.x = 0} / onSubmit={flow(this.method).prevent}
1663
+ // Bare `submit={…}` on a <form> is a recognised shorthand for onSubmit (→ flow:submit).
1664
+ const eventName =
1665
+ name.startsWith("on") && name.length > 2
1666
+ ? name.slice(2).toLowerCase()
1667
+ : name === "submit"
1668
+ ? "submit"
1669
+ : null;
1670
+ if (eventName !== null) {
1671
+ const event = eventName;
1672
+ // onClick={this.method} — server action (named method reference).
1673
+ if (_isThisProp(expr)) {
1674
+ emitStatic(` flow:${event}="${(expr as ts.PropertyAccessExpression).name.text}"`);
1675
+ continue;
1676
+ }
1677
+ // onClick={(e) => this.x = 0} — bare arrow → client expression, emitted verbatim
1678
+ // (this.→$flow.); the bridge auto-invokes it with the event. Handlers stay on the flow:*
1679
+ // form for consistency with the rest of the directive surface (loading/confirm/dirty all
1680
+ // key off it). The <For> path below is the one exception — a loop handler must be an
1681
+ // Alpine `@event`, because the loop variable only exists in Alpine's x-for scope.
1682
+ if (ts.isArrowFunction(expr)) {
1683
+ if (_cspSafe) {
1684
+ // CSP-safe: emit the bare expression body (no arrow wrapper). An event
1685
+ // param or a block body has no CSP-safe inline form → compile error.
1686
+ if (expr.parameters.length > 0) {
1687
+ throw new FlowValidationError(
1688
+ `[Flow CSP] onClick={(e) => …} can't access the event in CSP-safe mode.\n` +
1689
+ ` Move it to an @expose action (onClick={this.method}).\n File: ${filename}`,
1690
+ );
1691
+ }
1692
+ if (!ts.isExpression(expr.body)) {
1693
+ throw new FlowValidationError(
1694
+ `[Flow CSP] block-body arrows (() => { … }) aren't allowed in CSP-safe mode.\n` +
1695
+ ` Use a single expression or an @expose action.\n File: ${filename}`,
1696
+ );
1697
+ }
1698
+ const body = _rewriteClientExpr(expr.body.getText());
1699
+ _assertCspExpr(body, filename);
1700
+ emitStatic(` flow:${event}="${_escAttr(body)}"`);
1701
+ continue;
1702
+ }
1703
+ const src = _rewriteClientExpr(expr.getText());
1704
+ emitStatic(` flow:${event}="${_escAttr(src)}"`);
1705
+ continue;
1706
+ }
1707
+ // flow(() => …) with modifiers — let the runtime emit it (rare); bail this page.
1708
+ if (_findFlowArrow(expr)) {
1709
+ return bail(
1710
+ `\`${name}={flow(() => …)}\` carries modifiers the compiler can't emit`,
1711
+ "call the handler directly, or use flow(this.method) with modifiers",
1712
+ attr,
1713
+ );
1714
+ }
1715
+ // flow(this.method) / flow(this.method).mod1.mod2 — server action with modifiers.
1716
+ const flowPropExpr = _extractFlowProp(expr);
1717
+ if (flowPropExpr) {
1718
+ const method = flowPropExpr.name.text;
1719
+ const mods = _extractFlowModifiers(expr);
1720
+ const base = `flow:${event}`;
1721
+ emitStatic(` ${mods.length ? `${base}.${mods.join(".")}` : base}="${method}"`);
1722
+ continue;
1723
+ }
1724
+ // Non-this, non-flow ref — skip silently.
1725
+ continue;
1726
+ }
1727
+
1728
+ // show={$flow.$store.ui.open} / show={this.currentUrl() === "/"} — visibility driven by a
1729
+ // client-reactive value. Emit Alpine-native x-show (NOT flow:show, which only re-runs on
1730
+ // server patches), so it toggles instantly on the client. `show={this.prop}` still takes
1731
+ // the flow:show path below (server-owned boolean).
1732
+ if (
1733
+ name === "show" &&
1734
+ !_isThisProp(expr) &&
1735
+ (_referencesGel(expr) || _containsClientMagic(expr))
1736
+ ) {
1737
+ const src = _clientMagicSrc(expr, members, filename);
1738
+ _assertCspExpr(src, filename);
1739
+ emitStatic(` x-show="${_escAttr(src)}"`);
1740
+ const preset = _transitionPreset();
1741
+ if (preset) emitStatic(_emitShowTransition(preset));
1742
+ continue;
1743
+ }
1744
+
1745
+ // className={this.currentUrl() === "/" ? "on" : ""} / class={$flow.$store.ui.dark ? "dark" : ""}
1746
+ // — any attribute expression that references a client magic OR the `$flow` runtime compiles
1747
+ // to a reactive Alpine binding (:attr) with this.→$flow. ($flow left as-is), so it
1748
+ // re-evaluates client-side. Checked before the value/checked/class handlers so such an
1749
+ // expression isn't bailed.
1750
+ if (_containsClientMagic(expr) || _referencesGel(expr)) {
1751
+ const src = _clientMagicSrc(expr, members, filename);
1752
+ _assertCspExpr(src, filename);
1753
+ const htmlName = ATTR_RENAMES[name] ?? name;
1754
+ emitStatic(` :${htmlName}="${_escAttr(src)}"`);
1755
+ continue;
1756
+ }
1757
+
1758
+ // value={this.form.field} — nested binding for Form objects. The root (e.g.
1759
+ // `form`) must be an @expose prop; emits flow:model="form.field" so the client
1760
+ // edits the nested field and the server fills the Form on the round-trip.
1761
+ if (
1762
+ (name === "value" || name === "checked") &&
1763
+ ts.isPropertyAccessExpression(expr) &&
1764
+ _isThisProp(expr.expression)
1765
+ ) {
1766
+ const root = (expr.expression as ts.PropertyAccessExpression).name.text;
1767
+ const field = expr.name.text;
1768
+ if (members.exposed.has(root) && !members.locked.has(root)) {
1769
+ if (name === "value") {
1770
+ emitStatic(` flow:model${modelMod}="${root}.${field}"${modelExtra} value="`);
1771
+ emitDynamic(`__escAttr(this.${root}.${field})`);
1772
+ emitStatic('"');
1773
+ } else {
1774
+ emitStatic(` flow:model${modelMod}="${root}.${field}"${modelExtra}`);
1775
+ }
1776
+ continue;
1777
+ }
1778
+ }
1779
+
1780
+ // value={this.x} / checked={this.x} — the input binding (replaces `bind`).
1781
+ // @expose → two-way (flow:model); @locked → reactive read-only (:value + readonly).
1782
+ if ((name === "value" || name === "checked") && _isThisProp(expr)) {
1783
+ const prop = (expr as ts.PropertyAccessExpression).name.text;
1784
+ if (members.exposed.has(prop)) {
1785
+ if (name === "value") {
1786
+ emitStatic(` flow:model${modelMod}="${prop}"${modelExtra} value="`);
1787
+ emitDynamic(`__escAttr(this.${prop})`);
1788
+ emitStatic('"');
1789
+ } else {
1790
+ emitStatic(` flow:model${modelMod}="${prop}"${modelExtra}`); // bridge syncs checkbox .checked from state
1791
+ }
1792
+ continue;
1793
+ }
1794
+ if (members.locked.has(prop)) {
1795
+ if (name === "value") {
1796
+ emitStatic(` :value="$flow.${prop}" readonly value="`);
1797
+ emitDynamic(`__escAttr(this.${prop})`);
1798
+ emitStatic('"');
1799
+ } else {
1800
+ emitStatic(` :checked="$flow.${prop}" disabled`);
1801
+ }
1802
+ continue;
1803
+ }
1804
+ // Not a snapshot prop → fall through to the generic server-rendered value.
1805
+ }
1806
+
1807
+ // poll={{ every: '1s', action: this.method, keepAlive?: true, visible?: true }}
1808
+ if (name === "poll" && ts.isObjectLiteralExpression(expr)) {
1809
+ let every = "";
1810
+ let keepAlive = false;
1811
+ let visible = false;
1812
+ let actionMethod = "$refresh";
1813
+ for (const prop of expr.properties) {
1814
+ if (!ts.isPropertyAssignment(prop)) continue;
1815
+ const key = ts.isIdentifier(prop.name) ? prop.name.text : "";
1816
+ const val = prop.initializer;
1817
+ if (key === "every" && ts.isStringLiteral(val)) every = val.text;
1818
+ else if (key === "action" && _isThisProp(val))
1819
+ actionMethod = (val as ts.PropertyAccessExpression).name.text;
1820
+ else if (key === "keepAlive" && val.kind === ts.SyntaxKind.TrueKeyword) keepAlive = true;
1821
+ else if (key === "visible" && val.kind === ts.SyntaxKind.TrueKeyword) visible = true;
1822
+ }
1823
+ let attrName = "flow:poll";
1824
+ if (every) attrName += `.${every}`;
1825
+ if (keepAlive) attrName += ".keep-alive";
1826
+ if (visible) attrName += ".visible";
1827
+ emitStatic(` ${attrName}="${actionMethod}"`);
1828
+ continue;
1829
+ }
1830
+
1831
+ // show={this.prop} transition — animate via Alpine's real x-transition engine. The prop is
1832
+ // reactive on the client (`$flow.prop` reads comp.reactive[prop]), so x-show + x-transition
1833
+ // handles both visibility and enter/leave natively; no bridge flow:show / custom animator.
1834
+ // Plain show={this.prop} (no transition) stays on flow:show for full bridge integration
1835
+ // (modal Escape, showImportant, server-patch sync).
1836
+ if (name === "show" && _isThisProp(expr)) {
1837
+ const prop = (expr as ts.PropertyAccessExpression).name.text;
1838
+ if (!members.readable.has(prop)) {
1839
+ throw new FlowValidationError(
1840
+ `[Flow] show={this.${prop}} — "${prop}" does not match any @expose or @locked property.\n` +
1841
+ ` File: ${filename}`,
1842
+ );
1843
+ }
1844
+ const preset = _transitionPreset();
1845
+ if (preset) {
1846
+ emitStatic(` x-show="$flow.${prop}"` + _emitShowTransition(preset));
1847
+ } else {
1848
+ emitStatic(` flow:show="${prop}"`);
1849
+ }
1850
+ continue;
1851
+ }
1852
+
1853
+ // show={this.x} — simple this-prop expression directives
1854
+ if (name in STRING_DIRECTIVES && _isThisProp(expr)) {
1855
+ const prop = (expr as ts.PropertyAccessExpression).name.text;
1856
+ if (!members.readable.has(prop)) {
1857
+ throw new FlowValidationError(
1858
+ `[Flow] ${name}={this.${prop}} — "${prop}" does not match any @expose or @locked property.\n` +
1859
+ ` File: ${filename}`,
1860
+ );
1861
+ }
1862
+ emitStatic(` ${STRING_DIRECTIVES[name]}="${prop}"`);
1863
+ continue;
1864
+ }
1865
+
1866
+ // class={`base ${this.x ? 'a' : 'b'}`} — reactive class (Workstream G). When a
1867
+ // template literal's substitutions reference only snapshot props, split the literal
1868
+ // parts into a static `class` and the dynamic parts into Alpine's `:class` array, so
1869
+ // the class updates client-side with no round-trip. Decomposition (static vs dynamic)
1870
+ // avoids Alpine's class-merge duplication (static base never overlaps computed values).
1871
+ if (
1872
+ (name === "class" || name === "className") &&
1873
+ ts.isTemplateExpression(expr) &&
1874
+ _isSnapshotOnly(expr)
1875
+ ) {
1876
+ const staticParts: string[] = [];
1877
+ const dynamic: string[] = [];
1878
+ if (expr.head.text) staticParts.push(expr.head.text);
1879
+ for (const span of expr.templateSpans) {
1880
+ const part = _rewriteClientExpr(span.expression.getText());
1881
+ _assertCspExpr(part, filename); // CSP: substitutions must be subset-safe (no nested `…`/=>)
1882
+ dynamic.push(part);
1883
+ if (span.literal.text) staticParts.push(span.literal.text);
1884
+ }
1885
+ const staticClass = staticParts.join(" ").split(/\s+/).filter(Boolean).join(" ");
1886
+ if (staticClass) emitStatic(` class="${_escAttr(staticClass)}"`);
1887
+ emitStatic(` :class="${_escAttr(`[${dynamic.join(", ")}]`)}"`);
1888
+ continue;
1889
+ }
1890
+
1891
+ // class={['base', cond && 'extra']} / class={this.x} — not statically decomposable → runtime.
1892
+ if (name === "class" || name === "className") {
1893
+ return bail(
1894
+ `\`${name}={${_snippet(expr, 28)}}\` is not a static class string`,
1895
+ "inline the classes as a string literal, or build them in a template literal",
1896
+ attr,
1897
+ );
1898
+ }
1899
+
1900
+ // style={{ ... }} object — can't compile
1901
+ if (name === "style") {
1902
+ return bail(
1903
+ "`style={{ … }}` is an object literal",
1904
+ 'write style="…" as a string, or move it into a class',
1905
+ attr,
1906
+ );
1907
+ }
1908
+
1909
+ // Boolean true/false literal
1910
+ if (expr.kind === ts.SyntaxKind.TrueKeyword) {
1911
+ emitStatic(` ${name}`);
1912
+ continue;
1913
+ }
1914
+ if (expr.kind === ts.SyntaxKind.FalseKeyword) {
1915
+ // false → omit attribute
1916
+ continue;
1917
+ }
1918
+
1919
+ // This-prop for arbitrary attributes (e.g. value={this.x})
1920
+ if (_isThisProp(expr)) {
1921
+ const prop = (expr as ts.PropertyAccessExpression).name.text;
1922
+ if (!members.readable.has(prop)) {
1923
+ throw new FlowValidationError(
1924
+ `[Flow] ${name}={this.${prop}} — "${prop}" does not match any @expose or @locked property.\n` +
1925
+ ` File: ${filename}`,
1926
+ );
1927
+ }
1928
+ const htmlName = ATTR_RENAMES[name] ?? name;
1929
+ emitStatic(` ${htmlName}="`);
1930
+ emitDynamic(`__escAttr(this.${prop})`);
1931
+ emitStatic('"');
1932
+ continue;
1933
+ }
1934
+
1935
+ // Anything else (flow() chains, ternaries, etc.) — can't compile
1936
+ return bail(
1937
+ ts.isNumericLiteral(expr)
1938
+ ? `\`${name}={${expr.text}}\` is a numeric literal`
1939
+ : `\`${name}={${_snippet(expr, 28)}}\` is not a static value`,
1940
+ ts.isNumericLiteral(expr)
1941
+ ? `write ${name}="${expr.text}"`
1942
+ : `use a string literal, or bind it to an @expose property (${name}={this.x})`,
1943
+ attr,
1944
+ );
1945
+ }
1946
+
1947
+ return textProp;
1948
+ }
1949
+
1950
+ // ── Kick off ──────────────────────────────────────────────────────────────
1951
+ visitChild(returnExpr as unknown as ts.JsxChild);
1952
+ if (!canCompile) return null;
1953
+ flush();
1954
+ if (parts.length === 0) {
1955
+ return bail("render() produced no markup", "return the page's JSX", returnExpr);
1956
+ }
1957
+
1958
+ const htmlExpr = parts.join(" + ");
1959
+ const body = ` return { html: ${htmlExpr} };`;
1960
+ return leading ? ` ${leading}\n${body}` : body;
1961
+ }
1962
+
1963
+ // ── Helpers ───────────────────────────────────────────────────────────────────
1964
+
1965
+ function _isThisProp(node: ts.Node): node is ts.PropertyAccessExpression {
1966
+ return ts.isPropertyAccessExpression(node) && node.expression.kind === ts.SyntaxKind.ThisKeyword;
1967
+ }
1968
+
1969
+ /**
1970
+ * If `expr` references a client magic anywhere — `this.currentUrl(…)` /
1971
+ * `this.navigateCurrent(…)`, whether the whole value or nested in a larger
1972
+ * expression (`this.currentUrl() === "/" ? "on" : ""`) — return the magic name;
1973
+ * otherwise null. Such an expression compiles to a reactive client binding.
1974
+ */
1975
+ function _containsClientMagic(node: ts.Node): string | null {
1976
+ let found: string | null = null;
1977
+ const visit = (n: ts.Node): void => {
1978
+ if (found) return;
1979
+ if (
1980
+ ts.isPropertyAccessExpression(n) &&
1981
+ n.expression.kind === ts.SyntaxKind.ThisKeyword &&
1982
+ CLIENT_MAGICS.has(n.name.text)
1983
+ ) {
1984
+ found = n.name.text;
1985
+ return;
1986
+ }
1987
+ ts.forEachChild(n, visit);
1988
+ };
1989
+ visit(node);
1990
+ return found;
1991
+ }
1992
+
1993
+ /**
1994
+ * True if `node` reads the `$flow` client runtime object (`$flow.$store.ui.dark`,
1995
+ * `$flow.count`, `$flow.$parent.save()`) anywhere — used to route the expression to an
1996
+ * Alpine-native reactive binding (x-text / :attr / :class / x-show) so it updates on the
1997
+ * client with NO server round-trip. Matches `$flow` only as an access ROOT, never a property
1998
+ * name. `$flow` is written directly (it IS the runtime object), so it needs no rewrite.
1999
+ */
2000
+ function _referencesGel(node: ts.Node): boolean {
2001
+ let found = false;
2002
+ const visit = (n: ts.Node): void => {
2003
+ if (found) return;
2004
+ if (ts.isIdentifier(n) && n.text === "$flow") {
2005
+ const p = n.parent;
2006
+ // Skip when `$flow` is the member NAME (`obj.$flow`), not the object root.
2007
+ if (!(p && ts.isPropertyAccessExpression(p) && p.name === n)) {
2008
+ found = true;
2009
+ return;
2010
+ }
2011
+ }
2012
+ ts.forEachChild(n, visit);
2013
+ };
2014
+ visit(node);
2015
+ return found;
2016
+ }
2017
+
2018
+ /** True if `node` contains any JSX element/fragment (so it isn't a pure value expression). */
2019
+ function _containsJsx(node: ts.Node): boolean {
2020
+ let found = false;
2021
+ const visit = (n: ts.Node): void => {
2022
+ if (found) return;
2023
+ if (ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n) || ts.isJsxFragment(n)) {
2024
+ found = true;
2025
+ return;
2026
+ }
2027
+ ts.forEachChild(n, visit);
2028
+ };
2029
+ visit(node);
2030
+ return found;
2031
+ }
2032
+
2033
+ /**
2034
+ * Build the client text for a client-reactive expression — one that references a client
2035
+ * magic (`this.currentUrl()`) and/or the `$flow` runtime (`$flow.store.ui.dark`). Applies the
2036
+ * AOT client rewrite (bare `$flow.<magic>`→`$flow.$<magic>`, then `this.`→`$flow.`) after
2037
+ * validating that any non-magic `this.x` it references is a readable (@expose/@locked) prop —
2038
+ * else `$flow.x` would resolve to a server-action stub at runtime.
2039
+ */
2040
+ function _clientMagicSrc(expr: ts.Expression, members: MemberInfo, filename: string): string {
2041
+ for (const { name: ref } of _extractThisRefs(expr)) {
2042
+ if (CLIENT_MAGICS.has(ref)) continue;
2043
+ if (!members.readable.has(ref)) {
2044
+ throw new FlowValidationError(
2045
+ `[Flow] a client-reactive expression references this.${ref}, which is not an @expose or @locked property.\n` +
2046
+ ` File: ${filename}`,
2047
+ );
2048
+ }
2049
+ }
2050
+ return _rewriteClientExpr(expr.getText());
2051
+ }
2052
+
2053
+ function _isComponent(tag: string): boolean {
2054
+ const c = tag.charCodeAt(0);
2055
+ return c >= 65 && c <= 90; // A–Z
2056
+ }
2057
+
2058
+ function _tagName(tagNameNode: ts.JsxTagNameExpression): string | null {
2059
+ if (ts.isIdentifier(tagNameNode)) return tagNameNode.text;
2060
+ // member expressions like React.Fragment — skip
2061
+ return null;
2062
+ }
2063
+
2064
+ function _escHtml(s: string): string {
2065
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
2066
+ }
2067
+
2068
+ function _escAttr(s: string): string {
2069
+ return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
2070
+ }