@cosmicdrift/kumiko-headless 0.122.4 → 0.123.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": "0.122.4",
3
+ "version": "0.123.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>",
@@ -32,7 +32,7 @@
32
32
  }
33
33
  },
34
34
  "dependencies": {
35
- "@cosmicdrift/kumiko-framework": "0.122.4",
35
+ "@cosmicdrift/kumiko-framework": "0.123.0",
36
36
  "zod": "^4.4.3"
37
37
  },
38
38
  "publishConfig": {
package/src/apex/index.ts CHANGED
@@ -203,12 +203,18 @@ export type ApexPage = {
203
203
  readonly footer: ApexFooter;
204
204
  };
205
205
 
206
+ // JSON in <script>-Kontext: `<` als < serialisieren, damit weder
207
+ // `</script>` noch `<!--` aus dem Block ausbrechen kann (JSON bleibt valide).
208
+ function scriptSafeJsonHtml(value: unknown): string {
209
+ return JSON.stringify(value).replace(/</g, "\\u003c");
210
+ }
211
+
206
212
  function dim(img: ApexImage): string {
207
213
  return `${img.width !== undefined ? ` width="${img.width}"` : ""}${img.height !== undefined ? ` height="${img.height}"` : ""}`;
208
214
  }
209
215
 
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>`;
216
+ function svgIcon(innerHtml: string): string {
217
+ 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
218
  }
213
219
 
214
220
  function renderCta(cta: ApexCta): string {
@@ -446,7 +452,8 @@ function renderFooter(f: ApexFooter): string {
446
452
  export function renderApexPage(page: ApexPage): string {
447
453
  const { head, brand } = page;
448
454
  const theme = page.theme ?? "light";
449
- const css = (brand.fontFaceCss ?? "") + brand.tokensCss + APEX_STRUCTURAL_CSS;
455
+ // Brand-CSS ist app-authored (Trust-Boundary siehe Datei-Header), kein Tenant-Input.
456
+ const cssHtml = (brand.fontFaceCss ?? "") + brand.tokensCss + APEX_STRUCTURAL_CSS;
450
457
  const sections = page.sections.map(renderSection).join("\n\n ");
451
458
  const ogUrl =
452
459
  head.canonicalUrl !== undefined
@@ -495,7 +502,7 @@ export function renderApexPage(page: ApexPage): string {
495
502
  .join("");
496
503
  const schema =
497
504
  head.schemaJson !== undefined
498
- ? `\n <script type="application/ld+json">${JSON.stringify(head.schemaJson)}</script>`
505
+ ? `\n <script type="application/ld+json">${scriptSafeJsonHtml(head.schemaJson)}</script>`
499
506
  : "";
500
507
  return `<!doctype html>
501
508
  <html lang="${escapeHtml(head.lang)}">
@@ -507,7 +514,7 @@ export function renderApexPage(page: ApexPage): string {
507
514
  <meta property="og:title" content="${escapeHtml(head.title)}" />
508
515
  <meta property="og:description" content="${escapeHtml(head.description)}" />
509
516
  <meta property="og:type" content="website" />${ogUrl}${ogImage}${siteName}${locale}${twitterCard}${twitterSite}${favicon}${canonical}${alternates}${robots}${preconnects}${schema}
510
- <style>${css}</style>
517
+ <style>${cssHtml}</style>
511
518
  </head>
512
519
  <body${theme === "dark" ? ` class="apex-dark"` : ""}>
513
520
  ${renderApexHeader(page.header)}
@@ -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
+ });
@@ -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
+ }
@@ -35,6 +35,7 @@ function formatDateCell(
35
35
  }
36
36
 
37
37
  export { escapeHtml, escapeHtmlAttr, escapeXml } from "./escape";
38
+ export { type HtmlValue, html, RawHtml, raw } from "./html-template";
38
39
  export function applyFormatSpec(
39
40
  spec: { format: string } & Record<string, unknown>,
40
41
  value: unknown,
package/src/index.ts CHANGED
@@ -51,7 +51,16 @@ export type {
51
51
  SubmitResult,
52
52
  } from "./form";
53
53
  export { createFormController } from "./form";
54
- export { applyFormatSpec, escapeHtml, escapeHtmlAttr, escapeXml } from "./format";
54
+ export {
55
+ applyFormatSpec,
56
+ escapeHtml,
57
+ escapeHtmlAttr,
58
+ escapeXml,
59
+ type HtmlValue,
60
+ html,
61
+ RawHtml,
62
+ raw,
63
+ } from "./format";
55
64
  export type {
56
65
  NavDefinition,
57
66
  NavNode,