@cosmicdrift/kumiko-headless 0.327.0 → 0.328.1

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.
Files changed (109) hide show
  1. package/dist/apex/css.d.ts +3 -0
  2. package/{src/apex/css.ts → dist/apex/css.js} +11 -24
  3. package/dist/apex/index.d.ts +229 -0
  4. package/dist/apex/index.js +360 -0
  5. package/dist/apex/lightbox.d.ts +4 -0
  6. package/{src/apex/lightbox.ts → dist/apex/lightbox.js} +0 -4
  7. package/dist/contracts/asset.d.ts +15 -0
  8. package/dist/contracts/asset.js +17 -0
  9. package/dist/contracts/index.d.ts +3 -0
  10. package/dist/contracts/index.js +1 -0
  11. package/dist/contracts/locale.d.ts +7 -0
  12. package/dist/contracts/locale.js +11 -0
  13. package/dist/contracts/primitives.d.ts +93 -0
  14. package/dist/contracts/primitives.js +21 -0
  15. package/dist/dispatcher/index.d.ts +2 -0
  16. package/dist/dispatcher/index.js +1 -0
  17. package/dist/dispatcher/types.d.ts +86 -0
  18. package/dist/dispatcher/types.js +34 -0
  19. package/dist/form/form-controller.d.ts +2 -0
  20. package/dist/form/form-controller.js +404 -0
  21. package/dist/form/index.d.ts +3 -0
  22. package/dist/form/index.js +2 -0
  23. package/dist/form/types.d.ts +71 -0
  24. package/dist/form/types.js +1 -0
  25. package/dist/form/zod-bridge.d.ts +5 -0
  26. package/{src/form/zod-bridge.ts → dist/form/zod-bridge.js} +52 -58
  27. package/dist/format/escape.d.ts +5 -0
  28. package/{src/format/escape.ts → dist/format/escape.js} +43 -47
  29. package/dist/format/html-template.d.ts +9 -0
  30. package/dist/format/html-template.js +39 -0
  31. package/dist/format/index.d.ts +10 -0
  32. package/dist/format/index.js +192 -0
  33. package/dist/format/money.d.ts +1 -0
  34. package/{src/format/money.ts → dist/format/money.js} +6 -5
  35. package/dist/index.d.ts +14 -0
  36. package/dist/index.js +9 -0
  37. package/dist/locale-routing/index.d.ts +39 -0
  38. package/dist/locale-routing/index.js +143 -0
  39. package/dist/nav/index.d.ts +2 -0
  40. package/dist/nav/index.js +1 -0
  41. package/dist/nav/resolve.d.ts +2 -0
  42. package/dist/nav/resolve.js +58 -0
  43. package/dist/nav/types.d.ts +26 -0
  44. package/dist/nav/types.js +1 -0
  45. package/dist/store/create-store.d.ts +2 -0
  46. package/{src/store/create-store.ts → dist/store/create-store.js} +21 -23
  47. package/dist/store/equality.d.ts +1 -0
  48. package/{src/store/equality.ts → dist/store/equality.js} +19 -17
  49. package/dist/store/index.d.ts +3 -0
  50. package/dist/store/index.js +2 -0
  51. package/dist/store/types.d.ts +7 -0
  52. package/dist/store/types.js +10 -0
  53. package/dist/view-model/edit.d.ts +10 -0
  54. package/dist/view-model/edit.js +392 -0
  55. package/dist/view-model/embedded-list.d.ts +27 -0
  56. package/dist/view-model/embedded-list.js +63 -0
  57. package/dist/view-model/index.d.ts +7 -0
  58. package/dist/view-model/index.js +3 -0
  59. package/dist/view-model/list.d.ts +16 -0
  60. package/dist/view-model/list.js +201 -0
  61. package/dist/view-model/types.d.ts +300 -0
  62. package/dist/view-model/types.js +1 -0
  63. package/package.json +31 -12
  64. package/src/changes.json +7 -0
  65. package/src/apex/__tests__/lightbox.test.ts +0 -20
  66. package/src/apex/__tests__/page-head.test.ts +0 -63
  67. package/src/apex/__tests__/render.test.ts +0 -401
  68. package/src/apex/index.ts +0 -667
  69. package/src/contracts/__tests__/contracts.test.ts +0 -123
  70. package/src/contracts/__tests__/field-issue-compat.test.ts +0 -19
  71. package/src/contracts/asset.ts +0 -48
  72. package/src/contracts/index.ts +0 -23
  73. package/src/contracts/locale.ts +0 -33
  74. package/src/contracts/primitives.ts +0 -158
  75. package/src/dispatcher/__tests__/contract.test.ts +0 -163
  76. package/src/dispatcher/index.ts +0 -16
  77. package/src/dispatcher/types.ts +0 -217
  78. package/src/form/__tests__/conditional-fields.test.ts +0 -177
  79. package/src/form/__tests__/form-controller.test.ts +0 -231
  80. package/src/form/__tests__/submit.test.ts +0 -575
  81. package/src/form/__tests__/validation.test.ts +0 -274
  82. package/src/form/form-controller.ts +0 -458
  83. package/src/form/index.ts +0 -15
  84. package/src/form/types.ts +0 -297
  85. package/src/format/__tests__/escape.test.ts +0 -79
  86. package/src/format/__tests__/format.test.ts +0 -281
  87. package/src/format/__tests__/html-template.test.ts +0 -47
  88. package/src/format/html-template.ts +0 -49
  89. package/src/format/index.ts +0 -219
  90. package/src/index.ts +0 -118
  91. package/src/locale-routing/__tests__/locale-routing.test.ts +0 -276
  92. package/src/locale-routing/index.ts +0 -218
  93. package/src/nav/__tests__/resolve.test.ts +0 -279
  94. package/src/nav/index.ts +0 -8
  95. package/src/nav/resolve.ts +0 -60
  96. package/src/nav/types.ts +0 -70
  97. package/src/store/__tests__/create-store.test.ts +0 -139
  98. package/src/store/__tests__/equality.test.ts +0 -66
  99. package/src/store/index.ts +0 -3
  100. package/src/store/types.ts +0 -27
  101. package/src/view-model/__tests__/edit.test.ts +0 -1072
  102. package/src/view-model/__tests__/embedded-list.test.ts +0 -96
  103. package/src/view-model/__tests__/field-labels.test.ts +0 -62
  104. package/src/view-model/__tests__/list.test.ts +0 -428
  105. package/src/view-model/edit.ts +0 -641
  106. package/src/view-model/embedded-list.ts +0 -89
  107. package/src/view-model/index.ts +0 -43
  108. package/src/view-model/list.ts +0 -277
  109. package/src/view-model/types.ts +0 -392
