@cosmicdrift/kumiko-headless 0.35.0 → 0.37.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.35.0",
3
+ "version": "0.37.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>",
@@ -28,7 +28,7 @@
28
28
  }
29
29
  },
30
30
  "dependencies": {
31
- "@cosmicdrift/kumiko-framework": "0.21.0",
31
+ "@cosmicdrift/kumiko-framework": "0.35.0",
32
32
  "zod": "^4.4.3"
33
33
  },
34
34
  "publishConfig": {
@@ -0,0 +1,79 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { escapeHtml, escapeHtmlAttr, escapeXml } from "../escape";
3
+
4
+ describe("escapeHtml", () => {
5
+ test('escapes & < > "', () => {
6
+ expect(escapeHtml(`& < > "`)).toBe("&amp; &lt; &gt; &quot;");
7
+ });
8
+
9
+ test("does not escape '", () => {
10
+ expect(escapeHtml("it's")).toBe("it's");
11
+ });
12
+
13
+ test("plain text passes through", () => {
14
+ expect(escapeHtml("Hello, World!")).toBe("Hello, World!");
15
+ });
16
+
17
+ test("empty string", () => {
18
+ expect(escapeHtml("")).toBe("");
19
+ });
20
+
21
+ test("multiple occurrences", () => {
22
+ expect(escapeHtml(`<div class="foo">&bar</div>`)).toBe(
23
+ "&lt;div class=&quot;foo&quot;&gt;&amp;bar&lt;/div&gt;",
24
+ );
25
+ });
26
+
27
+ test("no double-escape", () => {
28
+ const escaped = escapeHtml(`& < > "`);
29
+ expect(escapeHtml(escaped)).toBe("&amp;amp; &amp;lt; &amp;gt; &amp;quot;");
30
+ });
31
+ });
32
+
33
+ describe("escapeHtmlAttr", () => {
34
+ test('escapes & " < > with priority on & and "', () => {
35
+ expect(escapeHtmlAttr(`& " < >`)).toBe("&amp; &quot; &lt; &gt;");
36
+ });
37
+
38
+ test("does not escape '", () => {
39
+ expect(escapeHtmlAttr("it's")).toBe("it's");
40
+ });
41
+
42
+ test("plain attribute value passes through", () => {
43
+ expect(escapeHtmlAttr("https://example.com?a=1&b=2")).toBe("https://example.com?a=1&amp;b=2");
44
+ });
45
+
46
+ test("empty string", () => {
47
+ expect(escapeHtmlAttr("")).toBe("");
48
+ });
49
+
50
+ test("no double-escape", () => {
51
+ const escaped = escapeHtmlAttr(`& " < >`);
52
+ expect(escapeHtmlAttr(escaped)).toBe("&amp;amp; &amp;quot; &amp;lt; &amp;gt;");
53
+ });
54
+ });
55
+
56
+ describe("escapeXml", () => {
57
+ test("escapes & < > \" '", () => {
58
+ expect(escapeXml(`& < > " '`)).toBe("&amp; &lt; &gt; &quot; &apos;");
59
+ });
60
+
61
+ test("plain text passes through", () => {
62
+ expect(escapeXml("Hello, World!")).toBe("Hello, World!");
63
+ });
64
+
65
+ test("empty string", () => {
66
+ expect(escapeXml("")).toBe("");
67
+ });
68
+
69
+ test("multiple occurrences", () => {
70
+ expect(escapeXml(`<element attr="value">it's & stuff</element>`)).toBe(
71
+ "&lt;element attr=&quot;value&quot;&gt;it&apos;s &amp; stuff&lt;/element&gt;",
72
+ );
73
+ });
74
+
75
+ test("no double-escape", () => {
76
+ const escaped = escapeXml(`& < > " '`);
77
+ expect(escapeXml(escaped)).toBe("&amp;amp; &amp;lt; &amp;gt; &amp;quot; &amp;apos;");
78
+ });
79
+ });
@@ -0,0 +1,24 @@
1
+ export function escapeHtml(s: string): string {
2
+ return s
3
+ .replace(/&/g, "&amp;")
4
+ .replace(/</g, "&lt;")
5
+ .replace(/>/g, "&gt;")
6
+ .replace(/"/g, "&quot;");
7
+ }
8
+
9
+ export function escapeHtmlAttr(s: string): string {
10
+ return s
11
+ .replace(/&/g, "&amp;")
12
+ .replace(/"/g, "&quot;")
13
+ .replace(/</g, "&lt;")
14
+ .replace(/>/g, "&gt;");
15
+ }
16
+
17
+ export function escapeXml(s: string): string {
18
+ return s
19
+ .replace(/&/g, "&amp;")
20
+ .replace(/</g, "&lt;")
21
+ .replace(/>/g, "&gt;")
22
+ .replace(/"/g, "&quot;")
23
+ .replace(/'/g, "&apos;");
24
+ }
@@ -0,0 +1,78 @@
1
+ // Pure format utilities — no web or platform dependencies.
2
+ // Shared between renderer-web, renderer-native, and server-side tests.
3
+
4
+ function formatDateCell(
5
+ value: unknown,
6
+ type: string,
7
+ opts?: {
8
+ locale?: string;
9
+ dateStyle?: Intl.DateTimeFormatOptions["dateStyle"];
10
+ timeStyle?: Intl.DateTimeFormatOptions["timeStyle"];
11
+ },
12
+ ): string {
13
+ try {
14
+ const raw = typeof value === "string" ? value : String(value);
15
+ const date = new Date(raw);
16
+ if (Number.isNaN(date.getTime())) return raw;
17
+ const locale = opts?.locale;
18
+ if (opts?.dateStyle || opts?.timeStyle) {
19
+ return date.toLocaleString(locale, {
20
+ dateStyle: opts.dateStyle,
21
+ timeStyle: opts.timeStyle,
22
+ });
23
+ }
24
+ if (type === "date") return date.toLocaleDateString(locale);
25
+ return date.toLocaleString(locale, {
26
+ year: "numeric",
27
+ month: "short",
28
+ day: "numeric",
29
+ hour: "2-digit",
30
+ minute: "2-digit",
31
+ });
32
+ } catch {
33
+ return String(value);
34
+ }
35
+ }
36
+
37
+ export { escapeHtml, escapeHtmlAttr, escapeXml } from "./escape";
38
+ export function applyFormatSpec(
39
+ spec: { format: string } & Record<string, unknown>,
40
+ value: unknown,
41
+ ): string {
42
+ if (value === null || value === undefined || value === "") return "";
43
+ switch (spec.format) {
44
+ case "timestamp":
45
+ case "date":
46
+ return formatDateCell(value, spec.format, {
47
+ locale: spec["locale"] as string | undefined,
48
+ dateStyle: spec["dateStyle"] as Intl.DateTimeFormatOptions["dateStyle"],
49
+ timeStyle:
50
+ spec.format === "timestamp"
51
+ ? (spec["timeStyle"] as Intl.DateTimeFormatOptions["timeStyle"])
52
+ : undefined,
53
+ });
54
+ case "boolean": {
55
+ if (value === true) return (spec["trueLabel"] as string | undefined) ?? "✓";
56
+ if (value === false) return (spec["falseLabel"] as string | undefined) ?? "";
57
+ return "";
58
+ }
59
+ case "currency": {
60
+ const sym = (spec["symbol"] as string | undefined) ?? "";
61
+ return sym.length > 0 ? `${value} ${sym}` : String(value);
62
+ }
63
+ case "priority": {
64
+ const emptyLabel = (spec["emptyLabel"] as string | undefined) ?? "—";
65
+ const prefix = (spec["prefix"] as string | undefined) ?? "";
66
+ if (value === 0 || value === undefined || value === null) return emptyLabel;
67
+ return `${prefix}${value}`;
68
+ }
69
+ default:
70
+ if (typeof process !== "undefined" && process.env.NODE_ENV !== "production") {
71
+ // biome-ignore lint/suspicious/noConsole: dev-only warning
72
+ console.warn(
73
+ `[kumiko] applyFormatSpec: unbekannter Format-Key "${spec.format}" — via FieldFormatRegistry module augmentation registriert?`,
74
+ );
75
+ }
76
+ return typeof value === "string" ? value : String(value);
77
+ }
78
+ }
package/src/index.ts CHANGED
@@ -51,6 +51,7 @@ export type {
51
51
  SubmitResult,
52
52
  } from "./form";
53
53
  export { createFormController } from "./form";
54
+ export { applyFormatSpec, escapeHtml, escapeHtmlAttr, escapeXml } from "./format";
54
55
  export type {
55
56
  NavDefinition,
56
57
  NavNode,