@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-headless",
3
- "version": "1.0.0",
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": "1.0.0",
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
- expect(renderApexPage(page())).toContain("--footer-cols:0");
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(inner: string): string {
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">${inner}</svg>`;
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 ctas = (s.ctas ?? []).map(renderCta).join("\n ");
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
- ${ctas !== "" ? `<div class="hero-cta">${ctas}</div>` : ""}
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 cards = s.items
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
- ${cards}
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 items = [...cap, ...benefits].join("\n ");
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
- ${items}
317
+ ${itemsHtml}
304
318
  </ul>
305
- <a class="${cls}" href="${escapeHtml(t.cta.href)}">${escapeHtml(t.cta.label)}</a>
319
+ ${cta}
306
320
  </article>`;
307
321
  }
308
322
 
309
323
  function renderPricingGrid(s: ApexPricingGridSection): string {
310
- const cards = s.tiers.map(renderPricingCard).join("\n ");
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
- ${cards}
330
+ ${cardsHtml}
317
331
  </div>
318
332
  </div>
319
333
  </section>`;
320
334
  }
321
335
 
322
336
  function renderInfoGrid(s: ApexInfoGridSection): string {
323
- const items = s.items
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
- ${items}
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 items = m.items
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" aria-hidden="true">${svgIcon('<path d="m6 9 6 6 6-6"/>')}</span></button><div class="nav-menu__panel">${items}${footer}</div></div>`;
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 navLinks = (h.navLinks ?? []).map(renderNavEntry).join("\n ");
403
- const actions = (h.actions ?? []).map(renderCta);
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
- ${navLinks !== "" ? `<nav class="nav-links">${navLinks}</nav>` : ""}
408
- ${actions.length > 0 ? `<div class="nav-actions">${actions.join("\n ")}</div>` : ""}
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
- export function renderApexPage(page: ApexPage): string {
447
- const { head, brand } = page;
448
- const theme = page.theme ?? "light";
449
- const css = (brand.fontFaceCss ?? "") + brand.tokensCss + APEX_STRUCTURAL_CSS;
450
- const sections = page.sections.map(renderSection).join("\n\n ");
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">${JSON.stringify(head.schemaJson)}</script>`
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
- <title>${escapeHtml(head.title)}</title>
506
- <meta name="description" content="${escapeHtml(head.description)}" />
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
- ${sections}
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">&times;</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) {
@@ -9,6 +9,8 @@ export type {
9
9
  PendingWrite,
10
10
  QueryOpts,
11
11
  QueryResult,
12
+ StreamOpts,
12
13
  WriteOpts,
13
14
  WriteResult,
14
15
  } from "./types";
16
+ export { StreamFrame, type StreamFrameEvent } from "./types";
@@ -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
- // Skip work when the new value is identical to the current one —
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
- // Detect any effective change before rebuilding setValues with a
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>&lt;script&gt;alert(&quot;1&quot;)&lt;/script&gt;</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("&quot;&gt;&lt;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 &amp; 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>&lt;x&gt;</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>&lt;i&gt;</p>");
45
+ expect(fragment).toBeInstanceOf(RawHtml);
46
+ });
47
+ });
@@ -22,3 +22,58 @@ export function escapeXml(s: string): string {
22
22
  .replace(/"/g, "&quot;")
23
23
  .replace(/'/g, "&apos;");
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 (&colon;, &#58;, &#x3a;, &Tab;,
49
+ // &#9;, ...) while parsing the href attribute, before the URL parser ever
50
+ // sees the value — so "javascript&colon;alert(1)" and "java&Tab;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 "&colon;" 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
+ }
@@ -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
- return date.toLocaleString(locale, {
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") return date.toLocaleDateString(locale);
25
- return date.toLocaleString(locale, {
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 { applyFormatSpec, escapeHtml, escapeHtmlAttr, escapeXml } from "./format";
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();