@cosmicdrift/kumiko-renderer-web 0.176.2 → 0.178.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-renderer-web",
3
- "version": "0.176.2",
3
+ "version": "0.178.0",
4
4
  "description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -16,9 +16,9 @@
16
16
  "./styles.css": "./src/styles.css"
17
17
  },
18
18
  "dependencies": {
19
- "@cosmicdrift/kumiko-dispatcher-live": "0.176.2",
20
- "@cosmicdrift/kumiko-headless": "0.176.2",
21
- "@cosmicdrift/kumiko-renderer": "0.176.2",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.178.0",
20
+ "@cosmicdrift/kumiko-headless": "0.178.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.178.0",
22
22
  "@radix-ui/react-dialog": "^1.1.15",
23
23
  "@radix-ui/react-dropdown-menu": "^2.1.16",
24
24
  "@radix-ui/react-label": "^2.1.8",
@@ -0,0 +1,102 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+ import type { TreeChildrenSubscribe } from "@cosmicdrift/kumiko-framework/engine";
3
+ import type { QualifiedContentCollection } from "@cosmicdrift/kumiko-renderer";
4
+ import type { ClientFeatureDefinition } from "../client-plugin";
5
+ import { buildNavProviderMaps } from "../create-app";
6
+
7
+ // Two ways a nav node gets its children: the app wires a navProvider by hand,
8
+ // or a feature derives one per r.contentCollection() from the schema. This
9
+ // pins how they compose — the derived one is the weaker claim.
10
+
11
+ const provider = (): TreeChildrenSubscribe => () => () => () => {};
12
+
13
+ function collection(id: string, kind: string): QualifiedContentCollection {
14
+ return { id, kind, nav: { label: `mail:nav.${id}` }, navQn: `mail:nav:${id}` };
15
+ }
16
+
17
+ describe("buildNavProviderMaps", () => {
18
+ test("qualifiziert lokale nav-ids, lässt fertige QNs durch", () => {
19
+ const feature: ClientFeatureDefinition = {
20
+ name: "cms",
21
+ navProviders: { content: provider(), "app:nav:legal": provider() },
22
+ navEntities: { content: ["text-block"] },
23
+ };
24
+
25
+ const { navProviders, navEntities } = buildNavProviderMaps([feature], []);
26
+ expect([...navProviders.keys()].sort()).toEqual(["app:nav:legal", "cms:nav:content"]);
27
+ expect(navEntities.get("cms:nav:content")).toEqual(["text-block"]);
28
+ });
29
+
30
+ test("Collections werden unter ihrer Schema-QN registriert, mit SSE-Entities", () => {
31
+ const feature: ClientFeatureDefinition = {
32
+ name: "template-resolver",
33
+ navProvidersFromCollections: (collections) => ({
34
+ providers: Object.fromEntries(collections.map((c) => [c.navQn, provider()])),
35
+ entities: Object.fromEntries(collections.map((c) => [c.navQn, ["template-resource"]])),
36
+ }),
37
+ };
38
+
39
+ const { navProviders, navEntities } = buildNavProviderMaps(
40
+ [feature],
41
+ [collection("templates", "mail-html"), collection("prompts", "ai-prompt")],
42
+ );
43
+ expect([...navProviders.keys()].sort()).toEqual(["mail:nav:prompts", "mail:nav:templates"]);
44
+ expect(navEntities.get("mail:nav:prompts")).toEqual(["template-resource"]);
45
+ });
46
+
47
+ test("ein expliziter navProvider gewinnt gegen den abgeleiteten — ohne Konflikt-Warnung", () => {
48
+ const explicitProvider = provider();
49
+ const derivedFeature: ClientFeatureDefinition = {
50
+ name: "template-resolver",
51
+ navProvidersFromCollections: () => ({
52
+ providers: { "mail:nav:templates": provider() },
53
+ }),
54
+ };
55
+ const appFeature: ClientFeatureDefinition = {
56
+ name: "app",
57
+ navProviders: { "mail:nav:templates": explicitProvider },
58
+ };
59
+
60
+ const warn = console.warn;
61
+ const warnings: unknown[] = [];
62
+ console.warn = mock((...args: unknown[]) => warnings.push(args));
63
+ try {
64
+ const { navProviders } = buildNavProviderMaps(
65
+ [derivedFeature, appFeature],
66
+ [collection("templates", "mail-html")],
67
+ );
68
+ expect(navProviders.get("mail:nav:templates")).toBe(explicitProvider);
69
+ // Overriding a derived provider is the documented escape hatch, not a
70
+ // conflict — warning here would train people to ignore the warning.
71
+ expect(warnings).toHaveLength(0);
72
+ } finally {
73
+ console.warn = warn;
74
+ }
75
+ });
76
+
77
+ test("zwei explizite Provider auf derselben QN warnen weiterhin", () => {
78
+ const features: ClientFeatureDefinition[] = [
79
+ { name: "a", navProviders: { "x:nav:content": provider() } },
80
+ { name: "b", navProviders: { "x:nav:content": provider() } },
81
+ ];
82
+
83
+ const warn = console.warn;
84
+ const warnings: unknown[] = [];
85
+ console.warn = mock((...args: unknown[]) => warnings.push(args));
86
+ try {
87
+ buildNavProviderMaps(features, []);
88
+ expect(warnings).toHaveLength(1);
89
+ } finally {
90
+ console.warn = warn;
91
+ }
92
+ });
93
+
94
+ test("Feature ohne Collections-Factory bleibt unberührt von deklarierten Collections", () => {
95
+ const feature: ClientFeatureDefinition = { name: "cms", navProviders: { content: provider() } };
96
+ const { navProviders } = buildNavProviderMaps(
97
+ [feature],
98
+ [collection("templates", "mail-html")],
99
+ );
100
+ expect([...navProviders.keys()]).toEqual(["cms:nav:content"]);
101
+ });
102
+ });
@@ -13,10 +13,20 @@ import type { TargetRef, TreeChildrenSubscribe } from "@cosmicdrift/kumiko-frame
13
13
  import type {
14
14
  ColumnRendererComponent,
15
15
  ExtensionSectionComponent,
16
+ QualifiedContentCollection,
16
17
  TranslationsByLocale,
17
18
  } from "@cosmicdrift/kumiko-renderer";
18
19
  import type { ComponentType, ReactNode } from "react";
19
20
 
21
+ /** What a `navProvidersFromCollections` factory returns: the providers plus
22
+ * the entities whose live events should re-fire them. Without `entities` a
23
+ * derived tree would sit stale until re-mount — the hand-wired path has the
24
+ * SSE refresh and the derived one must not lose it. */
25
+ export type CollectionNavProviders = {
26
+ readonly providers: Readonly<Record<string, TreeChildrenSubscribe>>;
27
+ readonly entities?: Readonly<Record<string, readonly string[]>>;
28
+ };
29
+
20
30
  export type ClientFeatureDefinition = {
21
31
  readonly name: string;
22
32
  /** Context-Provider die um den kompletten Renderer-Tree gewrapped
@@ -68,6 +78,16 @@ export type ClientFeatureDefinition = {
68
78
  * (analog `treeEntities`). Live-Event für eine Entity → Provider des
69
79
  * Knotens wird neu aufgerufen → neue Kinder erscheinen live. */
70
80
  readonly navEntities?: Readonly<Record<string, readonly string[]>>;
81
+ /** Nav providers derived from the app schema — called with every
82
+ * `r.contentCollection()` the app declares. A feature that serves any
83
+ * number of collections (template-resolver) builds one provider per
84
+ * collection this way, without the app writing navId + kind a second time
85
+ * and without the renderer having to know bundled-features. Merged with
86
+ * `navProviders`; the static map wins on key collision (explicit beats
87
+ * derived). */
88
+ readonly navProvidersFromCollections?: (
89
+ collections: readonly QualifiedContentCollection[],
90
+ ) => CollectionNavProviders;
71
91
 
72
92
  /** Editor-Resolver-Komponenten pro featureId:action-Key. Wenn ein
73
93
  * TreeNode mit target angeklickt wird, schlägt der EditorPanel das
@@ -26,6 +26,7 @@ import {
26
26
  NavProvider,
27
27
  PrimitivesProvider,
28
28
  type PrimitivesRegistry,
29
+ type QualifiedContentCollection,
29
30
  qualifyScreenId,
30
31
  TokensProvider,
31
32
  type TranslationsByLocale,
@@ -58,6 +59,55 @@ export function qualifyNavProviderKey(feature: string, id: string): string {
58
59
  return id.includes(":nav:") ? id : `${feature}:nav:${id}`;
59
60
  }
60
61
 
62
+ // Nav-Provider-Map: ein navProvider hängt dynamische Children an einen
63
+ // konkreten r.nav({provider:true})-Knoten (per QN). Lokale nav-ids werden wie
64
+ // in r.nav mit dem Feature-Namen qualifiziert; bereits qualifizierte QNs
65
+ // (cross-feature, z.B. App registriert Nav für ein bundled-feature) gehen
66
+ // unverändert durch.
67
+ //
68
+ // Schema-derived providers (r.contentCollection) run first and hold the weaker
69
+ // claim on a QN — an explicitly registered navProvider overrides them without
70
+ // comment. Only two EXPLICIT registrations on the same QN are the conflict
71
+ // that deserves the warning.
72
+ export function buildNavProviderMaps(
73
+ clientFeatures: readonly ClientFeatureDefinition[],
74
+ collections: readonly QualifiedContentCollection[],
75
+ ): {
76
+ readonly navProviders: ReadonlyMap<string, TreeChildrenSubscribe>;
77
+ readonly navEntities: ReadonlyMap<string, readonly string[]>;
78
+ } {
79
+ const navProviders = new Map<string, TreeChildrenSubscribe>();
80
+ const navEntities = new Map<string, readonly string[]>();
81
+ const derivedQns = new Set<string>();
82
+ for (const f of clientFeatures) {
83
+ if (f.navProvidersFromCollections === undefined) continue;
84
+ const derived = f.navProvidersFromCollections(collections);
85
+ for (const [qn, provider] of Object.entries(derived.providers)) {
86
+ navProviders.set(qn, provider);
87
+ derivedQns.add(qn);
88
+ }
89
+ for (const [qn, entities] of Object.entries(derived.entities ?? {})) {
90
+ if (entities.length > 0) navEntities.set(qn, entities);
91
+ }
92
+ }
93
+ for (const f of clientFeatures) {
94
+ for (const [navId, provider] of Object.entries(f.navProviders ?? {})) {
95
+ const qn = qualifyNavProviderKey(f.name, navId);
96
+ if (navProviders.has(qn) && !derivedQns.delete(qn)) {
97
+ // biome-ignore lint/suspicious/noConsole: dev-warning für Schema-Konflikte
98
+ console.warn(
99
+ `[kumiko] navProvider for "${qn}" defined by multiple clientFeatures — last wins.`,
100
+ );
101
+ }
102
+ navProviders.set(qn, provider);
103
+ }
104
+ for (const [navId, entities] of Object.entries(f.navEntities ?? {})) {
105
+ if (entities.length > 0) navEntities.set(qualifyNavProviderKey(f.name, navId), entities);
106
+ }
107
+ }
108
+ return { navProviders, navEntities };
109
+ }
110
+
61
111
  // Web-Bootstrap. Mounted den ganzen Kumiko-Render-Stack im Browser:
62
112
  // Tokens (class-based light/dark via <html>), Primitives (HTML),
63
113
  // Navigation (window.history), LiveEvents (EventSource), Dispatcher
@@ -270,28 +320,10 @@ export function createKumikoApp(options: CreateKumikoAppOptions = {}): { readonl
270
320
  }
271
321
  }
272
322
 
273
- // Nav-Provider-Map: ein navProvider hängt dynamische Children an einen
274
- // konkreten r.nav({provider:true})-Knoten (per QN). Lokale nav-ids werden
275
- // wie in r.nav mit dem Feature-Namen qualifiziert; bereits qualifizierte
276
- // QNs (cross-feature, z.B. App registriert Nav für ein bundled-feature)
277
- // gehen unverändert durch.
278
- const navProviders = new Map<string, TreeChildrenSubscribe>();
279
- const navEntities = new Map<string, readonly string[]>();
280
- for (const f of clientFeatures) {
281
- for (const [navId, provider] of Object.entries(f.navProviders ?? {})) {
282
- const qn = qualifyNavProviderKey(f.name, navId);
283
- if (navProviders.has(qn)) {
284
- // biome-ignore lint/suspicious/noConsole: dev-warning für Schema-Konflikte
285
- console.warn(
286
- `[kumiko] navProvider for "${qn}" defined by multiple clientFeatures — last wins.`,
287
- );
288
- }
289
- navProviders.set(qn, provider);
290
- }
291
- for (const [navId, entities] of Object.entries(f.navEntities ?? {})) {
292
- if (entities.length > 0) navEntities.set(qualifyNavProviderKey(f.name, navId), entities);
293
- }
294
- }
323
+ const { navProviders, navEntities } = buildNavProviderMaps(
324
+ clientFeatures,
325
+ app.features.flatMap((f) => f.contentCollections ?? []),
326
+ );
295
327
 
296
328
  // Editor-Resolver aggregieren — keyed by "featureId:action". Gleiche
297
329
  // Last-Wins-Semantik wie columnRenderers. Warnung bei Kollision.