@@ -1,6 +1,3 @@
1
- import type { ZodError, ZodIssue } from "zod";
2
- import type { FieldIssue } from "../dispatcher";
3
-
4
1
  // Translates a ZodError into the same FieldIssue shape the server emits via
5
2
  // its own zod-bridge (packages/framework/src/errors/zod-bridge.ts). The two
6
3
  // bridges stay in sync on purpose: a field failing zod on the client reads
@@ -17,75 +14,72 @@ import type { FieldIssue } from "../dispatcher";
17
14
  // tend to introduce new param keys, and the server's classes.test.ts is
18
15
  // what catches them. A value added there should be added here too.
19
16
  const ISSUE_PARAM_KEYS = [
20
- "minimum",
21
- "maximum",
22
- "expected",
23
- "received",
24
- "type",
25
- "inclusive",
26
- "exact",
27
- "keys",
28
- // Zod 4 additions
29
- "format",
30
- "divisor",
31
- "values",
32
- "pattern",
33
- ] as const;
34
-
17
+ "minimum",
18
+ "maximum",
19
+ "expected",
20
+ "received",
21
+ "type",
22
+ "inclusive",
23
+ "exact",
24
+ "keys",
25
+ // Zod 4 additions
26
+ "format",
27
+ "divisor",
28
+ "values",
29
+ "pattern",
30
+ ];
35
31
  // Flat FieldIssue list — same shape as server-side ValidationError.fields.
36
32
  // The form-controller groups this by `path` for its snapshot's errors map.
