// ── @zerotal/flow JSX runtime ─────────────────────────────────────────────────── // // The `@zerotal/flow/jsx-runtime` entry — the automatic-JSX factory module the // TypeScript/Bun toolchain resolves to when compiling `.tsx`. Configured via tsconfig: // `{ "jsxImportSource": "@zerotal/flow" }`. It renders JSX straight to an HTML string // (server-side), wiring Flow directives and embedding child Component placeholders. // Apps do not call `jsx`/`jsxs` directly — the compiler emits those calls; the public // surface here is the `HtmlNode` type and `Fragment`. import { AsyncLocalStorage } from "node:async_hooks"; import { escapeHtml } from "@zerotal/core/helpers"; import { getExposedProps, getLockedProps } from "./decorators.ts"; import { _setExposedKeyCapture, _consumeExposedKeyCapture, _consumeExposedKeyReads, } from "./utils.ts"; import { URL_ATTRIBUTES, sanitizeUrl } from "./urlSafety.ts"; /** The rendered output of a JSX element: an object carrying its serialized HTML string. */ export type HtmlNode = { html: string }; interface FlowDirectives { /** error={this.errors.field} — shows the field's first validation message, reactively. */ error?: any; /** Input sync timing for value={…}/checked={…}: `live` syncs each keystroke, `blur` on blur. */ live?: boolean; blur?: boolean; /** Coerce the bound value to a number before syncing (value={…} number). */ number?: boolean; /** Trim whitespace from the bound value before syncing (value={…} trim). */ trim?: boolean; /** Persist an unsubmitted input value to localStorage under this key (draft="post-body"). */ draft?: string; /** Focus this field on mount, morph-safe (autoFocus). */ autoFocus?: boolean; /** After a patch, focus this field if it becomes invalid — WCAG first-error focus (focusOnError). */ focusOnError?: boolean; bindClass?: string | false | null | undefined; bindStyle?: Record; bindHref?: string; bindAttr?: Record; showOnLoading?: boolean | string; hideOnLoading?: boolean; loadingClass?: string; loadingClassRemove?: string; loadingAttr?: string; loadingTarget?: any; loadingTargetExcept?: any; delay?: boolean | string; showOnDirty?: boolean; hideOnDirty?: boolean; dirtyClass?: string; dirtyClassRemove?: string; dirtyAttr?: string; dirtyTarget?: any; navigate?: boolean; navigateHover?: boolean; navigateDown?: boolean; navigatePreserveScroll?: boolean; current?: string; currentExact?: boolean; currentStrict?: boolean; cloak?: boolean; /** Move this element to a target selector on the client (e.g. teleport="body"). */ teleport?: string; // ── Tier 3 UI plugins ─────────────────────────────────────────────────────── /** Input mask, e.g. mask="(999) 999-9999" (@alpinejs/mask → x-mask). */ mask?: string; /** Trap focus while the expression is truthy, e.g. trap="$flow.open" (x-trap). */ trap?: string; /** Animate show/hide height — pair with a native x-show (@alpinejs/collapse → x-collapse). */ collapse?: boolean; /** Position relative to another element, e.g. anchor="$refs.btn" (@alpinejs/anchor → x-anchor). */ anchor?: string; /** * Gate the action behind a styled confirmation dialog. A string is the body * text; the object form configures the dialog (title, button labels, a `danger` * variant, a custom `icon`, or a type-to-confirm `prompt`). * * @example confirm="Delete this item?" * @example confirm={{ title: "Delete project", message: "This can't be undone.", danger: true, confirm: "Delete" }} * @example confirm={{ message: "Type the repo name to confirm.", prompt: "acme/web" }} */ confirm?: | string | { message: string; title?: string; /** Confirm button label. */ confirm?: string; /** Cancel button label. */ cancel?: string; /** Visual style — `danger` (red, destructive) or `primary` (default). */ variant?: "danger" | "primary"; /** Shorthand for `variant: "danger"`. */ danger?: boolean; /** Custom icon (emoji/text/SVG), or `false` to hide it. */ icon?: string | false; /** Show a type-to-confirm input; Confirm unlocks when the value matches this. */ prompt?: string; }; /** Animate a show= element's enter/leave: `transition` (fade) or a preset string * ("fade" | "scale" | "slide-up" | "slide-down" | "slide-left" | "slide-right"). */ transition?: boolean | string | Record; transitionIn?: Record; transitionOut?: Record; onInit?: any; onIntersect?: any; onIntersectLeave?: any; /** Fire onIntersect only once, then stop observing (x-intersect `.once`). */ intersectOnce?: boolean; /** IntersectionObserver rootMargin, e.g. "200px" to fire before the element enters view. */ intersectMargin?: string; /** How much must be visible before onIntersect fires: "half" | "full" | a 0..1 number. */ intersectThreshold?: string | number; poll?: boolean | string | { every?: string; action?: any; keepAlive?: boolean; visible?: boolean }; showOnOffline?: boolean; hideOnOffline?: boolean; offlineClass?: string; offlineClassRemove?: string; offlineAttr?: string; /** Show this element after an action fails (optimistic failed state); pair with `hideOnError`. */ showOnError?: boolean; hideOnError?: boolean; ignore?: boolean; ignoreSelf?: boolean; ref?: any; replace?: boolean; replaceSelf?: boolean; show?: any; showImportant?: boolean; /** * The reorder action, as a method reference or its name. * * Read off the container a child was **dropped into** and called `(key, index)`, * so the destination is encoded in which method runs and nowhere in the payload. * Dragging between containers therefore needs one action per container. */ onSort?: any; sortItem?: any; sortGroup?: string; /** * Emitted as `flow:sort:group-id`, and **currently read by nothing** — neither the * client runtime nor the compiler looks for it. Setting it has no effect today. */ sortGroupId?: any; sortHandle?: boolean; sortIgnore?: boolean; stream?: string; streamReplace?: boolean; text?: any; // Fallbacks for the older flow:* syntax just in case [key: `flow:${string}`]: any; } // TypeScript resolves JSX element/attribute types by looking up a JSX namespace — // there is no module-shaped way to provide it. // eslint-disable-next-line @typescript-eslint/no-namespace export namespace JSX { export type Element = HtmlNode; export interface ElementChildrenAttribute { children: unknown; } /** Class components must look like a Flow Component (have render()). */ export interface ElementClass { render(): unknown; } /** `key` is accepted on every element/component; `slots` supplies named slot content to child components. */ export interface IntrinsicAttributes { key?: string | number; /** A child component's default slot — its plain children. */ children?: unknown; /** Defer a child component's mount until its placeholder enters the viewport. */ lazy?: boolean; /** Mount a child component right after the first paint (non-blocking). */ defer?: boolean; /** * Render a child component's placeholder now and its real markup later on the * same response — no second round trip. For content that is definitely needed * and merely slow; use `lazy`/`defer` for content that may never be needed. */ stream?: boolean; /** * Named slot content for a child component: `slots={{ header:

