@cosmicdrift/kumiko-headless 0.105.2 → 0.108.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.105.2",
3
+ "version": "0.108.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.105.2",
35
+ "@cosmicdrift/kumiko-framework": "0.108.0",
36
36
  "zod": "^4.4.3"
37
37
  },
38
38
  "publishConfig": {
package/src/apex/index.ts CHANGED
@@ -24,16 +24,27 @@ export type ApexBrand = {
24
24
  export type ApexCtaVariant = "primary" | "secondary" | "link";
25
25
  export type ApexCta = {
26
26
  readonly label: string;
27
+ /** App-authored only, no user input — escapeHtml encodes entities but does
28
+ * NOT sanitize the URL scheme (a `javascript:`/`data:` URI survives it
29
+ * unchanged). Trust boundary is the deploy-time apex config, not runtime. */
27
30
  readonly href: string;
28
31
  /** "link" renders a plain anchor (no .btn). Default "primary". */
29
32
  readonly variant?: ApexCtaVariant;
30
33
  };
31
34
 
32
- export type ApexLink = { readonly label: string; readonly href: string };
35
+ // `kind` is optional here (footer links / ApexNavMenu.footer don't need it) —
36
+ // it exists so ApexNavEntry (ApexLink | ApexNavMenu) is a real discriminated
37
+ // union: TS narrows on `entry.kind === "menu"` without a structural
38
+ // `"items" in entry` check or an `as` cast.
39
+ export type ApexLink = { readonly kind?: "link"; readonly label: string; readonly href: string };
33
40
 
34
41
  /** One entry inside a dropdown nav menu: icon + title + optional description. */
35
42
  export type ApexNavMenuItem = {
36
- /** Inner SVG markup (paths), wrapped by the standard 24px icon <svg>. Trusted. */
43
+ /** Inner SVG markup (paths), wrapped by the standard 24px icon <svg>.
44
+ * ponytail: rendered verbatim, no sanitizer — trusted because it's
45
+ * app-authored at deploy time, same trust boundary as ApexCta.href. If an
46
+ * app ever sources this from tenant/user content, sanitize at that
47
+ * construction boundary before it reaches here. */
37
48
  readonly icon?: string;
38
49
  readonly title: string;
39
50
  readonly desc?: string;
@@ -88,7 +99,11 @@ export type ApexHeroSection = {
88
99
  };
89
100
 
90
101
  export type ApexFeature = {
91
- /** Inner SVG markup (paths), wrapped by the standard 24px icon <svg>. Trusted. */
102
+ /** Inner SVG markup (paths), wrapped by the standard 24px icon <svg>.
103
+ * ponytail: rendered verbatim, no sanitizer — trusted because it's
104
+ * app-authored at deploy time, same trust boundary as ApexCta.href. If an
105
+ * app ever sources this from tenant/user content, sanitize at that
106
+ * construction boundary before it reaches here. */
92
107
  readonly icon?: string;
93
108
  readonly title: string;
94
109
  readonly desc: string;
@@ -373,7 +388,7 @@ function renderNavMenu(m: ApexNavMenu): string {
373
388
  }
374
389
 
375
390
  function renderNavEntry(entry: ApexNavEntry): string {
376
- return "items" in entry
391
+ return entry.kind === "menu"
377
392
  ? renderNavMenu(entry)
378
393
  : `<a href="${escapeHtml(entry.href)}">${escapeHtml(entry.label)}</a>`;
379
394
  }
@@ -140,6 +140,21 @@ describe("computeListViewModel", () => {
140
140
  });
141
141
  });
142
142
 
143
+ test("labeled column with no matching field AND no renderer → throws (label alone isn't enough)", () => {
144
+ // Regression (697/1): renderer is what actually draws a virtual column —
145
+ // a label with no renderer would otherwise push an empty, unrendered
146
+ // column into the view model instead of catching the author typo.
147
+ expect(() =>
148
+ computeListViewModel({
149
+ screen: listScreen(["title", { field: "tags", label: "Tags" }]),
150
+ entity: taskEntity,
151
+ rows: [],
152
+ translate,
153
+ featureName: "tasks",
154
+ }),
155
+ ).toThrow(/unknown field "tags"/);
156
+ });
157
+
143
158
  test("label overrides the field-convention header on a real field", () => {
144
159
  const vm = computeListViewModel({
145
160
  screen: listScreen([{ field: "title", label: "custom.header" }]),
@@ -42,19 +42,20 @@ export function computeListViewModel(input: ComputeListViewModelInput): ListView
42
42
  // columns carry no reference/select metadata and never server-sort.
43
43
  const derivedDef = entity.derivedFields?.[normalized.field];
44
44
  if (!derivedDef) {
45
- // A labeled column with no matching stored/derived field is a *virtual*
46
- // presentational column drawn entirely by a columnRenderer component
47
- // from the row (e.g. tag chips); value is undefined and it never
48
- // server-sorts. `field` is just the column key. Without a label it's an
49
- // author typo (or a stale field-rename) fail loud so the renderer
50
- // doesn't silently draw an empty column.
51
- if (normalized.label !== undefined) {
45
+ // A virtual presentational column drawn entirely by a columnRenderer
46
+ // component from the row (e.g. tag chips); value is undefined and it
47
+ // never server-sorts. `field` is just the column key. It needs BOTH a
48
+ // label AND a renderer renderer is what actually draws it; a label
49
+ // alone with no renderer would push an empty, unrendered column into
50
+ // the view model. Missing either is an author typo (or a stale
51
+ // field-rename) fail loud instead of silently drawing nothing.
52
+ if (normalized.label !== undefined && normalized.renderer !== undefined) {
52
53
  columns.push({
53
54
  field: normalized.field,
54
55
  label: translate(normalized.label),
55
56
  type: "text",
56
57
  sortable: false,
57
- ...(normalized.renderer !== undefined && { renderer: normalized.renderer }),
58
+ renderer: normalized.renderer,
58
59
  });
59
60
  continue;
60
61
  }