37
- export function zodErrorToFieldIssues(error: ZodError): FieldIssue[] {
38
- return error.issues.map<FieldIssue>((issue) => {
39
- const params = extractIssueParams(issue);
40
- return {
41
- // Empty-path issues (top-level object/array failures) use "(root)" so
42
- // the form-controller has a stable key to show "form itself is
43
- // malformed" errors under — matches what the server does.
44
- path: issue.path.map(String).join(".") || "(root)",
45
- code: issue.code,
46
- i18nKey: resolveI18nKey(issue),
47
- ...(params && { params }),
48
- };
49
- });
33
+ export function zodErrorToFieldIssues(error) {
34
+ return error.issues.map((issue) => {
35
+ const params = extractIssueParams(issue);
36
+ return {
37
+ // Empty-path issues (top-level object/array failures) use "(root)" so
38
+ // the form-controller has a stable key to show "form itself is
39
+ // malformed" errors under — matches what the server does.
40
+ path: issue.path.map(String).join(".") || "(root)",
41
+ code: issue.code,
42
+ i18nKey: resolveI18nKey(issue),
43
+ ...(params && { params }),
44
+ };
45
+ });
50
46
  }
51
-
52
47
  // Same magic key both sides of a superRefine can set to opt out of the
53
48
  // generic `errors.validation.custom` message — see form-schema.ts.
54
49
  export const I18N_KEY_PARAM = "i18nKey";
55
-
56
50
  // `code: "custom"` is zod's catch-all for every superRefine/refine check;
57
51
  // left mechanical it'd collapse onto one generic key, so a superRefine can
58
52
  // set `params[I18N_KEY_PARAM]` to override it. Keep in sync with the server
59
53
  // mirror (packages/framework/src/errors/zod-bridge.ts).
60
- function resolveI18nKey(issue: ZodIssue): string {
61
- if (issue.code === "custom") {
62
- const override = issue.params?.[I18N_KEY_PARAM];
63
- if (typeof override === "string") return override;
64
- }
65
- return `errors.validation.${issue.code}`;
54
+ function resolveI18nKey(issue) {
55
+ if (issue.code === "custom") {
56
+ const override = issue.params?.[I18N_KEY_PARAM];
57
+ if (typeof override === "string")
58
+ return override;
59
+ }
60
+ return `errors.validation.${issue.code}`;
66
61
  }
67
-
68
- function extractIssueParams(issue: ZodIssue): Readonly<Record<string, unknown>> | undefined {
69
- const out: Record<string, unknown> = {};
70
- const bag = issue as unknown as Record<string, unknown>; // @cast-boundary zod-issue
71
- for (const key of ISSUE_PARAM_KEYS) {
72
- if (bag[key] !== undefined) out[key] = bag[key];
73
- }
74
- return Object.keys(out).length > 0 ? out : undefined;
62
+ function extractIssueParams(issue) {
63
+ const out = {};
64
+ const bag = issue; // @cast-boundary zod-issue
65
+ for (const key of ISSUE_PARAM_KEYS) {
66
+ if (bag[key] !== undefined)
67
+ out[key] = bag[key];
68
+ }
69
+ return Object.keys(out).length > 0 ? out : undefined;
75
70
  }
76
-
77
71
  // Groups a flat FieldIssue list into the `errors` map carried by a
78
72
  // FormSnapshot. A field with multiple issues (e.g. both "required" and
79
73
  // "min") keeps both — UIs that only render the first can pick the first
80
74
  // element, those that want a full list have it.
81
- export function groupIssuesByPath(
82
- issues: readonly FieldIssue[],
83
- ): Record<string, readonly FieldIssue[]> {
84
- const out: Record<string, FieldIssue[]> = {};
85
- for (const issue of issues) {
86
- const bucket = out[issue.path];
87
- if (bucket) bucket.push(issue);
88
- else out[issue.path] = [issue];
89
- }
90
- return out;
75
+ export function groupIssuesByPath(issues) {
76
+ const out = {};
77
+ for (const issue of issues) {
78
+ const bucket = out[issue.path];
79
+ if (bucket)
80
+ bucket.push(issue);
81
+ else
82
+ out[issue.path] = [issue];
83
+ }
84
+ return out;
91
85
  }
@@ -0,0 +1,5 @@
1
+ export declare function escapeHtml(s: string): string;
2
+ export declare function escapeHtmlAttr(s: string): string;
3
+ export declare function escapeXml(s: string): string;
4
+ export declare function stripControlChars(value: string): string;
5
+ export declare function isSafeHref(href: string): boolean;
@@ -1,28 +1,25 @@
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;");
1
+ export function escapeHtml(s) {
2
+ return s
3
+ .replace(/&/g, "&amp;")
4
+ .replace(/</g, "&lt;")
5
+ .replace(/>/g, "&gt;")
6
+ .replace(/"/g, "&quot;");
7
7
  }
8
-
9
8
  // Superset of escapeHtml for attribute contexts: additionally escapes ' so
10
9
  // single-quoted attributes cannot be broken out of.
11
- export function escapeHtmlAttr(s: string): string {
12
- return escapeHtml(s).replace(/'/g, "&#39;");
10
+ export function escapeHtmlAttr(s) {
11
+ return escapeHtml(s).replace(/'/g, "&#39;");
13
12
  }
14
-
15
13
  // Shared API for app repos (publicstatus badge-SVG renders XML by hand) —
16
14
  // no in-repo consumer yet, deliberately exported for cross-repo dedup.
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;");
15
+ export function escapeXml(s) {
16
+ return s
17
+ .replace(/&/g, "&amp;")
18
+ .replace(/</g, "&lt;")
19
+ .replace(/>/g, "&gt;")
20
+ .replace(/"/g, "&quot;")
21
+ .replace(/'/g, "&apos;");
24
22
  }
25
-
26
23
  // Strips ASCII control chars + space (codepoints 0x00-0x20 and 0x7f) — the
27
24
  // same characters browsers strip before scheme detection. `.trim()` alone
28
25
  // only removes leading/trailing whitespace, so a scheme like
@@ -31,15 +28,15 @@ export function escapeXml(s: string): string {
31
28
  // `return true`, and the browser then normalizes it back to "javascript:"
32
29
  // and executes it. Written as a char-code filter rather than a regex range
33
30
  // 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;
31
+ export function stripControlChars(value) {
32
+ let result = "";
33
+ for (const ch of value) {
34
+ const code = ch.codePointAt(0) ?? 0;
35
+ if (code > 0x20 && code !== 0x7f)
36
+ result += ch;
37
+ }
38
+ return result;
41
39
  }
42
-
43
40
  // http(s)/mailto or scheme-less (relative/anchor) allowed; javascript:,
44
41
  // data:, vbscript:, etc. rejected. Shared between renderer-web's Link
45
42
  // primitive and page-render's server-side markdown renderer (both take
@@ -53,27 +50,26 @@ export function stripControlChars(value: string): string {
53
50
  // (not delete) so the scheme check sees what the browser will execute —
54
51
  // deleting "&colon;" would leave no colon at all, which reads as a safe
55
52
  // 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: "&",
53
+ const NAMED_HTML_ENTITY_CHARS = {
54
+ colon: ":",
55
+ tab: "\t",
56
+ newline: "\n",
57
+ semi: ";",
58
+ amp: "&",
62
59
  };
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
- });
60
+ function decodeHtmlEntities(value) {
61
+ return value.replace(/&(#x[0-9a-f]+|#[0-9]+|[a-z][a-z0-9]*);/gi, (match, entity) => {
62
+ if (entity[0] === "#") {
63
+ const isHex = entity[1]?.toLowerCase() === "x";
64
+ const code = Number.parseInt(isHex ? entity.slice(2) : entity.slice(1), isHex ? 16 : 10);
65
+ return Number.isFinite(code) ? String.fromCodePoint(code) : match;
66
+ }
67
+ return NAMED_HTML_ENTITY_CHARS[entity.toLowerCase()] ?? match;
68
+ });
73
69
  }
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);
70
+ export function isSafeHref(href) {
71
+ const trimmed = stripControlChars(decodeHtmlEntities(href)).toLowerCase();
72
+ if (!/^[a-z][a-z0-9+.-]*:/.test(trimmed))
73
+ return true;
74
+ return /^(?:https?|mailto):/.test(trimmed);
79
75
  }
@@ -0,0 +1,9 @@
1
+ export declare class RawHtml {
2
+ readonly html: string;
3
+ constructor(html: string);
4
+ toString(): string;
5
+ }
6
+ /** Markiert bereits escaptes/vertrauenswürdiges Markup für html`...`. */
7
+ export declare function raw(html: string): RawHtml;
8
+ export type HtmlValue = string | number | boolean | null | undefined | RawHtml | ReadonlyArray<HtmlValue>;
9
+ export declare function html(strings: TemplateStringsArray, ...values: ReadonlyArray<HtmlValue>): RawHtml;
@@ -0,0 +1,39 @@
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
+ import { escapeHtml } from "./escape.js";
6
+ export class RawHtml {
7
+ html;
8
+ constructor(html) {
9
+ this.html = html;
10
+ }
11
+ toString() {
12
+ return this.html;
13
+ }
14
+ }
15
+ /** Markiert bereits escaptes/vertrauenswürdiges Markup für html`...`. */
16
+ export function raw(html) {
17
+ return new RawHtml(html);
18
+ }
19
+ function renderValue(value) {
20
+ if (value === null || value === undefined)
21
+ return "";
22
+ if (value instanceof RawHtml)
23
+ return value.html;
24
+ if (Array.isArray(value))
25
+ return value.map(renderValue).join("");
26
+ if (typeof value === "string")
27
+ return escapeHtml(value);
28
+ return String(value);
29
+ }
30
+ // Rückgabe ist RawHtml, damit Fragmente verschachtelbar sind ohne doppelt zu
31
+ // escapen: `html`<div>${item}</div>`` innerhalb eines äußeren html`...`
32
+ // passiert unverändert durch. toString() liefert das fertige Markup.
33
+ export function html(strings, ...values) {
34
+ let out = strings[0] ?? "";
35
+ values.forEach((value, i) => {
36
+ out += renderValue(value) + (strings[i + 1] ?? "");
37
+ });
38
+ return new RawHtml(out);
39
+ }
@@ -0,0 +1,10 @@
1
+ import { Temporal } from "temporal-polyfill";
2
+ export declare function toInstant(raw: string): Temporal.Instant;
3
+ /** Exported for UnitKey parity tests against @cosmicdrift/kumiko-types/screen. */
4
+ export declare const UNIT_FORMAT_KEYS: readonly string[];
5
+ export { escapeHtml, escapeHtmlAttr, escapeXml, isSafeHref, stripControlChars } from "./escape.js";
6
+ export { type HtmlValue, html, RawHtml, raw } from "./html-template.js";
7
+ export { currencyDecimals } from "./money.js";
8
+ export declare function applyFormatSpec(spec: {
9
+ format: string;
10
+ } & Record<string, unknown>, value: unknown, translate?: (key: string) => string): string;
@@ -0,0 +1,192 @@
1
+ // Pure format utilities — no web or platform dependencies.
2
+ // Shared between renderer-web, renderer-native, and server-side tests.
3
+ import { Temporal } from "temporal-polyfill";
4
+ function toPlainDate(raw) {
5
+ try {
6
+ return Temporal.PlainDate.from(raw);
7
+ }
8
+ catch {
9
+ // "date"-typed field stored as a full instant (day-boundary timestamp) —
10
+ // take the calendar date in the local zone rather than fail.
11
+ return toInstant(raw).toZonedDateTimeISO(Temporal.Now.timeZoneId()).toPlainDate();
12
+ }
13
+ }
14
+ // Temporal.Instant.from is stricter than the `new Date(raw)` this replaced:
15
+ // it requires a UTC designator/offset (Z/+hh:mm). Timestamps without one
16
+ // (e.g. "2026-07-18T12:00:00", still valid input to `new Date`) throw here
17
+ // instead of parsing — fall back to reading them as a local wall-clock time,
18
+ // same posture as toPlainDate's own fallback above.
19
+ export function toInstant(raw) {
20
+ try {
21
+ return Temporal.Instant.from(raw);
22
+ }
23
+ catch {
24
+ return Temporal.PlainDateTime.from(raw).toZonedDateTime(Temporal.Now.timeZoneId()).toInstant();
25
+ }
26
+ }
27
+ function formatDateCell(value, type, opts) {
28
+ try {
29
+ const raw = typeof value === "string" ? value : String(value);
30
+ const locale = opts?.locale;
31
+ if (opts?.dateStyle || opts?.timeStyle) {
32
+ if (type === "date") {
33
+ // ponytail: timeStyle is ignored here on purpose — a PlainDate has no
34
+ // time component to format, so a timeStyle-only "date" field falls
35
+ // back to PlainDate's default dateStyle instead of rendering a time.
36
+ return toPlainDate(raw).toLocaleString(locale, {
37
+ dateStyle: opts.dateStyle,
38
+ });
39
+ }
40
+ return toInstant(raw).toLocaleString(locale, {
41
+ dateStyle: opts.dateStyle,
42
+ timeStyle: opts.timeStyle,
43
+ });
44
+ }
45
+ if (type === "date") {
46
+ return toPlainDate(raw).toLocaleString(locale, {
47
+ year: "numeric",
48
+ month: "2-digit",
49
+ day: "2-digit",
50
+ });
51
+ }
52
+ return toInstant(raw).toLocaleString(locale, {
53
+ year: "numeric",
54
+ month: "short",
55
+ day: "numeric",
56
+ hour: "2-digit",
57
+ minute: "2-digit",
58
+ });
59
+ }
60
+ catch {
61
+ return String(value);
62
+ }
63
+ }
64
+ // No `locale` fallback to a browser API here (headless has none) — passing
65
+ // `undefined` to Intl.NumberFormat resolves the runtime's default locale,
66
+ // the same value a browser's navigator.language-based guess would produce.
67
+ function formatNumberCell(value, locale, grouping = true) {
68
+ if (typeof value !== "number" || !Number.isFinite(value))
69
+ return String(value);
70
+ try {
71
+ return new Intl.NumberFormat(locale, { useGrouping: grouping }).format(value);
72
+ }
73
+ catch {
74
+ // Malformed BCP-47 locale tag — mirrors formatDateCell's fallback above.
75
+ return String(value);
76
+ }
77
+ }
78
+ // Two-tier unit table for `{ format: "unit" }`. Keys mirror
79
+ // @cosmicdrift/kumiko-types' UnitKey (kept as a local literal union instead
80
+ // of an import — headless has no build-time dependency on kumiko-types
81
+ // today, and applyFormatSpec's spec param is already an untyped
82
+ // Record<string, unknown>, cast per-case like every other format below).
83
+ const UNIT_INTL_IDS = {
84
+ km: "kilometer",
85
+ m: "meter",
86
+ kg: "kilogram",
87
+ percent: "percent",
88
+ mi: "mile",
89
+ };
90
+ // "m2" (square meter) has no ECMA-402 sanctioned unit — Intl.NumberFormat
91
+ // throws RangeError for "square-meter". Locale-format the number and append
92
+ // the literal suffix instead of going through `style: "unit"`.
93
+ const UNIT_SUFFIXES = { m2: "m²" };
94
+ function isIntlUnit(unit) {
95
+ return Object.hasOwn(UNIT_INTL_IDS, unit);
96
+ }
97
+ function isSuffixUnit(unit) {
98
+ return Object.hasOwn(UNIT_SUFFIXES, unit);
99
+ }
100
+ function formatUnitCell(value, unit, locale, unitDisplay) {
101
+ const n = typeof value === "number" ? value : Number(value);
102
+ if (unit === undefined || !Number.isFinite(n))
103
+ return String(value);
104
+ if (isIntlUnit(unit)) {
105
+ try {
106
+ return new Intl.NumberFormat(locale, {
107
+ style: "unit",
108
+ unit: UNIT_INTL_IDS[unit],
109
+ unitDisplay,
110
+ }).format(n);
111
+ }
112
+ catch {
113
+ // Malformed BCP-47 locale tag — mirrors formatNumberCell's fallback below.
114
+ return formatNumberCell(n, locale);
115
+ }
116
+ }
117
+ if (isSuffixUnit(unit)) {
118
+ return `${formatNumberCell(n, locale)} ${UNIT_SUFFIXES[unit]}`;
119
+ }
120
+ return String(value);
121
+ }
122
+ /** Exported for UnitKey parity tests against @cosmicdrift/kumiko-types/screen. */
123
+ export const UNIT_FORMAT_KEYS = [
124
+ ...Object.keys(UNIT_INTL_IDS),
125
+ ...Object.keys(UNIT_SUFFIXES),
126
+ ];
127
+ export { escapeHtml, escapeHtmlAttr, escapeXml, isSafeHref, stripControlChars } from "./escape.js";
128
+ export { html, RawHtml, raw } from "./html-template.js";
129
+ export { currencyDecimals } from "./money.js";
130
+ export function applyFormatSpec(spec, value, translate) {
131
+ const isEmpty = value === null || value === undefined || value === "";
132
+ // priority renders its emptyLabel for empty values — every other format
133
+ // collapses empty to "".
134
+ if (isEmpty && spec.format !== "priority")
135
+ return "";
136
+ switch (spec.format) {
137
+ case "timestamp":
138
+ case "date":
139
+ return formatDateCell(value, spec.format, {
140
+ locale: spec["locale"],
141
+ dateStyle: spec["dateStyle"],
142
+ timeStyle: spec.format === "timestamp"
143
+ ? spec["timeStyle"]
144
+ : undefined,
145
+ });
146
+ case "boolean": {
147
+ if (value === true) {
148
+ const trueLabel = spec["trueLabel"];
149
+ return trueLabel === undefined ? "✓" : (translate?.(trueLabel) ?? trueLabel);
150
+ }
151
+ if (value === false) {
152
+ const falseLabel = spec["falseLabel"];
153
+ return falseLabel === undefined ? "" : (translate?.(falseLabel) ?? falseLabel);
154
+ }
155
+ return "";
156
+ }
157
+ case "currency": {
158
+ const sym = spec["symbol"] ?? "";
159
+ return sym.length > 0 ? `${value} ${sym}` : String(value);
160
+ }
161
+ case "number":
162
+ case "decimal":
163
+ case "bigInt":
164
+ return formatNumberCell(value, spec["locale"], spec["grouping"] ?? true);
165
+ case "unit":
166
+ return formatUnitCell(value, spec["unit"], spec["locale"], spec["unitDisplay"] ?? "short");
167
+ case "priority": {
168
+ const emptyLabel = spec["emptyLabel"] ?? "—";
169
+ const prefix = spec["prefix"] ?? "";
170
+ if (isEmpty || value === 0)
171
+ return emptyLabel;
172
+ return `${prefix}${value}`;
173
+ }
174
+ case "enumOption": {
175
+ const raw = typeof value === "string" ? value : String(value);
176
+ const keyPrefix = spec["keyPrefix"];
177
+ if (keyPrefix === undefined || translate === undefined)
178
+ return raw;
179
+ const key = `${keyPrefix}${raw}`;
180
+ const translated = translate(key);
181
+ return translated === key ? raw : translated;
182
+ }
183
+ case "json":
184
+ return JSON.stringify(value, null, spec["indent"] ?? 2);
185
+ default:
186
+ if (typeof process !== "undefined" && process.env.NODE_ENV !== "production") {
187
+ // biome-ignore lint/suspicious/noConsole: dev-only warning
188
+ console.warn(`[kumiko] applyFormatSpec: unknown format key "${spec.format}" — registered via FieldFormatRegistry module augmentation?`);
189
+ }
190
+ return typeof value === "string" ? value : String(value);
191
+ }
192
+ }
@@ -0,0 +1 @@
1
+ export declare function currencyDecimals(code: string): number;
@@ -3,9 +3,10 @@
3
3
  // MoneyInput (minor-unit display factor) and RenderField's minor/major-unit
4
4
  // conversion (kumiko-framework#1923) — a single source keeps both sides
5
5
  // scaling the same currency the same way.
6
- export function currencyDecimals(code: string): number {
7
- if (code === "JPY" || code === "KRW" || code === "VND" || code === "ISK") return 0;
8
- if (code === "BHD" || code === "JOD" || code === "KWD" || code === "OMR" || code === "TND")
9
- return 3;
10
- return 2;
6
+ export function currencyDecimals(code) {
7
+ if (code === "JPY" || code === "KRW" || code === "VND" || code === "ISK")
8
+ return 0;
9
+ if (code === "BHD" || code === "JOD" || code === "KWD" || code === "OMR" || code === "TND")
10
+ return 3;
11
+ return 2;
11
12
  }
@@ -0,0 +1,14 @@
1
+ export type { ParsedRefTarget } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ export { parseRefTarget } from "@cosmicdrift/kumiko-framework/ui-types";
3
+ export type { AssetResolution, AssetResolveContext, AssetResolver, BadgeProps, ButtonProps, CardProps, DatePickerProps, IconProps, LocaleResolver, ModalProps, NumberInputProps, PrimitiveCommonProps, PrimitivesContract, SelectOption, SelectProps, TextInputProps, ToastIntent, ToastProps, ToggleProps, } from "./contracts/index.js";
4
+ export type { BatchResult, Command, Dispatcher, DispatcherError, DispatcherStatus, FieldIssue, PendingFile, PendingWrite, QueryOpts, QueryResult, StreamOpts, WriteOpts, WriteResult, } from "./dispatcher/index.js";
5
+ export { StreamFrame, type StreamFrameEvent } from "./dispatcher/index.js";
6
+ export type { FieldConditionPredicate, FieldConditions, FieldConditionValue, FieldState, FormController, FormControllerOptions, FormSnapshot, FormValues, SubmitConfig, SubmitPayloadMode, SubmitResult, } from "./form/index.js";
7
+ export { createFormController, I18N_KEY_PARAM } from "./form/index.js";
8
+ export { applyFormatSpec, currencyDecimals, escapeHtml, escapeHtmlAttr, escapeXml, type HtmlValue, html, isSafeHref, RawHtml, raw, stripControlChars, toInstant, } from "./format/index.js";
9
+ export type { NavDefinition, NavNode, NavRegistrySlice, NavTree, ResolveNavigationOptions, } from "./nav/index.js";
10
+ export { resolveNavigation } from "./nav/index.js";
11
+ export type { Store, WritableStore } from "./store/index.js";
12
+ export { createStore, shallowEqual } from "./store/index.js";
13
+ export type { ComputeEditViewModelInput, ComputeListViewModelInput, DerivedCellRoundingTarget, EditExtensionSectionViewModel, EditFieldSpec, EditFieldsSectionViewModel, EditFieldViewModel, EditRelatedListSectionViewModel, EditSectionSpec, EditSectionViewModel, EditViewModel, EditWriteFormSectionViewModel, EmbeddedDerivedOp, EmbeddedListCellViewModel, EmbeddedListIssueGroups, FieldConditionCtx, FieldRenderer, ListColumnSpec, ListColumnViewModel, ListRowViewModel, ListViewModel, RuntimeRenderer, ScreenSlots, Translate, } from "./view-model/index.js";
14
+ export { computeDerivedCellValue, computeEditViewModel, computeListViewModel, embeddedCellLabelKey, embeddedCellOptionLabelKey, fieldLabelKey, fieldOptionLabelKey, fieldOptionLabelKeyPrefix, groupEmbeddedListIssues, roundDerivedCellValue, sumEmbeddedListColumn, } from "./view-model/index.js";
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
1
+ // Re-Export aus framework/ui-types damit Renderer-Code denselben
2
+ // Parser nutzt wie der Server-Boot-Validator (Cross-Feature-Refs).
3
+ export { parseRefTarget } from "@cosmicdrift/kumiko-framework/ui-types";
4
+ export { StreamFrame } from "./dispatcher/index.js";
5
+ export { createFormController, I18N_KEY_PARAM } from "./form/index.js";
6
+ export { applyFormatSpec, currencyDecimals, escapeHtml, escapeHtmlAttr, escapeXml, html, isSafeHref, RawHtml, raw, stripControlChars, toInstant, } from "./format/index.js";
7
+ export { resolveNavigation } from "./nav/index.js";
8
+ export { createStore, shallowEqual } from "./store/index.js";
9
+ export { computeDerivedCellValue, computeEditViewModel, computeListViewModel, embeddedCellLabelKey, embeddedCellOptionLabelKey, fieldLabelKey, fieldOptionLabelKey, fieldOptionLabelKeyPrefix, groupEmbeddedListIssues, roundDerivedCellValue, sumEmbeddedListColumn, } from "./view-model/index.js";
@@ -0,0 +1,39 @@
1
+ import type { LocaleResolver } from "../contracts/index.js";
2
+ export type LocaleRouterConfig<TPage extends string> = {
3
+ /** Canonical default, usually "de". No URL prefix. */
4
+ readonly defaultLocale: string;
5
+ /** Locales that get a URL prefix, e.g. ["en"] → /en/... */
6
+ readonly prefixedLocales: readonly string[];
7
+ /** Prefix segment per locale, default: locale code ("en" → "/en"). */
8
+ readonly prefixFor?: (locale: string) => string;
9
+ /** Logical page → path per locale. Every page must define defaultLocale path. */
10
+ readonly routes: Record<TPage, Record<string, string>>;
11
+ /** Legacy slug-only paths for detectLang/resolvePage, e.g. { en: ["/features"] }. */
12
+ readonly localeHints?: Readonly<Record<string, readonly string[]>>;
13
+ /** Fallback page when altLocalePath cannot resolve pathname. Default: "home". */
14
+ readonly homePage?: TPage;
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, targetLocale?: string): string;
21
+ sectionAnchor(page: TPage, locale: string, fragment: string): string;
22
+ };
23
+ export declare function createLocaleRouter<TPage extends string>(config: LocaleRouterConfig<TPage>): LocaleRouter<TPage>;
24
+ export type WrapUrlLocaleResolverOptions = {
25
+ resolvePage: (pathname: string) => string | undefined;
26
+ detectLang: (pathname: string) => string;
27
+ pathname?: () => string | undefined;
28
+ /** Optional pathname-change subscription (browser default: popstate). */
29
+ subscribePathname?: (listener: () => void) => () => void;
30
+ /** When set, translate against the URL-resolved locale instead of base.translate
31
+ * (needed for fixed-T / cloneInstance resolvers that are not locale-live). */
32
+ translateFor?: (locale: string, key: string, params?: Readonly<Record<string, unknown>>) => string;
33
+ };
34
+ /**
35
+ * Prefer URL-derived locale when `resolvePage` matches the current path;
36
+ * otherwise defer to `base.locale()`. Passes through setLocale and the rest of LocaleResolver.
37
+ * Without `translateFor`, `base.translate` must already be locale-live.
38
+ */
39
+ export declare function wrapUrlLocaleResolver(base: LocaleResolver, options: WrapUrlLocaleResolverOptions): LocaleResolver;