@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,47 @@
|
|
|
1
|
+
// ── @zerotal/flow CSP-safe client bundle entry point ─────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Built by FlowProvider.onBooting() when `flow.cspSafe` is enabled, producing
|
|
4
|
+
// the `/__flow/runtime.js` served to browsers under a strict Content-Security-
|
|
5
|
+
// Policy (no `'unsafe-eval'`).
|
|
6
|
+
//
|
|
7
|
+
// Identical to ./index.ts except it installs the eval-free CSP evaluator
|
|
8
|
+
// (cspAdapter) before Alpine.start() and puts the bridge in CSP mode.
|
|
9
|
+
|
|
10
|
+
import Alpine from "alpinejs";
|
|
11
|
+
import morph from "@alpinejs/morph";
|
|
12
|
+
import mask from "@alpinejs/mask";
|
|
13
|
+
import focus from "@alpinejs/focus";
|
|
14
|
+
import collapse from "@alpinejs/collapse";
|
|
15
|
+
import anchor from "@alpinejs/anchor";
|
|
16
|
+
import persist from "@alpinejs/persist";
|
|
17
|
+
import resize from "@alpinejs/resize";
|
|
18
|
+
|
|
19
|
+
import { initBridge, registerFlowMagic, setCspMode } from "./bridge.ts";
|
|
20
|
+
import { registerHeadless } from "./headless.ts";
|
|
21
|
+
import { installCspEvaluator } from "./cspAdapter.ts";
|
|
22
|
+
import { initClientStore } from "../store.ts";
|
|
23
|
+
|
|
24
|
+
(window as unknown as Record<string, unknown>).Alpine = Alpine;
|
|
25
|
+
|
|
26
|
+
// Swap Alpine's evaluator for the eval-free interpreter BEFORE anything evaluates.
|
|
27
|
+
installCspEvaluator(Alpine as never);
|
|
28
|
+
setCspMode(true); // bridge-managed bindings (flow:text, …) also avoid new Function
|
|
29
|
+
|
|
30
|
+
Alpine.plugin(morph);
|
|
31
|
+
Alpine.plugin(mask);
|
|
32
|
+
Alpine.plugin(focus);
|
|
33
|
+
Alpine.plugin(collapse);
|
|
34
|
+
Alpine.plugin(anchor);
|
|
35
|
+
Alpine.plugin(persist);
|
|
36
|
+
Alpine.plugin(resize);
|
|
37
|
+
|
|
38
|
+
registerFlowMagic(Alpine as never);
|
|
39
|
+
registerHeadless(Alpine as never);
|
|
40
|
+
|
|
41
|
+
document.addEventListener("DOMContentLoaded", () => {
|
|
42
|
+
initBridge();
|
|
43
|
+
// Create the reactive global `store` (draining any defineStore() calls) BEFORE Alpine.start,
|
|
44
|
+
// so `$flow.store.*` bindings resolve against one shared object on their first evaluation.
|
|
45
|
+
initClientStore(Alpine as never);
|
|
46
|
+
Alpine.start();
|
|
47
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// ── @zerotal/flow client bundle entry point ─────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// This file is the entry point for Bun.build() in FlowProvider.onBooting().
|
|
4
|
+
// It produces /__flow/runtime.js served to browsers.
|
|
5
|
+
//
|
|
6
|
+
// Includes:
|
|
7
|
+
// - Alpine.js core
|
|
8
|
+
// - @alpinejs/morph plugin (DOM diffing)
|
|
9
|
+
// - UI plugins: mask, focus (x-trap/$focus), collapse, anchor, persist ($persist), resize (x-resize)
|
|
10
|
+
// - Flow WS bridge + $flow magic
|
|
11
|
+
|
|
12
|
+
import Alpine from "alpinejs";
|
|
13
|
+
import morph from "@alpinejs/morph";
|
|
14
|
+
import mask from "@alpinejs/mask";
|
|
15
|
+
import focus from "@alpinejs/focus";
|
|
16
|
+
import collapse from "@alpinejs/collapse";
|
|
17
|
+
import anchor from "@alpinejs/anchor";
|
|
18
|
+
import persist from "@alpinejs/persist";
|
|
19
|
+
import resize from "@alpinejs/resize";
|
|
20
|
+
|
|
21
|
+
import { initBridge, registerFlowMagic } from "./bridge.ts";
|
|
22
|
+
import { registerHeadless } from "./headless.ts";
|
|
23
|
+
import { initClientStore } from "../store.ts";
|
|
24
|
+
|
|
25
|
+
// Expose Alpine on window so FlowComponent and third-party Alpine plugins
|
|
26
|
+
// can access it via window.Alpine (standard Alpine convention).
|
|
27
|
+
(window as unknown as Record<string, unknown>).Alpine = Alpine;
|
|
28
|
+
|
|
29
|
+
// Register Alpine plugins (morph powers server patches; the rest back the Tier 3
|
|
30
|
+
// directives: mask → x-mask, focus → x-trap/$focus, collapse → x-collapse,
|
|
31
|
+
// anchor → x-anchor, persist → $persist, resize → x-resize).
|
|
32
|
+
Alpine.plugin(morph);
|
|
33
|
+
Alpine.plugin(mask);
|
|
34
|
+
Alpine.plugin(focus);
|
|
35
|
+
Alpine.plugin(collapse);
|
|
36
|
+
Alpine.plugin(anchor);
|
|
37
|
+
Alpine.plugin(persist);
|
|
38
|
+
Alpine.plugin(resize);
|
|
39
|
+
|
|
40
|
+
// Register $flow magic BEFORE Alpine.start() so it's available in x-data
|
|
41
|
+
registerFlowMagic(Alpine as never);
|
|
42
|
+
|
|
43
|
+
// Register headless Alpine.data factories (flowRadioGroup, flowListbox).
|
|
44
|
+
registerHeadless(Alpine as never);
|
|
45
|
+
|
|
46
|
+
// initBridge() MUST run before Alpine.start(). Alpine evaluates x-text / x-show
|
|
47
|
+
// expressions immediately on start; the $flow magic needs _components populated
|
|
48
|
+
// (by initBridge) or it returns undefined and no reactive dependency is tracked —
|
|
49
|
+
// meaning x-text bindings would never update after the initial render.
|
|
50
|
+
document.addEventListener("DOMContentLoaded", () => {
|
|
51
|
+
initBridge(); // ← registers FlowComponents into _components first
|
|
52
|
+
// Create the reactive global `store` (draining any defineStore() calls queued at
|
|
53
|
+
// app start) BEFORE Alpine.start, so `store.*` bindings resolve on first evaluation.
|
|
54
|
+
initClientStore(Alpine as never);
|
|
55
|
+
Alpine.start(); // ← then Alpine processes x-text etc. with $flow + store working
|
|
56
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// ── Optimistic collections (client) ────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// `appendOptimistic(prop, item)` / `removeOptimistic(prop, match)` mutate a reactive array on the
|
|
4
|
+
// client *before* the server confirms, so a list add/remove shows instantly. The item survives any
|
|
5
|
+
// interim (broadcast/event) patch — the pending ops are re-applied on top of each server merge —
|
|
6
|
+
// and is reconciled when the owning action's patch lands: on success the authoritative server list
|
|
7
|
+
// stands (the real, persisted row); on failure the server list is unchanged, so the optimistic
|
|
8
|
+
// change rolls back automatically. This module is the pure, DOM-free core (the bridge applies it to
|
|
9
|
+
// the live Alpine store).
|
|
10
|
+
|
|
11
|
+
/** A pending optimistic mutation on one array prop. */
|
|
12
|
+
export interface OptOp {
|
|
13
|
+
prop: string;
|
|
14
|
+
kind: "append" | "remove";
|
|
15
|
+
/** The exact item reference appended (append ops). */
|
|
16
|
+
item?: unknown;
|
|
17
|
+
/** Predicate identifying the removed item(s) (remove ops). */
|
|
18
|
+
match?: (item: unknown) => boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Append `item` unless the array already holds that exact reference (idempotent re-apply). */
|
|
22
|
+
export function applyAppend(arr: readonly unknown[], item: unknown): unknown[] {
|
|
23
|
+
return arr.includes(item) ? [...arr] : [...arr, item];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Remove every item matching `match`. */
|
|
27
|
+
export function applyRemove(arr: readonly unknown[], match: (item: unknown) => boolean): unknown[] {
|
|
28
|
+
return arr.filter((x) => !match(x));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Re-apply all pending ops onto a fresh (server) snapshot of the arrays, keyed by prop. Pure —
|
|
33
|
+
* returns a new record, leaving `current` untouched. Ops for props absent from `current` (or
|
|
34
|
+
* non-array values) are skipped.
|
|
35
|
+
*/
|
|
36
|
+
export function reapply(
|
|
37
|
+
current: Record<string, unknown[]>,
|
|
38
|
+
ops: readonly OptOp[],
|
|
39
|
+
): Record<string, unknown[]> {
|
|
40
|
+
const out: Record<string, unknown[]> = { ...current };
|
|
41
|
+
for (const op of ops) {
|
|
42
|
+
const arr = out[op.prop];
|
|
43
|
+
if (!Array.isArray(arr)) continue;
|
|
44
|
+
out[op.prop] = op.kind === "append" ? applyAppend(arr, op.item) : applyRemove(arr, op.match!);
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The distinct prop names touched by a set of ops (so the bridge knows which arrays to refresh). */
|
|
50
|
+
export function opProps(ops: readonly OptOp[]): string[] {
|
|
51
|
+
return [...new Set(ops.map((o) => o.prop))];
|
|
52
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// ── Time-travel devtools (client, dev-only) ───────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// The engine already signs and delta-encodes the full component state on every round-trip,
|
|
4
|
+
// and the client already receives + reconstructs that snapshot for each patch. This module
|
|
5
|
+
// RECORDS that stream — one frame per applied patch (plus the initial mount) — into a capped
|
|
6
|
+
// ring buffer, and lets a dev scrub back to any frame: "jump" re-applies a held snapshot + its
|
|
7
|
+
// HTML to the live component (client-only, no server), so state and DOM restore exactly.
|
|
8
|
+
//
|
|
9
|
+
// It is intentionally DOM-free and dependency-free (types only) so the core is unit-testable.
|
|
10
|
+
// The one DOM-touching step — applying a frame to a live component — is an APPLIER function the
|
|
11
|
+
// bridge injects via setTimelineApplier(); everything else is pure data.
|
|
12
|
+
|
|
13
|
+
import type { Snapshot, SnapshotData } from "../types.ts";
|
|
14
|
+
|
|
15
|
+
/** One recorded step in the timeline: the component's state (and DOM) after a patch. */
|
|
16
|
+
export interface TimelineFrame {
|
|
17
|
+
/** Monotonic global sequence number (ordering across all components). */
|
|
18
|
+
seq: number;
|
|
19
|
+
compId: string;
|
|
20
|
+
compName: string;
|
|
21
|
+
/** The action that produced this frame — a method name, `"$set"`, or `"mount"`. */
|
|
22
|
+
action: string;
|
|
23
|
+
/** The full signed snapshot at this frame (restored verbatim on a jump). */
|
|
24
|
+
snapshot: Snapshot;
|
|
25
|
+
/** Snapshot-data field names that changed since this component's previous frame. */
|
|
26
|
+
changed: string[];
|
|
27
|
+
/** The component's outer HTML at this frame, for DOM restore on a jump. */
|
|
28
|
+
html: string | null;
|
|
29
|
+
ts: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const MAX_FRAMES = 250;
|
|
33
|
+
|
|
34
|
+
let _enabled = false;
|
|
35
|
+
let _seq = 0;
|
|
36
|
+
const _frames: TimelineFrame[] = [];
|
|
37
|
+
const _lastDataByComp = new Map<string, SnapshotData>();
|
|
38
|
+
const _currentByComp = new Map<string, number>(); // compId → seq currently displayed (live = latest)
|
|
39
|
+
const _subs = new Set<() => void>();
|
|
40
|
+
|
|
41
|
+
/** DOM applier injected by the bridge; restores a frame's snapshot + HTML into the live component. */
|
|
42
|
+
type Applier = (frame: TimelineFrame) => void;
|
|
43
|
+
let _apply: Applier | null = null;
|
|
44
|
+
|
|
45
|
+
export function setTimelineEnabled(on: boolean): void {
|
|
46
|
+
_enabled = on;
|
|
47
|
+
}
|
|
48
|
+
export function isTimelineEnabled(): boolean {
|
|
49
|
+
return _enabled;
|
|
50
|
+
}
|
|
51
|
+
export function setTimelineApplier(fn: Applier): void {
|
|
52
|
+
_apply = fn;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Subscribe to timeline changes (a new frame, or a jump) — used to re-render the panel. */
|
|
56
|
+
export function onTimelineChange(fn: () => void): void {
|
|
57
|
+
_subs.add(fn);
|
|
58
|
+
}
|
|
59
|
+
function _notify(): void {
|
|
60
|
+
for (const fn of _subs) {
|
|
61
|
+
try {
|
|
62
|
+
fn();
|
|
63
|
+
} catch {
|
|
64
|
+
/* a panel render error must not break recording */
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Field names that differ between two snapshot-data maps (added, removed, or changed value). */
|
|
70
|
+
export function computeChanged(prev: SnapshotData | undefined, next: SnapshotData): string[] {
|
|
71
|
+
const changed: string[] = [];
|
|
72
|
+
for (const [k, v] of Object.entries(next)) {
|
|
73
|
+
const before = prev?.[k];
|
|
74
|
+
if (before === undefined || JSON.stringify(before) !== JSON.stringify(v)) changed.push(k);
|
|
75
|
+
}
|
|
76
|
+
if (prev) for (const k of Object.keys(prev)) if (!(k in next)) changed.push(k);
|
|
77
|
+
return changed;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Record a frame for a just-applied patch (or the initial mount). No-op unless enabled.
|
|
82
|
+
* Computes the changed fields vs this component's previous frame and appends to the ring buffer.
|
|
83
|
+
*/
|
|
84
|
+
export function recordFrame(input: {
|
|
85
|
+
compId: string;
|
|
86
|
+
compName: string;
|
|
87
|
+
action: string;
|
|
88
|
+
snapshot: Snapshot;
|
|
89
|
+
html: string | null;
|
|
90
|
+
}): TimelineFrame | null {
|
|
91
|
+
if (!_enabled) return null;
|
|
92
|
+
const changed = computeChanged(_lastDataByComp.get(input.compId), input.snapshot.data);
|
|
93
|
+
_lastDataByComp.set(input.compId, input.snapshot.data);
|
|
94
|
+
const frame: TimelineFrame = {
|
|
95
|
+
seq: _seq++,
|
|
96
|
+
compId: input.compId,
|
|
97
|
+
compName: input.compName,
|
|
98
|
+
action: input.action,
|
|
99
|
+
snapshot: input.snapshot,
|
|
100
|
+
changed,
|
|
101
|
+
html: input.html,
|
|
102
|
+
ts: Date.now(),
|
|
103
|
+
};
|
|
104
|
+
_frames.push(frame);
|
|
105
|
+
if (_frames.length > MAX_FRAMES) _frames.shift();
|
|
106
|
+
_currentByComp.set(frame.compId, frame.seq); // a live frame → we're at the latest
|
|
107
|
+
_notify();
|
|
108
|
+
return frame;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function getFrames(): readonly TimelineFrame[] {
|
|
112
|
+
return _frames;
|
|
113
|
+
}
|
|
114
|
+
export function getFramesFor(compId: string): TimelineFrame[] {
|
|
115
|
+
return _frames.filter((f) => f.compId === compId);
|
|
116
|
+
}
|
|
117
|
+
export function frameBySeq(seq: number): TimelineFrame | undefined {
|
|
118
|
+
return _frames.find((f) => f.seq === seq);
|
|
119
|
+
}
|
|
120
|
+
export function latestFrameFor(compId: string): TimelineFrame | undefined {
|
|
121
|
+
for (let i = _frames.length - 1; i >= 0; i--)
|
|
122
|
+
if (_frames[i]!.compId === compId) return _frames[i];
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
/** The seq currently displayed for a component (its latest frame unless the user jumped back). */
|
|
126
|
+
export function currentSeq(compId: string): number | undefined {
|
|
127
|
+
return _currentByComp.get(compId);
|
|
128
|
+
}
|
|
129
|
+
/** True when the component is showing its most recent frame (not rewound). */
|
|
130
|
+
export function isLive(compId: string): boolean {
|
|
131
|
+
const latest = latestFrameFor(compId);
|
|
132
|
+
return !latest || _currentByComp.get(compId) === latest.seq;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Restore the component to the frame with this seq (client-only). Returns false if unavailable. */
|
|
136
|
+
export function jumpTo(seq: number): boolean {
|
|
137
|
+
const f = frameBySeq(seq);
|
|
138
|
+
if (!f || !_apply) return false;
|
|
139
|
+
_apply(f);
|
|
140
|
+
_currentByComp.set(f.compId, seq);
|
|
141
|
+
_notify();
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Return a rewound component to its most recent frame. */
|
|
146
|
+
export function resumeLive(compId: string): boolean {
|
|
147
|
+
const f = latestFrameFor(compId);
|
|
148
|
+
return f ? jumpTo(f.seq) : false;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** @internal Test hook — clear all recorded state. */
|
|
152
|
+
export function _resetTimeline(): void {
|
|
153
|
+
_seq = 0;
|
|
154
|
+
_frames.length = 0;
|
|
155
|
+
_lastDataByComp.clear();
|
|
156
|
+
_currentByComp.clear();
|
|
157
|
+
_subs.clear();
|
|
158
|
+
_apply = null;
|
|
159
|
+
_enabled = false;
|
|
160
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure URL helpers for SPA navigation. No Alpine / DOM imports so this stays
|
|
3
|
+
* unit-testable in isolation — the `$flow.currentUrl` / `$flow.navigateCurrent`
|
|
4
|
+
* helpers in bridge.ts feed it `window.location.href`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Options for {@link buildUrlWithQuery}: query params to merge and an optional hash. */
|
|
8
|
+
export interface CurrentUrlOptions {
|
|
9
|
+
/**
|
|
10
|
+
* Query params to add/update/remove on top of the base URL. A value of
|
|
11
|
+
* `null`, `undefined`, or `""` removes the param; anything else is stringified
|
|
12
|
+
* and set. Params not listed here are preserved.
|
|
13
|
+
*/
|
|
14
|
+
query?: Record<string, unknown>;
|
|
15
|
+
/** Replace the URL hash. `""` clears it; omit to leave the hash untouched. */
|
|
16
|
+
hash?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Build a URL from `baseHref`, merging the given query-param changes.
|
|
21
|
+
*
|
|
22
|
+
* Mirrors the ergonomics of filter UIs: pass only the params you want to change,
|
|
23
|
+
* clear one by passing an empty/nullish value, and keep everything else.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* // base: /users?search=john&status=active&page=2
|
|
27
|
+
* buildUrlWithQuery(location.href, { query: { page: 3 } })
|
|
28
|
+
* // → /users?search=john&status=active&page=3
|
|
29
|
+
*
|
|
30
|
+
* buildUrlWithQuery(location.href, { query: { status: null, page: 1 } })
|
|
31
|
+
* // → /users?search=john&page=1 (status removed)
|
|
32
|
+
*/
|
|
33
|
+
export function buildUrlWithQuery(baseHref: string, options: CurrentUrlOptions = {}): string {
|
|
34
|
+
const url = new URL(baseHref);
|
|
35
|
+
|
|
36
|
+
if (options.query) {
|
|
37
|
+
for (const [key, value] of Object.entries(options.query)) {
|
|
38
|
+
if (value === null || value === undefined || value === "") {
|
|
39
|
+
url.searchParams.delete(key);
|
|
40
|
+
} else {
|
|
41
|
+
url.searchParams.set(key, String(value));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (options.hash !== undefined) {
|
|
47
|
+
url.hash = options.hash;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return url.toString();
|
|
51
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `make:flow` command — scaffolds a Flow page or child component.
|
|
3
|
+
*
|
|
4
|
+
* This is the Flow counterpart to `make:controller` / `make:page`: the
|
|
5
|
+
* flagship server-driven layer previously had no generator, so every
|
|
6
|
+
* `Component` was hand-written. `make:flow Name` emits a ready-to-run class
|
|
7
|
+
* with an `@expose`/`render` skeleton; `--child`, `--crud`, and `--layout`
|
|
8
|
+
* cover the common shapes.
|
|
9
|
+
*/
|
|
10
|
+
import { Command } from "@zerotal/core";
|
|
11
|
+
import { existsSync } from "node:fs";
|
|
12
|
+
|
|
13
|
+
/** Where pages live, in preference order. Real apps use `app/flow/pages`. */
|
|
14
|
+
const PAGE_DIRS = ["app/flow/pages", "app/flow"];
|
|
15
|
+
/** Where child components live, in preference order. */
|
|
16
|
+
const CHILD_DIRS = ["app/flow/components", "app/flow/components"];
|
|
17
|
+
|
|
18
|
+
export class MakeFlowCommand extends Command {
|
|
19
|
+
static override commandName = "make:flow";
|
|
20
|
+
static override description = "Create a new Flow page or child component";
|
|
21
|
+
static override needsApp = false;
|
|
22
|
+
|
|
23
|
+
static override args = [
|
|
24
|
+
{
|
|
25
|
+
name: "name",
|
|
26
|
+
required: true,
|
|
27
|
+
description: "Class name, optionally nested (e.g. Dashboard or Users/Index)",
|
|
28
|
+
},
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
static override flags = [
|
|
32
|
+
{
|
|
33
|
+
name: "child",
|
|
34
|
+
type: "boolean" as const,
|
|
35
|
+
description: "Scaffold a child component (props come from its parent) instead of a page",
|
|
36
|
+
default: false,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: "crud",
|
|
40
|
+
type: "boolean" as const,
|
|
41
|
+
description: "Scaffold a resourceful page with list/create/edit/delete actions",
|
|
42
|
+
default: false,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: "layout",
|
|
46
|
+
type: "string" as const,
|
|
47
|
+
description: "Wrap the page in a layout, e.g. --layout AppLayout",
|
|
48
|
+
default: "",
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: "dir",
|
|
52
|
+
type: "string" as const,
|
|
53
|
+
description: "Override the target directory (default: app/flow/pages or app/flow/components)",
|
|
54
|
+
default: "",
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
override async run(): Promise<void> {
|
|
59
|
+
const name = this.args["name"]!; // e.g. "Users/Index"
|
|
60
|
+
const child = this.flags["child"] as boolean;
|
|
61
|
+
const crud = this.flags["crud"] as boolean;
|
|
62
|
+
const layout = ((this.flags["layout"] as string | undefined) ?? "").trim();
|
|
63
|
+
const dirFlag = ((this.flags["dir"] as string | undefined) ?? "").trim();
|
|
64
|
+
|
|
65
|
+
const componentName = name.includes("/") ? name.split("/").pop()! : name;
|
|
66
|
+
const baseDir = dirFlag
|
|
67
|
+
? dirFlag.replace(/\\/g, "/").replace(/\/+$/, "")
|
|
68
|
+
: _pickDir(child ? CHILD_DIRS : PAGE_DIRS);
|
|
69
|
+
const path = `${baseDir}/${name}.tsx`;
|
|
70
|
+
|
|
71
|
+
if (await Bun.file(path).exists()) {
|
|
72
|
+
this.error(`File already exists: ${path}`);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Depth relative to the pages dir, so the layout import resolves correctly:
|
|
77
|
+
// a page at app/flow/pages/Foo.tsx reaches app/flow/layouts/AppLayout.tsx via ../layouts/…
|
|
78
|
+
const depth = name.split("/").length;
|
|
79
|
+
const prefix = "../".repeat(depth);
|
|
80
|
+
|
|
81
|
+
let stub: string;
|
|
82
|
+
if (child) {
|
|
83
|
+
if (crud || layout) {
|
|
84
|
+
this.warn("--crud and --layout are ignored for a child component.");
|
|
85
|
+
}
|
|
86
|
+
stub = _childStub(componentName);
|
|
87
|
+
} else if (crud) {
|
|
88
|
+
stub = _crudStub(componentName, layout, prefix);
|
|
89
|
+
} else {
|
|
90
|
+
stub = _pageStub(componentName, layout, prefix);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
await Bun.write(path, stub);
|
|
94
|
+
this.info(`Created: ${path}`);
|
|
95
|
+
|
|
96
|
+
if (layout && !child) {
|
|
97
|
+
this.dim(
|
|
98
|
+
` Layout: assumes \`export { ${layout} }\` from ${prefix}layouts/${layout}.tsx — ` +
|
|
99
|
+
`adjust the import if your layout's path or export name differs.`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!child) {
|
|
104
|
+
// File-based routing (`.fileBasedRouting("app/flow/pages")`) auto-discovers this page.
|
|
105
|
+
// Show the explicit registration too, for apps that register routes by hand.
|
|
106
|
+
const url = _urlFor(name);
|
|
107
|
+
this.dim(
|
|
108
|
+
" File-based routing serves this automatically. To register it explicitly, add to routes/web.ts:",
|
|
109
|
+
);
|
|
110
|
+
this.dim(` Router.flow("${url}", ${componentName});`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** First existing candidate directory, else the first candidate as the default. */
|
|
116
|
+
function _pickDir(candidates: string[]): string {
|
|
117
|
+
return candidates.find((d) => existsSync(d)) ?? candidates[0]!;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Derive a kebab-case URL path from a (possibly nested) class name. */
|
|
121
|
+
function _urlFor(name: string): string {
|
|
122
|
+
const segments = name.split("/").map((seg) =>
|
|
123
|
+
seg
|
|
124
|
+
.replace(/Page$/, "")
|
|
125
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
|
126
|
+
.toLowerCase(),
|
|
127
|
+
);
|
|
128
|
+
const path = segments.filter(Boolean).join("/");
|
|
129
|
+
return "/" + path;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function _layoutImport(layout: string, prefix: string): string {
|
|
133
|
+
return layout ? `import { ${layout} } from "${prefix}layouts/${layout}.tsx";\n` : "";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function _layoutStatic(layout: string): string {
|
|
137
|
+
return layout ? ` static layout = ${layout};\n\n` : "";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** A minimal page: one exposed field, one action, a render. */
|
|
141
|
+
function _pageStub(name: string, layout: string, prefix: string): string {
|
|
142
|
+
return `/** @jsxImportSource @zerotal/flow */
|
|
143
|
+
import { Component, expose } from "@zerotal/flow";
|
|
144
|
+
${_layoutImport(layout, prefix)}
|
|
145
|
+
export class ${name} extends Component {
|
|
146
|
+
${_layoutStatic(layout)} @expose count = 0;
|
|
147
|
+
|
|
148
|
+
@expose increment(): void {
|
|
149
|
+
this.count++;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
override async render() {
|
|
153
|
+
return (
|
|
154
|
+
<div>
|
|
155
|
+
<h1>${name}</h1>
|
|
156
|
+
<p>Count: {this.count}</p>
|
|
157
|
+
<button onClick={this.increment}>+</button>
|
|
158
|
+
</div>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** A child component: props assigned by the parent, its own isolated state. */
|
|
166
|
+
function _childStub(name: string): string {
|
|
167
|
+
return `/** @jsxImportSource @zerotal/flow */
|
|
168
|
+
import { Component, expose, locked } from "@zerotal/flow";
|
|
169
|
+
|
|
170
|
+
export class ${name} extends Component {
|
|
171
|
+
// A prop from the parent lands on the same-named field before any hook runs.
|
|
172
|
+
// Mark it @locked so the value survives WebSocket round-trips.
|
|
173
|
+
@locked label = "Count";
|
|
174
|
+
|
|
175
|
+
@expose count = 0;
|
|
176
|
+
|
|
177
|
+
@expose increment(): void {
|
|
178
|
+
this.count++;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
override async render() {
|
|
182
|
+
return (
|
|
183
|
+
<div class="rounded-lg border p-4">
|
|
184
|
+
<p class="text-sm text-gray-500">{this.label}</p>
|
|
185
|
+
<p class="text-2xl font-bold">{this.count}</p>
|
|
186
|
+
<button onClick={this.increment}>+</button>
|
|
187
|
+
</div>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** A resourceful page: list + create/edit/delete, validation, and a form. */
|
|
195
|
+
function _crudStub(name: string, layout: string, prefix: string): string {
|
|
196
|
+
return `/** @jsxImportSource @zerotal/flow */
|
|
197
|
+
import { Component, expose, locked, validate } from "@zerotal/flow";
|
|
198
|
+
${_layoutImport(layout, prefix)}
|
|
199
|
+
// TODO: swap this shape for your model type.
|
|
200
|
+
interface Item {
|
|
201
|
+
id: number;
|
|
202
|
+
name: string;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export class ${name} extends Component {
|
|
206
|
+
${_layoutStatic(layout)} @locked items: Item[] = [];
|
|
207
|
+
@expose editingId: number | null = null;
|
|
208
|
+
@expose @validate((rule) => rule.required().min(2)) name = "";
|
|
209
|
+
|
|
210
|
+
override async onMount(): Promise<void> {
|
|
211
|
+
await this.load();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
@expose async load(): Promise<void> {
|
|
215
|
+
// TODO: load from your model, e.g. this.items = await Item.query().get();
|
|
216
|
+
this.items = [];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
@expose async save(): Promise<void> {
|
|
220
|
+
await this.validate();
|
|
221
|
+
|
|
222
|
+
if (this.editingId === null) {
|
|
223
|
+
// TODO: create — await Item.create({ name: this.name });
|
|
224
|
+
} else {
|
|
225
|
+
// TODO: update — await Item.where("id", this.editingId).update({ name: this.name });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
this.reset();
|
|
229
|
+
this.refresh(); // re-run onMount() → reload the list
|
|
230
|
+
this.flash("Saved.");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
@expose edit(id: number): void {
|
|
234
|
+
const item = this.items.find((i) => i.id === id);
|
|
235
|
+
if (!item) return;
|
|
236
|
+
this.editingId = id;
|
|
237
|
+
this.name = item.name;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
@expose async destroy(id: number): Promise<void> {
|
|
241
|
+
// TODO: delete — await Item.where("id", id).delete();
|
|
242
|
+
this.refresh();
|
|
243
|
+
this.flash("Deleted.");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
@expose reset(): void {
|
|
247
|
+
this.editingId = null;
|
|
248
|
+
this.name = "";
|
|
249
|
+
this.resetValidation();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
override async render() {
|
|
253
|
+
return (
|
|
254
|
+
<div class="max-w-xl mx-auto space-y-6 p-6">
|
|
255
|
+
<h1 class="text-2xl font-bold">${name}</h1>
|
|
256
|
+
|
|
257
|
+
<form onSubmit={this.save} class="flex items-start gap-2">
|
|
258
|
+
<div class="flex-1">
|
|
259
|
+
<input value={this.name} class="input w-full" placeholder="Name" />
|
|
260
|
+
<span error={this.errors.name} class="text-sm text-red-500" />
|
|
261
|
+
</div>
|
|
262
|
+
<button type="submit" loadingAttr="disabled">
|
|
263
|
+
{this.editingId === null ? "Add" : "Update"}
|
|
264
|
+
</button>
|
|
265
|
+
{this.editingId !== null && (
|
|
266
|
+
<button type="button" onClick={this.reset}>
|
|
267
|
+
Cancel
|
|
268
|
+
</button>
|
|
269
|
+
)}
|
|
270
|
+
</form>
|
|
271
|
+
|
|
272
|
+
<ul class="divide-y">
|
|
273
|
+
{this.items.map((item) => (
|
|
274
|
+
<li key={String(item.id)} class="flex items-center justify-between py-2">
|
|
275
|
+
<span>{item.name}</span>
|
|
276
|
+
<span class="flex gap-3">
|
|
277
|
+
<button onClick={() => this.edit(item.id)}>Edit</button>
|
|
278
|
+
<button onClick={() => this.destroy(item.id)} confirm="Delete this?" class="text-red-600">
|
|
279
|
+
Delete
|
|
280
|
+
</button>
|
|
281
|
+
</span>
|
|
282
|
+
</li>
|
|
283
|
+
))}
|
|
284
|
+
</ul>
|
|
285
|
+
</div>
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
`;
|
|
290
|
+
}
|