@cosmicdrift/kumiko-headless 1.0.0 → 2.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/package.json +7 -2
- package/src/apex/__tests__/lightbox.test.ts +20 -0
- package/src/apex/__tests__/render.test.ts +54 -1
- package/src/apex/css.ts +11 -1
- package/src/apex/index.ts +69 -39
- package/src/apex/lightbox.ts +50 -0
- package/src/dispatcher/__tests__/contract.test.ts +3 -0
- package/src/dispatcher/index.ts +2 -0
- package/src/dispatcher/types.ts +27 -0
- package/src/form/__tests__/submit.test.ts +3 -0
- package/src/form/form-controller.ts +6 -3
- package/src/format/__tests__/format.test.ts +21 -0
- package/src/format/__tests__/html-template.test.ts +47 -0
- package/src/format/escape.ts +55 -0
- package/src/format/html-template.ts +49 -0
- package/src/format/index.ts +40 -6
- package/src/index.ts +15 -1
- package/src/locale-routing/__tests__/locale-routing.test.ts +118 -0
- package/src/locale-routing/index.ts +144 -0
- package/src/store/create-store.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-headless",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Headless UI logic for Kumiko — Dispatcher contract, Form-Controller, View-Model, Nav-Resolver. Plattform- und React-frei; jeder Renderer (renderer, renderer-web, renderer-native, …) komponiert darauf.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -29,10 +29,15 @@
|
|
|
29
29
|
"./apex": {
|
|
30
30
|
"types": "./src/apex/index.ts",
|
|
31
31
|
"default": "./src/apex/index.ts"
|
|
32
|
+
},
|
|
33
|
+
"./locale-routing": {
|
|
34
|
+
"types": "./src/locale-routing/index.ts",
|
|
35
|
+
"default": "./src/locale-routing/index.ts"
|
|
32
36
|
}
|
|
33
37
|
},
|
|
34
38
|
"dependencies": {
|
|
35
|
-
"@cosmicdrift/kumiko-framework": "
|
|
39
|
+
"@cosmicdrift/kumiko-framework": "2.0.0",
|
|
40
|
+
"temporal-polyfill": "^0.3.2",
|
|
36
41
|
"zod": "^4.4.3"
|
|
37
42
|
},
|
|
38
43
|
"publishConfig": {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { APEX_LIGHTBOX_SCRIPT, APEX_LIGHTBOX_SCRIPT_CSP_HASH } from "../index";
|
|
4
|
+
|
|
5
|
+
function scriptBody(html: string): string {
|
|
6
|
+
const match = html.match(/^<script>(?<body>[\s\S]*)<\/script>$/);
|
|
7
|
+
const body = match?.groups?.["body"];
|
|
8
|
+
if (body === undefined) {
|
|
9
|
+
throw new Error("APEX_LIGHTBOX_SCRIPT isn't a single <script>...</script> string");
|
|
10
|
+
}
|
|
11
|
+
return body;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("APEX_LIGHTBOX_SCRIPT_CSP_HASH", () => {
|
|
15
|
+
test("matches the actual script content byte-for-byte", () => {
|
|
16
|
+
const body = scriptBody(APEX_LIGHTBOX_SCRIPT);
|
|
17
|
+
const hash = `sha256-${createHash("sha256").update(body).digest("base64")}`;
|
|
18
|
+
expect(hash).toBe(APEX_LIGHTBOX_SCRIPT_CSP_HASH);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -122,6 +122,8 @@ describe("renderApexPage", () => {
|
|
|
122
122
|
expect(html.indexOf("price-cap")).toBeLessThan(html.indexOf("b1"));
|
|
123
123
|
// featured tier without explicit cta variant → primary button
|
|
124
124
|
expect(html).toContain('class="btn btn-primary" href="/s"');
|
|
125
|
+
|
|
126
|
+
expect(html).not.toContain("btn-link");
|
|
125
127
|
});
|
|
126
128
|
|
|
127
129
|
test("cta variant link renders a plain anchor, default renders a button", () => {
|
|
@@ -140,6 +142,29 @@ describe("renderApexPage", () => {
|
|
|
140
142
|
expect(html).toContain('<a class="btn btn-primary" href="/signup">Start</a>');
|
|
141
143
|
});
|
|
142
144
|
|
|
145
|
+
test("pricing-tier cta variant link renders a plain anchor via renderCta, not a btn-link class", () => {
|
|
146
|
+
const html = renderApexPage(
|
|
147
|
+
page({
|
|
148
|
+
sections: [
|
|
149
|
+
{
|
|
150
|
+
kind: "pricing-grid",
|
|
151
|
+
heading: "Preise",
|
|
152
|
+
tiers: [
|
|
153
|
+
{
|
|
154
|
+
name: "Free",
|
|
155
|
+
amount: "0 €",
|
|
156
|
+
benefits: ["b1"],
|
|
157
|
+
cta: { label: "Los", href: "/free", variant: "link" },
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
},
|
|
161
|
+
],
|
|
162
|
+
}),
|
|
163
|
+
);
|
|
164
|
+
expect(html).toContain('<a href="/free">Los</a>');
|
|
165
|
+
expect(html).not.toContain("btn-link");
|
|
166
|
+
});
|
|
167
|
+
|
|
143
168
|
test("footer --footer-cols reflects column count and survives without columns", () => {
|
|
144
169
|
const twoCols = renderApexPage(
|
|
145
170
|
page({
|
|
@@ -153,7 +178,11 @@ describe("renderApexPage", () => {
|
|
|
153
178
|
}),
|
|
154
179
|
);
|
|
155
180
|
expect(twoCols).toContain("--footer-cols:2");
|
|
156
|
-
|
|
181
|
+
// 502/2: --footer-cols:0 makes grid-template-columns's repeat(0, 1fr)
|
|
182
|
+
// invalid CSS — the browser drops the whole declaration. Clamped to a
|
|
183
|
+
// minimum of 1 so a footer with no columns still gets a valid grid.
|
|
184
|
+
expect(renderApexPage(page())).toContain("--footer-cols:1");
|
|
185
|
+
expect(renderApexPage(page())).not.toContain("--footer-cols:0");
|
|
157
186
|
});
|
|
158
187
|
|
|
159
188
|
test("renders every section kind without throwing or leaking undefined", () => {
|
|
@@ -189,6 +218,11 @@ describe("renderApexPage", () => {
|
|
|
189
218
|
);
|
|
190
219
|
expect(withRobots).toContain('<meta name="robots" content="noindex, nofollow" />');
|
|
191
220
|
});
|
|
221
|
+
test("omits both description meta tags when description is empty", () => {
|
|
222
|
+
const html = renderApexPage(page({ head: { lang: "de", title: "T", description: "" } }));
|
|
223
|
+
expect(html).not.toContain('<meta name="description"');
|
|
224
|
+
expect(html).not.toContain('<meta property="og:description"');
|
|
225
|
+
});
|
|
192
226
|
|
|
193
227
|
test("renders og:site_name and og:locale", () => {
|
|
194
228
|
const html = renderApexPage(
|
|
@@ -247,6 +281,25 @@ describe("renderApexPage", () => {
|
|
|
247
281
|
expect(html).toContain('<link rel="preconnect" href="https://api.example.com" />');
|
|
248
282
|
});
|
|
249
283
|
|
|
284
|
+
test("hero screenshot includes apex lightbox chrome", () => {
|
|
285
|
+
const html = renderApexPage(
|
|
286
|
+
page({
|
|
287
|
+
sections: [
|
|
288
|
+
{
|
|
289
|
+
kind: "hero",
|
|
290
|
+
title: "h",
|
|
291
|
+
tagline: "t",
|
|
292
|
+
screenshot: { src: "/shots/demo.png", alt: "Dashboard" },
|
|
293
|
+
},
|
|
294
|
+
],
|
|
295
|
+
}),
|
|
296
|
+
);
|
|
297
|
+
expect(html).toContain('class="shot-frame"');
|
|
298
|
+
expect(html).toContain('<dialog id="apex-lightbox"');
|
|
299
|
+
expect(html).toContain("apex-lightbox");
|
|
300
|
+
expect(html).toContain(".shot-frame img");
|
|
301
|
+
});
|
|
302
|
+
|
|
250
303
|
test("renders schemaJson as json-ld script tag", () => {
|
|
251
304
|
const html = renderApexPage(
|
|
252
305
|
page({
|
package/src/apex/css.ts
CHANGED
|
@@ -59,10 +59,20 @@ const HERO = `
|
|
|
59
59
|
.hero-meta { margin-top: 1.5rem; font-size: 0.875rem; color: var(--fg-subtle); }
|
|
60
60
|
.hero-meta strong { color: var(--fg-muted); font-weight: 600; }
|
|
61
61
|
.shot-frame { border-radius: 0.75rem; border: 1px solid var(--border); background: var(--bg-card);
|
|
62
|
-
box-shadow: var(--shadow); overflow: hidden; }
|
|
62
|
+
box-shadow: var(--shadow); overflow: hidden; cursor: zoom-in; }
|
|
63
63
|
.shot-bar { display: flex; gap: 0.4rem; padding: 0.6rem 0.85rem; border-bottom: 1px solid var(--border); background: var(--bg-muted); }
|
|
64
64
|
.shot-bar span { width: 0.65rem; height: 0.65rem; border-radius: 50%; background: var(--border); }
|
|
65
65
|
.shot-frame img { display: block; width: 100%; height: auto; }
|
|
66
|
+
.apex-lightbox { border: none; padding: 0; margin: auto; max-width: 95vw; max-height: 90vh;
|
|
67
|
+
background: transparent; overflow: visible; }
|
|
68
|
+
.apex-lightbox::backdrop { background: rgba(15, 23, 42, 0.72); }
|
|
69
|
+
.apex-lightbox__img { display: block; max-width: 90vw; max-height: 85vh; width: auto; height: auto;
|
|
70
|
+
border-radius: 0.75rem; border: 1px solid var(--border); box-shadow: var(--shadow); }
|
|
71
|
+
.apex-lightbox__close { position: fixed; top: 1rem; right: 1rem; z-index: 1; width: 2.5rem; height: 2.5rem;
|
|
72
|
+
border: 1px solid var(--border); border-radius: 0.5rem; background: var(--bg-card); color: var(--fg);
|
|
73
|
+
font-size: 1.5rem; line-height: 1; cursor: pointer; }
|
|
74
|
+
.apex-lightbox__close:hover { background: var(--bg-muted); }
|
|
75
|
+
.apex-dark .apex-lightbox__close { background: var(--on-dark); color: var(--primary); border-color: var(--on-dark-border); }
|
|
66
76
|
`;
|
|
67
77
|
|
|
68
78
|
const FEATURES = `
|
package/src/apex/index.ts
CHANGED
|
@@ -6,8 +6,14 @@
|
|
|
6
6
|
|
|
7
7
|
import { escapeHtml } from "../format";
|
|
8
8
|
import { APEX_STRUCTURAL_CSS } from "./css";
|
|
9
|
+
import { APEX_LIGHTBOX_HTML, APEX_LIGHTBOX_SCRIPT } from "./lightbox";
|
|
9
10
|
|
|
10
11
|
export { APEX_NAV_MENU_CSS, APEX_STRUCTURAL_CSS } from "./css";
|
|
12
|
+
export {
|
|
13
|
+
APEX_LIGHTBOX_HTML,
|
|
14
|
+
APEX_LIGHTBOX_SCRIPT,
|
|
15
|
+
APEX_LIGHTBOX_SCRIPT_CSP_HASH,
|
|
16
|
+
} from "./lightbox";
|
|
11
17
|
|
|
12
18
|
export type ApexTheme = "light" | "dark";
|
|
13
19
|
|
|
@@ -203,12 +209,18 @@ export type ApexPage = {
|
|
|
203
209
|
readonly footer: ApexFooter;
|
|
204
210
|
};
|
|
205
211
|
|
|
212
|
+
// JSON in <script>-Kontext: `<` als < serialisieren, damit weder
|
|
213
|
+
// `</script>` noch `<!--` aus dem Block ausbrechen kann (JSON bleibt valide).
|
|
214
|
+
function scriptSafeJsonHtml(value: unknown): string {
|
|
215
|
+
return JSON.stringify(value).replace(/</g, "\\u003c");
|
|
216
|
+
}
|
|
217
|
+
|
|
206
218
|
function dim(img: ApexImage): string {
|
|
207
219
|
return `${img.width !== undefined ? ` width="${img.width}"` : ""}${img.height !== undefined ? ` height="${img.height}"` : ""}`;
|
|
208
220
|
}
|
|
209
221
|
|
|
210
|
-
function svgIcon(
|
|
211
|
-
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${
|
|
222
|
+
function svgIcon(innerHtml: string): string {
|
|
223
|
+
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${innerHtml}</svg>`;
|
|
212
224
|
}
|
|
213
225
|
|
|
214
226
|
function renderCta(cta: ApexCta): string {
|
|
@@ -241,7 +253,7 @@ function renderHero(s: ApexHeroSection): string {
|
|
|
241
253
|
s.logo !== undefined
|
|
242
254
|
? `<img class="hero-pony" src="${escapeHtml(s.logo.src)}" alt="${escapeHtml(s.logo.alt)}"${dim(s.logo)} />`
|
|
243
255
|
: "";
|
|
244
|
-
const
|
|
256
|
+
const ctasHtml = (s.ctas ?? []).map(renderCta).join("\n ");
|
|
245
257
|
const meta = s.metaHtml !== undefined ? `<p class="hero-meta">${s.metaHtml}</p>` : "";
|
|
246
258
|
const visual =
|
|
247
259
|
s.screenshot !== undefined
|
|
@@ -252,7 +264,7 @@ function renderHero(s: ApexHeroSection): string {
|
|
|
252
264
|
<div class="hero-copy">
|
|
253
265
|
${logo}<h1>${escapeHtml(s.title)}</h1>
|
|
254
266
|
<p class="tagline">${escapeHtml(s.tagline)}</p>
|
|
255
|
-
${
|
|
267
|
+
${ctasHtml !== "" ? `<div class="hero-cta">${ctasHtml}</div>` : ""}
|
|
256
268
|
${meta}
|
|
257
269
|
</div>
|
|
258
270
|
${visual}
|
|
@@ -261,7 +273,7 @@ function renderHero(s: ApexHeroSection): string {
|
|
|
261
273
|
}
|
|
262
274
|
|
|
263
275
|
function renderFeatureGrid(s: ApexFeatureGridSection): string {
|
|
264
|
-
const
|
|
276
|
+
const cardsHtml = s.items
|
|
265
277
|
.map(
|
|
266
278
|
(f) => `<article class="feature">
|
|
267
279
|
${f.icon !== undefined ? `<div class="feature-icon">${svgIcon(f.icon)}</div>` : ""}<h3>${escapeHtml(f.title)}</h3>
|
|
@@ -273,7 +285,7 @@ function renderFeatureGrid(s: ApexFeatureGridSection): string {
|
|
|
273
285
|
<div class="container">
|
|
274
286
|
${renderSectionHead(s)}
|
|
275
287
|
<div class="feature-grid">
|
|
276
|
-
${
|
|
288
|
+
${cardsHtml}
|
|
277
289
|
</div>
|
|
278
290
|
</div>
|
|
279
291
|
</section>`;
|
|
@@ -286,41 +298,43 @@ function renderPricingCard(t: ApexPricingTier): string {
|
|
|
286
298
|
const cap =
|
|
287
299
|
t.capLine !== undefined ? [`<li class="price-cap">${escapeHtml(t.capLine)}</li>`] : [];
|
|
288
300
|
const benefits = t.benefits.map((b) => `<li>${escapeHtml(b)}</li>`);
|
|
289
|
-
const
|
|
301
|
+
const itemsHtml = [...cap, ...benefits].join("\n ");
|
|
290
302
|
const per = t.priceSuffix !== undefined ? `<span>${escapeHtml(t.priceSuffix)}</span>` : "";
|
|
291
|
-
const cls =
|
|
292
|
-
t.cta.variant !== undefined
|
|
293
|
-
? `btn btn-${t.cta.variant}`
|
|
294
|
-
: featured
|
|
295
|
-
? "btn btn-primary"
|
|
296
|
-
: "btn btn-secondary";
|
|
297
303
|
const tagline =
|
|
298
304
|
t.tagline !== undefined ? `<p class="price-tagline">${escapeHtml(t.tagline)}</p>` : "";
|
|
305
|
+
// Delegates to renderCta so a `variant: "link"` tier CTA gets the same
|
|
306
|
+
// class-free anchor as everywhere else instead of re-deriving the class
|
|
307
|
+
// string here (a prior inline duplicate had no "link" case, always
|
|
308
|
+
// emitting `.btn-link`, which the structural CSS never defines).
|
|
309
|
+
const cta =
|
|
310
|
+
t.cta.variant === undefined
|
|
311
|
+
? renderCta({ ...t.cta, variant: featured ? "primary" : "secondary" })
|
|
312
|
+
: renderCta(t.cta);
|
|
299
313
|
return `<article class="price-card${featured ? " price-card--featured" : ""}">
|
|
300
314
|
${badge}<h3>${escapeHtml(t.name)}</h3>
|
|
301
315
|
${tagline}<div class="price-amount">${escapeHtml(t.amount)}${per}</div>
|
|
302
316
|
<ul class="price-list">
|
|
303
|
-
${
|
|
317
|
+
${itemsHtml}
|
|
304
318
|
</ul>
|
|
305
|
-
|
|
319
|
+
${cta}
|
|
306
320
|
</article>`;
|
|
307
321
|
}
|
|
308
322
|
|
|
309
323
|
function renderPricingGrid(s: ApexPricingGridSection): string {
|
|
310
|
-
const
|
|
324
|
+
const cardsHtml = s.tiers.map(renderPricingCard).join("\n ");
|
|
311
325
|
const idAttr = s.id !== undefined ? ` id="${escapeHtml(s.id)}"` : "";
|
|
312
326
|
return `<section${idAttr}>
|
|
313
327
|
<div class="container">
|
|
314
328
|
${renderSectionHead(s)}
|
|
315
329
|
<div class="price-grid">
|
|
316
|
-
${
|
|
330
|
+
${cardsHtml}
|
|
317
331
|
</div>
|
|
318
332
|
</div>
|
|
319
333
|
</section>`;
|
|
320
334
|
}
|
|
321
335
|
|
|
322
336
|
function renderInfoGrid(s: ApexInfoGridSection): string {
|
|
323
|
-
const
|
|
337
|
+
const itemsHtml = s.items
|
|
324
338
|
.map(
|
|
325
339
|
(i) => `<div class="trust-item">
|
|
326
340
|
<h3>${escapeHtml(i.title)}</h3>
|
|
@@ -332,7 +346,7 @@ function renderInfoGrid(s: ApexInfoGridSection): string {
|
|
|
332
346
|
<div class="container">
|
|
333
347
|
${renderSectionHead(s)}
|
|
334
348
|
<div class="trust-grid">
|
|
335
|
-
${
|
|
349
|
+
${itemsHtml}
|
|
336
350
|
</div>
|
|
337
351
|
</div>
|
|
338
352
|
</section>`;
|
|
@@ -370,7 +384,7 @@ function renderSection(s: ApexSection): string {
|
|
|
370
384
|
}
|
|
371
385
|
|
|
372
386
|
function renderNavMenu(m: ApexNavMenu): string {
|
|
373
|
-
const
|
|
387
|
+
const itemsHtml = m.items
|
|
374
388
|
.map(
|
|
375
389
|
(it) =>
|
|
376
390
|
`<a class="nav-menu__item" href="${escapeHtml(it.href)}">${
|
|
@@ -384,7 +398,7 @@ function renderNavMenu(m: ApexNavMenu): string {
|
|
|
384
398
|
m.footer !== undefined
|
|
385
399
|
? `<div class="nav-menu__sep"></div><a class="nav-menu__more" href="${escapeHtml(m.footer.href)}">${escapeHtml(m.footer.label)}</a>`
|
|
386
400
|
: "";
|
|
387
|
-
return `<div class="nav-menu"><button type="button" class="nav-menu__trigger" aria-haspopup="true">${escapeHtml(m.label)}<span class="nav-menu__chev"
|
|
401
|
+
return `<div class="nav-menu"><button type="button" class="nav-menu__trigger" aria-haspopup="true">${escapeHtml(m.label)}<span class="nav-menu__chev">${svgIcon('<path d="m6 9 6 6 6-6"/>')}</span></button><div class="nav-menu__panel">${itemsHtml}${footer}</div></div>`;
|
|
388
402
|
}
|
|
389
403
|
|
|
390
404
|
function renderNavEntry(entry: ApexNavEntry): string {
|
|
@@ -399,13 +413,13 @@ function renderNavEntry(entry: ApexNavEntry): string {
|
|
|
399
413
|
export function renderApexHeader(h: ApexHeader): string {
|
|
400
414
|
const logo =
|
|
401
415
|
h.brand.logoSrc !== undefined ? `<img src="${escapeHtml(h.brand.logoSrc)}" alt="" /> ` : "";
|
|
402
|
-
const
|
|
403
|
-
const
|
|
416
|
+
const navLinksHtml = (h.navLinks ?? []).map(renderNavEntry).join("\n ");
|
|
417
|
+
const actionsHtml = (h.actions ?? []).map(renderCta).join("\n ");
|
|
404
418
|
return `<header>
|
|
405
419
|
<div class="container nav">
|
|
406
420
|
<div class="brand"><a href="${escapeHtml(h.brand.href)}">${logo}${escapeHtml(h.brand.label)}</a></div>
|
|
407
|
-
${
|
|
408
|
-
${
|
|
421
|
+
${navLinksHtml !== "" ? `<nav class="nav-links">${navLinksHtml}</nav>` : ""}
|
|
422
|
+
${actionsHtml !== "" ? `<div class="nav-actions">${actionsHtml}</div>` : ""}
|
|
409
423
|
</div>
|
|
410
424
|
</header>`;
|
|
411
425
|
}
|
|
@@ -431,7 +445,7 @@ function renderFooter(f: ApexFooter): string {
|
|
|
431
445
|
: "";
|
|
432
446
|
return `<footer>
|
|
433
447
|
<div class="container">
|
|
434
|
-
<div class="footer-grid" style="--footer-cols:${cols.length}">
|
|
448
|
+
<div class="footer-grid" style="--footer-cols:${Math.max(1, cols.length)}">
|
|
435
449
|
<div>
|
|
436
450
|
<div class="footer-brand">${logo}${escapeHtml(f.brand.label)}</div>
|
|
437
451
|
${f.tagline !== undefined ? `<p class="footer-tagline">${escapeHtml(f.tagline)}</p>` : ""}
|
|
@@ -443,11 +457,11 @@ function renderFooter(f: ApexFooter): string {
|
|
|
443
457
|
</footer>`;
|
|
444
458
|
}
|
|
445
459
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
460
|
+
// All <title>/meta/link/script tags for an ApexHead — the single source both
|
|
461
|
+
// renderApexPage and any other head (e.g. page-render's wrapInLayout) splice
|
|
462
|
+
// into their own <head>. Extracted verbatim from renderApexPage so existing
|
|
463
|
+
// output stays byte-identical (see render.test.ts regression coverage).
|
|
464
|
+
export function renderApexHeadTags(head: ApexHead): string {
|
|
451
465
|
const ogUrl =
|
|
452
466
|
head.canonicalUrl !== undefined
|
|
453
467
|
? `\n <meta property="og:url" content="${escapeHtml(head.canonicalUrl)}" />`
|
|
@@ -495,26 +509,42 @@ export function renderApexPage(page: ApexPage): string {
|
|
|
495
509
|
.join("");
|
|
496
510
|
const schema =
|
|
497
511
|
head.schemaJson !== undefined
|
|
498
|
-
? `\n <script type="application/ld+json">${
|
|
512
|
+
? `\n <script type="application/ld+json">${scriptSafeJsonHtml(head.schemaJson)}</script>`
|
|
499
513
|
: "";
|
|
514
|
+
const metaDescription = head.description
|
|
515
|
+
? `\n <meta name="description" content="${escapeHtml(head.description)}" />`
|
|
516
|
+
: "";
|
|
517
|
+
const ogDescription = head.description
|
|
518
|
+
? `\n <meta property="og:description" content="${escapeHtml(head.description)}" />`
|
|
519
|
+
: "";
|
|
520
|
+
return `<title>${escapeHtml(head.title)}</title>${metaDescription}
|
|
521
|
+
<meta property="og:title" content="${escapeHtml(head.title)}" />${ogDescription}
|
|
522
|
+
<meta property="og:type" content="website" />${ogUrl}${ogImage}${siteName}${locale}${twitterCard}${twitterSite}${favicon}${canonical}${alternates}${robots}${preconnects}${schema}`;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
export function renderApexPage(page: ApexPage): string {
|
|
526
|
+
const { head, brand } = page;
|
|
527
|
+
const theme = page.theme ?? "light";
|
|
528
|
+
// Brand-CSS ist app-authored (Trust-Boundary siehe Datei-Header), kein Tenant-Input.
|
|
529
|
+
const cssHtml = (brand.fontFaceCss ?? "") + brand.tokensCss + APEX_STRUCTURAL_CSS;
|
|
530
|
+
const sectionsHtml = page.sections.map(renderSection).join("\n\n ");
|
|
500
531
|
return `<!doctype html>
|
|
501
532
|
<html lang="${escapeHtml(head.lang)}">
|
|
502
533
|
<head>
|
|
503
534
|
<meta charset="utf-8" />
|
|
504
535
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
505
|
-
|
|
506
|
-
<
|
|
507
|
-
<meta property="og:title" content="${escapeHtml(head.title)}" />
|
|
508
|
-
<meta property="og:description" content="${escapeHtml(head.description)}" />
|
|
509
|
-
<meta property="og:type" content="website" />${ogUrl}${ogImage}${siteName}${locale}${twitterCard}${twitterSite}${favicon}${canonical}${alternates}${robots}${preconnects}${schema}
|
|
510
|
-
<style>${css}</style>
|
|
536
|
+
${renderApexHeadTags(head)}
|
|
537
|
+
<style>${cssHtml}</style>
|
|
511
538
|
</head>
|
|
512
539
|
<body${theme === "dark" ? ` class="apex-dark"` : ""}>
|
|
513
540
|
${renderApexHeader(page.header)}
|
|
514
541
|
|
|
515
|
-
${
|
|
542
|
+
${sectionsHtml}
|
|
516
543
|
|
|
517
544
|
${renderFooter(page.footer)}
|
|
545
|
+
|
|
546
|
+
${APEX_LIGHTBOX_HTML}
|
|
547
|
+
${APEX_LIGHTBOX_SCRIPT}
|
|
518
548
|
</body>
|
|
519
549
|
</html>`;
|
|
520
550
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Vanilla lightbox for Apex marketing pages — click .shot-frame img to enlarge.
|
|
2
|
+
// Injected by renderApexPage; no React, no per-app wiring.
|
|
3
|
+
|
|
4
|
+
export const APEX_LIGHTBOX_HTML = `<dialog id="apex-lightbox" class="apex-lightbox" aria-label="Screenshot preview">
|
|
5
|
+
<button type="button" class="apex-lightbox__close" aria-label="Close">×</button>
|
|
6
|
+
<img class="apex-lightbox__img" alt="" />
|
|
7
|
+
</dialog>`;
|
|
8
|
+
|
|
9
|
+
// Split from APEX_LIGHTBOX_SCRIPT so APEX_LIGHTBOX_SCRIPT_CSP_HASH hashes
|
|
10
|
+
// exactly the bytes the browser executes — CSP's script-src hash-source
|
|
11
|
+
// covers the content between <script> and </script>, nothing more/less.
|
|
12
|
+
const APEX_LIGHTBOX_SCRIPT_BODY = `
|
|
13
|
+
(function () {
|
|
14
|
+
var dlg = document.getElementById("apex-lightbox");
|
|
15
|
+
if (!dlg) return;
|
|
16
|
+
var img = dlg.querySelector(".apex-lightbox__img");
|
|
17
|
+
var closeBtn = dlg.querySelector(".apex-lightbox__close");
|
|
18
|
+
if (!img || !closeBtn) return;
|
|
19
|
+
function open(src, alt) {
|
|
20
|
+
img.src = src;
|
|
21
|
+
img.alt = alt || "";
|
|
22
|
+
if (typeof dlg.showModal === "function") dlg.showModal();
|
|
23
|
+
}
|
|
24
|
+
function close() {
|
|
25
|
+
if (dlg.open) dlg.close();
|
|
26
|
+
}
|
|
27
|
+
document.addEventListener("click", function (e) {
|
|
28
|
+
var t = e.target;
|
|
29
|
+
if (!(t instanceof HTMLImageElement)) return;
|
|
30
|
+
if (!t.closest(".shot-frame")) return;
|
|
31
|
+
e.preventDefault();
|
|
32
|
+
open(t.currentSrc || t.src, t.alt);
|
|
33
|
+
});
|
|
34
|
+
closeBtn.addEventListener("click", close);
|
|
35
|
+
dlg.addEventListener("click", function (e) {
|
|
36
|
+
if (e.target === dlg) close();
|
|
37
|
+
});
|
|
38
|
+
dlg.addEventListener("cancel", function (e) {
|
|
39
|
+
e.preventDefault();
|
|
40
|
+
close();
|
|
41
|
+
});
|
|
42
|
+
})();
|
|
43
|
+
`;
|
|
44
|
+
|
|
45
|
+
/** ponytail: one delegated listener; no-op when no .shot-frame on the page. */
|
|
46
|
+
export const APEX_LIGHTBOX_SCRIPT = `<script>${APEX_LIGHTBOX_SCRIPT_BODY}</script>`;
|
|
47
|
+
|
|
48
|
+
// CSP hash-source for the inline script above; apex output is often
|
|
49
|
+
// pre-rendered, so a per-request nonce can't work. Guarded by lightbox.test.ts.
|
|
50
|
+
export const APEX_LIGHTBOX_SCRIPT_CSP_HASH = "sha256-f+hHLpDuQsjmtFZCjdM13D9NaMTCyOKaawAhfLf/X9o=";
|
|
@@ -60,6 +60,9 @@ function createFakeDispatcher(options?: {
|
|
|
60
60
|
return result;
|
|
61
61
|
},
|
|
62
62
|
statusStore,
|
|
63
|
+
async *stream() {
|
|
64
|
+
// streams unsupported in the sync fake — empty generator
|
|
65
|
+
},
|
|
63
66
|
pendingWrites: () => pendingWritesStore,
|
|
64
67
|
pendingFiles: () => pendingFilesStore,
|
|
65
68
|
setStatus(next) {
|
package/src/dispatcher/index.ts
CHANGED
package/src/dispatcher/types.ts
CHANGED
|
@@ -149,6 +149,22 @@ export type QueryOpts = {
|
|
|
149
149
|
readonly signal?: AbortSignal;
|
|
150
150
|
};
|
|
151
151
|
|
|
152
|
+
export type StreamOpts = {
|
|
153
|
+
readonly signal?: AbortSignal;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// SSE frame event names for POST /api/stream — shared across the server route,
|
|
157
|
+
// the live-dispatcher wire parser, and their tests so a typo on one side
|
|
158
|
+
// fails the type-checker instead of the integration test.
|
|
159
|
+
export const StreamFrame = {
|
|
160
|
+
chunk: "chunk",
|
|
161
|
+
ping: "ping",
|
|
162
|
+
done: "done",
|
|
163
|
+
error: "error",
|
|
164
|
+
} as const;
|
|
165
|
+
|
|
166
|
+
export type StreamFrameEvent = (typeof StreamFrame)[keyof typeof StreamFrame];
|
|
167
|
+
|
|
152
168
|
// ---------------------------------------------------------------------------
|
|
153
169
|
// The contract
|
|
154
170
|
// ---------------------------------------------------------------------------
|
|
@@ -173,6 +189,17 @@ export type Dispatcher = {
|
|
|
173
189
|
|
|
174
190
|
batch(commands: readonly Command[], opts?: WriteOpts): Promise<BatchResult>;
|
|
175
191
|
|
|
192
|
+
// Dispatcher-driven SSE (`POST /api/stream`, #1380/#1382). Yields one
|
|
193
|
+
// value per `chunk` frame. Terminal `done` ends the generator; terminal
|
|
194
|
+
// `error` rejects with a DispatcherError (thrown). Heartbeat `ping`
|
|
195
|
+
// frames are swallowed. Abort via opts.signal cancels the fetch and
|
|
196
|
+
// rejects with code `aborted`.
|
|
197
|
+
stream<TChunk = unknown>(
|
|
198
|
+
type: string,
|
|
199
|
+
payload: unknown,
|
|
200
|
+
opts?: StreamOpts,
|
|
201
|
+
): AsyncGenerator<TChunk, void, undefined>;
|
|
202
|
+
|
|
176
203
|
// --- Status ---
|
|
177
204
|
|
|
178
205
|
// Subscribe/Emit-Store für Online/Offline/Syncing-Transitions. Konsumenten
|
|
@@ -21,6 +21,7 @@ function makeDispatcher(response?: WriteResult): Dispatcher & {
|
|
|
21
21
|
async batch() {
|
|
22
22
|
return { isSuccess: true, results: [] };
|
|
23
23
|
},
|
|
24
|
+
async *stream() {},
|
|
24
25
|
statusStore: createStore("online"),
|
|
25
26
|
pendingWrites: () => [],
|
|
26
27
|
pendingFiles: () => [],
|
|
@@ -166,6 +167,7 @@ describe("createFormController — submit()", () => {
|
|
|
166
167
|
return { isSuccess: true as const, results: [] };
|
|
167
168
|
},
|
|
168
169
|
statusStore: createStore("online"),
|
|
170
|
+
async *stream() {},
|
|
169
171
|
pendingWrites: () => [],
|
|
170
172
|
pendingFiles: () => [],
|
|
171
173
|
};
|
|
@@ -269,6 +271,7 @@ describe("createFormController — submit()", () => {
|
|
|
269
271
|
return { isSuccess: true as const, results: [] };
|
|
270
272
|
},
|
|
271
273
|
statusStore: createStore("online"),
|
|
274
|
+
async *stream() {},
|
|
272
275
|
pendingWrites: () => [],
|
|
273
276
|
pendingFiles: () => [],
|
|
274
277
|
};
|
|
@@ -197,7 +197,7 @@ export function createFormController<TValues extends FormValues, TCtx = unknown>
|
|
|
197
197
|
getSnapshot: snapshotStore.getSnapshot,
|
|
198
198
|
subscribe: snapshotStore.subscribe,
|
|
199
199
|
setField(key, value) {
|
|
200
|
-
//
|
|
200
|
+
// skip: value unchanged, avoid notify/re-render on identical set
|
|
201
201
|
// avoids a notify + re-render for "setField with same value" which
|
|
202
202
|
// happens a lot in controlled inputs on every keystroke of an
|
|
203
203
|
// untouched field.
|
|
@@ -206,7 +206,7 @@ export function createFormController<TValues extends FormValues, TCtx = unknown>
|
|
|
206
206
|
invalidate();
|
|
207
207
|
},
|
|
208
208
|
setValues(partial) {
|
|
209
|
-
//
|
|
209
|
+
// skip: partial matches current values, avoid no-op notify
|
|
210
210
|
// partial that matches current values shouldn't fire listeners.
|
|
211
211
|
let changed = false;
|
|
212
212
|
const v = values as Record<string, unknown>; // @cast-boundary form-values
|
|
@@ -217,15 +217,18 @@ export function createFormController<TValues extends FormValues, TCtx = unknown>
|
|
|
217
217
|
break;
|
|
218
218
|
}
|
|
219
219
|
}
|
|
220
|
+
// skip: no key in partial actually changed, avoid no-op notify
|
|
220
221
|
if (!changed) return;
|
|
221
222
|
values = { ...values, ...partial };
|
|
222
223
|
invalidate();
|
|
223
224
|
},
|
|
224
225
|
clearErrors(path) {
|
|
225
226
|
if (path === undefined) {
|
|
227
|
+
// skip: no errors present, avoid no-op notify
|
|
226
228
|
if (Object.keys(errors).length === 0) return;
|
|
227
229
|
errors = Object.freeze({});
|
|
228
230
|
} else {
|
|
231
|
+
// skip: path has no error entry, avoid no-op notify
|
|
229
232
|
if (!(path in errors)) return;
|
|
230
233
|
const next: Record<string, readonly FieldIssue[]> = { ...errors };
|
|
231
234
|
delete next[path];
|
|
@@ -239,8 +242,8 @@ export function createFormController<TValues extends FormValues, TCtx = unknown>
|
|
|
239
242
|
},
|
|
240
243
|
validate: runValidate,
|
|
241
244
|
reset() {
|
|
242
|
-
// Cheap no-op when already at baseline and no errors to clear.
|
|
243
245
|
const alreadyClean = !snapshotStore.getSnapshot().isDirty && Object.keys(errors).length === 0;
|
|
246
|
+
// skip: already at baseline with no errors, no-op reset
|
|
244
247
|
if (alreadyClean) return;
|
|
245
248
|
values = { ...initial };
|
|
246
249
|
errors = Object.freeze({});
|
|
@@ -68,4 +68,25 @@ describe("applyFormatSpec — timestamp/date (formatDateCell-Pfad)", () => {
|
|
|
68
68
|
expect(applyFormatSpec({ format: "timestamp" }, "kein-datum")).toBe("kein-datum");
|
|
69
69
|
expect(applyFormatSpec({ format: "date" }, "kein-datum")).toBe("kein-datum");
|
|
70
70
|
});
|
|
71
|
+
|
|
72
|
+
test("offset-lose Timestamps (kein Z/Offset) fallen NICHT auf den Rohstring zurück", () => {
|
|
73
|
+
// Temporal.Instant.from is stricter than the old `new Date(raw)` —
|
|
74
|
+
// without a UTC designator/offset it throws. Both forms must still
|
|
75
|
+
// format instead of passing through raw (see toInstant fallback in
|
|
76
|
+
// index.ts).
|
|
77
|
+
const withoutOffset = "2026-01-15T12:00:00";
|
|
78
|
+
const withoutTime = "2026-01-15 12:00:00";
|
|
79
|
+
for (const raw of [withoutOffset, withoutTime]) {
|
|
80
|
+
const out = applyFormatSpec({ format: "timestamp", locale: "en-US" }, raw);
|
|
81
|
+
expect(out).not.toBe(raw);
|
|
82
|
+
expect(out).toContain("2026");
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("offset-loser Timestamp im date-Format fällt NICHT auf den Rohstring zurück", () => {
|
|
87
|
+
const withoutOffset = "2026-01-15T12:00:00";
|
|
88
|
+
const out = applyFormatSpec({ format: "date", locale: "en-US" }, withoutOffset);
|
|
89
|
+
expect(out).not.toBe(withoutOffset);
|
|
90
|
+
expect(out).toContain("2026");
|
|
91
|
+
});
|
|
71
92
|
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { html, RawHtml, raw } from "../html-template";
|
|
3
|
+
|
|
4
|
+
const XSS = `<script>alert("1")</script>`;
|
|
5
|
+
|
|
6
|
+
describe("html tagged template", () => {
|
|
7
|
+
test("escapes string interpolations", () => {
|
|
8
|
+
expect(html`<p>${XSS}</p>`.toString()).toBe(
|
|
9
|
+
"<p><script>alert("1")</script></p>",
|
|
10
|
+
);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("escapes attribute breakouts (double quotes)", () => {
|
|
14
|
+
const href = `"><img src=x onerror=alert(1)>`;
|
|
15
|
+
const out = html`<a href="${href}">x</a>`.toString();
|
|
16
|
+
expect(out).not.toContain('"><img');
|
|
17
|
+
expect(out).toContain(""><img");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("raw() passes prerendered markup through unchanged", () => {
|
|
21
|
+
expect(html`<div>${raw("<b>ok</b>")}</div>`.toString()).toBe("<div><b>ok</b></div>");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("nested html`...` fragments are not double-escaped", () => {
|
|
25
|
+
const item = html`<li>${"a & b"}</li>`;
|
|
26
|
+
expect(html`<ul>${item}</ul>`.toString()).toBe("<ul><li>a & b</li></ul>");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("arrays are joined with each element escaped", () => {
|
|
30
|
+
const items = ["<x>", "y"].map((v) => html`<li>${v}</li>`);
|
|
31
|
+
expect(html`<ul>${items}</ul>`.toString()).toBe("<ul><li><x></li><li>y</li></ul>");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("null and undefined render as empty string", () => {
|
|
35
|
+
expect(html`<p>${null}${undefined}</p>`.toString()).toBe("<p></p>");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("numbers and booleans render via String()", () => {
|
|
39
|
+
expect(html`<td>${42}${false}</td>`.toString()).toBe("<td>42false</td>");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("toString() makes fragments usable in plain string contexts", () => {
|
|
43
|
+
const fragment = html`<p>${"<i>"}</p>`;
|
|
44
|
+
expect(`${fragment}`).toBe("<p><i></p>");
|
|
45
|
+
expect(fragment).toBeInstanceOf(RawHtml);
|
|
46
|
+
});
|
|
47
|
+
});
|
package/src/format/escape.ts
CHANGED
|
@@ -22,3 +22,58 @@ export function escapeXml(s: string): string {
|
|
|
22
22
|
.replace(/"/g, """)
|
|
23
23
|
.replace(/'/g, "'");
|
|
24
24
|
}
|
|
25
|
+
|
|
26
|
+
// Strips ASCII control chars + space (codepoints 0x00-0x20 and 0x7f) — the
|
|
27
|
+
// same characters browsers strip before scheme detection. `.trim()` alone
|
|
28
|
+
// only removes leading/trailing whitespace, so a scheme like
|
|
29
|
+
// "java\tscript:" (tab embedded mid-scheme) survives to isSafeHref's regex
|
|
30
|
+
// check below, breaks its character class before the ":", falls through to
|
|
31
|
+
// `return true`, and the browser then normalizes it back to "javascript:"
|
|
32
|
+
// and executes it. Written as a char-code filter rather than a regex range
|
|
33
|
+
// to avoid embedding literal control characters in source.
|
|
34
|
+
export function stripControlChars(value: string): string {
|
|
35
|
+
let result = "";
|
|
36
|
+
for (const ch of value) {
|
|
37
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
38
|
+
if (code > 0x20 && code !== 0x7f) result += ch;
|
|
39
|
+
}
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// http(s)/mailto or scheme-less (relative/anchor) allowed; javascript:,
|
|
44
|
+
// data:, vbscript:, etc. rejected. Shared between renderer-web's Link
|
|
45
|
+
// primitive and page-render's server-side markdown renderer (both take
|
|
46
|
+
// untrusted tenant-authored hrefs) — no sanitizer dependency needed for a
|
|
47
|
+
// four-line regex check.
|
|
48
|
+
// Browsers decode HTML character references (:, :, :, 	,
|
|
49
|
+
// 	, ...) while parsing the href attribute, before the URL parser ever
|
|
50
|
+
// sees the value — so "javascript:alert(1)" and "java	script:"
|
|
51
|
+
// both normalize to an executable javascript: URL at click time even though
|
|
52
|
+
// neither contains a literal ":" for the regex below to catch. Decode
|
|
53
|
+
// (not delete) so the scheme check sees what the browser will execute —
|
|
54
|
+
// deleting ":" would leave no colon at all, which reads as a safe
|
|
55
|
+
// scheme-less href, exactly backwards.
|
|
56
|
+
const NAMED_HTML_ENTITY_CHARS: Record<string, string> = {
|
|
57
|
+
colon: ":",
|
|
58
|
+
tab: "\t",
|
|
59
|
+
newline: "\n",
|
|
60
|
+
semi: ";",
|
|
61
|
+
amp: "&",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function decodeHtmlEntities(value: string): string {
|
|
65
|
+
return value.replace(/&(#x[0-9a-f]+|#[0-9]+|[a-z][a-z0-9]*);/gi, (match, entity: string) => {
|
|
66
|
+
if (entity[0] === "#") {
|
|
67
|
+
const isHex = entity[1]?.toLowerCase() === "x";
|
|
68
|
+
const code = Number.parseInt(isHex ? entity.slice(2) : entity.slice(1), isHex ? 16 : 10);
|
|
69
|
+
return Number.isFinite(code) ? String.fromCodePoint(code) : match;
|
|
70
|
+
}
|
|
71
|
+
return NAMED_HTML_ENTITY_CHARS[entity.toLowerCase()] ?? match;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function isSafeHref(href: string): boolean {
|
|
76
|
+
const trimmed = stripControlChars(decodeHtmlEntities(href)).toLowerCase();
|
|
77
|
+
if (!/^[a-z][a-z0-9+.-]*:/.test(trimmed)) return true;
|
|
78
|
+
return /^(?:https?|mailto):/.test(trimmed);
|
|
79
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// html`...` — Tagged-Template, das jede Interpolation automatisch HTML-escaped.
|
|
2
|
+
// Macht Escaping strukturell statt per-Callsite-Konvention: vergessen ist
|
|
3
|
+
// unmöglich, bewusst rohes HTML braucht ein explizites raw(). Der
|
|
4
|
+
// HTML-Escape-Guard (kumiko-guards) akzeptiert html`...` als safe.
|
|
5
|
+
|
|
6
|
+
import { escapeHtml } from "./escape";
|
|
7
|
+
|
|
8
|
+
export class RawHtml {
|
|
9
|
+
readonly html: string;
|
|
10
|
+
constructor(html: string) {
|
|
11
|
+
this.html = html;
|
|
12
|
+
}
|
|
13
|
+
toString(): string {
|
|
14
|
+
return this.html;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Markiert bereits escaptes/vertrauenswürdiges Markup für html`...`. */
|
|
19
|
+
export function raw(html: string): RawHtml {
|
|
20
|
+
return new RawHtml(html);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type HtmlValue =
|
|
24
|
+
| string
|
|
25
|
+
| number
|
|
26
|
+
| boolean
|
|
27
|
+
| null
|
|
28
|
+
| undefined
|
|
29
|
+
| RawHtml
|
|
30
|
+
| ReadonlyArray<HtmlValue>;
|
|
31
|
+
|
|
32
|
+
function renderValue(value: HtmlValue): string {
|
|
33
|
+
if (value === null || value === undefined) return "";
|
|
34
|
+
if (value instanceof RawHtml) return value.html;
|
|
35
|
+
if (Array.isArray(value)) return value.map(renderValue).join("");
|
|
36
|
+
if (typeof value === "string") return escapeHtml(value);
|
|
37
|
+
return String(value);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Rückgabe ist RawHtml, damit Fragmente verschachtelbar sind ohne doppelt zu
|
|
41
|
+
// escapen: `html`<div>${item}</div>`` innerhalb eines äußeren html`...`
|
|
42
|
+
// passiert unverändert durch. toString() liefert das fertige Markup.
|
|
43
|
+
export function html(strings: TemplateStringsArray, ...values: ReadonlyArray<HtmlValue>): RawHtml {
|
|
44
|
+
let out = strings[0] ?? "";
|
|
45
|
+
values.forEach((value, i) => {
|
|
46
|
+
out += renderValue(value) + (strings[i + 1] ?? "");
|
|
47
|
+
});
|
|
48
|
+
return new RawHtml(out);
|
|
49
|
+
}
|
package/src/format/index.ts
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
1
|
// Pure format utilities — no web or platform dependencies.
|
|
2
2
|
// Shared between renderer-web, renderer-native, and server-side tests.
|
|
3
3
|
|
|
4
|
+
import { Temporal } from "temporal-polyfill";
|
|
5
|
+
|
|
6
|
+
function toPlainDate(raw: string): Temporal.PlainDate {
|
|
7
|
+
try {
|
|
8
|
+
return Temporal.PlainDate.from(raw);
|
|
9
|
+
} catch {
|
|
10
|
+
// "date"-typed field stored as a full instant (day-boundary timestamp) —
|
|
11
|
+
// take the calendar date in the local zone rather than fail.
|
|
12
|
+
return toInstant(raw).toZonedDateTimeISO(Temporal.Now.timeZoneId()).toPlainDate();
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Temporal.Instant.from is stricter than the `new Date(raw)` this replaced:
|
|
17
|
+
// it requires a UTC designator/offset (Z/+hh:mm). Timestamps without one
|
|
18
|
+
// (e.g. "2026-07-18T12:00:00", still valid input to `new Date`) throw here
|
|
19
|
+
// instead of parsing — fall back to reading them as a local wall-clock time,
|
|
20
|
+
// same posture as toPlainDate's own fallback above.
|
|
21
|
+
export function toInstant(raw: string): Temporal.Instant {
|
|
22
|
+
try {
|
|
23
|
+
return Temporal.Instant.from(raw);
|
|
24
|
+
} catch {
|
|
25
|
+
return Temporal.PlainDateTime.from(raw).toZonedDateTime(Temporal.Now.timeZoneId()).toInstant();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
4
29
|
function formatDateCell(
|
|
5
30
|
value: unknown,
|
|
6
31
|
type: string,
|
|
@@ -12,17 +37,25 @@ function formatDateCell(
|
|
|
12
37
|
): string {
|
|
13
38
|
try {
|
|
14
39
|
const raw = typeof value === "string" ? value : String(value);
|
|
15
|
-
const date = new Date(raw);
|
|
16
|
-
if (Number.isNaN(date.getTime())) return raw;
|
|
17
40
|
const locale = opts?.locale;
|
|
18
41
|
if (opts?.dateStyle || opts?.timeStyle) {
|
|
19
|
-
|
|
42
|
+
if (type === "date") {
|
|
43
|
+
// ponytail: timeStyle is ignored here on purpose — a PlainDate has no
|
|
44
|
+
// time component to format, so a timeStyle-only "date" field falls
|
|
45
|
+
// back to PlainDate's default dateStyle instead of rendering a time.
|
|
46
|
+
return toPlainDate(raw).toLocaleString(locale, {
|
|
47
|
+
dateStyle: opts.dateStyle,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return toInstant(raw).toLocaleString(locale, {
|
|
20
51
|
dateStyle: opts.dateStyle,
|
|
21
52
|
timeStyle: opts.timeStyle,
|
|
22
53
|
});
|
|
23
54
|
}
|
|
24
|
-
if (type === "date")
|
|
25
|
-
|
|
55
|
+
if (type === "date") {
|
|
56
|
+
return toPlainDate(raw).toLocaleString(locale);
|
|
57
|
+
}
|
|
58
|
+
return toInstant(raw).toLocaleString(locale, {
|
|
26
59
|
year: "numeric",
|
|
27
60
|
month: "short",
|
|
28
61
|
day: "numeric",
|
|
@@ -34,7 +67,8 @@ function formatDateCell(
|
|
|
34
67
|
}
|
|
35
68
|
}
|
|
36
69
|
|
|
37
|
-
export { escapeHtml, escapeHtmlAttr, escapeXml } from "./escape";
|
|
70
|
+
export { escapeHtml, escapeHtmlAttr, escapeXml, isSafeHref, stripControlChars } from "./escape";
|
|
71
|
+
export { type HtmlValue, html, RawHtml, raw } from "./html-template";
|
|
38
72
|
export function applyFormatSpec(
|
|
39
73
|
spec: { format: string } & Record<string, unknown>,
|
|
40
74
|
value: unknown,
|
package/src/index.ts
CHANGED
|
@@ -34,9 +34,11 @@ export type {
|
|
|
34
34
|
PendingWrite,
|
|
35
35
|
QueryOpts,
|
|
36
36
|
QueryResult,
|
|
37
|
+
StreamOpts,
|
|
37
38
|
WriteOpts,
|
|
38
39
|
WriteResult,
|
|
39
40
|
} from "./dispatcher";
|
|
41
|
+
export { StreamFrame, type StreamFrameEvent } from "./dispatcher";
|
|
40
42
|
export type {
|
|
41
43
|
FieldConditionPredicate,
|
|
42
44
|
FieldConditions,
|
|
@@ -51,7 +53,19 @@ export type {
|
|
|
51
53
|
SubmitResult,
|
|
52
54
|
} from "./form";
|
|
53
55
|
export { createFormController } from "./form";
|
|
54
|
-
export {
|
|
56
|
+
export {
|
|
57
|
+
applyFormatSpec,
|
|
58
|
+
escapeHtml,
|
|
59
|
+
escapeHtmlAttr,
|
|
60
|
+
escapeXml,
|
|
61
|
+
type HtmlValue,
|
|
62
|
+
html,
|
|
63
|
+
isSafeHref,
|
|
64
|
+
RawHtml,
|
|
65
|
+
raw,
|
|
66
|
+
stripControlChars,
|
|
67
|
+
toInstant,
|
|
68
|
+
} from "./format";
|
|
55
69
|
export type {
|
|
56
70
|
NavDefinition,
|
|
57
71
|
NavNode,
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createLocaleRouter } from "../index";
|
|
3
|
+
|
|
4
|
+
type MoneyHorsePage = "home" | "features" | "rechner" | "budget" | "ltv";
|
|
5
|
+
|
|
6
|
+
const moneyHorseRouter = createLocaleRouter<MoneyHorsePage>({
|
|
7
|
+
defaultLocale: "de",
|
|
8
|
+
prefixedLocales: ["en"],
|
|
9
|
+
routes: {
|
|
10
|
+
home: { de: "/", en: "/en" },
|
|
11
|
+
features: { de: "/funktionen", en: "/en/features" },
|
|
12
|
+
rechner: { de: "/rechner", en: "/en/rechner" },
|
|
13
|
+
budget: { de: "/budget-rechner", en: "/en/budget-rechner" },
|
|
14
|
+
ltv: { de: "/beleihungsauslauf", en: "/en/beleihungsauslauf" },
|
|
15
|
+
},
|
|
16
|
+
localeHints: { en: ["/features"] },
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("createLocaleRouter money-horse config", () => {
|
|
20
|
+
test("detectLang: prefixed, default, legacy hint", () => {
|
|
21
|
+
expect(moneyHorseRouter.detectLang("/")).toBe("de");
|
|
22
|
+
expect(moneyHorseRouter.detectLang("/en")).toBe("en");
|
|
23
|
+
expect(moneyHorseRouter.detectLang("/en/rechner")).toBe("en");
|
|
24
|
+
expect(moneyHorseRouter.detectLang("/rechner")).toBe("de");
|
|
25
|
+
expect(moneyHorseRouter.detectLang("/features")).toBe("en");
|
|
26
|
+
expect(moneyHorseRouter.detectLang("/features/")).toBe("en");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("publicPath returns canonical paths per locale", () => {
|
|
30
|
+
expect(moneyHorseRouter.publicPath("features", "de")).toBe("/funktionen");
|
|
31
|
+
expect(moneyHorseRouter.publicPath("features", "en")).toBe("/en/features");
|
|
32
|
+
expect(moneyHorseRouter.publicPath("rechner", "en")).toBe("/en/rechner");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("resolvePage maps canonical and legacy paths", () => {
|
|
36
|
+
expect(moneyHorseRouter.resolvePage("/funktionen")).toBe("features");
|
|
37
|
+
expect(moneyHorseRouter.resolvePage("/en/features")).toBe("features");
|
|
38
|
+
expect(moneyHorseRouter.resolvePage("/features")).toBe("features");
|
|
39
|
+
expect(moneyHorseRouter.resolvePage("/en/rechner")).toBe("rechner");
|
|
40
|
+
expect(moneyHorseRouter.resolvePage("/login")).toBeUndefined();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("altLocalePath keeps logical page across locales", () => {
|
|
44
|
+
expect(moneyHorseRouter.altLocalePath("/en")).toBe("/");
|
|
45
|
+
expect(moneyHorseRouter.altLocalePath("/")).toBe("/en");
|
|
46
|
+
expect(moneyHorseRouter.altLocalePath("/en/features")).toBe("/funktionen");
|
|
47
|
+
expect(moneyHorseRouter.altLocalePath("/funktionen")).toBe("/en/features");
|
|
48
|
+
expect(moneyHorseRouter.altLocalePath("/features")).toBe("/funktionen");
|
|
49
|
+
expect(moneyHorseRouter.altLocalePath("/en/rechner")).toBe("/rechner");
|
|
50
|
+
expect(moneyHorseRouter.altLocalePath("/rechner")).toBe("/en/rechner");
|
|
51
|
+
expect(moneyHorseRouter.altLocalePath("/unknown")).toBe("/en");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("sectionAnchor attaches fragment to page path", () => {
|
|
55
|
+
expect(moneyHorseRouter.sectionAnchor("home", "de", "pricing")).toBe("/#pricing");
|
|
56
|
+
expect(moneyHorseRouter.sectionAnchor("home", "en", "pricing")).toBe("/en#pricing");
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
type PublicStatusPage = "home" | "developers";
|
|
61
|
+
|
|
62
|
+
const publicStatusRouter = createLocaleRouter<PublicStatusPage>({
|
|
63
|
+
defaultLocale: "de",
|
|
64
|
+
prefixedLocales: ["en"],
|
|
65
|
+
routes: {
|
|
66
|
+
home: { de: "/", en: "/en" },
|
|
67
|
+
developers: { de: "/developers", en: "/en/developers" },
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("createLocaleRouter publicstatus config", () => {
|
|
72
|
+
test("altLocalePath for developers", () => {
|
|
73
|
+
expect(publicStatusRouter.altLocalePath("/en/developers")).toBe("/developers");
|
|
74
|
+
expect(publicStatusRouter.altLocalePath("/developers")).toBe("/en/developers");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("createLocaleRouter inverted default (website-style)", () => {
|
|
79
|
+
const router = createLocaleRouter({
|
|
80
|
+
defaultLocale: "en",
|
|
81
|
+
prefixedLocales: ["de"],
|
|
82
|
+
prefixFor: () => "/de",
|
|
83
|
+
routes: {
|
|
84
|
+
home: { en: "/", de: "/de" },
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("detectLang with non-default prefix locale", () => {
|
|
89
|
+
expect(router.detectLang("/")).toBe("en");
|
|
90
|
+
expect(router.detectLang("/de")).toBe("de");
|
|
91
|
+
expect(router.altLocalePath("/")).toBe("/de");
|
|
92
|
+
expect(router.altLocalePath("/de")).toBe("/");
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("createLocaleRouter homePage validation", () => {
|
|
97
|
+
test("throws at construction when homePage has no routes entry", () => {
|
|
98
|
+
expect(() =>
|
|
99
|
+
createLocaleRouter({
|
|
100
|
+
defaultLocale: "de",
|
|
101
|
+
prefixedLocales: ["en"],
|
|
102
|
+
routes: { features: { de: "/funktionen", en: "/en/features" } },
|
|
103
|
+
}),
|
|
104
|
+
).toThrow(/homePage/);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("publicPath throws instead of crashing when page is missing from routes", () => {
|
|
108
|
+
const router = createLocaleRouter<"home" | "features">({
|
|
109
|
+
defaultLocale: "de",
|
|
110
|
+
prefixedLocales: ["en"],
|
|
111
|
+
routes: { home: { de: "/", en: "/en" } } as unknown as Record<
|
|
112
|
+
"home" | "features",
|
|
113
|
+
Record<string, string>
|
|
114
|
+
>,
|
|
115
|
+
});
|
|
116
|
+
expect(() => router.publicPath("features", "de")).toThrow(/no path for page/);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
export type LocaleRouterConfig<TPage extends string> = {
|
|
2
|
+
/** Canonical default, usually "de". No URL prefix. */
|
|
3
|
+
readonly defaultLocale: string;
|
|
4
|
+
/** Locales that get a URL prefix, e.g. ["en"] → /en/... */
|
|
5
|
+
readonly prefixedLocales: readonly string[];
|
|
6
|
+
/** Prefix segment per locale, default: locale code ("en" → "/en"). */
|
|
7
|
+
readonly prefixFor?: (locale: string) => string;
|
|
8
|
+
/** Logical page → path per locale. Every page must define defaultLocale path. */
|
|
9
|
+
readonly routes: Record<TPage, Record<string, string>>;
|
|
10
|
+
/** Legacy slug-only paths for detectLang/resolvePage, e.g. { en: ["/features"] }. */
|
|
11
|
+
readonly localeHints?: Readonly<Record<string, readonly string[]>>;
|
|
12
|
+
/** Fallback page when altLocalePath cannot resolve pathname. Default: "home". */
|
|
13
|
+
readonly homePage?: TPage;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type LocaleRouter<TPage extends string> = {
|
|
17
|
+
detectLang(pathname: string): string;
|
|
18
|
+
publicPath(page: TPage, locale: string): string;
|
|
19
|
+
resolvePage(pathname: string): TPage | undefined;
|
|
20
|
+
altLocalePath(pathname: string): string;
|
|
21
|
+
sectionAnchor(page: TPage, locale: string, fragment: string): string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function normalizePath(pathname: string): string {
|
|
25
|
+
if (pathname === "" || pathname === "/") return "/";
|
|
26
|
+
return pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function defaultPrefixFor(locale: string): string {
|
|
30
|
+
return `/${locale}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function stripLocalePrefix(
|
|
34
|
+
path: string,
|
|
35
|
+
locale: string,
|
|
36
|
+
prefixFor: (locale: string) => string,
|
|
37
|
+
): string {
|
|
38
|
+
const prefix = prefixFor(locale);
|
|
39
|
+
if (path === prefix) return "/";
|
|
40
|
+
if (path.startsWith(`${prefix}/`)) return path.slice(prefix.length);
|
|
41
|
+
return path;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createLocaleRouter<TPage extends string>(
|
|
45
|
+
config: LocaleRouterConfig<TPage>,
|
|
46
|
+
): LocaleRouter<TPage> {
|
|
47
|
+
const {
|
|
48
|
+
defaultLocale,
|
|
49
|
+
prefixedLocales,
|
|
50
|
+
routes,
|
|
51
|
+
localeHints = {},
|
|
52
|
+
prefixFor = defaultPrefixFor,
|
|
53
|
+
homePage = "home" as TPage,
|
|
54
|
+
} = config;
|
|
55
|
+
|
|
56
|
+
if (routes[homePage] === undefined) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`locale-routing: homePage "${String(homePage)}" has no entry in routes — altLocalePath's fallback would throw at request time`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
const pathIndex = new Map<string, { page: TPage; locale: string }>();
|
|
62
|
+
|
|
63
|
+
for (const [page, localePaths] of Object.entries(routes) as [TPage, Record<string, string>][]) {
|
|
64
|
+
for (const [locale, routePath] of Object.entries(localePaths)) {
|
|
65
|
+
pathIndex.set(normalizePath(routePath), { page, locale });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for (const [hintLocale, hints] of Object.entries(localeHints)) {
|
|
70
|
+
for (const hint of hints) {
|
|
71
|
+
const normalizedHint = normalizePath(hint);
|
|
72
|
+
if (pathIndex.has(normalizedHint)) continue;
|
|
73
|
+
|
|
74
|
+
for (const [page, localePaths] of Object.entries(routes) as [
|
|
75
|
+
TPage,
|
|
76
|
+
Record<string, string>,
|
|
77
|
+
][]) {
|
|
78
|
+
const canonical = localePaths[hintLocale];
|
|
79
|
+
if (canonical === undefined) continue;
|
|
80
|
+
const canonicalNorm = normalizePath(canonical);
|
|
81
|
+
const hintSlug = stripLocalePrefix(normalizedHint, hintLocale, prefixFor);
|
|
82
|
+
const canonicalSlug = stripLocalePrefix(canonicalNorm, hintLocale, prefixFor);
|
|
83
|
+
if (hintSlug === canonicalSlug) {
|
|
84
|
+
pathIndex.set(normalizedHint, { page, locale: hintLocale });
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function detectLang(pathname: string): string {
|
|
92
|
+
const path = normalizePath(pathname);
|
|
93
|
+
for (const locale of prefixedLocales) {
|
|
94
|
+
const prefix = prefixFor(locale);
|
|
95
|
+
if (path === prefix || path.startsWith(`${prefix}/`)) return locale;
|
|
96
|
+
}
|
|
97
|
+
const resolved = pathIndex.get(path);
|
|
98
|
+
if (resolved !== undefined) return resolved.locale;
|
|
99
|
+
for (const [locale, hints] of Object.entries(localeHints)) {
|
|
100
|
+
if (hints.some((hint) => normalizePath(hint) === path)) return locale;
|
|
101
|
+
}
|
|
102
|
+
return defaultLocale;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function resolvePage(pathname: string): TPage | undefined {
|
|
106
|
+
return pathIndex.get(normalizePath(pathname))?.page;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function publicPath(page: TPage, locale: string): string {
|
|
110
|
+
const localePaths = routes[page];
|
|
111
|
+
const routePath = localePaths?.[locale];
|
|
112
|
+
if (routePath === undefined) {
|
|
113
|
+
throw new Error(`locale-routing: no path for page "${String(page)}" locale "${locale}"`);
|
|
114
|
+
}
|
|
115
|
+
return routePath;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Binary toggle (defaultLocale <-> prefixedLocales[0]) — altLocalePath has
|
|
119
|
+
// no way to target a specific alt locale beyond the first prefixed one.
|
|
120
|
+
// Fine for the current bilingual (de/en) setup; a third prefixed locale
|
|
121
|
+
// needs altLocalePath to take an explicit targetLocale param instead.
|
|
122
|
+
function otherLocale(currentLocale: string): string {
|
|
123
|
+
if (currentLocale === defaultLocale) {
|
|
124
|
+
return prefixedLocales[0] ?? defaultLocale;
|
|
125
|
+
}
|
|
126
|
+
return defaultLocale;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function altLocalePath(pathname: string): string {
|
|
130
|
+
const path = normalizePath(pathname);
|
|
131
|
+
const resolved = pathIndex.get(path);
|
|
132
|
+
const targetLocale = otherLocale(detectLang(pathname));
|
|
133
|
+
if (resolved !== undefined) {
|
|
134
|
+
return publicPath(resolved.page, targetLocale);
|
|
135
|
+
}
|
|
136
|
+
return publicPath(homePage, targetLocale);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function sectionAnchor(page: TPage, locale: string, fragment: string): string {
|
|
140
|
+
return `${publicPath(page, locale)}#${fragment}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { detectLang, publicPath, resolvePage, altLocalePath, sectionAnchor };
|
|
144
|
+
}
|
|
@@ -36,6 +36,7 @@ export function createStore<T>(initial: T): WritableStore<T> {
|
|
|
36
36
|
},
|
|
37
37
|
setState: (next) => {
|
|
38
38
|
const nextValue = typeof next === "function" ? (next as (prev: T) => T)(snapshot) : next;
|
|
39
|
+
// skip: next value identical to snapshot, avoid notifying listeners
|
|
39
40
|
if (Object.is(nextValue, snapshot)) return;
|
|
40
41
|
snapshot = nextValue;
|
|
41
42
|
for (const listener of listeners) listener();
|