@cosmicdrift/kumiko-headless 0.122.5 → 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 +2 -2
- package/src/apex/index.ts +12 -5
- package/src/format/__tests__/html-template.test.ts +47 -0
- package/src/format/html-template.ts +49 -0
- package/src/format/index.ts +1 -0
- package/src/index.ts +10 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-headless",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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(
|
|
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">${
|
|
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
|
-
|
|
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">${
|
|
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>${
|
|
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><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
|
+
});
|
|
@@ -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
|
@@ -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 {
|
|
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,
|