…

, footer: … }}`. * The child places each with `this.slot("header")`; its plain children are the default slot. */ slots?: Record; } /** * Props for class components: every own field of the Component subclass becomes * an optional prop — `` seeds those * fields before onMount(). Base Component internals are excluded. */ export type LibraryManagedAttributes = C extends new () => infer I ? Partial> & IntrinsicAttributes : P; export interface IntrinsicElements { [tag: string]: Record & FlowDirectives & { onClick?: any; onSubmit?: any; onChange?: any; onKeyDown?: any; onKeyUp?: any; onScroll?: any; // ... add others if needed, React types usually cover this but we don't depend on React types here [key: `on${string}`]: any; }; } } const VOID_ELEMENTS = new Set([ "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr", ]); // Text-child escaping delegates to the framework's one escaper // (@zerotal/core/helpers, Bun.escapeHTML underneath). It escapes quotes as // well as & < > — a superset of what text nodes strictly need, which is // harmless in content and keeps every runtime's output consistent. // Attribute values still go through escapeAttr below, whose contract is // coupled to this renderer's double-quoted-attribute splicing model. /** * Escape a value for use inside a double-quoted HTML attribute — which is the only form * this renderer emits, so `'` needs no escaping. * * `<` and `>` do, and used not to be covered. A user-controlled attribute is spliced into * finished HTML alongside the child-placeholder comment (``), which a * blind `split`/`join` replaces wherever it appears — including inside an attribute value, * where the substituted child markup's own quotes then terminate the attribute early and * everything after it is parsed as markup. * * This is not what keeps a value out of an Alpine expression: an `x-on:*` / `:*` attribute * is executable code, and the browser hands Alpine the *decoded* value. Use * {@link jsLiteral} to embed a value in one. */ function escapeAttr(s: string): string { return String(s) .replace(/&/g, "&") .replace(/"/g, """) .replace(//g, ">"); } // ── Render context ──────────────────────────────────────────────────────────── // Scoped per-render via AsyncLocalStorage so concurrent top-level renders // (e.g. two simultaneous GET requests each awaiting DB calls) cannot // cross-contaminate their page reference or child-thunk lists. /** * How a failed child render is rendered instead of propagating. * * Installed by `` onto the thunks whose tokens appear in its * children — see `_protectThunks`. */ export interface _ThunkBoundary { fallback: (error: unknown) => string; } interface _Thunk { token: string; run: () => Promise; boundary?: _ThunkBoundary; } interface _RenderCtx { page: Record; thunks: _Thunk[]; seq: number; /** * Per-render random suffix on the child-placeholder token. * * The splice that resolves child components is a blind `split`/`join` over finished HTML, * so any occurrence of the token gets replaced — including one a user put in a string. * Escaping in `escapeAttr`/`escapeHtml` is the actual control; this makes the token * unguessable so a path that ever misses an escape still cannot be aimed at. */ nonce: string; } const _renderCtxStorage = new AsyncLocalStorage<_RenderCtx>(); /** @internal — returns the render context for the current async execution. */ function _getRenderCtx(): _RenderCtx | undefined { return _renderCtxStorage.getStore(); } // ── Nested component thunks ─────────────────────────────────────────────────── // can't render inline: jsx() is synchronous but a // child render is async (onMount, dehydrate). jsx() therefore emits a unique // comment token and queues a thunk; _renderFlowPage() runs the thunks // SEQUENTIALLY after the parent render completes (sequential execution keeps // the render context from interleaving) and splices the html in. /** * Render a Component to HTML: establishes a per-request render context via * AsyncLocalStorage, awaits the page's render(), then resolves any nested * `` children queued during evaluation. * Used by the route handler, the WS dispatcher, and Component.child() (recursion * handles grandchildren — each recursive call gets its own ALS scope). */ export async function _renderFlowPage( page: object, render: () => Promise, ): Promise { // Lifecycle: rendering() before, rendered(html) after — covers every render // path (initial GET, WS patch, nested children, tests) since they all route here. const hooks = page as { onRendering?: () => Promise; onRendered?: (html: string) => Promise; }; if (typeof hooks.onRendering === "function") await hooks.onRendering(); // Scope @computed memoization to this render pass: getters cache their result // while __flowComputing is set, and the cache is discarded when the pass ends. const _cc = page as Record; _cc["__flowComputing"] = true; _cc["__flowComputed"] = new Map(); const _endComputed = () => { _cc["__flowComputing"] = false; _cc["__flowComputed"] = undefined; }; // ── AOT-compiled fast path ──────────────────────────────────────────────── // Compiled render() functions emit HTML via string concatenation, bypassing // jsx(), getter instrumentation, and the thunk/token system entirely. if ((page.constructor as { __flowCompiled?: boolean }).__flowCompiled) { try { const node = await render(); if (typeof hooks.onRendered === "function") await hooks.onRendered(node.html); return node.html; } finally { _endComputed(); } } const ctx: _RenderCtx = { page: page as Record, thunks: [], seq: 0, nonce: crypto.randomUUID().slice(0, 8), }; let rendered: string; try { rendered = await _renderCtxStorage.run(ctx, async () => { // Temporarily instrument snapshot props so _resolveBindName can detect // which property was accessed when flow(this.prop) is evaluated. // We instrument both @expose and @locked (locked is in exposed by design). const exposed = getExposedProps(page); const savedDescs: Map = new Map(); for (const key of exposed) { const desc = Object.getOwnPropertyDescriptor(page, key); if (!desc) continue; // not an own property — cannot instrument if (desc.get) { // Already has a getter (e.g. Bun Stage 3 auto-accessor for decorated fields). // Wrap it so the key capture still fires on access. const origGet = desc.get; savedDescs.set(key, desc); Object.defineProperty(page, key, { ...desc, get() { _setExposedKeyCapture(key); return origGet.call(this); }, }); } else { // Plain data property — replace with a capture getter. const captured = desc.value as unknown; savedDescs.set(key, desc); Object.defineProperty(page, key, { get() { _setExposedKeyCapture(key); return captured; }, set(v: unknown) { Object.defineProperty(page, key, { value: v, writable: true, configurable: true, enumerable: true, }); }, configurable: true, enumerable: true, }); } } // Instrument Form fields too, so a nested binding (value={this.form.email}) // captures the full path "form.email" — enabling Form binding in the runtime // renderer (the compiler resolves it from the AST instead). const savedFormDescs: Array<{ form: Record; field: string; desc: PropertyDescriptor; }> = []; for (const [key, savedDesc] of savedDescs) { // Read the ORIGINAL value (not the instrumented getter) to avoid polluting capture. const fv = savedDesc && !savedDesc.get ? savedDesc.value : undefined; if ( !fv || typeof fv !== "object" || (fv as { __isFlowForm?: unknown }).__isFlowForm !== true ) continue; const form = fv as Record; for (const field of Object.keys(form)) { if (field.startsWith("_") || field === "rules" || typeof form[field] === "function") continue; const fdesc = Object.getOwnPropertyDescriptor(form, field); if (!fdesc || fdesc.get) continue; const fcap = `${key}.${field}`; const captured = fdesc.value as unknown; savedFormDescs.push({ form, field, desc: fdesc }); Object.defineProperty(form, field, { get() { _setExposedKeyCapture(fcap); return captured; }, set(v: unknown) { Object.defineProperty(form, field, { value: v, writable: true, configurable: true, enumerable: true, }); }, configurable: true, enumerable: true, }); } } try { const node = await render(); let html = node.html; for (const { token, run, boundary } of ctx.thunks) { let childHtml: string; try { childHtml = (await run()).html; } catch (error) { // Unprotected children still take the page down — a boundary is the // opt-in, and swallowing errors by default would hide real bugs. if (!boundary) throw error; childHtml = boundary.fallback(error); } html = html.split(token).join(childHtml); } return html; } finally { for (const [key, desc] of savedDescs) { if (desc) Object.defineProperty(page, key, desc); } for (const { form, field, desc } of savedFormDescs) { Object.defineProperty(form, field, desc); } } }); } finally { _endComputed(); } if (typeof hooks.onRendered === "function") await hooks.onRendered(rendered); return rendered; } /** * Resolve `bind={this.draft}` (a value) or `bind="draft"` (a name) to the * snapshot property name it refers to. * * `@locked` props are registered in BOTH `_exposedProps` and `_lockedProps` * by the @locked decorator (so they appear in the snapshot). This means * `getExposedProps()` already includes locked props. * * @param allowLocked - When false (default — for two-way bind attrs), locked * props are filtered out of the search so they cannot be bound. When true * (read-only attrs like `text`), the full exposed set (which includes locked) * is used. */ export function _resolveBindName( value: unknown, attr: string, hint?: string, allowLocked = false, ): string { const page = _getRenderCtx()?.page ?? null; if (!page) { throw new Error( `[Flow] ${attr}={...} used outside a Component render. ` + `Use {...this.bind('propName')} instead.`, ); } const exposed = getExposedProps(page); // already includes @locked props // When allowLocked = false (two-way bind), exclude @locked keys so they // can't be bound. When allowLocked = true (text, show…), use the full set. const keys = allowLocked ? exposed : (() => { const locked = getLockedProps(page); return locked.size === 0 ? exposed : new Set([...exposed].filter((k) => !locked.has(k))); })(); // Name form: text="ticks" or bind="draft". if (typeof value === "string" && keys.has(value)) return value; // Capture fast path: the instrumented getter set _exposedKeyCapture when // this.propName was evaluated in the template. It's a single slot, and *all* // of an element's props are evaluated before jsx() runs — so a later binding // (e.g. class={"x " + this.accent}) overwrites the capture that text={this.count} // set. Guard with a value-identity check: only trust the captured key when its // current value is the one we're resolving, so text always binds to its own // property regardless of sibling bindings on the same element. A mismatch (a // stale capture, or a flow(expr) whose computed value differs from the property) // falls through to value-scan and the hint fallback below. const capturedKey = _consumeExposedKeyCapture(); if ( capturedKey && keys.has(capturedKey) && Object.is((page as Record)[capturedKey], value) ) { return capturedKey; } // Value form fallback: text={someExpr} — match by identity. // When allowLocked is true we also explicitly scan @locked props in case // the Bun runtime's decorator implementation didn't register them in // _exposedProps (defence against any Stage 3 decorator edge-cases). const scanKeys = allowLocked ? new Set([...keys, ...getLockedProps(page)]) : keys; const candidates: string[] = []; for (const key of scanKeys) { if (Object.is((page as Record)[key], value)) candidates.push(key); } // Deduplicate (a key may appear in both exposed and locked). const unique = [...new Set(candidates)]; if (unique.length === 1) return unique[0]!; if (unique.length > 1) { if (hint && unique.includes(hint)) return hint; throw new Error( `[Flow] ${attr}={...} is ambiguous — multiple @expose props currently hold ` + `the same value (${unique.join(", ")}). ` + `Disambiguate with ${attr}="propName".`, ); } // Last-chance: trust the hint key directly when value-identity matching // produced no candidates. This handles text={flow(expr)} where the // expression reads a property (capturing __key) but doesn't return the // property value itself (e.g. a conditional or computed string). if (hint && scanKeys.has(hint)) return hint; throw new Error( `[Flow] ${attr}={...} does not match any @expose property on ` + `${page.constructor?.name ?? "the page"}. ` + `Pass the property itself (e.g. ${attr}={this.draft}) or its name (${attr}="draft").`, ); } /** * Non-throwing resolver for `value={this.x}` / `checked={this.x}` input bindings. * Returns the snapshot prop the value came from (confirmed via the instrumented-getter * capture AND a value-identity match, so a literal or server value never false-positives), * or null when the attribute is just an ordinary server-rendered value. * `locked` distinguishes @locked (read-only, reactive) from @expose (two-way). * * @remarks * The getter capture is a single slot and *all* of an element's props are evaluated * before `jsx()` runs, so any later prop that reads an exposed property clobbers it — * `value={this.destination} disabled={this.notSure}` leaves the capture pointing at * `notSure`. The freshness check then fails and the element renders with no * `flow:model` at all: the field accepts typing and nothing ever reaches the server. * When the capture is unusable we therefore fall back to a value-identity scan over * the exposed props, exactly as `_resolveBindName` does — binding correctly whenever * the answer is unambiguous, rather than silently dropping the binding. */ function _resolveValueBind(value: unknown): { name: string; locked: boolean } | null { const page = _getRenderCtx()?.page ?? null; if (!page) return null; const cap = _consumeExposedKeyCapture(); const exposed = getExposedProps(page); // includes @locked // Dotted capture (e.g. "form.email") — a Form field. The root must be an exposed // prop and the nested value must identity-match (freshness guard). Form fields // are always writable (two-way), so locked is false. if (cap && cap.includes(".")) { const root = cap.slice(0, cap.indexOf(".")); if (!exposed.has(root)) return null; const nested = cap .split(".") .reduce((o, k) => (o == null ? undefined : (o as Record)[k]), page); if (!Object.is(nested, value)) return null; return { name: cap, locked: false }; } // Capture fast path: trust it only when it is fresh for THIS value (guards against // a stale capture lingering from a previous element that didn't consume it). if (cap && exposed.has(cap) && Object.is((page as Record)[cap], value)) { _consumeExposedKeyReads(); // this element is resolved; don't leak reads to the next return { name: cap, locked: getLockedProps(page).has(cap) }; } // The single slot was clobbered by a later prop on the same element (the B2 case: // `value={this.destination} disabled={this.notSure}`). Fall back to the keys actually // read while this element's props were evaluated, most recent first, and take the one // whose current value IS this value. // // Crucially this is NOT a scan of every exposed prop: a key only qualifies if it was // genuinely read through a `this.` getter, so a literal `value="CUSTOM"` that happens // to equal some property's current value still binds nothing. const reads = _consumeExposedKeyReads(); for (const key of reads) { if (key === cap) continue; // already rejected above if (!exposed.has(key)) continue; if (!Object.is((page as Record)[key], value)) continue; return { name: key, locked: getLockedProps(page).has(key) }; } return null; } /** * Read a compiler-injected bind key for a binding prop off a component's props. * * The AOT bind-injection pass emits `__flowBinds={{ show: "sheetOpen" }}` for * `show={this.sheetOpen}` (and `bind`/`query`) on component elements, so a * binding component resolves its bound property statically — reliable even when * the runtime getter-capture was clobbered by a sibling/child binding, and * unambiguous when several props share a value (e.g. two `false`s). Binding * components consult this before `name=`/value-identity resolution. Returns null * when no injection is present (hand-written JSX, or a non-compiled page). */ export function _injectedBindKey(props: unknown, attr: string): string | null { const binds = (props as { __flowBinds?: Record } | null)?.__flowBinds; const key = binds && binds[attr]; return typeof key === "string" ? key : null; } /** * @internal Resolve a reactive prop value (e.g. `this.open`) to its snapshot * property name, or null when the value isn't a bound @expose/@locked prop. * * Native components (e.g. ) are plain functions that * receive their children already-evaluated, so the getter capture for `show` is * usually clobbered by a reactive child (e.g. an inner value={this.x}). We * therefore try the (freshness-checked) capture first, then fall back to a * value-identity match over the exposed props — unambiguous in the common case * where the bound prop's current value is unique among exposed props. When the * page was compiled, `_injectedBindKey` (consulted by the component first) makes * this path a fallback for hand-written / non-compiled JSX only. */ export function _resolveReactiveName(value: unknown): string | null { const viaCapture = _resolveValueBind(value); if (viaCapture) { // _resolveValueBind's freshness check re-reads the prop through the // instrumented getter, which re-arms the capture. Clear it so a following // sibling (e.g. a `: a literal `value` attribute on a : remember the value so the matching