@kumikijs/runtime 0.1.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/README.md +20 -0
- package/dist/index.d.ts +363 -0
- package/dist/index.js +1682 -0
- package/package.json +58 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1682 @@
|
|
|
1
|
+
//#region src/scenario.ts
|
|
2
|
+
const settle$1 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
3
|
+
async function runScenario(app, root, scenario, opts = {}) {
|
|
4
|
+
const settleMs = opts.settleMs ?? 25;
|
|
5
|
+
const steps = [];
|
|
6
|
+
let errorBuf = [];
|
|
7
|
+
const onError = (ev) => {
|
|
8
|
+
errorBuf.push(ev.message || String(ev.error));
|
|
9
|
+
};
|
|
10
|
+
const onRejection = (ev) => {
|
|
11
|
+
errorBuf.push(`unhandled rejection: ${String(ev.reason)}`);
|
|
12
|
+
};
|
|
13
|
+
const origConsoleError = console.error;
|
|
14
|
+
console.error = (...args) => {
|
|
15
|
+
errorBuf.push(args.map(String).join(" "));
|
|
16
|
+
};
|
|
17
|
+
const w = globalThis;
|
|
18
|
+
w.addEventListener?.("error", onError);
|
|
19
|
+
w.addEventListener?.("unhandledrejection", onRejection);
|
|
20
|
+
const emitBuf = [];
|
|
21
|
+
const scripts = scenario.effects ?? {};
|
|
22
|
+
const cursors = {};
|
|
23
|
+
const def = scenario.defaultEffect ?? {
|
|
24
|
+
outcome: "ok",
|
|
25
|
+
value: null
|
|
26
|
+
};
|
|
27
|
+
for (const [name, eff] of Object.entries(app.effects)) eff.invoke = async (input) => {
|
|
28
|
+
emitBuf.push({
|
|
29
|
+
effect: name,
|
|
30
|
+
args: [input]
|
|
31
|
+
});
|
|
32
|
+
const queue = scripts[name];
|
|
33
|
+
if (queue && queue.length > 0) {
|
|
34
|
+
const idx = cursors[name] ?? 0;
|
|
35
|
+
const scripted = queue[Math.min(idx, queue.length - 1)] ?? def;
|
|
36
|
+
cursors[name] = idx + 1;
|
|
37
|
+
return {
|
|
38
|
+
kind: scripted.outcome,
|
|
39
|
+
value: scripted.value ?? null
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
kind: def.outcome,
|
|
44
|
+
value: def.value ?? null
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
const dispatchable = app;
|
|
48
|
+
try {
|
|
49
|
+
try {
|
|
50
|
+
mount(app, root);
|
|
51
|
+
} catch (e) {
|
|
52
|
+
steps.push(mkStep(void 0, "mount", [`mount threw: ${errStr$1(e)}`], [], app, root, []));
|
|
53
|
+
return finish();
|
|
54
|
+
}
|
|
55
|
+
await settle$1(settleMs);
|
|
56
|
+
for (const step of scenario.steps) {
|
|
57
|
+
errorBuf = [];
|
|
58
|
+
emitBuf.length = 0;
|
|
59
|
+
const actionDesc = step.do ? describeAction(step.do) : void 0;
|
|
60
|
+
if (step.do) {
|
|
61
|
+
try {
|
|
62
|
+
performAction(step.do, root, dispatchable);
|
|
63
|
+
} catch (e) {
|
|
64
|
+
errorBuf.push(`action threw: ${errStr$1(e)}`);
|
|
65
|
+
}
|
|
66
|
+
await settle$1(settleMs);
|
|
67
|
+
}
|
|
68
|
+
const result = mkStep(step.label, actionDesc, [...errorBuf], [...emitBuf], app, root, evaluateExpect(step.expect, errorBuf, app, root));
|
|
69
|
+
steps.push(result);
|
|
70
|
+
}
|
|
71
|
+
return finish();
|
|
72
|
+
} finally {
|
|
73
|
+
console.error = origConsoleError;
|
|
74
|
+
w.removeEventListener?.("error", onError);
|
|
75
|
+
w.removeEventListener?.("unhandledrejection", onRejection);
|
|
76
|
+
}
|
|
77
|
+
function finish() {
|
|
78
|
+
return {
|
|
79
|
+
ok: steps.every((s) => s.errors.length === 0 && s.failures.length === 0),
|
|
80
|
+
steps
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function mkStep(label, action, errors, emits, app, root, failures) {
|
|
85
|
+
const step = {
|
|
86
|
+
errors,
|
|
87
|
+
emits,
|
|
88
|
+
state: snapshotState(app),
|
|
89
|
+
domText: (root.textContent ?? "").replace(/\s+/g, " ").trim(),
|
|
90
|
+
failures
|
|
91
|
+
};
|
|
92
|
+
if (label !== void 0) step.label = label;
|
|
93
|
+
if (action !== void 0) step.action = action;
|
|
94
|
+
return step;
|
|
95
|
+
}
|
|
96
|
+
function describeAction(a) {
|
|
97
|
+
if ("dispatch" in a) return `dispatch ${a.dispatch}`;
|
|
98
|
+
if ("clickText" in a) return `clickText "${a.clickText}"`;
|
|
99
|
+
if ("click" in a) return `click ${a.click}`;
|
|
100
|
+
if ("fill" in a) return `fill ${a.fill}="${a.value}"`;
|
|
101
|
+
if ("choose" in a) return `choose ${a.choose}="${a.value}"`;
|
|
102
|
+
return `navigate ${a.navigate}`;
|
|
103
|
+
}
|
|
104
|
+
function performAction(a, root, app) {
|
|
105
|
+
if ("dispatch" in a) {
|
|
106
|
+
app._dispatch?.(a.dispatch, a.payload ?? {});
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if ("navigate" in a) {
|
|
110
|
+
app._navigate?.(a.navigate);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if ("clickText" in a) {
|
|
114
|
+
const target = Array.from(root.querySelectorAll("button, a, [role='button']")).find((e) => (e.textContent ?? "").includes(a.clickText));
|
|
115
|
+
if (!target) throw new Error(`no clickable element with text "${a.clickText}"`);
|
|
116
|
+
target.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if ("click" in a) {
|
|
120
|
+
const el = root.querySelector(a.click);
|
|
121
|
+
if (!el) throw new Error(`no element matching selector ${a.click}`);
|
|
122
|
+
el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if ("fill" in a) {
|
|
126
|
+
const el = root.querySelector(a.fill);
|
|
127
|
+
if (!el) throw new Error(`no input matching selector ${a.fill}`);
|
|
128
|
+
el.value = a.value;
|
|
129
|
+
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
130
|
+
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const sel = root.querySelector(a.choose);
|
|
134
|
+
if (!sel) throw new Error(`no select matching selector ${a.choose}`);
|
|
135
|
+
const opt = Array.from(sel.options).find((o) => o.value === a.value || (o.textContent ?? "").trim() === a.value);
|
|
136
|
+
if (!opt) throw new Error(`no option "${a.value}" in select ${a.choose}`);
|
|
137
|
+
sel.value = opt.value;
|
|
138
|
+
sel.dispatchEvent(new Event("change", { bubbles: true }));
|
|
139
|
+
}
|
|
140
|
+
function evaluateExpect(expect, errors, app, root) {
|
|
141
|
+
if (!expect) return [];
|
|
142
|
+
const failures = [];
|
|
143
|
+
if (expect.noErrors && errors.length > 0) failures.push(`expected no errors but got: ${errors.join("; ")}`);
|
|
144
|
+
if (expect.state) {
|
|
145
|
+
const state = snapshotState(app);
|
|
146
|
+
for (const [key, want] of Object.entries(expect.state)) {
|
|
147
|
+
const got = readPath(state, key);
|
|
148
|
+
if (!matches(want, got)) failures.push(`state ${key}: expected ${j(want)}, got ${j(got)}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const text = root.textContent ?? "";
|
|
152
|
+
for (const s of expect.domIncludes ?? []) if (!text.includes(s)) failures.push(`DOM should include "${s}"`);
|
|
153
|
+
for (const s of expect.domExcludes ?? []) if (text.includes(s)) failures.push(`DOM should NOT include "${s}"`);
|
|
154
|
+
return failures;
|
|
155
|
+
}
|
|
156
|
+
function snapshotState(app) {
|
|
157
|
+
const live = app.live ?? {};
|
|
158
|
+
const out = {};
|
|
159
|
+
for (const [k, v] of Object.entries(live)) {
|
|
160
|
+
if (k === "route") continue;
|
|
161
|
+
out[k] = sanitize(v);
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
function sanitize(v) {
|
|
166
|
+
if (v === null || typeof v !== "object") return typeof v === "function" ? "[fn]" : v;
|
|
167
|
+
if (Array.isArray(v)) return v.map(sanitize);
|
|
168
|
+
const out = {};
|
|
169
|
+
for (const [k, val] of Object.entries(v)) {
|
|
170
|
+
if (typeof val === "function") continue;
|
|
171
|
+
out[k] = sanitize(val);
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
function readPath(obj, path) {
|
|
176
|
+
let cur = obj;
|
|
177
|
+
for (const seg of path.split(".")) {
|
|
178
|
+
if (cur === null || typeof cur !== "object") return void 0;
|
|
179
|
+
cur = cur[seg];
|
|
180
|
+
}
|
|
181
|
+
return cur;
|
|
182
|
+
}
|
|
183
|
+
/** Partial structural match: every key/element in `want` must be present in `got`. */
|
|
184
|
+
function matches(want, got) {
|
|
185
|
+
if (want === null || typeof want !== "object") return want === got;
|
|
186
|
+
if (Array.isArray(want)) {
|
|
187
|
+
if (!Array.isArray(got) || got.length !== want.length) return false;
|
|
188
|
+
return want.every((w, i) => matches(w, got[i]));
|
|
189
|
+
}
|
|
190
|
+
if (got === null || typeof got !== "object") return false;
|
|
191
|
+
const g = got;
|
|
192
|
+
return Object.entries(want).every(([k, w]) => matches(w, g[k]));
|
|
193
|
+
}
|
|
194
|
+
function j(v) {
|
|
195
|
+
try {
|
|
196
|
+
return JSON.stringify(v);
|
|
197
|
+
} catch {
|
|
198
|
+
return String(v);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
function errStr$1(e) {
|
|
202
|
+
return e instanceof Error ? e.message : String(e);
|
|
203
|
+
}
|
|
204
|
+
//#endregion
|
|
205
|
+
//#region src/smoke.ts
|
|
206
|
+
const settle = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
207
|
+
/** Position + tag + text signature: stable across re-renders of the same element. */
|
|
208
|
+
function signature(el, index) {
|
|
209
|
+
const tag = el.tagName.toLowerCase();
|
|
210
|
+
const label = (el.textContent ?? "").trim().slice(0, 24);
|
|
211
|
+
return label ? `${tag}[${index}] ("${label}")` : `${tag}[${index}]`;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Mount `app` into `root`, drive its UI, and report runtime failures.
|
|
215
|
+
* Runs in any DOM environment (jsdom for CI, a real browser for the playground).
|
|
216
|
+
*/
|
|
217
|
+
async function smoke(app, root, opts = {}) {
|
|
218
|
+
const { interact = true, maxInteractions = 40, settleMs = 30 } = opts;
|
|
219
|
+
const issues = [];
|
|
220
|
+
let currentTrigger;
|
|
221
|
+
let phase = "mount";
|
|
222
|
+
const onError = (ev) => {
|
|
223
|
+
issues.push({
|
|
224
|
+
phase,
|
|
225
|
+
message: ev.message || String(ev.error),
|
|
226
|
+
trigger: currentTrigger
|
|
227
|
+
});
|
|
228
|
+
};
|
|
229
|
+
const onRejection = (ev) => {
|
|
230
|
+
issues.push({
|
|
231
|
+
phase: "async",
|
|
232
|
+
message: `unhandled rejection: ${String(ev.reason)}`,
|
|
233
|
+
trigger: currentTrigger
|
|
234
|
+
});
|
|
235
|
+
};
|
|
236
|
+
const origConsoleError = console.error;
|
|
237
|
+
console.error = (...args) => {
|
|
238
|
+
issues.push({
|
|
239
|
+
phase,
|
|
240
|
+
message: args.map(String).join(" "),
|
|
241
|
+
trigger: currentTrigger
|
|
242
|
+
});
|
|
243
|
+
};
|
|
244
|
+
const w = globalThis;
|
|
245
|
+
w.addEventListener?.("error", onError);
|
|
246
|
+
w.addEventListener?.("unhandledrejection", onRejection);
|
|
247
|
+
let mounted = false;
|
|
248
|
+
let rendered = false;
|
|
249
|
+
let interactions = 0;
|
|
250
|
+
let dispose;
|
|
251
|
+
try {
|
|
252
|
+
phase = "mount";
|
|
253
|
+
try {
|
|
254
|
+
dispose = mount(app, root).dispose;
|
|
255
|
+
mounted = true;
|
|
256
|
+
} catch (e) {
|
|
257
|
+
issues.push({
|
|
258
|
+
phase: "mount",
|
|
259
|
+
message: errStr(e)
|
|
260
|
+
});
|
|
261
|
+
return finish();
|
|
262
|
+
}
|
|
263
|
+
phase = "async";
|
|
264
|
+
await settle(settleMs);
|
|
265
|
+
phase = "initial-render";
|
|
266
|
+
rendered = hasContent(root);
|
|
267
|
+
if (!rendered) issues.push({
|
|
268
|
+
phase: "initial-render",
|
|
269
|
+
message: "root is empty after mount"
|
|
270
|
+
});
|
|
271
|
+
if (interact && mounted) {
|
|
272
|
+
phase = "interaction";
|
|
273
|
+
const fired = /* @__PURE__ */ new Set();
|
|
274
|
+
for (let round = 0; round < maxInteractions; round++) {
|
|
275
|
+
const next = collectInteractive(root).map((el, i) => [el, signature(el, i)]).find(([, sig]) => !fired.has(sig));
|
|
276
|
+
if (!next) break;
|
|
277
|
+
const [el, sig] = next;
|
|
278
|
+
fired.add(sig);
|
|
279
|
+
currentTrigger = `${actionFor(el)} ${sig}`;
|
|
280
|
+
try {
|
|
281
|
+
fire(el);
|
|
282
|
+
} catch (e) {
|
|
283
|
+
issues.push({
|
|
284
|
+
phase: "interaction",
|
|
285
|
+
message: errStr(e),
|
|
286
|
+
trigger: currentTrigger
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
interactions++;
|
|
290
|
+
await settle(settleMs);
|
|
291
|
+
if (!hasContent(root)) {
|
|
292
|
+
issues.push({
|
|
293
|
+
phase: "interaction",
|
|
294
|
+
message: "root became empty after interaction",
|
|
295
|
+
trigger: currentTrigger
|
|
296
|
+
});
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
currentTrigger = void 0;
|
|
301
|
+
}
|
|
302
|
+
return finish();
|
|
303
|
+
} finally {
|
|
304
|
+
try {
|
|
305
|
+
dispose?.();
|
|
306
|
+
} catch {}
|
|
307
|
+
console.error = origConsoleError;
|
|
308
|
+
w.removeEventListener?.("error", onError);
|
|
309
|
+
w.removeEventListener?.("unhandledrejection", onRejection);
|
|
310
|
+
}
|
|
311
|
+
function finish() {
|
|
312
|
+
return {
|
|
313
|
+
ok: issues.length === 0 && mounted && rendered,
|
|
314
|
+
mounted,
|
|
315
|
+
rendered,
|
|
316
|
+
interactions,
|
|
317
|
+
issues
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
function hasContent(root) {
|
|
322
|
+
return root.childElementCount > 0 || (root.textContent ?? "").trim().length > 0;
|
|
323
|
+
}
|
|
324
|
+
function collectInteractive(root) {
|
|
325
|
+
return Array.from(root.querySelectorAll("button, input, textarea, select, [data-kumiki-bind]"));
|
|
326
|
+
}
|
|
327
|
+
function actionFor(el) {
|
|
328
|
+
const tag = el.tagName.toLowerCase();
|
|
329
|
+
if (tag === "select") return "change";
|
|
330
|
+
if (tag === "input" || tag === "textarea") return "input";
|
|
331
|
+
return "click";
|
|
332
|
+
}
|
|
333
|
+
function fire(el) {
|
|
334
|
+
const tag = el.tagName.toLowerCase();
|
|
335
|
+
if (tag === "select") {
|
|
336
|
+
const sel = el;
|
|
337
|
+
if (sel.options.length > 1) sel.selectedIndex = sel.options.length - 1;
|
|
338
|
+
sel.dispatchEvent(new Event("change", { bubbles: true }));
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (tag === "textarea") {
|
|
342
|
+
el.value = "smoke";
|
|
343
|
+
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (tag === "input") {
|
|
347
|
+
const inp = el;
|
|
348
|
+
if (inp.type === "checkbox" || inp.type === "radio") el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
349
|
+
else {
|
|
350
|
+
inp.value = "smoke";
|
|
351
|
+
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
352
|
+
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
353
|
+
}
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
357
|
+
}
|
|
358
|
+
function errStr(e) {
|
|
359
|
+
if (e instanceof Error) return e.stack ? `${e.message}\n${e.stack.split("\n")[1]?.trim() ?? ""}` : e.message;
|
|
360
|
+
return String(e);
|
|
361
|
+
}
|
|
362
|
+
//#endregion
|
|
363
|
+
//#region src/index.ts
|
|
364
|
+
function parseLocation(routes, loc) {
|
|
365
|
+
const path = loc.pathname || "/";
|
|
366
|
+
const query = {};
|
|
367
|
+
const params = new URLSearchParams(loc.search);
|
|
368
|
+
for (const [k, v] of params.entries()) query[k] = v;
|
|
369
|
+
const hash = loc.hash ? loc.hash.slice(1) : null;
|
|
370
|
+
if (!routes) return {
|
|
371
|
+
path,
|
|
372
|
+
pattern: path,
|
|
373
|
+
params: {},
|
|
374
|
+
query,
|
|
375
|
+
hash
|
|
376
|
+
};
|
|
377
|
+
for (const r of routes) {
|
|
378
|
+
if ("redirectTo" in r) continue;
|
|
379
|
+
const m = matchPattern(r.pattern, path);
|
|
380
|
+
if (m) return {
|
|
381
|
+
path,
|
|
382
|
+
pattern: r.pattern,
|
|
383
|
+
params: m,
|
|
384
|
+
query,
|
|
385
|
+
hash
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
return {
|
|
389
|
+
path,
|
|
390
|
+
pattern: "/404",
|
|
391
|
+
params: {},
|
|
392
|
+
query,
|
|
393
|
+
hash
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
function matchPattern(pattern, path) {
|
|
397
|
+
if (pattern === "/404") return null;
|
|
398
|
+
const patSegs = pattern.split("/").filter(Boolean);
|
|
399
|
+
const pathSegs = path.split("/").filter(Boolean);
|
|
400
|
+
const params = {};
|
|
401
|
+
for (let i = 0; i < patSegs.length; i++) {
|
|
402
|
+
const p = patSegs[i];
|
|
403
|
+
if (p === "*") return params;
|
|
404
|
+
const s = pathSegs[i];
|
|
405
|
+
if (s === void 0) return null;
|
|
406
|
+
if (p.startsWith(":")) params[p.slice(1)] = decodeURIComponent(s);
|
|
407
|
+
else if (p !== s) return null;
|
|
408
|
+
}
|
|
409
|
+
if (pathSegs.length !== patSegs.length) return null;
|
|
410
|
+
return params;
|
|
411
|
+
}
|
|
412
|
+
function emptyRoute() {
|
|
413
|
+
return {
|
|
414
|
+
path: "/",
|
|
415
|
+
pattern: "/",
|
|
416
|
+
params: {},
|
|
417
|
+
query: {},
|
|
418
|
+
hash: null
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
function mount(app, target) {
|
|
422
|
+
if (!app.live) {
|
|
423
|
+
app.live = {};
|
|
424
|
+
for (const [k, v] of Object.entries(app.slots)) app.live[k] = v.value;
|
|
425
|
+
}
|
|
426
|
+
if (!("route" in app.live)) app.live.route = emptyRoute();
|
|
427
|
+
const slotValues = app.live;
|
|
428
|
+
const dispatcher = makeEffectDispatcher(app, makeCapabilityRegistry(app.caps), (effect, outcome, value, key) => {
|
|
429
|
+
handleEffectResult(effect, outcome, value, key);
|
|
430
|
+
});
|
|
431
|
+
let currentRoot = null;
|
|
432
|
+
let disposed = false;
|
|
433
|
+
const render = () => {
|
|
434
|
+
if (disposed) return;
|
|
435
|
+
let snap = null;
|
|
436
|
+
const active = document.activeElement;
|
|
437
|
+
if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA") && target.contains(active)) {
|
|
438
|
+
const el = active;
|
|
439
|
+
snap = {
|
|
440
|
+
bind: el.dataset.kumikiBind ?? void 0,
|
|
441
|
+
id: el.id || void 0,
|
|
442
|
+
path: domPath(el, target),
|
|
443
|
+
selStart: el.selectionStart,
|
|
444
|
+
selEnd: el.selectionEnd
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
maybeReapplyTheme(app);
|
|
448
|
+
const dom = renderTile(pickRootTile(app));
|
|
449
|
+
if (currentRoot) target.replaceChild(dom, currentRoot);
|
|
450
|
+
else target.appendChild(dom);
|
|
451
|
+
currentRoot = dom;
|
|
452
|
+
if (snap) {
|
|
453
|
+
let sel = snap.bind ? target.querySelector(`[data-kumiki-bind="${snap.bind}"]`) : snap.id ? target.querySelector(`#${CSS.escape(snap.id)}`) : null;
|
|
454
|
+
if (!sel && snap.path) sel = elementAtPath(snap.path, target);
|
|
455
|
+
if (sel && (sel.tagName === "INPUT" || sel.tagName === "TEXTAREA")) {
|
|
456
|
+
const el = sel;
|
|
457
|
+
el.focus();
|
|
458
|
+
if (snap.selStart !== null && snap.selEnd !== null) try {
|
|
459
|
+
el.setSelectionRange(snap.selStart, snap.selEnd);
|
|
460
|
+
} catch {}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
};
|
|
464
|
+
function pickRootTile(app) {
|
|
465
|
+
if (app.routes && app.routes.length > 0) {
|
|
466
|
+
const cur = slotValues.route;
|
|
467
|
+
for (const r of app.routes) if (r.pattern === cur.pattern && "tile" in r) return r.tile();
|
|
468
|
+
for (const r of app.routes) if (r.pattern === "/404" && "tile" in r) return r.tile();
|
|
469
|
+
}
|
|
470
|
+
return app.root ? app.root() : {
|
|
471
|
+
kind: "text",
|
|
472
|
+
text: "(no root)"
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
function applyReducer(r, payload) {
|
|
476
|
+
if (disposed) return;
|
|
477
|
+
const result = r.apply(slotValues, payload);
|
|
478
|
+
for (const [k, v] of Object.entries(result.slots)) {
|
|
479
|
+
const meta = app.slots[k];
|
|
480
|
+
if (meta?.refine && !meta.refine(v)) continue;
|
|
481
|
+
slotValues[k] = v;
|
|
482
|
+
}
|
|
483
|
+
for (const emit of result.emits) dispatcher.dispatch(emit);
|
|
484
|
+
render();
|
|
485
|
+
}
|
|
486
|
+
function handleEffectResult(effect, outcome, value, key) {
|
|
487
|
+
for (const r of app.reducers) if (r.event.kind === "effect" && r.event.effect === effect && r.event.outcome === outcome) applyReducer(r, {
|
|
488
|
+
$1: value,
|
|
489
|
+
$2: key
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
function updateRoute(newPath, replace) {
|
|
493
|
+
if (replace) history.replaceState(null, "", newPath);
|
|
494
|
+
else history.pushState(null, "", newPath);
|
|
495
|
+
syncRouteFromLocation();
|
|
496
|
+
}
|
|
497
|
+
function syncRouteFromLocation() {
|
|
498
|
+
const oldRoute = slotValues.route;
|
|
499
|
+
const newRoute = parseLocation(app.routes, location);
|
|
500
|
+
slotValues.route = newRoute;
|
|
501
|
+
if (oldRoute && oldRoute.pattern !== newRoute.pattern) {
|
|
502
|
+
for (const r of app.reducers) if (r.event.kind === "lifecycle" && r.event.name === `route.leave(${JSON.stringify(oldRoute.pattern)})`) applyReducer(r, { $route: oldRoute });
|
|
503
|
+
}
|
|
504
|
+
for (const r of app.reducers) if (r.event.kind === "lifecycle" && r.event.name === `route.enter(${JSON.stringify(newRoute.pattern)})`) applyReducer(r, { $route: newRoute });
|
|
505
|
+
render();
|
|
506
|
+
}
|
|
507
|
+
registerBuiltinEffects(app, updateRoute, () => slotValues, () => render());
|
|
508
|
+
lastAppliedThemeName = null;
|
|
509
|
+
applyThemeDefaults(app);
|
|
510
|
+
lastAppliedThemeName = app.live?.[app.themeName ?? ""] ?? app.themeName ?? null;
|
|
511
|
+
if (app.routes && app.routes.length > 0) {
|
|
512
|
+
for (const r of app.routes) if ("redirectTo" in r && matchPattern(r.pattern, location.pathname)) {
|
|
513
|
+
history.replaceState(null, "", r.redirectTo);
|
|
514
|
+
break;
|
|
515
|
+
}
|
|
516
|
+
slotValues.route = parseLocation(app.routes, location);
|
|
517
|
+
window.addEventListener("popstate", () => syncRouteFromLocation());
|
|
518
|
+
}
|
|
519
|
+
app._rerender = render;
|
|
520
|
+
app._dispatch = (reducerName, el) => {
|
|
521
|
+
const r = app.reducers.find((x) => x.name === reducerName);
|
|
522
|
+
if (!r) return;
|
|
523
|
+
applyReducer(r, {
|
|
524
|
+
$el: el,
|
|
525
|
+
$event: el
|
|
526
|
+
});
|
|
527
|
+
};
|
|
528
|
+
app._setSlot = (name, value) => {
|
|
529
|
+
const meta = app.slots[name];
|
|
530
|
+
if (meta?.refine && !meta.refine(value)) return;
|
|
531
|
+
slotValues[name] = value;
|
|
532
|
+
render();
|
|
533
|
+
};
|
|
534
|
+
app._navigate = (path, replace) => {
|
|
535
|
+
updateRoute(path, !!replace);
|
|
536
|
+
};
|
|
537
|
+
for (const emit of app.init) dispatcher.dispatch(emit);
|
|
538
|
+
for (const r of app.reducers) if (r.event.kind === "lifecycle" && r.event.name === "app.start") applyReducer(r, {});
|
|
539
|
+
const timerHandles = [];
|
|
540
|
+
for (const r of app.reducers) if (r.event.kind === "timer") {
|
|
541
|
+
const handle = setInterval(() => applyReducer(r, {}), r.event.intervalMs);
|
|
542
|
+
timerHandles.push(handle);
|
|
543
|
+
}
|
|
544
|
+
if (app.routes && app.routes.length > 0) {
|
|
545
|
+
const cur = slotValues.route;
|
|
546
|
+
for (const r of app.reducers) if (r.event.kind === "lifecycle" && r.event.name === `route.enter(${JSON.stringify(cur.pattern)})`) applyReducer(r, { $route: cur });
|
|
547
|
+
}
|
|
548
|
+
render();
|
|
549
|
+
return { dispose: () => {
|
|
550
|
+
disposed = true;
|
|
551
|
+
for (const h of timerHandles) clearInterval(h);
|
|
552
|
+
target.replaceChildren();
|
|
553
|
+
dispatcher.dispose();
|
|
554
|
+
} };
|
|
555
|
+
}
|
|
556
|
+
function makeCapabilityRegistry(allowed) {
|
|
557
|
+
const ok = new Set(allowed);
|
|
558
|
+
return { has: (c) => ok.has(c) };
|
|
559
|
+
}
|
|
560
|
+
function makeEffectDispatcher(app, caps, onResult) {
|
|
561
|
+
const state = {
|
|
562
|
+
inflight: /* @__PURE__ */ new Map(),
|
|
563
|
+
timers: /* @__PURE__ */ new Map(),
|
|
564
|
+
onceSeen: /* @__PURE__ */ new Map()
|
|
565
|
+
};
|
|
566
|
+
const launch = async (eff, input, key) => {
|
|
567
|
+
if (!caps.has(eff.cap)) {
|
|
568
|
+
console.warn(`Capability "${eff.cap}" not declared in app.caps`);
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
try {
|
|
572
|
+
const res = await eff.invoke(input, caps);
|
|
573
|
+
onResult(eff.name, res.kind, res.value, input);
|
|
574
|
+
} catch (e) {
|
|
575
|
+
onResult(eff.name, "err", { message: String(e) }, input);
|
|
576
|
+
} finally {
|
|
577
|
+
if (state.inflight.get(`${eff.name}:${key}`)) state.inflight.delete(`${eff.name}:${key}`);
|
|
578
|
+
}
|
|
579
|
+
};
|
|
580
|
+
return {
|
|
581
|
+
dispatch(emit) {
|
|
582
|
+
const eff = app.effects[emit.effect];
|
|
583
|
+
if (!eff) return;
|
|
584
|
+
const input = emit.args[0];
|
|
585
|
+
const policy = eff.policy ?? { kind: "default" };
|
|
586
|
+
const keyOf = (input) => {
|
|
587
|
+
if (policy.kind === "latest-per-key") return policy.keyOf(input);
|
|
588
|
+
return "_";
|
|
589
|
+
};
|
|
590
|
+
const key = keyOf(input);
|
|
591
|
+
const id = `${eff.name}:${key}`;
|
|
592
|
+
if (policy.kind === "once") {
|
|
593
|
+
const seen = state.onceSeen.get(eff.name) ?? /* @__PURE__ */ new Set();
|
|
594
|
+
const k = JSON.stringify(input ?? null);
|
|
595
|
+
if (seen.has(k)) return;
|
|
596
|
+
seen.add(k);
|
|
597
|
+
state.onceSeen.set(eff.name, seen);
|
|
598
|
+
launch(eff, input, key);
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
if (policy.kind === "debounce") {
|
|
602
|
+
const t = state.timers.get(id);
|
|
603
|
+
if (t) clearTimeout(t);
|
|
604
|
+
state.timers.set(id, setTimeout(() => {
|
|
605
|
+
state.timers.delete(id);
|
|
606
|
+
launch(eff, input, key);
|
|
607
|
+
}, policy.ms));
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
if (policy.kind === "throttle") {
|
|
611
|
+
if (state.timers.has(id)) return;
|
|
612
|
+
state.timers.set(id, setTimeout(() => state.timers.delete(id), policy.ms));
|
|
613
|
+
launch(eff, input, key);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
if (policy.kind === "latest" || policy.kind === "latest-per-key") {
|
|
617
|
+
const ic = state.inflight.get(id);
|
|
618
|
+
if (ic) ic.abort();
|
|
619
|
+
const ctl = new AbortController();
|
|
620
|
+
state.inflight.set(id, ctl);
|
|
621
|
+
launch(eff, input, key);
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
launch(eff, input, key);
|
|
625
|
+
},
|
|
626
|
+
dispose() {
|
|
627
|
+
for (const t of state.timers.values()) clearTimeout(t);
|
|
628
|
+
state.timers.clear();
|
|
629
|
+
for (const c of state.inflight.values()) c.abort();
|
|
630
|
+
state.inflight.clear();
|
|
631
|
+
}
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
function registerBuiltinEffects(app, navigate, getLive, rerender) {
|
|
635
|
+
app.effects.navigate = {
|
|
636
|
+
name: "navigate",
|
|
637
|
+
cap: "nav.push",
|
|
638
|
+
invoke: async (input) => {
|
|
639
|
+
navigate(buildPath(input), false);
|
|
640
|
+
return {
|
|
641
|
+
kind: "ok",
|
|
642
|
+
value: null
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
app.effects["navigate-replace"] = {
|
|
647
|
+
name: "navigate-replace",
|
|
648
|
+
cap: "nav.replace",
|
|
649
|
+
invoke: async (input) => {
|
|
650
|
+
navigate(buildPath(input), true);
|
|
651
|
+
return {
|
|
652
|
+
kind: "ok",
|
|
653
|
+
value: null
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
};
|
|
657
|
+
app.effects["navigate-back"] = {
|
|
658
|
+
name: "navigate-back",
|
|
659
|
+
cap: "nav.back",
|
|
660
|
+
invoke: async () => {
|
|
661
|
+
history.back();
|
|
662
|
+
return {
|
|
663
|
+
kind: "ok",
|
|
664
|
+
value: null
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
};
|
|
668
|
+
app.effects.toast = {
|
|
669
|
+
name: "toast",
|
|
670
|
+
cap: "notification.show",
|
|
671
|
+
invoke: async (input) => {
|
|
672
|
+
const t = input;
|
|
673
|
+
const banner = document.createElement("div");
|
|
674
|
+
banner.style.cssText = "position:fixed;bottom:24px;right:24px;padding:8px 16px;background:#1a1a1a;color:#fff;border-radius:8px;z-index:9999;";
|
|
675
|
+
banner.textContent = t.text ?? "";
|
|
676
|
+
document.body.appendChild(banner);
|
|
677
|
+
setTimeout(() => banner.remove(), 3e3);
|
|
678
|
+
return {
|
|
679
|
+
kind: "ok",
|
|
680
|
+
value: null
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
};
|
|
684
|
+
app.effects.log = {
|
|
685
|
+
name: "log",
|
|
686
|
+
cap: "log.write",
|
|
687
|
+
invoke: async (input) => {
|
|
688
|
+
console.log("[kumiki]", input);
|
|
689
|
+
return {
|
|
690
|
+
kind: "ok",
|
|
691
|
+
value: null
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
function buildPath(x) {
|
|
697
|
+
let p = x.path;
|
|
698
|
+
if (x.params) for (const [k, v] of Object.entries(x.params)) {
|
|
699
|
+
p = p.replace(`{${k}}`, encodeURIComponent(v));
|
|
700
|
+
p = p.replace(`:${k}`, encodeURIComponent(v));
|
|
701
|
+
}
|
|
702
|
+
if (x.query) {
|
|
703
|
+
const q = new URLSearchParams(x.query).toString();
|
|
704
|
+
if (q) p += `?${q}`;
|
|
705
|
+
}
|
|
706
|
+
return p;
|
|
707
|
+
}
|
|
708
|
+
/** Record the child-index chain from `root` down to `el`, for focus restore. */
|
|
709
|
+
function domPath(el, root) {
|
|
710
|
+
const path = [];
|
|
711
|
+
let cur = el;
|
|
712
|
+
while (cur && cur !== root) {
|
|
713
|
+
const parent = cur.parentElement;
|
|
714
|
+
if (!parent) break;
|
|
715
|
+
path.unshift(Array.prototype.indexOf.call(parent.children, cur));
|
|
716
|
+
cur = parent;
|
|
717
|
+
}
|
|
718
|
+
return path;
|
|
719
|
+
}
|
|
720
|
+
/** Re-walk a child-index chain produced by domPath to find the element. */
|
|
721
|
+
function elementAtPath(path, root) {
|
|
722
|
+
let cur = root;
|
|
723
|
+
for (const idx of path) {
|
|
724
|
+
if (!cur) return null;
|
|
725
|
+
cur = cur.children[idx] ?? null;
|
|
726
|
+
}
|
|
727
|
+
return cur;
|
|
728
|
+
}
|
|
729
|
+
function _setPathHelper(obj, path, value) {
|
|
730
|
+
if (path.length === 0) return value;
|
|
731
|
+
const [head, ...rest] = path;
|
|
732
|
+
const cur = obj && typeof obj === "object" ? obj : {};
|
|
733
|
+
return {
|
|
734
|
+
...cur,
|
|
735
|
+
[head]: _setPathHelper(cur[head], rest, value)
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
function renderTile(node) {
|
|
739
|
+
switch (node.kind) {
|
|
740
|
+
case "page":
|
|
741
|
+
case "column": {
|
|
742
|
+
const div = document.createElement("div");
|
|
743
|
+
div.dataset.kumikiTile = node.kind;
|
|
744
|
+
div.style.display = "flex";
|
|
745
|
+
div.style.flexDirection = "column";
|
|
746
|
+
applyContainerProps(div, node.props);
|
|
747
|
+
for (const child of node.children) if (child != null) div.appendChild(renderTile(child));
|
|
748
|
+
return div;
|
|
749
|
+
}
|
|
750
|
+
case "row": {
|
|
751
|
+
const div = document.createElement("div");
|
|
752
|
+
div.dataset.kumikiTile = "row";
|
|
753
|
+
div.style.display = "flex";
|
|
754
|
+
div.style.flexDirection = "row";
|
|
755
|
+
applyContainerProps(div, node.props);
|
|
756
|
+
for (const child of node.children) if (child != null) div.appendChild(renderTile(child));
|
|
757
|
+
return div;
|
|
758
|
+
}
|
|
759
|
+
case "card":
|
|
760
|
+
case "box":
|
|
761
|
+
case "panel":
|
|
762
|
+
case "fieldset":
|
|
763
|
+
case "stack":
|
|
764
|
+
case "region":
|
|
765
|
+
case "scroll": {
|
|
766
|
+
const div = document.createElement("div");
|
|
767
|
+
div.dataset.kumikiTile = node.kind;
|
|
768
|
+
if (node.kind === "card") {
|
|
769
|
+
if (!node.props || node.props.pad === void 0) div.style.padding = "16px";
|
|
770
|
+
div.style.marginBottom = "12px";
|
|
771
|
+
div.style.borderRadius = "8px";
|
|
772
|
+
}
|
|
773
|
+
if (node.kind === "scroll") div.style.overflow = "auto";
|
|
774
|
+
if (node.kind === "stack") {
|
|
775
|
+
div.style.display = "flex";
|
|
776
|
+
div.style.flexDirection = "column";
|
|
777
|
+
}
|
|
778
|
+
applyContainerProps(div, node.props);
|
|
779
|
+
for (const child of node.children) if (child != null) div.appendChild(renderTile(child));
|
|
780
|
+
return div;
|
|
781
|
+
}
|
|
782
|
+
case "grid": {
|
|
783
|
+
const div = document.createElement("div");
|
|
784
|
+
div.dataset.kumikiTile = "grid";
|
|
785
|
+
div.style.display = "grid";
|
|
786
|
+
const cols = node.props?.cols;
|
|
787
|
+
if (typeof cols === "number") div.style.gridTemplateColumns = `repeat(${cols}, 1fr)`;
|
|
788
|
+
else if (typeof cols === "string") div.style.gridTemplateColumns = cols;
|
|
789
|
+
else div.style.gridTemplateColumns = "repeat(3, 1fr)";
|
|
790
|
+
applyContainerProps(div, node.props);
|
|
791
|
+
for (const child of node.children) if (child != null) div.appendChild(renderTile(child));
|
|
792
|
+
return div;
|
|
793
|
+
}
|
|
794
|
+
case "divider": {
|
|
795
|
+
const hr = document.createElement("hr");
|
|
796
|
+
hr.dataset.kumikiTile = "divider";
|
|
797
|
+
return hr;
|
|
798
|
+
}
|
|
799
|
+
case "heading": {
|
|
800
|
+
const h = document.createElement("h1");
|
|
801
|
+
h.dataset.kumikiTile = "heading";
|
|
802
|
+
h.textContent = node.text;
|
|
803
|
+
applyTextProps(h, node.props);
|
|
804
|
+
return h;
|
|
805
|
+
}
|
|
806
|
+
case "text": {
|
|
807
|
+
const span = document.createElement("span");
|
|
808
|
+
span.dataset.kumikiTile = "text";
|
|
809
|
+
span.textContent = node.text;
|
|
810
|
+
applyTextProps(span, node.props);
|
|
811
|
+
return span;
|
|
812
|
+
}
|
|
813
|
+
case "button": {
|
|
814
|
+
const b = document.createElement("button");
|
|
815
|
+
b.dataset.kumikiTile = "button";
|
|
816
|
+
b.textContent = node.text;
|
|
817
|
+
if (node.disabled) b.disabled = true;
|
|
818
|
+
if (node.props?.onClick) b.addEventListener("click", (e) => {
|
|
819
|
+
e.preventDefault();
|
|
820
|
+
node.props?.onClick?.(node.props?.el ?? {});
|
|
821
|
+
});
|
|
822
|
+
return b;
|
|
823
|
+
}
|
|
824
|
+
case "input": {
|
|
825
|
+
const inp = document.createElement("input");
|
|
826
|
+
inp.dataset.kumikiTile = "input";
|
|
827
|
+
inp.type = node.type ?? "text";
|
|
828
|
+
if (node.placeholder) inp.placeholder = node.placeholder;
|
|
829
|
+
if (node.required) inp.required = true;
|
|
830
|
+
if (node.autoFocus) inp.autofocus = true;
|
|
831
|
+
if (node.id) inp.id = node.id;
|
|
832
|
+
if (node.bind) {
|
|
833
|
+
const fullPath = node.bindPath && node.bindPath.length > 0 ? `${node.bind}.${node.bindPath.join(".")}` : node.bind;
|
|
834
|
+
inp.dataset.kumikiBind = fullPath;
|
|
835
|
+
}
|
|
836
|
+
inp.value = node.value ?? "";
|
|
837
|
+
if (node.bind) {
|
|
838
|
+
const slotName = node.bind;
|
|
839
|
+
const bindPath = node.bindPath;
|
|
840
|
+
inp.addEventListener("input", () => {
|
|
841
|
+
const app = window.__kumikiApp;
|
|
842
|
+
if (!app?._setSlot) return;
|
|
843
|
+
if (bindPath && bindPath.length > 0) {
|
|
844
|
+
const next = _setPathHelper(app.live?.[slotName] ?? {}, bindPath, inp.value);
|
|
845
|
+
app._setSlot(slotName, next);
|
|
846
|
+
} else app._setSlot(slotName, inp.value);
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
if (node.props?.onInput) inp.addEventListener("input", () => {
|
|
850
|
+
node.props?.onInput?.({
|
|
851
|
+
...node.props?.el ?? {},
|
|
852
|
+
value: inp.value
|
|
853
|
+
});
|
|
854
|
+
});
|
|
855
|
+
if (node.props?.onChange) inp.addEventListener("change", () => {
|
|
856
|
+
node.props?.onChange?.({
|
|
857
|
+
...node.props?.el ?? {},
|
|
858
|
+
value: inp.value
|
|
859
|
+
});
|
|
860
|
+
});
|
|
861
|
+
return inp;
|
|
862
|
+
}
|
|
863
|
+
case "textarea": {
|
|
864
|
+
const ta = document.createElement("textarea");
|
|
865
|
+
ta.dataset.kumikiTile = "textarea";
|
|
866
|
+
if (node.rows) ta.rows = node.rows;
|
|
867
|
+
if (node.placeholder) ta.placeholder = node.placeholder;
|
|
868
|
+
if (node.id) ta.id = node.id;
|
|
869
|
+
if (node.bind) {
|
|
870
|
+
const fullPath = node.bindPath && node.bindPath.length > 0 ? `${node.bind}.${node.bindPath.join(".")}` : node.bind;
|
|
871
|
+
ta.dataset.kumikiBind = fullPath;
|
|
872
|
+
}
|
|
873
|
+
ta.value = node.value ?? "";
|
|
874
|
+
if (node.bind) {
|
|
875
|
+
const slotName = node.bind;
|
|
876
|
+
const bindPath = node.bindPath;
|
|
877
|
+
ta.addEventListener("input", () => {
|
|
878
|
+
const app = window.__kumikiApp;
|
|
879
|
+
if (!app?._setSlot) return;
|
|
880
|
+
if (bindPath && bindPath.length > 0) {
|
|
881
|
+
const next = _setPathHelper(app.live?.[slotName] ?? {}, bindPath, ta.value);
|
|
882
|
+
app._setSlot(slotName, next);
|
|
883
|
+
} else app._setSlot(slotName, ta.value);
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
return ta;
|
|
887
|
+
}
|
|
888
|
+
case "check": {
|
|
889
|
+
const wrap = document.createElement("label");
|
|
890
|
+
wrap.dataset.kumikiTile = "check";
|
|
891
|
+
const inp = document.createElement("input");
|
|
892
|
+
inp.type = "checkbox";
|
|
893
|
+
inp.checked = node.checked;
|
|
894
|
+
if (node.props?.onClick) inp.addEventListener("change", () => {
|
|
895
|
+
node.props?.onClick?.(node.props?.el ?? {});
|
|
896
|
+
});
|
|
897
|
+
wrap.appendChild(inp);
|
|
898
|
+
return wrap;
|
|
899
|
+
}
|
|
900
|
+
case "spinner": {
|
|
901
|
+
const span = document.createElement("span");
|
|
902
|
+
span.dataset.kumikiTile = "spinner";
|
|
903
|
+
span.textContent = "…";
|
|
904
|
+
return span;
|
|
905
|
+
}
|
|
906
|
+
case "select": {
|
|
907
|
+
const sel = document.createElement("select");
|
|
908
|
+
sel.dataset.kumikiTile = "select";
|
|
909
|
+
const options = node.options ?? [];
|
|
910
|
+
const currentValue = node.value;
|
|
911
|
+
const valueKey = (v) => {
|
|
912
|
+
if (v && typeof v === "object" && "_tag" in v) {
|
|
913
|
+
const t = v;
|
|
914
|
+
const parts = [String(t._tag)];
|
|
915
|
+
for (let i = 0; `_${i}` in t; i++) parts.push(valueKey(t[`_${i}`]));
|
|
916
|
+
return parts.join("|");
|
|
917
|
+
}
|
|
918
|
+
return JSON.stringify(v);
|
|
919
|
+
};
|
|
920
|
+
const currentKey = valueKey(currentValue);
|
|
921
|
+
if (node.placeholder) {
|
|
922
|
+
const ph = document.createElement("option");
|
|
923
|
+
ph.value = "";
|
|
924
|
+
ph.textContent = String(node.placeholder);
|
|
925
|
+
ph.disabled = true;
|
|
926
|
+
if (currentValue == null) ph.selected = true;
|
|
927
|
+
sel.appendChild(ph);
|
|
928
|
+
}
|
|
929
|
+
for (const opt of options) {
|
|
930
|
+
const o = document.createElement("option");
|
|
931
|
+
const k = valueKey(opt.value);
|
|
932
|
+
o.value = k;
|
|
933
|
+
o.textContent = String(opt.label);
|
|
934
|
+
if (k === currentKey) o.selected = true;
|
|
935
|
+
sel.appendChild(o);
|
|
936
|
+
}
|
|
937
|
+
sel.addEventListener("change", () => {
|
|
938
|
+
const k = sel.value;
|
|
939
|
+
const matched = options.find((o) => valueKey(o.value) === k);
|
|
940
|
+
if (matched === void 0) return;
|
|
941
|
+
const app = window.__kumikiApp;
|
|
942
|
+
if (node.bind && app?._setSlot) {
|
|
943
|
+
const slotName = node.bind;
|
|
944
|
+
const bindPath = node.bindPath;
|
|
945
|
+
if (bindPath && bindPath.length > 0) {
|
|
946
|
+
const next = _setPathHelper(app.live?.[slotName] ?? {}, bindPath, matched.value);
|
|
947
|
+
app._setSlot(slotName, next);
|
|
948
|
+
} else app._setSlot(slotName, matched.value);
|
|
949
|
+
}
|
|
950
|
+
if (node.props?.onChange) node.props.onChange({
|
|
951
|
+
...node.props.el ?? {},
|
|
952
|
+
value: matched.value
|
|
953
|
+
});
|
|
954
|
+
});
|
|
955
|
+
return sel;
|
|
956
|
+
}
|
|
957
|
+
case "radio": {
|
|
958
|
+
const wrap = document.createElement("label");
|
|
959
|
+
wrap.dataset.kumikiTile = "radio";
|
|
960
|
+
const inp = document.createElement("input");
|
|
961
|
+
inp.type = "radio";
|
|
962
|
+
if (node.group) inp.name = String(node.group);
|
|
963
|
+
inp.checked = !!node.selected;
|
|
964
|
+
const labelText = node.props?.label ?? "";
|
|
965
|
+
wrap.appendChild(inp);
|
|
966
|
+
if (labelText) {
|
|
967
|
+
const span = document.createElement("span");
|
|
968
|
+
span.textContent = labelText;
|
|
969
|
+
wrap.appendChild(span);
|
|
970
|
+
}
|
|
971
|
+
if (node.props?.onClick) inp.addEventListener("change", () => {
|
|
972
|
+
node.props?.onClick?.(node.props?.el ?? {});
|
|
973
|
+
});
|
|
974
|
+
return wrap;
|
|
975
|
+
}
|
|
976
|
+
case "skeleton": {
|
|
977
|
+
const div = document.createElement("div");
|
|
978
|
+
div.dataset.kumikiTile = "skeleton";
|
|
979
|
+
div.style.background = "#eee";
|
|
980
|
+
div.style.borderRadius = "8px";
|
|
981
|
+
div.style.minHeight = "60px";
|
|
982
|
+
const h = node.props?.h;
|
|
983
|
+
if (typeof h === "number") div.style.height = `${h}px`;
|
|
984
|
+
return div;
|
|
985
|
+
}
|
|
986
|
+
case "form": {
|
|
987
|
+
const form = document.createElement("form");
|
|
988
|
+
form.dataset.kumikiTile = "form";
|
|
989
|
+
form.addEventListener("submit", (e) => {
|
|
990
|
+
e.preventDefault();
|
|
991
|
+
if (node.props?.onSubmit) node.props.onSubmit(node.props.el ?? {});
|
|
992
|
+
});
|
|
993
|
+
for (const child of node.children) if (child != null) form.appendChild(renderTile(child));
|
|
994
|
+
return form;
|
|
995
|
+
}
|
|
996
|
+
case "label": {
|
|
997
|
+
const lbl = document.createElement("label");
|
|
998
|
+
lbl.dataset.kumikiTile = "label";
|
|
999
|
+
lbl.textContent = node.text;
|
|
1000
|
+
const forAttr = node.props?.for;
|
|
1001
|
+
if (typeof forAttr === "string") lbl.htmlFor = forAttr;
|
|
1002
|
+
return lbl;
|
|
1003
|
+
}
|
|
1004
|
+
case "link": {
|
|
1005
|
+
const a = document.createElement("a");
|
|
1006
|
+
a.dataset.kumikiTile = "link";
|
|
1007
|
+
a.href = node.to;
|
|
1008
|
+
a.textContent = node.text;
|
|
1009
|
+
a.addEventListener("click", (e) => {
|
|
1010
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
|
|
1011
|
+
e.preventDefault();
|
|
1012
|
+
const nav = window.__kumikiApp?._navigate;
|
|
1013
|
+
if (nav) nav(node.to, false);
|
|
1014
|
+
});
|
|
1015
|
+
return a;
|
|
1016
|
+
}
|
|
1017
|
+
case "markdown": {
|
|
1018
|
+
const div = document.createElement("div");
|
|
1019
|
+
div.dataset.kumikiTile = "markdown";
|
|
1020
|
+
const paragraphs = (node.text ?? "").split(/\n\s*\n/);
|
|
1021
|
+
for (const para of paragraphs) {
|
|
1022
|
+
const p = document.createElement("p");
|
|
1023
|
+
p.textContent = para.trim();
|
|
1024
|
+
p.style.whiteSpace = "pre-wrap";
|
|
1025
|
+
div.appendChild(p);
|
|
1026
|
+
}
|
|
1027
|
+
return div;
|
|
1028
|
+
}
|
|
1029
|
+
case "image": {
|
|
1030
|
+
const img = document.createElement("img");
|
|
1031
|
+
img.dataset.kumikiTile = "image";
|
|
1032
|
+
img.src = node.src;
|
|
1033
|
+
const alt = node.props?.alt;
|
|
1034
|
+
if (typeof alt === "string") img.alt = alt;
|
|
1035
|
+
return img;
|
|
1036
|
+
}
|
|
1037
|
+
case "icon": {
|
|
1038
|
+
const span = document.createElement("span");
|
|
1039
|
+
span.dataset.kumikiTile = "icon";
|
|
1040
|
+
span.textContent = `[${node.name}]`;
|
|
1041
|
+
return span;
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
function applyContainerProps(el, props) {
|
|
1046
|
+
if (!props) return;
|
|
1047
|
+
applyResponsive(el, props.gap, (v) => el.style.gap = mapToken(String(v)));
|
|
1048
|
+
applyResponsive(el, props.align, (v) => el.style.alignItems = mapAlign(String(v)));
|
|
1049
|
+
applyResponsive(el, props.justify, (v) => el.style.justifyContent = mapJustify(String(v)));
|
|
1050
|
+
applyResponsive(el, props.pad, (v) => el.style.padding = mapToken(String(v)));
|
|
1051
|
+
const mw = props["max-w"] ?? props.maxWidth;
|
|
1052
|
+
if (mw !== void 0) el.style.maxWidth = typeof mw === "number" ? `${mw}px` : String(mw);
|
|
1053
|
+
if (typeof props.bg === "string") el.style.background = mapColor(props.bg);
|
|
1054
|
+
if (typeof props.radius === "string") el.style.borderRadius = mapToken(props.radius);
|
|
1055
|
+
applyStateStyles(el, props);
|
|
1056
|
+
applyTransition(el, props);
|
|
1057
|
+
}
|
|
1058
|
+
/** Apply a value that may be a literal or a responsive `{base, sm, md, lg, xl}` map. */
|
|
1059
|
+
function applyResponsive(_el, raw, set) {
|
|
1060
|
+
if (raw === void 0 || raw === null) return;
|
|
1061
|
+
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
1062
|
+
set(raw);
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
const m = raw;
|
|
1066
|
+
if (m.base !== void 0) set(m.base);
|
|
1067
|
+
for (const [bp, q] of [
|
|
1068
|
+
["xl", "(min-width: 1280px)"],
|
|
1069
|
+
["lg", "(min-width: 1024px)"],
|
|
1070
|
+
["md", "(min-width: 768px)"],
|
|
1071
|
+
["sm", "(min-width: 640px)"]
|
|
1072
|
+
]) if (m[bp] !== void 0 && window.matchMedia(q).matches) {
|
|
1073
|
+
set(m[bp]);
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
let animationStylesInjected = false;
|
|
1078
|
+
function ensureAnimationStyles() {
|
|
1079
|
+
if (animationStylesInjected) return;
|
|
1080
|
+
animationStylesInjected = true;
|
|
1081
|
+
const css = `
|
|
1082
|
+
@keyframes kumiki-fade { from { opacity: 0 } to { opacity: 1 } }
|
|
1083
|
+
@keyframes kumiki-slide-up { from { transform: translateY(8px); opacity: 0 } to { transform: translateY(0); opacity: 1 } }
|
|
1084
|
+
@keyframes kumiki-slide-down { from { transform: translateY(-8px); opacity: 0 } to { transform: translateY(0); opacity: 1 } }
|
|
1085
|
+
.kumiki-anim { animation-fill-mode: both; animation-timing-function: ease; animation-duration: 300ms; }
|
|
1086
|
+
.kumiki-anim-fade { animation-name: kumiki-fade; }
|
|
1087
|
+
.kumiki-anim-slide-up { animation-name: kumiki-slide-up; }
|
|
1088
|
+
.kumiki-anim-slide-down { animation-name: kumiki-slide-down; }
|
|
1089
|
+
.kumiki-anim-fast { animation-duration: 150ms; }
|
|
1090
|
+
.kumiki-anim-normal { animation-duration: 300ms; }
|
|
1091
|
+
.kumiki-anim-slow { animation-duration: 600ms; }
|
|
1092
|
+
`;
|
|
1093
|
+
const style = document.createElement("style");
|
|
1094
|
+
style.id = "kumiki-animations";
|
|
1095
|
+
style.appendChild(document.createTextNode(css));
|
|
1096
|
+
document.head.appendChild(style);
|
|
1097
|
+
}
|
|
1098
|
+
function applyTransition(el, props) {
|
|
1099
|
+
if (!props) return;
|
|
1100
|
+
const t = props.transition;
|
|
1101
|
+
if (typeof t !== "string") return;
|
|
1102
|
+
ensureAnimationStyles();
|
|
1103
|
+
el.classList.add("kumiki-anim", `kumiki-anim-${t}`);
|
|
1104
|
+
const d = props["transition-duration"];
|
|
1105
|
+
if (typeof d === "string") el.classList.add(`kumiki-anim-${d}`);
|
|
1106
|
+
}
|
|
1107
|
+
let stateStyleSeq = 0;
|
|
1108
|
+
let stateStylesEl = null;
|
|
1109
|
+
function applyStateStyles(el, props) {
|
|
1110
|
+
for (const state of [
|
|
1111
|
+
"hover",
|
|
1112
|
+
"focus",
|
|
1113
|
+
"active",
|
|
1114
|
+
"disabled",
|
|
1115
|
+
"selected"
|
|
1116
|
+
]) {
|
|
1117
|
+
const sub = props[state];
|
|
1118
|
+
if (!sub || typeof sub !== "object" || Array.isArray(sub)) continue;
|
|
1119
|
+
const id = `s${++stateStyleSeq}`;
|
|
1120
|
+
el.dataset.kumikiState = el.dataset.kumikiState ? `${el.dataset.kumikiState} ${id}` : id;
|
|
1121
|
+
const decls = stateStyleDecls(sub);
|
|
1122
|
+
if (!stateStylesEl) {
|
|
1123
|
+
stateStylesEl = document.createElement("style");
|
|
1124
|
+
stateStylesEl.id = "kumiki-state-styles";
|
|
1125
|
+
document.head.appendChild(stateStylesEl);
|
|
1126
|
+
}
|
|
1127
|
+
const selector = state === "hover" ? ":hover" : state === "focus" ? ":focus" : state === "active" ? ":active" : state === "disabled" ? ":disabled" : "[data-kumiki-selected]";
|
|
1128
|
+
stateStylesEl.appendChild(document.createTextNode(`[data-kumiki-state~="${id}"]${selector} { ${decls} }\n`));
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
function stateStyleDecls(sub) {
|
|
1132
|
+
const decls = [];
|
|
1133
|
+
if (typeof sub.bg === "string") decls.push(`background: ${mapColor(sub.bg)}`);
|
|
1134
|
+
if (typeof sub.color === "string") decls.push(`color: ${mapColor(sub.color)}`);
|
|
1135
|
+
if (typeof sub.shadow === "string") decls.push(`box-shadow: ${sub.shadow}`);
|
|
1136
|
+
return decls.join("; ");
|
|
1137
|
+
}
|
|
1138
|
+
function applyTextProps(el, props) {
|
|
1139
|
+
if (!props) return;
|
|
1140
|
+
if (props.strike) el.style.textDecoration = "line-through";
|
|
1141
|
+
if (typeof props.color === "string") el.style.color = mapColor(props.color);
|
|
1142
|
+
if (typeof props.size === "string") el.style.fontSize = mapSize(props.size);
|
|
1143
|
+
if (props.weight === "bold") el.style.fontWeight = "700";
|
|
1144
|
+
applyStateStyles(el, props);
|
|
1145
|
+
}
|
|
1146
|
+
let lastAppliedThemeName = null;
|
|
1147
|
+
function maybeReapplyTheme(app) {
|
|
1148
|
+
let name = app.themeName;
|
|
1149
|
+
if (name && app.themes && !(name in app.themes) && app.live && typeof app.live[name] === "string") name = app.live[name];
|
|
1150
|
+
if (name === lastAppliedThemeName) return;
|
|
1151
|
+
lastAppliedThemeName = name ?? null;
|
|
1152
|
+
applyThemeDefaults(app);
|
|
1153
|
+
}
|
|
1154
|
+
function applyThemeDefaults(app) {
|
|
1155
|
+
window.__kumikiApp = app;
|
|
1156
|
+
const theme = currentTheme();
|
|
1157
|
+
if (!theme) return;
|
|
1158
|
+
const colors = theme.colors ?? {};
|
|
1159
|
+
const typography = theme.typography ?? {};
|
|
1160
|
+
const sizes = typography.size ?? {};
|
|
1161
|
+
if (typeof colors.bg === "string") document.body.style.background = colors.bg;
|
|
1162
|
+
if (typeof colors.fg === "string") document.body.style.color = colors.fg;
|
|
1163
|
+
if (typeof typography.family === "string") document.body.style.fontFamily = typography.family;
|
|
1164
|
+
if (typeof sizes.md === "string") document.body.style.fontSize = sizes.md;
|
|
1165
|
+
if (typeof typography["line-height"] === "string") document.body.style.lineHeight = String(typography["line-height"]);
|
|
1166
|
+
const prior = document.getElementById("kumiki-theme-base");
|
|
1167
|
+
if (prior) prior.remove();
|
|
1168
|
+
const css = document.createElement("style");
|
|
1169
|
+
css.id = "kumiki-theme-base";
|
|
1170
|
+
css.appendChild(document.createTextNode(`
|
|
1171
|
+
[data-kumiki-tile="card"] {
|
|
1172
|
+
background: ${typeof colors.surface === "string" ? colors.surface : "#fff"};
|
|
1173
|
+
border: 1px solid ${typeof colors.border === "string" ? colors.border : "#e0e0e0"};
|
|
1174
|
+
box-shadow: ${themeShadow(theme, "sm") ?? "0 1px 2px rgba(0,0,0,0.08)"};
|
|
1175
|
+
}
|
|
1176
|
+
[data-kumiki-tile="button"] {
|
|
1177
|
+
background: ${typeof colors.surface === "string" ? colors.surface : "#fff"};
|
|
1178
|
+
color: ${typeof colors.fg === "string" ? colors.fg : "#1a1a1a"};
|
|
1179
|
+
border: 1px solid ${typeof colors.border === "string" ? colors.border : "#ddd"};
|
|
1180
|
+
padding: 6px 12px;
|
|
1181
|
+
cursor: pointer;
|
|
1182
|
+
border-radius: ${themeRadius(theme, "md") ?? "8px"};
|
|
1183
|
+
}
|
|
1184
|
+
[data-kumiki-tile="button"]:hover { filter: brightness(0.97); }
|
|
1185
|
+
[data-kumiki-tile="input"], [data-kumiki-tile="textarea"] {
|
|
1186
|
+
font: inherit;
|
|
1187
|
+
padding: 6px 10px;
|
|
1188
|
+
border: 1px solid ${typeof colors.border === "string" ? colors.border : "#ddd"};
|
|
1189
|
+
border-radius: ${themeRadius(theme, "sm") ?? "4px"};
|
|
1190
|
+
background: ${typeof colors.surface === "string" ? colors.surface : "#fff"};
|
|
1191
|
+
color: ${typeof colors.fg === "string" ? colors.fg : "#1a1a1a"};
|
|
1192
|
+
}
|
|
1193
|
+
[data-kumiki-tile="input"]:focus, [data-kumiki-tile="textarea"]:focus {
|
|
1194
|
+
outline: 2px solid ${typeof colors.primary === "string" ? colors.primary : "#0070f3"};
|
|
1195
|
+
outline-offset: 1px;
|
|
1196
|
+
}
|
|
1197
|
+
[data-kumiki-tile="link"] {
|
|
1198
|
+
color: ${typeof colors.primary === "string" ? colors.primary : "#0070f3"};
|
|
1199
|
+
text-decoration: none;
|
|
1200
|
+
}
|
|
1201
|
+
[data-kumiki-tile="link"]:hover { text-decoration: underline; }
|
|
1202
|
+
[data-kumiki-tile="heading"] {
|
|
1203
|
+
font-size: ${typeof sizes.xl === "string" ? sizes.xl : "28px"};
|
|
1204
|
+
font-weight: 700;
|
|
1205
|
+
margin: 0 0 8px;
|
|
1206
|
+
}
|
|
1207
|
+
[data-kumiki-tile="markdown"] p { margin: 0 0 12px; }
|
|
1208
|
+
`));
|
|
1209
|
+
document.head.appendChild(css);
|
|
1210
|
+
}
|
|
1211
|
+
function themeShadow(theme, key) {
|
|
1212
|
+
const shadow = theme.shadow;
|
|
1213
|
+
if (shadow && typeof shadow === "object" && !Array.isArray(shadow)) {
|
|
1214
|
+
const v = shadow[key];
|
|
1215
|
+
if (typeof v === "string") return v;
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
function themeRadius(theme, key) {
|
|
1219
|
+
const radius = theme.radius;
|
|
1220
|
+
if (radius && typeof radius === "object" && !Array.isArray(radius)) {
|
|
1221
|
+
const v = radius[key];
|
|
1222
|
+
if (typeof v === "string") return v;
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
function currentTheme() {
|
|
1226
|
+
const app = window.__kumikiApp;
|
|
1227
|
+
if (!app?.themes) return null;
|
|
1228
|
+
let name = app.themeName;
|
|
1229
|
+
if (name && !(name in app.themes) && app.live && typeof app.live[name] === "string") name = app.live[name];
|
|
1230
|
+
if (!name) name = Object.keys(app.themes)[0];
|
|
1231
|
+
if (!name) return null;
|
|
1232
|
+
return app.themes[name] ?? null;
|
|
1233
|
+
}
|
|
1234
|
+
function mapToken(t) {
|
|
1235
|
+
const theme = currentTheme();
|
|
1236
|
+
if (theme?.spacing && typeof theme.spacing === "object") {
|
|
1237
|
+
const sec = theme.spacing;
|
|
1238
|
+
if (t in sec) {
|
|
1239
|
+
const v = sec[t];
|
|
1240
|
+
if (typeof v === "string") return v;
|
|
1241
|
+
if (typeof v === "number") return `${v}px`;
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
switch (t) {
|
|
1245
|
+
case "xs": return "4px";
|
|
1246
|
+
case "sm": return "8px";
|
|
1247
|
+
case "md": return "16px";
|
|
1248
|
+
case "lg": return "24px";
|
|
1249
|
+
case "xl": return "40px";
|
|
1250
|
+
case "xxl": return "64px";
|
|
1251
|
+
default: return t;
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
function mapAlign(a) {
|
|
1255
|
+
switch (a) {
|
|
1256
|
+
case "start": return "flex-start";
|
|
1257
|
+
case "end": return "flex-end";
|
|
1258
|
+
case "center": return "center";
|
|
1259
|
+
case "stretch": return "stretch";
|
|
1260
|
+
default: return a;
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
function mapJustify(a) {
|
|
1264
|
+
switch (a) {
|
|
1265
|
+
case "start": return "flex-start";
|
|
1266
|
+
case "end": return "flex-end";
|
|
1267
|
+
case "center": return "center";
|
|
1268
|
+
case "between": return "space-between";
|
|
1269
|
+
case "around": return "space-around";
|
|
1270
|
+
default: return a;
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
function mapColor(c) {
|
|
1274
|
+
const theme = currentTheme();
|
|
1275
|
+
if (theme?.colors && typeof theme.colors === "object") {
|
|
1276
|
+
const sec = theme.colors;
|
|
1277
|
+
if (c in sec) {
|
|
1278
|
+
const v = sec[c];
|
|
1279
|
+
if (typeof v === "string") return v;
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
switch (c) {
|
|
1283
|
+
case "muted": return "#888";
|
|
1284
|
+
case "danger": return "#c4222a";
|
|
1285
|
+
case "primary": return "#0070f3";
|
|
1286
|
+
case "fg": return "#1a1a1a";
|
|
1287
|
+
case "surface": return "#f7f7f7";
|
|
1288
|
+
default: return c;
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
function mapSize(s) {
|
|
1292
|
+
const theme = currentTheme();
|
|
1293
|
+
if (theme?.typography && typeof theme.typography === "object") {
|
|
1294
|
+
const sz = theme.typography.size;
|
|
1295
|
+
if (sz && typeof sz === "object" && !Array.isArray(sz) && s in sz) {
|
|
1296
|
+
const v = sz[s];
|
|
1297
|
+
if (typeof v === "string") return v;
|
|
1298
|
+
if (typeof v === "number") return `${v}px`;
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
switch (s) {
|
|
1302
|
+
case "sm": return "14px";
|
|
1303
|
+
case "md": return "16px";
|
|
1304
|
+
case "lg": return "20px";
|
|
1305
|
+
case "xl": return "28px";
|
|
1306
|
+
case "xxl": return "40px";
|
|
1307
|
+
default: return s;
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
const _stdlib = {
|
|
1311
|
+
mapSize(m) {
|
|
1312
|
+
if (m instanceof Map) return m.size;
|
|
1313
|
+
if (m && typeof m === "object") return Object.keys(m).length;
|
|
1314
|
+
return 0;
|
|
1315
|
+
},
|
|
1316
|
+
mapKeys(m) {
|
|
1317
|
+
return m ? Object.keys(m) : [];
|
|
1318
|
+
},
|
|
1319
|
+
mapValues(m) {
|
|
1320
|
+
return m ? Object.values(m) : [];
|
|
1321
|
+
},
|
|
1322
|
+
mapEntries(m) {
|
|
1323
|
+
return m ? Object.entries(m) : [];
|
|
1324
|
+
},
|
|
1325
|
+
mapGet(m, k) {
|
|
1326
|
+
return m ? m[k] : void 0;
|
|
1327
|
+
},
|
|
1328
|
+
/** Polymorphic `.get-or(default)` for Option-like values. */
|
|
1329
|
+
getOr(v, fallback) {
|
|
1330
|
+
if (v && typeof v === "object" && "_tag" in v) {
|
|
1331
|
+
const tagged = v;
|
|
1332
|
+
if (tagged._tag === "Some" || tagged._tag === "Ok") return tagged._0;
|
|
1333
|
+
if (tagged._tag === "None" || tagged._tag === "Err") return fallback;
|
|
1334
|
+
}
|
|
1335
|
+
return v ?? fallback;
|
|
1336
|
+
},
|
|
1337
|
+
mapGetOr(m, k, def) {
|
|
1338
|
+
if (m && k in m) return m[k];
|
|
1339
|
+
return def;
|
|
1340
|
+
},
|
|
1341
|
+
mapInsert(m, k, v) {
|
|
1342
|
+
return {
|
|
1343
|
+
...m,
|
|
1344
|
+
[k]: v
|
|
1345
|
+
};
|
|
1346
|
+
},
|
|
1347
|
+
mapRemove(m, k) {
|
|
1348
|
+
const out = {};
|
|
1349
|
+
for (const [kk, vv] of Object.entries(m ?? {})) if (kk !== k) out[kk] = vv;
|
|
1350
|
+
return out;
|
|
1351
|
+
},
|
|
1352
|
+
mapFilter(m, pred) {
|
|
1353
|
+
const out = {};
|
|
1354
|
+
for (const [k, v] of Object.entries(m ?? {})) if (pred(k, v)) out[k] = v;
|
|
1355
|
+
return out;
|
|
1356
|
+
},
|
|
1357
|
+
/**
|
|
1358
|
+
* Polymorphic `.filter` dispatch — used by codegen when the receiver type
|
|
1359
|
+
* isn't statically known (e.g. `m.keys.filter(...)` vs `m.filter(...)`).
|
|
1360
|
+
* Arrays go through Array.prototype.filter; objects (Maps in Kumiki) fall
|
|
1361
|
+
* back to the (k, v) → boolean predicate of mapFilter.
|
|
1362
|
+
*/
|
|
1363
|
+
filter(coll, pred) {
|
|
1364
|
+
if (Array.isArray(coll)) return coll.filter((x) => pred(x));
|
|
1365
|
+
if (coll && typeof coll === "object") {
|
|
1366
|
+
const out = {};
|
|
1367
|
+
for (const [k, v] of Object.entries(coll)) if (pred(k, v)) out[k] = v;
|
|
1368
|
+
return out;
|
|
1369
|
+
}
|
|
1370
|
+
return [];
|
|
1371
|
+
},
|
|
1372
|
+
listSize(xs) {
|
|
1373
|
+
return xs?.length ?? 0;
|
|
1374
|
+
},
|
|
1375
|
+
listFilter(xs, pred) {
|
|
1376
|
+
return (xs ?? []).filter(pred);
|
|
1377
|
+
},
|
|
1378
|
+
listMap(xs, fn) {
|
|
1379
|
+
return (xs ?? []).map(fn);
|
|
1380
|
+
},
|
|
1381
|
+
/** Polymorphic `.map`: over List elements, or over Option/Result Some/Ok. */
|
|
1382
|
+
mapOver(coll, fn) {
|
|
1383
|
+
if (Array.isArray(coll)) return coll.map(fn);
|
|
1384
|
+
if (coll && typeof coll === "object" && "_tag" in coll) {
|
|
1385
|
+
const tagged = coll;
|
|
1386
|
+
if (tagged._tag === "Some") return {
|
|
1387
|
+
_tag: "Some",
|
|
1388
|
+
_0: fn(tagged._0)
|
|
1389
|
+
};
|
|
1390
|
+
if (tagged._tag === "Ok") return {
|
|
1391
|
+
_tag: "Ok",
|
|
1392
|
+
_0: fn(tagged._0)
|
|
1393
|
+
};
|
|
1394
|
+
return coll;
|
|
1395
|
+
}
|
|
1396
|
+
return coll == null ? [] : fn(coll);
|
|
1397
|
+
},
|
|
1398
|
+
/** Option(T).flat-map(f): Some(v) -> f(v), None -> None. f returns an Option. */
|
|
1399
|
+
flatMapOption(opt, fn) {
|
|
1400
|
+
if (opt && typeof opt === "object" && "_tag" in opt) {
|
|
1401
|
+
const tagged = opt;
|
|
1402
|
+
if (tagged._tag === "Some" || tagged._tag === "Ok") return fn(tagged._0);
|
|
1403
|
+
return opt;
|
|
1404
|
+
}
|
|
1405
|
+
return _stdlib.None;
|
|
1406
|
+
},
|
|
1407
|
+
listSortBy(xs, keyOf) {
|
|
1408
|
+
return [...xs ?? []].sort((a, b) => keyOf(a) - keyOf(b));
|
|
1409
|
+
},
|
|
1410
|
+
/** List(T).fold(init, expr): left fold with $1=acc, $2=elem. */
|
|
1411
|
+
listFold(xs, init, fn) {
|
|
1412
|
+
let acc = init;
|
|
1413
|
+
for (const x of xs ?? []) acc = fn(acc, x);
|
|
1414
|
+
return acc;
|
|
1415
|
+
},
|
|
1416
|
+
setHas(s, x) {
|
|
1417
|
+
return !!s && String(x) in s;
|
|
1418
|
+
},
|
|
1419
|
+
setToggle(s, x) {
|
|
1420
|
+
const k = String(x);
|
|
1421
|
+
const cur = { ...s ?? {} };
|
|
1422
|
+
if (k in cur) {
|
|
1423
|
+
delete cur[k];
|
|
1424
|
+
return cur;
|
|
1425
|
+
}
|
|
1426
|
+
cur[k] = true;
|
|
1427
|
+
return cur;
|
|
1428
|
+
},
|
|
1429
|
+
add(a, b) {
|
|
1430
|
+
if (typeof a === "string" || typeof b === "string") return String(a) + String(b);
|
|
1431
|
+
return a + b;
|
|
1432
|
+
},
|
|
1433
|
+
show(v) {
|
|
1434
|
+
if (v === null || v === void 0) return "";
|
|
1435
|
+
if (typeof v === "object" && v && "_tag" in v) return v._tag;
|
|
1436
|
+
return String(v);
|
|
1437
|
+
},
|
|
1438
|
+
eq(a, b) {
|
|
1439
|
+
if (a === b) return true;
|
|
1440
|
+
if (a == null || b == null) return false;
|
|
1441
|
+
if (typeof a === "object" && typeof b === "object") {
|
|
1442
|
+
const ao = a;
|
|
1443
|
+
const bo = b;
|
|
1444
|
+
if (ao._tag !== void 0 || bo._tag !== void 0) {
|
|
1445
|
+
if (ao._tag !== bo._tag) return false;
|
|
1446
|
+
for (const k of Object.keys(ao)) if (!Object.is(ao[k], bo[k])) return false;
|
|
1447
|
+
return true;
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
return false;
|
|
1451
|
+
},
|
|
1452
|
+
freshId() {
|
|
1453
|
+
const c = globalThis.crypto;
|
|
1454
|
+
if (c?.randomUUID) return c.randomUUID();
|
|
1455
|
+
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
1456
|
+
},
|
|
1457
|
+
now() {
|
|
1458
|
+
return Date.now();
|
|
1459
|
+
},
|
|
1460
|
+
recordCopy(rec, patch) {
|
|
1461
|
+
return {
|
|
1462
|
+
...rec,
|
|
1463
|
+
...patch
|
|
1464
|
+
};
|
|
1465
|
+
},
|
|
1466
|
+
unwrap(opt) {
|
|
1467
|
+
if (opt && typeof opt === "object" && "_tag" in opt) {
|
|
1468
|
+
const o = opt;
|
|
1469
|
+
if (o._tag === "Some") return o._0;
|
|
1470
|
+
}
|
|
1471
|
+
return opt;
|
|
1472
|
+
},
|
|
1473
|
+
optionGetOr(opt, def) {
|
|
1474
|
+
if (opt && typeof opt === "object" && "_tag" in opt) {
|
|
1475
|
+
const o = opt;
|
|
1476
|
+
if (o._tag === "Some") return o._0;
|
|
1477
|
+
if (o._tag === "None") return def;
|
|
1478
|
+
}
|
|
1479
|
+
return opt ?? def;
|
|
1480
|
+
},
|
|
1481
|
+
Some(v) {
|
|
1482
|
+
return {
|
|
1483
|
+
_tag: "Some",
|
|
1484
|
+
_0: v
|
|
1485
|
+
};
|
|
1486
|
+
},
|
|
1487
|
+
None: { _tag: "None" },
|
|
1488
|
+
Ok(v) {
|
|
1489
|
+
return {
|
|
1490
|
+
_tag: "Ok",
|
|
1491
|
+
_0: v
|
|
1492
|
+
};
|
|
1493
|
+
},
|
|
1494
|
+
Err(v) {
|
|
1495
|
+
return {
|
|
1496
|
+
_tag: "Err",
|
|
1497
|
+
_0: v
|
|
1498
|
+
};
|
|
1499
|
+
},
|
|
1500
|
+
variant(tag, ...args) {
|
|
1501
|
+
const o = { _tag: tag };
|
|
1502
|
+
args.forEach((a, i) => {
|
|
1503
|
+
o[`_${i}`] = a;
|
|
1504
|
+
});
|
|
1505
|
+
return o;
|
|
1506
|
+
},
|
|
1507
|
+
variantIs(v, tag) {
|
|
1508
|
+
return !!v && typeof v === "object" && "_tag" in v && v._tag === tag;
|
|
1509
|
+
},
|
|
1510
|
+
/** List(T).chunk(n) → List(List(T)). The last chunk may be shorter. */
|
|
1511
|
+
listChunk(xs, n) {
|
|
1512
|
+
const arr = xs ?? [];
|
|
1513
|
+
const size = Math.max(1, Math.floor(n));
|
|
1514
|
+
const out = [];
|
|
1515
|
+
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
|
|
1516
|
+
return out;
|
|
1517
|
+
},
|
|
1518
|
+
/** List(T).zip(other) → List(Tuple(T, U)); truncates to the shorter list. */
|
|
1519
|
+
listZip(a, b) {
|
|
1520
|
+
const xs = a ?? [];
|
|
1521
|
+
const ys = b ?? [];
|
|
1522
|
+
const n = Math.min(xs.length, ys.length);
|
|
1523
|
+
const out = [];
|
|
1524
|
+
for (let i = 0; i < n; i++) out.push([xs[i], ys[i]]);
|
|
1525
|
+
return out;
|
|
1526
|
+
},
|
|
1527
|
+
/** Map(K,V).update(k, fn): apply fn to the current value of k, no-op if absent. */
|
|
1528
|
+
mapUpdate(m, k, fn) {
|
|
1529
|
+
const obj = m ?? {};
|
|
1530
|
+
if (!(k in obj)) return obj;
|
|
1531
|
+
return {
|
|
1532
|
+
...obj,
|
|
1533
|
+
[k]: fn(obj[k])
|
|
1534
|
+
};
|
|
1535
|
+
},
|
|
1536
|
+
/** Set(T).add(x). Sets are stored as `{ [String(x)]: true }`. */
|
|
1537
|
+
setAdd(s, x) {
|
|
1538
|
+
return {
|
|
1539
|
+
...s ?? {},
|
|
1540
|
+
[String(x)]: true
|
|
1541
|
+
};
|
|
1542
|
+
},
|
|
1543
|
+
/** Set(T).union(other). */
|
|
1544
|
+
setUnion(a, b) {
|
|
1545
|
+
return {
|
|
1546
|
+
...a ?? {},
|
|
1547
|
+
...b ?? {}
|
|
1548
|
+
};
|
|
1549
|
+
},
|
|
1550
|
+
/** Set(T).intersect(other) — keys present in both. */
|
|
1551
|
+
setIntersect(a, b) {
|
|
1552
|
+
const bb = b ?? {};
|
|
1553
|
+
const out = {};
|
|
1554
|
+
for (const k of Object.keys(a ?? {})) if (k in bb) out[k] = true;
|
|
1555
|
+
return out;
|
|
1556
|
+
},
|
|
1557
|
+
/** Set(T).diff(other) — keys in a not in b. */
|
|
1558
|
+
setDiff(a, b) {
|
|
1559
|
+
const bb = b ?? {};
|
|
1560
|
+
const out = {};
|
|
1561
|
+
for (const k of Object.keys(a ?? {})) if (!(k in bb)) out[k] = true;
|
|
1562
|
+
return out;
|
|
1563
|
+
},
|
|
1564
|
+
/** Option(T).or / Result(T,E).or — receiver when Some/Ok, else `other`. */
|
|
1565
|
+
or(v, other) {
|
|
1566
|
+
if (v && typeof v === "object" && "_tag" in v) {
|
|
1567
|
+
const tag = v._tag;
|
|
1568
|
+
if (tag === "Some" || tag === "Ok") return v;
|
|
1569
|
+
if (tag === "None" || tag === "Err") return other;
|
|
1570
|
+
}
|
|
1571
|
+
return v ?? other;
|
|
1572
|
+
},
|
|
1573
|
+
/** Result(T,E).map-err(fn) — maps the Err payload, passes Ok through unchanged. */
|
|
1574
|
+
mapErr(r, fn) {
|
|
1575
|
+
if (r && typeof r === "object" && "_tag" in r) {
|
|
1576
|
+
const t = r;
|
|
1577
|
+
if (t._tag === "Err") return {
|
|
1578
|
+
_tag: "Err",
|
|
1579
|
+
_0: fn(t._0)
|
|
1580
|
+
};
|
|
1581
|
+
}
|
|
1582
|
+
return r;
|
|
1583
|
+
},
|
|
1584
|
+
/** Polymorphic `.diff`: numeric magnitude (Time/Duration) or Set difference. */
|
|
1585
|
+
diff(a, b) {
|
|
1586
|
+
if (typeof a === "number" || typeof b === "number") return Math.abs(a - b);
|
|
1587
|
+
return _stdlib.setDiff(a, b);
|
|
1588
|
+
}
|
|
1589
|
+
};
|
|
1590
|
+
const builtinEffects = {
|
|
1591
|
+
async storageRead(input) {
|
|
1592
|
+
const { key } = input;
|
|
1593
|
+
try {
|
|
1594
|
+
const raw = localStorage.getItem(key);
|
|
1595
|
+
if (raw === null) return {
|
|
1596
|
+
kind: "ok",
|
|
1597
|
+
value: _stdlib.None
|
|
1598
|
+
};
|
|
1599
|
+
const value = JSON.parse(raw);
|
|
1600
|
+
return {
|
|
1601
|
+
kind: "ok",
|
|
1602
|
+
value: _stdlib.Some(value)
|
|
1603
|
+
};
|
|
1604
|
+
} catch (e) {
|
|
1605
|
+
return {
|
|
1606
|
+
kind: "err",
|
|
1607
|
+
value: { message: String(e) }
|
|
1608
|
+
};
|
|
1609
|
+
}
|
|
1610
|
+
},
|
|
1611
|
+
async storageWrite(input) {
|
|
1612
|
+
const { key, value } = input;
|
|
1613
|
+
try {
|
|
1614
|
+
localStorage.setItem(key, JSON.stringify(value));
|
|
1615
|
+
return {
|
|
1616
|
+
kind: "ok",
|
|
1617
|
+
value: null
|
|
1618
|
+
};
|
|
1619
|
+
} catch (e) {
|
|
1620
|
+
return {
|
|
1621
|
+
kind: "err",
|
|
1622
|
+
value: { message: String(e) }
|
|
1623
|
+
};
|
|
1624
|
+
}
|
|
1625
|
+
},
|
|
1626
|
+
async httpFetch(method, input, baseUrl) {
|
|
1627
|
+
const x = input;
|
|
1628
|
+
const url = (baseUrl ?? "") + (x.url ?? "");
|
|
1629
|
+
const init = {
|
|
1630
|
+
method,
|
|
1631
|
+
headers: { ...x.headers ?? {} }
|
|
1632
|
+
};
|
|
1633
|
+
if (x.body !== void 0 && method !== "GET" && method !== "HEAD") {
|
|
1634
|
+
const headers = init.headers;
|
|
1635
|
+
if (typeof x.body === "string") init.body = x.body;
|
|
1636
|
+
else {
|
|
1637
|
+
init.body = JSON.stringify(x.body);
|
|
1638
|
+
if (!headers["Content-Type"]) headers["Content-Type"] = "application/json";
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
try {
|
|
1642
|
+
const res = await fetch(url, init);
|
|
1643
|
+
if (res.status === 401 || res.status === 403 || res.status >= 500) return {
|
|
1644
|
+
kind: "err",
|
|
1645
|
+
value: {
|
|
1646
|
+
status: res.status,
|
|
1647
|
+
message: res.statusText,
|
|
1648
|
+
body: await res.text().catch(() => "")
|
|
1649
|
+
}
|
|
1650
|
+
};
|
|
1651
|
+
if (!res.ok) return {
|
|
1652
|
+
kind: "err",
|
|
1653
|
+
value: {
|
|
1654
|
+
status: res.status,
|
|
1655
|
+
message: res.statusText,
|
|
1656
|
+
body: await res.text().catch(() => "")
|
|
1657
|
+
}
|
|
1658
|
+
};
|
|
1659
|
+
const decode = x.decode ?? "json";
|
|
1660
|
+
let value;
|
|
1661
|
+
if (decode === "json") value = await res.json();
|
|
1662
|
+
else if (decode === "text") value = await res.text();
|
|
1663
|
+
else if (decode === "none") value = null;
|
|
1664
|
+
else value = await res.text();
|
|
1665
|
+
return {
|
|
1666
|
+
kind: "ok",
|
|
1667
|
+
value
|
|
1668
|
+
};
|
|
1669
|
+
} catch (e) {
|
|
1670
|
+
return {
|
|
1671
|
+
kind: "err",
|
|
1672
|
+
value: {
|
|
1673
|
+
status: 0,
|
|
1674
|
+
message: String(e),
|
|
1675
|
+
body: ""
|
|
1676
|
+
}
|
|
1677
|
+
};
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
};
|
|
1681
|
+
//#endregion
|
|
1682
|
+
export { _stdlib, builtinEffects, mount, runScenario, smoke };
|