@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.
|
|
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.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
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
|
package/src/app/create-app.tsx
CHANGED
|
@@ -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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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.
|