@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,1272 @@
1
+ // ── @zerotal/flow JSX runtime ───────────────────────────────────────────────────
2
+ //
3
+ // The `@zerotal/flow/jsx-runtime` entry — the automatic-JSX factory module the
4
+ // TypeScript/Bun toolchain resolves to when compiling `.tsx`. Configured via tsconfig:
5
+ // `{ "jsxImportSource": "@zerotal/flow" }`. It renders JSX straight to an HTML string
6
+ // (server-side), wiring Flow directives and embedding child Component placeholders.
7
+ // Apps do not call `jsx`/`jsxs` directly — the compiler emits those calls; the public
8
+ // surface here is the `HtmlNode` type and `Fragment`.
9
+
10
+ import { AsyncLocalStorage } from "node:async_hooks";
11
+ import { escapeHtml } from "@zerotal/core/helpers";
12
+ import { getExposedProps, getLockedProps } from "./decorators.ts";
13
+ import { _setExposedKeyCapture, _consumeExposedKeyCapture } from "./utils.ts";
14
+ import { URL_ATTRIBUTES, sanitizeUrl } from "./urlSafety.ts";
15
+
16
+ /** The rendered output of a JSX element: an object carrying its serialized HTML string. */
17
+ export type HtmlNode = { html: string };
18
+
19
+ interface FlowDirectives {
20
+ /** error={this.errors.field} — shows the field's first validation message, reactively. */
21
+ error?: any;
22
+ /** Input sync timing for value={…}/checked={…}: `live` syncs each keystroke, `blur` on blur. */
23
+ live?: boolean;
24
+ blur?: boolean;
25
+ /** Coerce the bound value to a number before syncing (value={…} number). */
26
+ number?: boolean;
27
+ /** Trim whitespace from the bound value before syncing (value={…} trim). */
28
+ trim?: boolean;
29
+ /** Persist an unsubmitted input value to localStorage under this key (draft="post-body"). */
30
+ draft?: string;
31
+ /** Focus this field on mount, morph-safe (autoFocus). */
32
+ autoFocus?: boolean;
33
+ /** After a patch, focus this field if it becomes invalid — WCAG first-error focus (focusOnError). */
34
+ focusOnError?: boolean;
35
+ bindClass?: string | false | null | undefined;
36
+ bindStyle?: Record<string, string | number>;
37
+ bindHref?: string;
38
+ bindAttr?: Record<string, string | number | boolean>;
39
+
40
+ showOnLoading?: boolean | string;
41
+ hideOnLoading?: boolean;
42
+ loadingClass?: string;
43
+ loadingClassRemove?: string;
44
+ loadingAttr?: string;
45
+ loadingTarget?: any;
46
+ loadingTargetExcept?: any;
47
+ delay?: boolean | string;
48
+
49
+ showOnDirty?: boolean;
50
+ hideOnDirty?: boolean;
51
+ dirtyClass?: string;
52
+ dirtyClassRemove?: string;
53
+ dirtyAttr?: string;
54
+ dirtyTarget?: any;
55
+
56
+ navigate?: boolean;
57
+ navigateHover?: boolean;
58
+ current?: string;
59
+ currentExact?: boolean;
60
+ currentStrict?: boolean;
61
+
62
+ cloak?: boolean;
63
+
64
+ /** Move this element to a target selector on the client (e.g. teleport="body"). */
65
+ teleport?: string;
66
+
67
+ // ── Tier 3 UI plugins ───────────────────────────────────────────────────────
68
+ /** Input mask, e.g. mask="(999) 999-9999" (@alpinejs/mask → x-mask). */
69
+ mask?: string;
70
+ /** Trap focus while the expression is truthy, e.g. trap="$flow.open" (x-trap). */
71
+ trap?: string;
72
+ /** Animate show/hide height — pair with a native x-show (@alpinejs/collapse → x-collapse). */
73
+ collapse?: boolean;
74
+ /** Position relative to another element, e.g. anchor="$refs.btn" (@alpinejs/anchor → x-anchor). */
75
+ anchor?: string;
76
+
77
+ /**
78
+ * Gate the action behind a styled confirmation dialog. A string is the body
79
+ * text; the object form configures the dialog (title, button labels, a `danger`
80
+ * variant, a custom `icon`, or a type-to-confirm `prompt`).
81
+ *
82
+ * @example confirm="Delete this item?"
83
+ * @example confirm={{ title: "Delete project", message: "This can't be undone.", danger: true, confirm: "Delete" }}
84
+ * @example confirm={{ message: "Type the repo name to confirm.", prompt: "acme/web" }}
85
+ */
86
+ confirm?:
87
+ | string
88
+ | {
89
+ message: string;
90
+ title?: string;
91
+ /** Confirm button label. */
92
+ confirm?: string;
93
+ /** Cancel button label. */
94
+ cancel?: string;
95
+ /** Visual style — `danger` (red, destructive) or `primary` (default). */
96
+ variant?: "danger" | "primary";
97
+ /** Shorthand for `variant: "danger"`. */
98
+ danger?: boolean;
99
+ /** Custom icon (emoji/text/SVG), or `false` to hide it. */
100
+ icon?: string | false;
101
+ /** Show a type-to-confirm input; Confirm unlocks when the value matches this. */
102
+ prompt?: string;
103
+ };
104
+
105
+ /** Animate a show= element's enter/leave: `transition` (fade) or a preset string
106
+ * ("fade" | "scale" | "slide-up" | "slide-down" | "slide-left" | "slide-right"). */
107
+ transition?: boolean | string | Record<string, any>;
108
+ transitionIn?: Record<string, any>;
109
+ transitionOut?: Record<string, any>;
110
+
111
+ onInit?: any;
112
+ onIntersect?: any;
113
+ onIntersectLeave?: any;
114
+ /** Fire onIntersect only once, then stop observing (x-intersect `.once`). */
115
+ intersectOnce?: boolean;
116
+ /** IntersectionObserver rootMargin, e.g. "200px" to fire before the element enters view. */
117
+ intersectMargin?: string;
118
+ /** How much must be visible before onIntersect fires: "half" | "full" | a 0..1 number. */
119
+ intersectThreshold?: string | number;
120
+
121
+ poll?:
122
+ boolean | string | { every?: string; action?: any; keepAlive?: boolean; visible?: boolean };
123
+
124
+ showOnOffline?: boolean;
125
+ hideOnOffline?: boolean;
126
+ offlineClass?: string;
127
+ offlineClassRemove?: string;
128
+ offlineAttr?: string;
129
+
130
+ /** Show this element after an action fails (optimistic failed state); pair with `hideOnError`. */
131
+ showOnError?: boolean;
132
+ hideOnError?: boolean;
133
+
134
+ ignore?: boolean;
135
+ ignoreSelf?: boolean;
136
+
137
+ ref?: any;
138
+
139
+ replace?: boolean;
140
+ replaceSelf?: boolean;
141
+
142
+ show?: any;
143
+ showImportant?: boolean;
144
+
145
+ onSort?: any;
146
+ sortItem?: any;
147
+ sortGroup?: string;
148
+ sortGroupId?: any;
149
+ sortHandle?: boolean;
150
+ sortIgnore?: boolean;
151
+
152
+ stream?: string;
153
+ streamReplace?: boolean;
154
+
155
+ text?: any;
156
+
157
+ // Fallbacks for the older flow:* syntax just in case
158
+ [key: `flow:${string}`]: any;
159
+ }
160
+
161
+ export namespace JSX {
162
+ export type Element = HtmlNode;
163
+
164
+ export interface ElementChildrenAttribute {
165
+ children: unknown;
166
+ }
167
+
168
+ /** Class components must look like a Flow Component (have render()). */
169
+ export interface ElementClass {
170
+ render(): unknown;
171
+ }
172
+
173
+ /** `key` is accepted on every element/component; `slots` supplies named slot content to child components. */
174
+ export interface IntrinsicAttributes {
175
+ key?: string | number;
176
+ /** A child component's default slot — its plain children. */
177
+ children?: unknown;
178
+ /** Defer a child component's mount until its placeholder enters the viewport. */
179
+ lazy?: boolean;
180
+ /** Mount a child component right after the first paint (non-blocking). */
181
+ defer?: boolean;
182
+ /**
183
+ * Named slot content for a child component: `slots={{ header: <h2>…</h2>, footer: … }}`.
184
+ * The child places each with `this.slot("header")`; its plain children are the default slot.
185
+ */
186
+ slots?: Record<string, Element | string | number | boolean | null | undefined>;
187
+ }
188
+
189
+ /**
190
+ * Props for class components: every own field of the Component subclass becomes
191
+ * an optional prop — `<CounterWidget label="…" step={5} />` seeds those
192
+ * fields before onMount(). Base Component internals are excluded.
193
+ */
194
+ export type LibraryManagedAttributes<C, P> = C extends new () => infer I
195
+ ? Partial<Omit<I, keyof import("./Component.ts").Component>> & IntrinsicAttributes
196
+ : P;
197
+
198
+ export interface IntrinsicElements {
199
+ [tag: string]: Record<string, unknown> &
200
+ FlowDirectives & {
201
+ onClick?: any;
202
+ onSubmit?: any;
203
+ onChange?: any;
204
+ onKeyDown?: any;
205
+ onKeyUp?: any;
206
+ onScroll?: any;
207
+ // ... add others if needed, React types usually cover this but we don't depend on React types here
208
+ [key: `on${string}`]: any;
209
+ };
210
+ }
211
+ }
212
+
213
+ const VOID_ELEMENTS = new Set([
214
+ "area",
215
+ "base",
216
+ "br",
217
+ "col",
218
+ "embed",
219
+ "hr",
220
+ "img",
221
+ "input",
222
+ "link",
223
+ "meta",
224
+ "param",
225
+ "source",
226
+ "track",
227
+ "wbr",
228
+ ]);
229
+
230
+ // Text-child escaping delegates to the framework's one escaper
231
+ // (@zerotal/core/helpers, Bun.escapeHTML underneath). It escapes quotes as
232
+ // well as & < > — a superset of what text nodes strictly need, which is
233
+ // harmless in content and keeps every runtime's output consistent.
234
+ // Attribute values still go through escapeAttr below, whose contract is
235
+ // coupled to this renderer's double-quoted-attribute splicing model.
236
+
237
+ /**
238
+ * Escape a value for use inside a double-quoted HTML attribute — which is the only form
239
+ * this renderer emits, so `'` needs no escaping.
240
+ *
241
+ * `<` and `>` do, and used not to be covered. A user-controlled attribute is spliced into
242
+ * finished HTML alongside the child-placeholder comment (`<!--flow-child:…-->`), which a
243
+ * blind `split`/`join` replaces wherever it appears — including inside an attribute value,
244
+ * where the substituted child markup's own quotes then terminate the attribute early and
245
+ * everything after it is parsed as markup.
246
+ *
247
+ * This is not what keeps a value out of an Alpine expression: an `x-on:*` / `:*` attribute
248
+ * is executable code, and the browser hands Alpine the *decoded* value. Use
249
+ * {@link jsLiteral} to embed a value in one.
250
+ */
251
+ function escapeAttr(s: string): string {
252
+ return String(s)
253
+ .replace(/&/g, "&amp;")
254
+ .replace(/"/g, "&quot;")
255
+ .replace(/</g, "&lt;")
256
+ .replace(/>/g, "&gt;");
257
+ }
258
+
259
+ // ── Render context ────────────────────────────────────────────────────────────
260
+ // Scoped per-render via AsyncLocalStorage so concurrent top-level renders
261
+ // (e.g. two simultaneous GET requests each awaiting DB calls) cannot
262
+ // cross-contaminate their page reference or child-thunk lists.
263
+
264
+ interface _RenderCtx {
265
+ page: Record<string, unknown>;
266
+ thunks: Array<{ token: string; run: () => Promise<HtmlNode> }>;
267
+ seq: number;
268
+ /**
269
+ * Per-render random suffix on the child-placeholder token.
270
+ *
271
+ * The splice that resolves child components is a blind `split`/`join` over finished HTML,
272
+ * so any occurrence of the token gets replaced — including one a user put in a string.
273
+ * Escaping in `escapeAttr`/`escapeHtml` is the actual control; this makes the token
274
+ * unguessable so a path that ever misses an escape still cannot be aimed at.
275
+ */
276
+ nonce: string;
277
+ }
278
+
279
+ const _renderCtxStorage = new AsyncLocalStorage<_RenderCtx>();
280
+
281
+ /** @internal — returns the render context for the current async execution. */
282
+ function _getRenderCtx(): _RenderCtx | undefined {
283
+ return _renderCtxStorage.getStore();
284
+ }
285
+
286
+ // ── Nested component thunks ───────────────────────────────────────────────────
287
+ // <CounterWidget step={5} /> can't render inline: jsx() is synchronous but a
288
+ // child render is async (onMount, dehydrate). jsx() therefore emits a unique
289
+ // comment token and queues a thunk; _renderFlowPage() runs the thunks
290
+ // SEQUENTIALLY after the parent render completes (sequential execution keeps
291
+ // the render context from interleaving) and splices the html in.
292
+
293
+ /**
294
+ * Render a Component to HTML: establishes a per-request render context via
295
+ * AsyncLocalStorage, awaits the page's render(), then resolves any nested
296
+ * `<Component />` children queued during evaluation.
297
+ * Used by the route handler, the WS dispatcher, and Component.child() (recursion
298
+ * handles grandchildren — each recursive call gets its own ALS scope).
299
+ */
300
+ export async function _renderFlowPage(
301
+ page: object,
302
+ render: () => Promise<HtmlNode>,
303
+ ): Promise<string> {
304
+ // Lifecycle: rendering() before, rendered(html) after — covers every render
305
+ // path (initial GET, WS patch, nested children, tests) since they all route here.
306
+ const hooks = page as {
307
+ onRendering?: () => Promise<void>;
308
+ onRendered?: (html: string) => Promise<void>;
309
+ };
310
+ if (typeof hooks.onRendering === "function") await hooks.onRendering();
311
+
312
+ // Scope @computed memoization to this render pass: getters cache their result
313
+ // while __flowComputing is set, and the cache is discarded when the pass ends.
314
+ const _cc = page as Record<string, unknown>;
315
+ _cc["__flowComputing"] = true;
316
+ _cc["__flowComputed"] = new Map();
317
+ const _endComputed = () => {
318
+ _cc["__flowComputing"] = false;
319
+ _cc["__flowComputed"] = undefined;
320
+ };
321
+
322
+ // ── AOT-compiled fast path ────────────────────────────────────────────────
323
+ // Compiled render() functions emit HTML via string concatenation, bypassing
324
+ // jsx(), getter instrumentation, and the thunk/token system entirely.
325
+ if ((page.constructor as { __flowCompiled?: boolean }).__flowCompiled) {
326
+ try {
327
+ const node = await render();
328
+ if (typeof hooks.onRendered === "function") await hooks.onRendered(node.html);
329
+ return node.html;
330
+ } finally {
331
+ _endComputed();
332
+ }
333
+ }
334
+
335
+ const ctx: _RenderCtx = {
336
+ page: page as Record<string, unknown>,
337
+ thunks: [],
338
+ seq: 0,
339
+ nonce: crypto.randomUUID().slice(0, 8),
340
+ };
341
+
342
+ let rendered: string;
343
+ try {
344
+ rendered = await _renderCtxStorage.run(ctx, async () => {
345
+ // Temporarily instrument snapshot props so _resolveBindName can detect
346
+ // which property was accessed when flow(this.prop) is evaluated.
347
+ // We instrument both @expose and @locked (locked is in exposed by design).
348
+ const exposed = getExposedProps(page);
349
+ const savedDescs: Map<string, PropertyDescriptor | null> = new Map();
350
+ for (const key of exposed) {
351
+ const desc = Object.getOwnPropertyDescriptor(page, key);
352
+ if (!desc) continue; // not an own property — cannot instrument
353
+ if (desc.get) {
354
+ // Already has a getter (e.g. Bun Stage 3 auto-accessor for decorated fields).
355
+ // Wrap it so the key capture still fires on access.
356
+ const origGet = desc.get;
357
+ savedDescs.set(key, desc);
358
+ Object.defineProperty(page, key, {
359
+ ...desc,
360
+ get() {
361
+ _setExposedKeyCapture(key);
362
+ return origGet.call(this);
363
+ },
364
+ });
365
+ } else {
366
+ // Plain data property — replace with a capture getter.
367
+ const captured = desc.value as unknown;
368
+ savedDescs.set(key, desc);
369
+ Object.defineProperty(page, key, {
370
+ get() {
371
+ _setExposedKeyCapture(key);
372
+ return captured;
373
+ },
374
+ set(v: unknown) {
375
+ Object.defineProperty(page, key, {
376
+ value: v,
377
+ writable: true,
378
+ configurable: true,
379
+ enumerable: true,
380
+ });
381
+ },
382
+ configurable: true,
383
+ enumerable: true,
384
+ });
385
+ }
386
+ }
387
+
388
+ // Instrument Form fields too, so a nested binding (value={this.form.email})
389
+ // captures the full path "form.email" — enabling Form binding in the runtime
390
+ // renderer (the compiler resolves it from the AST instead).
391
+ const savedFormDescs: Array<{
392
+ form: Record<string, unknown>;
393
+ field: string;
394
+ desc: PropertyDescriptor;
395
+ }> = [];
396
+ for (const [key, savedDesc] of savedDescs) {
397
+ // Read the ORIGINAL value (not the instrumented getter) to avoid polluting capture.
398
+ const fv = savedDesc && !savedDesc.get ? savedDesc.value : undefined;
399
+ if (
400
+ !fv ||
401
+ typeof fv !== "object" ||
402
+ (fv as { __isFlowForm?: unknown }).__isFlowForm !== true
403
+ )
404
+ continue;
405
+ const form = fv as Record<string, unknown>;
406
+ for (const field of Object.keys(form)) {
407
+ if (field.startsWith("_") || field === "rules" || typeof form[field] === "function")
408
+ continue;
409
+ const fdesc = Object.getOwnPropertyDescriptor(form, field);
410
+ if (!fdesc || fdesc.get) continue;
411
+ const fcap = `${key}.${field}`;
412
+ const captured = fdesc.value as unknown;
413
+ savedFormDescs.push({ form, field, desc: fdesc });
414
+ Object.defineProperty(form, field, {
415
+ get() {
416
+ _setExposedKeyCapture(fcap);
417
+ return captured;
418
+ },
419
+ set(v: unknown) {
420
+ Object.defineProperty(form, field, {
421
+ value: v,
422
+ writable: true,
423
+ configurable: true,
424
+ enumerable: true,
425
+ });
426
+ },
427
+ configurable: true,
428
+ enumerable: true,
429
+ });
430
+ }
431
+ }
432
+
433
+ try {
434
+ const node = await render();
435
+ let html = node.html;
436
+ for (const { token, run } of ctx.thunks) {
437
+ const childNode = await run();
438
+ html = html.split(token).join(childNode.html);
439
+ }
440
+ return html;
441
+ } finally {
442
+ for (const [key, desc] of savedDescs) {
443
+ if (desc) Object.defineProperty(page, key, desc);
444
+ }
445
+ for (const { form, field, desc } of savedFormDescs) {
446
+ Object.defineProperty(form, field, desc);
447
+ }
448
+ }
449
+ });
450
+ } finally {
451
+ _endComputed();
452
+ }
453
+
454
+ if (typeof hooks.onRendered === "function") await hooks.onRendered(rendered);
455
+ return rendered;
456
+ }
457
+
458
+ /**
459
+ * Resolve `bind={this.draft}` (a value) or `bind="draft"` (a name) to the
460
+ * snapshot property name it refers to.
461
+ *
462
+ * `@locked` props are registered in BOTH `_exposedProps` and `_lockedProps`
463
+ * by the @locked decorator (so they appear in the snapshot). This means
464
+ * `getExposedProps()` already includes locked props.
465
+ *
466
+ * @param allowLocked - When false (default — for two-way bind attrs), locked
467
+ * props are filtered out of the search so they cannot be bound. When true
468
+ * (read-only attrs like `text`), the full exposed set (which includes locked)
469
+ * is used.
470
+ */
471
+ export function _resolveBindName(
472
+ value: unknown,
473
+ attr: string,
474
+ hint?: string,
475
+ allowLocked = false,
476
+ ): string {
477
+ const page = _getRenderCtx()?.page ?? null;
478
+ if (!page) {
479
+ throw new Error(
480
+ `[Flow] ${attr}={...} used outside a Component render. ` +
481
+ `Use {...this.bind('propName')} instead.`,
482
+ );
483
+ }
484
+
485
+ const exposed = getExposedProps(page); // already includes @locked props
486
+
487
+ // When allowLocked = false (two-way bind), exclude @locked keys so they
488
+ // can't be bound. When allowLocked = true (text, show…), use the full set.
489
+ const keys = allowLocked
490
+ ? exposed
491
+ : (() => {
492
+ const locked = getLockedProps(page);
493
+ return locked.size === 0 ? exposed : new Set([...exposed].filter((k) => !locked.has(k)));
494
+ })();
495
+
496
+ // Name form: text="ticks" or bind="draft".
497
+ if (typeof value === "string" && keys.has(value)) return value;
498
+
499
+ // Capture fast path: the instrumented getter set _exposedKeyCapture when
500
+ // this.propName was evaluated in the template. It's a single slot, and *all*
501
+ // of an element's props are evaluated before jsx() runs — so a later binding
502
+ // (e.g. class={"x " + this.accent}) overwrites the capture that text={this.count}
503
+ // set. Guard with a value-identity check: only trust the captured key when its
504
+ // current value is the one we're resolving, so text always binds to its own
505
+ // property regardless of sibling bindings on the same element. A mismatch (a
506
+ // stale capture, or a flow(expr) whose computed value differs from the property)
507
+ // falls through to value-scan and the hint fallback below.
508
+ const capturedKey = _consumeExposedKeyCapture();
509
+ if (
510
+ capturedKey &&
511
+ keys.has(capturedKey) &&
512
+ Object.is((page as Record<string, unknown>)[capturedKey], value)
513
+ ) {
514
+ return capturedKey;
515
+ }
516
+
517
+ // Value form fallback: text={someExpr} — match by identity.
518
+ // When allowLocked is true we also explicitly scan @locked props in case
519
+ // the Bun runtime's decorator implementation didn't register them in
520
+ // _exposedProps (defence against any Stage 3 decorator edge-cases).
521
+ const scanKeys = allowLocked ? new Set([...keys, ...getLockedProps(page)]) : keys;
522
+
523
+ const candidates: string[] = [];
524
+ for (const key of scanKeys) {
525
+ if (Object.is((page as Record<string, unknown>)[key], value)) candidates.push(key);
526
+ }
527
+
528
+ // Deduplicate (a key may appear in both exposed and locked).
529
+ const unique = [...new Set(candidates)];
530
+ if (unique.length === 1) return unique[0]!;
531
+
532
+ if (unique.length > 1) {
533
+ if (hint && unique.includes(hint)) return hint;
534
+ throw new Error(
535
+ `[Flow] ${attr}={...} is ambiguous — multiple @expose props currently hold ` +
536
+ `the same value (${unique.join(", ")}). ` +
537
+ `Disambiguate with ${attr}="propName".`,
538
+ );
539
+ }
540
+
541
+ // Last-chance: trust the hint key directly when value-identity matching
542
+ // produced no candidates. This handles text={flow(expr)} where the
543
+ // expression reads a property (capturing __key) but doesn't return the
544
+ // property value itself (e.g. a conditional or computed string).
545
+ if (hint && scanKeys.has(hint)) return hint;
546
+
547
+ throw new Error(
548
+ `[Flow] ${attr}={...} does not match any @expose property on ` +
549
+ `${page.constructor?.name ?? "the page"}. ` +
550
+ `Pass the property itself (e.g. ${attr}={this.draft}) or its name (${attr}="draft").`,
551
+ );
552
+ }
553
+
554
+ /**
555
+ * Non-throwing resolver for `value={this.x}` / `checked={this.x}` input bindings.
556
+ * Returns the snapshot prop the value came from (confirmed via the instrumented-getter
557
+ * capture AND a value-identity match, so a literal or server value never false-positives),
558
+ * or null when the attribute is just an ordinary server-rendered value.
559
+ * `locked` distinguishes @locked (read-only, reactive) from @expose (two-way).
560
+ */
561
+ function _resolveValueBind(value: unknown): { name: string; locked: boolean } | null {
562
+ const page = _getRenderCtx()?.page ?? null;
563
+ if (!page) return null;
564
+ const cap = _consumeExposedKeyCapture();
565
+ if (!cap) return null;
566
+ const exposed = getExposedProps(page); // includes @locked
567
+
568
+ // Dotted capture (e.g. "form.email") — a Form field. The root must be an exposed
569
+ // prop and the nested value must identity-match (freshness guard). Form fields
570
+ // are always writable (two-way), so locked is false.
571
+ if (cap.includes(".")) {
572
+ const root = cap.slice(0, cap.indexOf("."));
573
+ if (!exposed.has(root)) return null;
574
+ const nested = cap
575
+ .split(".")
576
+ .reduce<unknown>((o, k) => (o == null ? undefined : (o as Record<string, unknown>)[k]), page);
577
+ if (!Object.is(nested, value)) return null;
578
+ return { name: cap, locked: false };
579
+ }
580
+
581
+ if (!exposed.has(cap)) return null;
582
+ // Confirm the capture is fresh for THIS value (guards against a stale capture
583
+ // lingering from a previous element that didn't consume it).
584
+ if (!Object.is((page as Record<string, unknown>)[cap], value)) return null;
585
+ return { name: cap, locked: getLockedProps(page).has(cap) };
586
+ }
587
+
588
+ /**
589
+ * Read a compiler-injected bind key for a binding prop off a component's props.
590
+ *
591
+ * The AOT bind-injection pass emits `__flowBinds={{ show: "sheetOpen" }}` for
592
+ * `show={this.sheetOpen}` (and `bind`/`query`) on component elements, so a
593
+ * binding component resolves its bound property statically — reliable even when
594
+ * the runtime getter-capture was clobbered by a sibling/child binding, and
595
+ * unambiguous when several props share a value (e.g. two `false`s). Binding
596
+ * components consult this before `name=`/value-identity resolution. Returns null
597
+ * when no injection is present (hand-written JSX, or a non-compiled page).
598
+ */
599
+ export function _injectedBindKey(props: unknown, attr: string): string | null {
600
+ const binds = (props as { __flowBinds?: Record<string, string> } | null)?.__flowBinds;
601
+ const key = binds && binds[attr];
602
+ return typeof key === "string" ? key : null;
603
+ }
604
+
605
+ /**
606
+ * @internal Resolve a reactive prop value (e.g. `this.open`) to its snapshot
607
+ * property name, or null when the value isn't a bound @expose/@locked prop.
608
+ *
609
+ * Native components (e.g. <Modal show={this.open} />) are plain functions that
610
+ * receive their children already-evaluated, so the getter capture for `show` is
611
+ * usually clobbered by a reactive child (e.g. an inner value={this.x}). We
612
+ * therefore try the (freshness-checked) capture first, then fall back to a
613
+ * value-identity match over the exposed props — unambiguous in the common case
614
+ * where the bound prop's current value is unique among exposed props. When the
615
+ * page was compiled, `_injectedBindKey` (consulted by the component first) makes
616
+ * this path a fallback for hand-written / non-compiled JSX only.
617
+ */
618
+ export function _resolveReactiveName(value: unknown): string | null {
619
+ const viaCapture = _resolveValueBind(value);
620
+ if (viaCapture) {
621
+ // _resolveValueBind's freshness check re-reads the prop through the
622
+ // instrumented getter, which re-arms the capture. Clear it so a following
623
+ // sibling (e.g. a <Select>'s <option value> that equals the bound value)
624
+ // can't falsely inherit this binding.
625
+ _consumeExposedKeyCapture();
626
+ return viaCapture.name;
627
+ }
628
+
629
+ const page = _getRenderCtx()?.page ?? null;
630
+ if (!page) return null;
631
+ const matches: string[] = [];
632
+ for (const key of getExposedProps(page)) {
633
+ try {
634
+ if (Object.is((page as Record<string, unknown>)[key], value)) matches.push(key);
635
+ } catch {
636
+ /* skip throwing getters */
637
+ }
638
+ }
639
+ _consumeExposedKeyCapture(); // the loop above re-armed the capture; clear it
640
+ if (matches.length === 1) return matches[0]!;
641
+ // Ambiguous: two+ @expose props currently hold this value (e.g. two `null`s), so
642
+ // we can't tell which the binding meant. Drop it rather than guess — but WARN, since
643
+ // a silently-unbound control is a nasty surprise. Pass an explicit name to fix it.
644
+ if (matches.length > 1) {
645
+ console.warn(
646
+ `[Flow] A component binding is ambiguous: @expose props [${matches.join(", ")}] ` +
647
+ `currently share the same value, so the bound prop can't be resolved by value. ` +
648
+ `The control will render unbound. Pass an explicit \`name=\`/\`queryName=\` (or give the ` +
649
+ `props distinct initial values) on ${page.constructor?.name ?? "the page"}.`,
650
+ );
651
+ }
652
+ return null;
653
+ }
654
+
655
+ const PULSE_PROP_MAP: Record<string, string> = {
656
+ poll: "flow:poll",
657
+ confirm: "flow:confirm",
658
+ bindClass: "flow:bind:class",
659
+ bindHref: "flow:bind:href",
660
+ showOnLoading: "flow:loading",
661
+ hideOnLoading: "flow:loading.remove",
662
+ loadingClass: "flow:loading.class",
663
+ loadingClassRemove: "flow:loading.class.remove",
664
+ loadingAttr: "flow:loading.attr",
665
+ loadingTarget: "flow:target",
666
+ loadingTargetExcept: "flow:target.except",
667
+ delay: "flow:loading.delay",
668
+ showOnDirty: "flow:dirty",
669
+ hideOnDirty: "flow:dirty.remove",
670
+ dirtyClass: "flow:dirty.class",
671
+ dirtyClassRemove: "flow:dirty.class.remove",
672
+ dirtyAttr: "flow:dirty.attr",
673
+ dirtyTarget: "flow:target",
674
+ navigate: "flow:navigate",
675
+ navigateHover: "flow:navigate.hover",
676
+ current: "flow:current",
677
+ currentExact: "flow:current.exact",
678
+ currentStrict: "flow:current.strict",
679
+ cloak: "flow:cloak",
680
+ teleport: "flow:teleport",
681
+ mask: "x-mask",
682
+ trap: "x-trap",
683
+ collapse: "x-collapse",
684
+ anchor: "x-anchor",
685
+ onInit: "flow:init",
686
+ onIntersect: "flow:intersect",
687
+ onIntersectLeave: "flow:intersect.leave",
688
+ intersectOnce: "flow:intersect.once",
689
+ intersectMargin: "flow:intersect.margin",
690
+ intersectThreshold: "flow:intersect.threshold",
691
+ showOnOffline: "flow:offline",
692
+ hideOnOffline: "flow:offline.remove",
693
+ offlineClass: "flow:offline.class",
694
+ offlineClassRemove: "flow:offline.class.remove",
695
+ offlineAttr: "flow:offline.attr",
696
+ showOnError: "flow:failed",
697
+ hideOnError: "flow:failed.remove",
698
+ ignore: "flow:ignore",
699
+ ignoreSelf: "flow:ignore.self",
700
+ ref: "flow:ref",
701
+ replace: "flow:replace",
702
+ replaceSelf: "flow:replace.self",
703
+ show: "flow:show",
704
+ showImportant: "flow:show.important",
705
+ transition: "flow:transition",
706
+ autoFocus: "flow:autofocus",
707
+ focusOnError: "flow:focus-error",
708
+ draft: "flow:draft",
709
+ onSort: "flow:sort",
710
+ sortItem: "flow:sort:item",
711
+ sortGroup: "flow:sort:group",
712
+ sortGroupId: "flow:sort:group-id",
713
+ sortHandle: "flow:sort:handle",
714
+ sortIgnore: "flow:sort:ignore",
715
+ stream: "flow:stream",
716
+ streamReplace: "flow:stream.replace",
717
+ text: "flow:text",
718
+ };
719
+
720
+ function toFlowHtmlAttr(k: string): string {
721
+ if (!k.startsWith("flow:")) return k;
722
+ const local = k.slice("flow:".length);
723
+ return "flow:" + local.replace(/-/g, ".");
724
+ }
725
+
726
+ function renderChild(child: unknown): string {
727
+ if (child === null || child === undefined || child === false) return "";
728
+ if (child === true) return "";
729
+ if (typeof child === "string") return escapeHtml(child);
730
+ if (typeof child === "number") return String(child);
731
+ if (Array.isArray(child)) return child.map(renderChild).join("");
732
+ if (typeof child === "object" && "html" in (child as object)) {
733
+ return (child as HtmlNode).html;
734
+ }
735
+ return escapeHtml(String(child));
736
+ }
737
+
738
+ /**
739
+ * Serialise a client-handler function to its source, verbatim, rewriting `this.`→`$flow.`
740
+ * so it evaluates against the Alpine reactive proxy. The whole arrow ships (params + body);
741
+ * the bridge auto-invokes it with the DOM event, mirroring Alpine's `x-on` semantics.
742
+ * () => this.x = 0 → "() => $flow.x = 0"
743
+ * (e) => { this.save(e) } → "(e) => { $flow.save(e) }"
744
+ *
745
+ * Special case — a zero-arg arrow that is a single `this.method(...)` call commonly
746
+ * closes over a loop variable:
747
+ * RANGES.map((r) => <button onClick={() => this.setRange(r)} />)
748
+ * `toString()` only sees the *name* `r`, not its per-iteration value, so every button
749
+ * would ship the same broken `$flow.setRange(r)` referencing a server-only variable.
750
+ * {@link _captureLoopCall} resolves the real captured arguments and inlines them, so the
751
+ * four buttons correctly emit `$flow.setRange('live')`, `'1h'`, `'24h'`, `'7d'`.
752
+ */
753
+ function _clientExprFromFn(fn: { toString(): string }): string {
754
+ const src = fn.toString();
755
+ const call = /^\s*\(\s*\)\s*=>\s*this\.([A-Za-z_$][\w$]*)\s*\((.*)\)\s*;?\s*$/.exec(src);
756
+ // Skip when the argument list itself reads `this` — that's a reactive client value
757
+ // (e.g. `this.range`) which must stay an expression, not be frozen to a snapshot.
758
+ if (call && typeof fn === "function" && !/\bthis\b/.test(call[2] ?? "")) {
759
+ const inlined = _captureLoopCall(fn as () => void, call[1]!);
760
+ if (inlined !== null) return inlined;
761
+ }
762
+ return _normaliseBooleanShorthand(src.replace(/\bthis\./g, "$flow."));
763
+ }
764
+
765
+ /**
766
+ * Restore `false`/`true` from the `!1`/`!0` shorthand a JS engine's transpiler
767
+ * may emit for boolean literals (Bun does this), so the client expression shipped
768
+ * in the DOM is deterministic and readable regardless of how the handler source
769
+ * was transpiled. Semantics are identical; only the surface form changes. The
770
+ * `(?!\d)` guard keeps `!10`/`!17` (negated numbers) untouched.
771
+ */
772
+ function _normaliseBooleanShorthand(expr: string): string {
773
+ return expr.replace(/!1(?!\d)/g, "false").replace(/!0(?!\d)/g, "true");
774
+ }
775
+
776
+ /**
777
+ * Resolve a `() => this.method(loopVar)` handler to `$flow.method(<value>)` by capturing
778
+ * the real arguments. The arrow's lexical `this` is the component currently rendering
779
+ * ({@link _getRenderCtx}.page), so we temporarily replace `method` on that instance with
780
+ * a recorder and invoke the arrow: the closed-over args evaluate to their real values and
781
+ * are recorded **without running the real action**. Returns null if capture isn't possible
782
+ * (no render context, or the arrow's `this` isn't the active page) → caller falls back to
783
+ * verbatim serialisation.
784
+ */
785
+ function _captureLoopCall(fn: () => void, method: string): string | null {
786
+ const page = _getRenderCtx()?.page;
787
+ if (!page) return null;
788
+
789
+ const target = page as Record<string, unknown>;
790
+ const hadOwn = Object.prototype.hasOwnProperty.call(target, method);
791
+ const original = target[method];
792
+ let captured: unknown[] | null = null;
793
+ try {
794
+ target[method] = (...args: unknown[]): void => {
795
+ captured = args;
796
+ };
797
+ fn();
798
+ } catch {
799
+ captured = null; // unexpected shape → fall back, never inline a guess
800
+ } finally {
801
+ if (hadOwn) target[method] = original;
802
+ else delete target[method];
803
+ }
804
+
805
+ if (!captured) return null;
806
+ return `$flow.${method}(${(captured as unknown[]).map(_jsLiteral).join(", ")})`;
807
+ }
808
+
809
+ /** Render a captured argument as a JS literal safe inside a (double-quoted, escaped) attribute. */
810
+ function _jsLiteral(v: unknown): string {
811
+ if (typeof v === "string") return `'${v.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
812
+ if (v === undefined) return "undefined";
813
+ return JSON.stringify(v);
814
+ }
815
+
816
+ /**
817
+ * The automatic-JSX element factory. Emitted by the compiler for every JSX element — not
818
+ * called directly by application code. Renders an intrinsic tag (or a Flow Component /
819
+ * function component) to an {@link HtmlNode}, applying `flow:*` directives along the way.
820
+ * @internal
821
+ */
822
+ export function jsx(
823
+ type: string | ((props: Record<string, unknown>) => HtmlNode),
824
+ props: Record<string, unknown> | null,
825
+ _key?: unknown,
826
+ ): HtmlNode {
827
+ const p = props ?? {};
828
+
829
+ if (typeof type === "function") {
830
+ // Class component — a Flow Component used declaratively:
831
+ // <CounterWidget key="a" label="…" step={5} />
832
+ // Detected by the $set built-in on the prototype (every Component has it).
833
+ // Queued as a thunk and rendered by _renderFlowPage after the parent.
834
+ if (
835
+ (type as { prototype?: { __isFlowPage?: true } }).prototype?.__isFlowPage ||
836
+ (type as { prototype?: { $set?: unknown } }).prototype?.$set
837
+ ) {
838
+ const renderCtx = _getRenderCtx();
839
+ const parent = renderCtx?.page as unknown as
840
+ | {
841
+ child(
842
+ c: unknown,
843
+ o: {
844
+ key?: string | number;
845
+ props?: Record<string, unknown>;
846
+ slots?: Record<string, string>;
847
+ lazy?: boolean;
848
+ defer?: boolean;
849
+ },
850
+ ): Promise<HtmlNode>;
851
+ }
852
+ | undefined;
853
+ if (!parent || typeof parent.child !== "function") {
854
+ throw new Error(
855
+ `[Flow] <${(type as { name?: string }).name ?? "Component"} /> used outside a Component render.`,
856
+ );
857
+ }
858
+ // Slots: the child's plain children become the "default" slot; a `slots={{ … }}`
859
+ // prop supplies named slots. Both are rendered here — in the PARENT's scope — to
860
+ // HTML, then handed to child() and carried in the child's snapshot. The child
861
+ // places them with `this.slot(name)`.
862
+ // `lazy`/`defer` control child-loading (they map to child() options, not props);
863
+ // `children`/`slots` become slot content. Everything else is a prop for the child.
864
+ const {
865
+ children: _defaultSlot,
866
+ slots: _namedSlots,
867
+ lazy: _lazy,
868
+ defer: _defer,
869
+ ...componentProps
870
+ } = p;
871
+ const slots: Record<string, string> = {};
872
+ if (_defaultSlot !== undefined) {
873
+ const rendered = renderChild(_defaultSlot);
874
+ if (rendered) slots["default"] = rendered;
875
+ }
876
+ if (_namedSlots && typeof _namedSlots === "object") {
877
+ for (const [name, node] of Object.entries(_namedSlots as Record<string, unknown>)) {
878
+ if (node === undefined || node === null) continue;
879
+ slots[name] = renderChild(node);
880
+ }
881
+ }
882
+ const token = `<!--flow-child:${renderCtx!.nonce}:${renderCtx!.seq++}-->`;
883
+ renderCtx!.thunks.push({
884
+ token,
885
+ run: () =>
886
+ parent.child(type, {
887
+ ...(_key !== undefined && _key !== null ? { key: _key as string | number } : {}),
888
+ props: componentProps,
889
+ ...(_lazy ? { lazy: true } : {}),
890
+ ...(_defer ? { defer: true } : {}),
891
+ ...(Object.keys(slots).length ? { slots } : {}),
892
+ }),
893
+ });
894
+ return { html: token };
895
+ }
896
+
897
+ // Plain function component
898
+ return type(p);
899
+ }
900
+
901
+ const attrs: string[] = [];
902
+ let children = "";
903
+ let hasChildren = false;
904
+ let textAutoChild: string | null = null;
905
+ // For `<select value={...}>`: a literal `value` attribute on a <select> is non-standard and
906
+ // ignored by browsers — the displayed/submitted option is the one marked `selected`. We capture
907
+ // the intended value here and mark the matching <option selected> after children are built.
908
+ let selectValue: string | undefined;
909
+ let textareaValue: string | undefined;
910
+
911
+ for (const [k, v] of Object.entries(p)) {
912
+ if (v === undefined || v === null) continue;
913
+
914
+ // Internal: the compiler's bind-name injection (see _injectedBindKey). Consumed by
915
+ // binding components to resolve their bound prop statically; never an HTML attribute.
916
+ if (k === "__flowBinds") continue;
917
+
918
+ if (k === "children") {
919
+ children = renderChild(v);
920
+ hasChildren = true;
921
+ continue;
922
+ }
923
+
924
+ // One-way reactive text — text={this.time}:
925
+ // <div text={this.time} /> → <div flow:text="time">15:33:59</div>
926
+ // Emits the client directive AND injects the server value as children, so
927
+ // the first paint shows real data and the bridge keeps it live (DRY: the
928
+ // property is declared exactly once).
929
+ if (k === "text") {
930
+ let textV: unknown = v;
931
+ let textHint: string | undefined =
932
+ typeof p.name === "string" ? p.name : typeof p.id === "string" ? p.id : undefined;
933
+ let isFlowChain = false;
934
+ if (v && typeof v === "function" && (v as any).__isFlow) {
935
+ const target = (v as any).__target;
936
+ textV = typeof target === "function" ? (target as () => unknown)() : target;
937
+ textHint = ((v as any).__key as string | undefined) ?? textHint;
938
+ isFlowChain = true;
939
+ }
940
+ const name = _resolveBindName(textV, "text", textHint, true);
941
+ const page = _getRenderCtx()?.page;
942
+ // When flow() wraps a conditional/computed expression, the resolved property
943
+ // value won't match the evaluated string (e.g. ticks=0 but textV="Reset").
944
+ // In that case skip the flow:text directive and emit the evaluated text as
945
+ // static content — the bridge has no way to re-evaluate the expression.
946
+ if (isFlowChain && typeof textV === "string" && String(page?.[name] ?? "") !== textV) {
947
+ textAutoChild = escapeHtml(textV);
948
+ } else {
949
+ attrs.push(`flow:text="${escapeAttr(name)}"`);
950
+ textAutoChild = escapeHtml(String(page?.[name] ?? ""));
951
+ }
952
+ continue;
953
+ }
954
+
955
+ if (k === "dangerouslySetInnerHTML" && typeof v === "object") {
956
+ children = (v as Record<string, string>)["__html"] ?? "";
957
+ continue;
958
+ }
959
+
960
+ // error={this.errors.email} — the ErrorField sentinel carries its own field + message,
961
+ // so no name recovery is needed. Emits flow:error + flow:show; SSR text = first message.
962
+ if (k === "error" && v && typeof v === "object" && (v as any).__isErrorField === true) {
963
+ const errField = (v as any).__field as string;
964
+ const errValue = (v as any).__value as string;
965
+ attrs.push(`flow:error="${escapeAttr(errField)}"`);
966
+ attrs.push(`flow:show="errors.${escapeAttr(errField)}"`);
967
+ textAutoChild = escapeHtml(errValue);
968
+ continue;
969
+ }
970
+
971
+ // flow() proxy wrapper — flow(this.add).debounce('500ms'), flow(this.reset)…
972
+ if (v && typeof v === "function" && (v as any).__isFlow) {
973
+ const target = (v as any).__target;
974
+ const modifiers = ((v as any).__modifiers || []).join(".");
975
+
976
+ // Client expression — flow(() => this.ticks = 0) / flow(() => this.reset())
977
+ // An anonymous arrow function signals a client-side expression.
978
+ // The body is extracted via .toString() and `this.` is rewritten to `$flow.`
979
+ // so it evaluates against the component's reactive proxy in the browser.
980
+ // Emitted as flow:{event} so the bridge detects the $flow prefix and
981
+ // evaluates it via Alpine instead of sending a WebSocket action.
982
+ if (typeof target === "function" && !target.name && k.startsWith("on")) {
983
+ const body = _clientExprFromFn(target as Function);
984
+ const event = k.slice(2).toLowerCase();
985
+ const base = `flow:${event}`;
986
+ const attr = modifiers ? `${base}.${modifiers}` : base;
987
+ attrs.push(`${attr}="${escapeAttr(body)}"`);
988
+ continue;
989
+ }
990
+
991
+ let baseKey = "";
992
+ if (k.startsWith("on")) {
993
+ baseKey = `flow:${k.slice(2).toLowerCase()}`;
994
+ } else if (PULSE_PROP_MAP[k]) {
995
+ baseKey = PULSE_PROP_MAP[k];
996
+ } else {
997
+ baseKey = `flow:${k}`;
998
+ }
999
+
1000
+ // Methods carry their own name; property values resolve against the page's @expose props.
1001
+ const targetName =
1002
+ typeof target === "function"
1003
+ ? target.name || ""
1004
+ : _resolveBindName(
1005
+ target,
1006
+ k,
1007
+ ((v as unknown as Record<string, unknown>)["__key"] as string | undefined) ??
1008
+ (typeof p.name === "string" ? p.name : typeof p.id === "string" ? p.id : undefined),
1009
+ );
1010
+
1011
+ const attrName = modifiers ? `${baseKey}.${modifiers}` : baseKey;
1012
+ attrs.push(`${attrName}="${escapeAttr(targetName)}"`);
1013
+ continue;
1014
+ }
1015
+
1016
+ // Standard event handlers (non-flow() functions; flow() values are handled above).
1017
+ // Bare `submit={this.method}` on a <form> is shorthand for onSubmit (→ flow:submit).
1018
+ if ((k.startsWith("on") || k === "submit") && typeof v === "function") {
1019
+ const event = k === "submit" ? "submit" : k.slice(2).toLowerCase();
1020
+ const src = Function.prototype.toString.call(v);
1021
+ // Detect an inline arrow by its SOURCE, not by `.name`: an arrow passed as a JSX prop
1022
+ // (onClick={() => …}) gets its name *inferred* from the prop ("onClick"), so `.name`
1023
+ // can't distinguish it from a real method reference. Method refs stringify as
1024
+ // `name(...) {…}` / `function …`; arrows stringify as `(…) => …` / `x => …`.
1025
+ const isArrow =
1026
+ !src.startsWith("function") && /^\s*(async\s+)?(\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>/.test(src);
1027
+ const fnName = (v as { name?: string }).name;
1028
+ if (!isArrow && fnName && fnName !== "anonymous" && fnName !== "") {
1029
+ // Method reference — onClick={this.save} → server action.
1030
+ attrs.push(`flow:${event}="${escapeAttr(fnName)}"`);
1031
+ } else {
1032
+ // Inline arrow — onClick={(e) => this.x = 0} → client expression, emitted verbatim
1033
+ // (this.→$flow.); the bridge auto-invokes it with the event.
1034
+ attrs.push(
1035
+ `flow:${event}="${escapeAttr(_clientExprFromFn(v as (...a: unknown[]) => unknown))}"`,
1036
+ );
1037
+ }
1038
+ continue;
1039
+ }
1040
+
1041
+ // `live` / `blur` (sync timing) and `number` / `trim` (value coercion) are input-binding
1042
+ // modifiers consumed by the value/checked handler below, never emitted as standalone attrs.
1043
+ if (k === "live" || k === "blur" || k === "number" || k === "trim") continue;
1044
+
1045
+ // value={this.x} / checked={this.x} — the input binding (replaces `bind`).
1046
+ // @expose → two-way (flow:model[.live|.blur]); @locked → reactive read-only (:value + readonly).
1047
+ if (k === "value" || k === "checked") {
1048
+ // <select value={...}>: remember the value so the matching <option> can be marked `selected`
1049
+ // after children are built. A literal `value` attr on a <select> does not select an option.
1050
+ if (type === "select" && k === "value") selectValue = String(v ?? "");
1051
+ // <textarea value={...}>: same problem as <select>. A textarea has no `value`
1052
+ // content attribute — its value is its text content — so a literal one is
1053
+ // inert and the box renders empty until the client happens to sync it.
1054
+ // Remember it and emit it as the element's text below.
1055
+ if (type === "textarea" && k === "value") textareaValue = String(v ?? "");
1056
+ // An <option value={...}> is a plain attribute, never a two-way binding. Resolving it as one
1057
+ // would consume the value-capture meant for the enclosing <select> — leaving the <select>
1058
+ // unbound (or mis-attaching flow:model to the first option whose value matches). Emit it as an
1059
+ // ordinary attribute so the capture survives intact for the select that follows.
1060
+ if (type === "option" && k === "value") {
1061
+ attrs.push(`value="${escapeAttr(String(v ?? ""))}"`);
1062
+ continue;
1063
+ }
1064
+ const bound = _resolveValueBind(v);
1065
+ if (bound) {
1066
+ if (bound.locked) {
1067
+ attrs.push(`:${k}="$flow.${bound.name}"`);
1068
+ if (k === "value") attrs.push(`value="${escapeAttr(String(v ?? ""))}" readonly`);
1069
+ else {
1070
+ attrs.push("disabled");
1071
+ if (v) attrs.push("checked");
1072
+ }
1073
+ } else {
1074
+ const mod = p["blur"] ? ".blur" : p["live"] ? ".live" : "";
1075
+ attrs.push(`flow:model${mod}="${escapeAttr(bound.name)}"`);
1076
+ if (p["number"]) attrs.push("data-flow-number"); // `number` modifier → bridge coerces
1077
+ if (p["trim"]) attrs.push("data-flow-trim"); // `trim` modifier → bridge trims
1078
+ if (k === "value") attrs.push(`value="${escapeAttr(String(v ?? ""))}"`);
1079
+ else if (v) attrs.push("checked");
1080
+ }
1081
+ continue;
1082
+ }
1083
+ // else: ordinary server value — fall through to default attribute handling.
1084
+ }
1085
+
1086
+ // show={this.x} / showImportant={this.x} — reactive visibility. The prop is evaluated to a
1087
+ // boolean before we see it, so capture the snapshot prop NAME (like value/checked) and emit
1088
+ // flow:show="<prop>"; the bridge then toggles display reactively off comp.reactive[prop].
1089
+ // Without this the generic boolean branch would emit a valueless `flow:show` and never react.
1090
+ if (k === "show" || k === "showImportant") {
1091
+ const bound = _resolveValueBind(v);
1092
+ if (bound) {
1093
+ attrs.push(`${PULSE_PROP_MAP[k]}="${escapeAttr(bound.name)}"`);
1094
+ continue;
1095
+ }
1096
+ // else: a literal/computed boolean (not a snapshot prop) — fall through; static show/hide.
1097
+ }
1098
+
1099
+ // class={['base', condition && 'extra', ...]} — filter falsy, join
1100
+ if (k === "class" && Array.isArray(v)) {
1101
+ const cls = (v as unknown[]).filter(Boolean).join(" ");
1102
+ attrs.push(`class="${escapeAttr(cls)}"`);
1103
+ continue;
1104
+ }
1105
+ if (k === "className") {
1106
+ attrs.push(`class="${escapeAttr(String(v))}"`);
1107
+ continue;
1108
+ }
1109
+ if (k === "htmlFor") {
1110
+ attrs.push(`for="${escapeAttr(String(v))}"`);
1111
+ continue;
1112
+ }
1113
+
1114
+ let htmlKey = PULSE_PROP_MAP[k] || k;
1115
+ htmlKey = toFlowHtmlAttr(htmlKey);
1116
+
1117
+ // Special object formatting for `confirm={{ message, title?, confirm?, cancel?,
1118
+ // variant?, danger?, icon?, prompt? }}`. The bare {message} and {message,prompt}
1119
+ // shapes keep their legacy attribute encoding; any richer object is serialised to
1120
+ // a single flow:confirm.opts JSON attribute that the styled dialog reads.
1121
+ if (k === "confirm" && typeof v === "object" && v !== null && "message" in v) {
1122
+ const cv = v as any;
1123
+ const rich =
1124
+ "title" in cv ||
1125
+ "confirm" in cv ||
1126
+ "cancel" in cv ||
1127
+ "variant" in cv ||
1128
+ "danger" in cv ||
1129
+ "icon" in cv;
1130
+ if (rich) {
1131
+ const opts: Record<string, unknown> = { message: cv.message };
1132
+ if (cv.title) opts["title"] = cv.title;
1133
+ if (cv.confirm) opts["confirmLabel"] = cv.confirm;
1134
+ if (cv.cancel) opts["cancelLabel"] = cv.cancel;
1135
+ if (cv.variant) opts["variant"] = cv.variant;
1136
+ else if (cv.danger) opts["variant"] = "danger";
1137
+ if (cv.icon !== undefined) opts["icon"] = cv.icon;
1138
+ if (cv.prompt) opts["prompt"] = cv.prompt;
1139
+ attrs.push(`${htmlKey}.opts="${escapeAttr(JSON.stringify(opts))}"`);
1140
+ } else if (cv.prompt) {
1141
+ attrs.push(`${htmlKey}.prompt="${escapeAttr(cv.message + "|" + cv.prompt)}"`);
1142
+ } else {
1143
+ attrs.push(`${htmlKey}="${escapeAttr(cv.message)}"`);
1144
+ }
1145
+ continue;
1146
+ }
1147
+
1148
+ // Special formatting for `poll={{ every: '5s', action: this.refresh, ... }}`
1149
+ if (k === "poll" && typeof v === "object" && v !== null) {
1150
+ const pv = v as any;
1151
+ let finalKey = htmlKey;
1152
+ if (pv.every) finalKey += `.${pv.every}`;
1153
+ if (pv.keepAlive) finalKey += `.keep-alive`;
1154
+ if (pv.visible) finalKey += `.visible`;
1155
+ const actionName = pv.action?.name || "$refresh";
1156
+ attrs.push(`${finalKey}="${escapeAttr(actionName)}"`);
1157
+ continue;
1158
+ }
1159
+
1160
+ // Reactive attribute binding — bindAttr={{ disabled: 'isSaving', title: 'tooltip' }}
1161
+ // Emitted as JSON (attr → state property name); the client bridge keeps the
1162
+ // attributes in sync with the component's reactive state.
1163
+ if (k === "bindAttr" && typeof v === "object" && v !== null) {
1164
+ attrs.push(`flow:bind:attr="${escapeAttr(JSON.stringify(v))}"`);
1165
+ continue;
1166
+ }
1167
+
1168
+ // Style binding
1169
+ if (k === "bindStyle" && typeof v === "object" && v !== null) {
1170
+ const styleParts = [];
1171
+ for (const [sk, sv] of Object.entries(v as Record<string, string>)) {
1172
+ const kebab = sk.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
1173
+ // Escape single quotes in both key and value so the Alpine expression
1174
+ // string is always well-formed: bindStyle={{ color: "O'Brien red" }}
1175
+ const safeKey = kebab.replace(/'/g, "\\'");
1176
+ const safeVal = String(sv).replace(/'/g, "\\'");
1177
+ styleParts.push(`'${safeKey}': '${safeVal}'`);
1178
+ }
1179
+ attrs.push(`flow:bind:style="{ ${styleParts.join(", ")} }"`);
1180
+ continue;
1181
+ }
1182
+
1183
+ // style={{ display: 'none', fontSize: '12px' }} → style="display: none; font-size: 12px"
1184
+ if (k === "style" && typeof v === "object" && v !== null) {
1185
+ const css = Object.entries(v as Record<string, string | number>)
1186
+ .map(([sk, sv]) => `${sk.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase())}: ${sv}`)
1187
+ .join("; ");
1188
+ attrs.push(`style="${escapeAttr(css)}"`);
1189
+ continue;
1190
+ }
1191
+
1192
+ if (typeof v === "boolean") {
1193
+ if (v) attrs.push(htmlKey);
1194
+ continue;
1195
+ }
1196
+
1197
+ // A URL-bearing attribute is executed, not just displayed, when its scheme says so —
1198
+ // `<a href={this.profile.website}>` with a `javascript:` value is one-click XSS from a
1199
+ // field a user typed into. Escaping does nothing about scheme; the scheme is checked.
1200
+ const raw = String(v);
1201
+ attrs.push(`${htmlKey}="${escapeAttr(URL_ATTRIBUTES.has(htmlKey) ? sanitizeUrl(raw) : raw)}"`);
1202
+ }
1203
+
1204
+ if (textAutoChild !== null && !hasChildren) {
1205
+ children = textAutoChild;
1206
+ }
1207
+
1208
+ // <select value={x}>: browsers ignore a literal `value` attribute on a <select> — the shown and
1209
+ // submitted option is the one carrying `selected`. Mark the matching <option> so the server-
1210
+ // rendered select reflects its bound value (otherwise it always falls back to the first option).
1211
+ if (type === "select" && selectValue !== undefined) {
1212
+ children = _selectMatchingOption(children, selectValue);
1213
+ }
1214
+
1215
+ // <textarea value={x}>: the value is the element's text content. Written here
1216
+ // rather than as an attribute so the server-rendered box holds its value on
1217
+ // first paint — before any client sync, and with JavaScript off entirely.
1218
+ if (type === "textarea" && textareaValue !== undefined) {
1219
+ children = escapeHtml(textareaValue);
1220
+ }
1221
+
1222
+ const a = attrs.length ? " " + attrs.join(" ") : "";
1223
+
1224
+ return VOID_ELEMENTS.has(type)
1225
+ ? { html: `<${type}${a}>` }
1226
+ : { html: `<${type}${a}>${children}</${type}>` };
1227
+ }
1228
+
1229
+ /**
1230
+ * Mark the first `<option>` whose value equals `value` as `selected`, so a server-rendered
1231
+ * `<select>` displays and submits its bound value. An option's value is its `value` attribute, or
1232
+ * (per HTML) its trimmed text content when no `value` attribute is present. If the author already
1233
+ * hard-coded a `selected` option, the markup is left untouched. No-op when nothing matches.
1234
+ */
1235
+ function _selectMatchingOption(optionsHtml: string, value: string): string {
1236
+ if (/<option\b[^>]*\bselected\b/.test(optionsHtml)) return optionsHtml;
1237
+ const want = escapeAttr(value);
1238
+ let done = false;
1239
+ return optionsHtml.replace(
1240
+ /<option\b([^>]*)>([\s\S]*?)<\/option>/g,
1241
+ (full, optAttrs: string, inner: string) => {
1242
+ if (done) return full;
1243
+ const m = optAttrs.match(/\bvalue="([^"]*)"/);
1244
+ const optVal = m ? m[1] : escapeAttr(inner.trim());
1245
+ if (optVal === want) {
1246
+ done = true;
1247
+ return `<option${optAttrs} selected>${inner}</option>`;
1248
+ }
1249
+ return full;
1250
+ },
1251
+ );
1252
+ }
1253
+
1254
+ /**
1255
+ * The multi-child variant of {@link jsx} (elements with static children). Aliased to `jsx`;
1256
+ * emitted by the compiler, not called directly.
1257
+ * @internal
1258
+ */
1259
+ export const jsxs = jsx;
1260
+
1261
+ /**
1262
+ * JSX Fragment (`<>…</>`) — renders its children to a single {@link HtmlNode} with no
1263
+ * wrapper element.
1264
+ */
1265
+ export function Fragment({ children }: { children?: unknown }): HtmlNode {
1266
+ return { html: renderChild(children) };
1267
+ }
1268
+
1269
+ /** Extract the raw HTML string from a rendered {@link HtmlNode}. */
1270
+ export function renderToString(node: HtmlNode): string {
1271
+ return node.html;
1272
+ }