@cosmicdrift/kumiko-headless 0.34.2 → 0.36.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/format/__tests__/escape.test.ts +79 -0
- package/src/format/escape.ts +24 -0
- package/src/format/index.ts +78 -0
- package/src/index.ts +2 -0
- package/src/view-model/__tests__/list.test.ts +2 -2
- package/src/view-model/index.ts +1 -0
- package/src/view-model/types.ts +8 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-headless",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.36.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.
|
|
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("& < > "");
|
|
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
|
+
"<div class="foo">&bar</div>",
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("no double-escape", () => {
|
|
28
|
+
const escaped = escapeHtml(`& < > "`);
|
|
29
|
+
expect(escapeHtml(escaped)).toBe("&amp; &lt; &gt; &quot;");
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("escapeHtmlAttr", () => {
|
|
34
|
+
test('escapes & " < > with priority on & and "', () => {
|
|
35
|
+
expect(escapeHtmlAttr(`& " < >`)).toBe("& " < >");
|
|
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&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; &quot; &lt; &gt;");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("escapeXml", () => {
|
|
57
|
+
test("escapes & < > \" '", () => {
|
|
58
|
+
expect(escapeXml(`& < > " '`)).toBe("& < > " '");
|
|
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
|
+
"<element attr="value">it's & stuff</element>",
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("no double-escape", () => {
|
|
76
|
+
const escaped = escapeXml(`& < > " '`);
|
|
77
|
+
expect(escapeXml(escaped)).toBe("&amp; &lt; &gt; &quot; &apos;");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export function escapeHtml(s: string): string {
|
|
2
|
+
return s
|
|
3
|
+
.replace(/&/g, "&")
|
|
4
|
+
.replace(/</g, "<")
|
|
5
|
+
.replace(/>/g, ">")
|
|
6
|
+
.replace(/"/g, """);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function escapeHtmlAttr(s: string): string {
|
|
10
|
+
return s
|
|
11
|
+
.replace(/&/g, "&")
|
|
12
|
+
.replace(/"/g, """)
|
|
13
|
+
.replace(/</g, "<")
|
|
14
|
+
.replace(/>/g, ">");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function escapeXml(s: string): string {
|
|
18
|
+
return s
|
|
19
|
+
.replace(/&/g, "&")
|
|
20
|
+
.replace(/</g, "<")
|
|
21
|
+
.replace(/>/g, ">")
|
|
22
|
+
.replace(/"/g, """)
|
|
23
|
+
.replace(/'/g, "'");
|
|
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,
|
|
@@ -77,6 +78,7 @@ export type {
|
|
|
77
78
|
ListColumnViewModel,
|
|
78
79
|
ListRowViewModel,
|
|
79
80
|
ListViewModel,
|
|
81
|
+
RuntimeRenderer,
|
|
80
82
|
ScreenSlots,
|
|
81
83
|
Translate,
|
|
82
84
|
} from "./view-model";
|
|
@@ -57,7 +57,7 @@ describe("computeListViewModel", () => {
|
|
|
57
57
|
});
|
|
58
58
|
|
|
59
59
|
test("object-form column carries renderer through to the view model", () => {
|
|
60
|
-
const fmt =
|
|
60
|
+
const fmt = { format: "currency" as const, symbol: "€" };
|
|
61
61
|
const vm = computeListViewModel({
|
|
62
62
|
screen: listScreen([{ field: "title", renderer: fmt }]),
|
|
63
63
|
entity: taskEntity,
|
|
@@ -66,7 +66,7 @@ describe("computeListViewModel", () => {
|
|
|
66
66
|
featureName: "tasks",
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
-
expect(vm.columns[0]?.renderer).
|
|
69
|
+
expect(vm.columns[0]?.renderer).toEqual(fmt);
|
|
70
70
|
});
|
|
71
71
|
|
|
72
72
|
test("rows map to { id, values } with id pulled from the row", () => {
|
package/src/view-model/index.ts
CHANGED
package/src/view-model/types.ts
CHANGED
|
@@ -7,6 +7,11 @@ import type {
|
|
|
7
7
|
ScreenSlots,
|
|
8
8
|
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
9
9
|
|
|
10
|
+
// Runtime-only renderer — function form allowed here because the renderer
|
|
11
|
+
// layer (render-list) injects reference-column lookup closures at mount time.
|
|
12
|
+
// Never serialized to JSON.
|
|
13
|
+
export type RuntimeRenderer = (value: unknown, row?: Readonly<Record<string, unknown>>) => string;
|
|
14
|
+
|
|
10
15
|
// View-Models — plain data structures produced by computeListViewModel and
|
|
11
16
|
// computeEditViewModel. They flatten the combined [screen-def + entity-def
|
|
12
17
|
// + row-data + user] inputs into a shape the renderer draws directly,
|
|
@@ -29,12 +34,13 @@ import type {
|
|
|
29
34
|
// One column, fully resolved. `label` is the localized string the
|
|
30
35
|
// renderer puts in the column header; view-model builder runs it through
|
|
31
36
|
// LocaleResolver.translate() from the i18nKey wired onto the field.
|
|
32
|
-
// `renderer` passes through ScreenDefinition's FieldRenderer verbatim
|
|
37
|
+
// `renderer` passes through ScreenDefinition's FieldRenderer verbatim; the
|
|
38
|
+
// renderer layer may also inject a RuntimeRenderer closure (reference lookups).
|
|
33
39
|
export type ListColumnViewModel = {
|
|
34
40
|
readonly field: string;
|
|
35
41
|
readonly label: string;
|
|
36
42
|
readonly type: string; // field-type ("text", "number", "money", ...)
|
|
37
|
-
readonly renderer?: FieldRenderer;
|
|
43
|
+
readonly renderer?: FieldRenderer | RuntimeRenderer;
|
|
38
44
|
readonly sortable: boolean;
|
|
39
45
|
/** Nur bei `type: "select"` — translated Option-Labels keyed nach raw
|
|
40
46
|
* value. Renderer rendert `optionLabels[value]` statt humanizeSlug
|