@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.
- package/CHANGELOG.md +61 -0
- package/LICENSE +21 -0
- package/README.md +148 -0
- package/package.json +84 -0
- package/src/Component.ts +1498 -0
- package/src/Form.ts +189 -0
- package/src/Layout.ts +47 -0
- package/src/augment.ts +94 -0
- package/src/client/FlowClientError.ts +12 -0
- package/src/client/FlowComponent.ts +96 -0
- package/src/client/alpinejs-morph.d.ts +5 -0
- package/src/client/bridge.ts +3463 -0
- package/src/client/confirm.ts +338 -0
- package/src/client/cspAdapter.ts +79 -0
- package/src/client/cspEvaluator.ts +494 -0
- package/src/client/errorOverlay.ts +124 -0
- package/src/client/headless.ts +948 -0
- package/src/client/index.csp.ts +47 -0
- package/src/client/index.ts +56 -0
- package/src/client/optimistic.ts +52 -0
- package/src/client/timeline.ts +160 -0
- package/src/client/url.ts +51 -0
- package/src/commands/MakeFlowCommand.ts +290 -0
- package/src/compiler/cache.ts +101 -0
- package/src/compiler/directives.ts +107 -0
- package/src/compiler/helpers.ts +26 -0
- package/src/compiler/index.ts +172 -0
- package/src/compiler/transform.ts +2070 -0
- package/src/components.ts +1465 -0
- package/src/config.ts +52 -0
- package/src/decorators.ts +978 -0
- package/src/dehydrate.ts +333 -0
- package/src/durable.ts +243 -0
- package/src/events.ts +118 -0
- package/src/frameworkEvents.ts +40 -0
- package/src/headless.ts +1147 -0
- package/src/index.ts +267 -0
- package/src/jsx-dev-runtime.ts +14 -0
- package/src/jsx-runtime.ts +1272 -0
- package/src/mixins.ts +199 -0
- package/src/observability.ts +167 -0
- package/src/pagination.ts +300 -0
- package/src/presence.ts +81 -0
- package/src/provider/FlowProvider.ts +1401 -0
- package/src/registry.ts +118 -0
- package/src/renderContext.ts +55 -0
- package/src/router.ts +401 -0
- package/src/shared.ts +226 -0
- package/src/store.ts +116 -0
- package/src/synths/CarbonIntervalSynth.ts +22 -0
- package/src/synths/CarbonSynth.ts +20 -0
- package/src/synths/CollectionSynth.ts +62 -0
- package/src/synths/DateSynth.ts +24 -0
- package/src/synths/ModelSynth.ts +78 -0
- package/src/synths/TemporaryUploadedFileSynth.ts +19 -0
- package/src/synths/index.ts +148 -0
- package/src/testing.ts +361 -0
- package/src/types.ts +315 -0
- package/src/uploads/FileUploads.ts +94 -0
- package/src/uploads/TemporaryUploadedFile.ts +274 -0
- package/src/uploads/sign.ts +18 -0
- package/src/urlSafety.ts +0 -0
- package/src/utils.ts +216 -0
- 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, "&")
|
|
254
|
+
.replace(/"/g, """)
|
|
255
|
+
.replace(/</g, "<")
|
|
256
|
+
.replace(/>/g, ">");
|
|
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
|
+
}
|