@cosmicdrift/kumiko-bundled-features 0.3.0 → 0.4.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/package.json +7 -5
  3. package/src/delivery/__tests__/delivery.integration.ts +6 -0
  4. package/src/delivery/delivery-service.ts +4 -12
  5. package/src/delivery/feature.ts +6 -4
  6. package/src/delivery/index.ts +0 -1
  7. package/src/legal-pages/web/client-plugin.ts +50 -10
  8. package/src/renderer-foundation/README.md +86 -0
  9. package/src/renderer-foundation/__tests__/api.test.ts +188 -0
  10. package/src/renderer-foundation/__tests__/collect-plugins.integration.ts +101 -0
  11. package/src/renderer-foundation/api.ts +106 -0
  12. package/src/renderer-foundation/constants.ts +21 -0
  13. package/src/renderer-foundation/feature.ts +47 -0
  14. package/src/renderer-foundation/index.ts +25 -0
  15. package/src/renderer-foundation/types.ts +109 -0
  16. package/src/renderer-simple/__tests__/adapter.test.ts +50 -0
  17. package/src/renderer-simple/feature.ts +28 -3
  18. package/src/template-resolver/README.md +89 -0
  19. package/src/template-resolver/__tests__/handlers.integration.ts +403 -0
  20. package/src/template-resolver/__tests__/template-resolver.integration.ts +570 -0
  21. package/src/template-resolver/api.ts +189 -0
  22. package/src/template-resolver/constants.ts +28 -0
  23. package/src/template-resolver/feature.ts +36 -0
  24. package/src/template-resolver/handlers/archive.write.ts +42 -0
  25. package/src/template-resolver/handlers/find-by-id.query.ts +45 -0
  26. package/src/template-resolver/handlers/list.query.ts +69 -0
  27. package/src/template-resolver/handlers/publish.write.ts +45 -0
  28. package/src/template-resolver/handlers/shared.ts +41 -0
  29. package/src/template-resolver/handlers/upsert-system.write.ts +75 -0
  30. package/src/template-resolver/handlers/upsert-tenant.write.ts +98 -0
  31. package/src/template-resolver/index.ts +28 -0
  32. package/src/template-resolver/qualified-names.ts +24 -0
  33. package/src/template-resolver/table.ts +67 -0
  34. package/src/text-content/__tests__/text-content.integration.ts +54 -0
  35. package/src/text-content/handlers/by-slug.query.ts +1 -0
  36. package/src/text-content/handlers/by-tenant.query.ts +2 -0
  37. package/src/text-content/handlers/set.write.ts +23 -0
  38. package/src/text-content/seeding.ts +9 -1
  39. package/src/text-content/table.ts +6 -0
  40. package/src/text-content/web/__tests__/editor-read-only.test.tsx +125 -0
  41. package/src/text-content/web/__tests__/group-blocks.test.ts +221 -0
  42. package/src/text-content/web/client-plugin.tsx +378 -0
  43. package/src/text-content/web/client-plugin.ts +0 -113
@@ -0,0 +1,378 @@
1
+ // @runtime client
2
+ // Client-Feature-Factory für text-content Visual-Tree. Wird vom App-Code
3
+ // in createKumikoApp({ clientFeatures: [textContentClient()] }) eingehängt
4
+ // und liefert den treeProvider der Text-Blocks aus der by-tenant Query
5
+ // lädt, nach `folder`-Field gruppiert und als TreeNode[] emitted.
6
+ //
7
+ // **Folder-Gruppierung V.1.4**: Block.folder !== null → Knoten landet
8
+ // unter einem Container-Knoten mit Label folder. folder === null →
9
+ // Top-Level (root-node). Slug bleibt kebab-only validiert. Beispiele:
10
+ // - folder="page", slug="hero" → Folder "page", child "hero"
11
+ // - folder=null, slug="imprint" → root-node "imprint"
12
+ //
13
+ // **State**: TreeNode.state = "filled" wenn body gesetzt ist, sonst
14
+ // "stub" (hellgrau, Designer-Hinweis dass Slug existiert aber leer ist).
15
+ //
16
+ // **Fetch statt Subscribe**: V.1.4 ist Fetch-once beim Mount. Unsubscribe
17
+ // ist no-op. V.1.5+ kann SSE-driven Re-Emit einbauen wenn text-block-
18
+ // updated-Events propagiert werden (Stale-Tree-Fix nach save).
19
+
20
+ import { useShellUser } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/web";
21
+ import { CSRF_HEADER_NAME, readCsrfToken } from "@cosmicdrift/kumiko-dispatcher-live";
22
+ import type {
23
+ TargetRef,
24
+ TreeChildrenSubscribe,
25
+ TreeNode,
26
+ } from "@cosmicdrift/kumiko-framework/engine";
27
+ import { useDispatcher, usePrimitives, useQuery } from "@cosmicdrift/kumiko-renderer";
28
+ import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
29
+ import { type FormEvent, type ReactNode, useEffect, useState } from "react";
30
+ import { TextContentHandlers, TextContentQueries } from "../constants";
31
+
32
+ // Exported für Unit-Test (groupBlocksByFolder ist pure-function ohne
33
+ // fetch/DOM). Public-API für externe Konsumenten ist nicht intendiert —
34
+ // sub-path-Export endet bei textContentClient().
35
+ export type BlockSummary = {
36
+ readonly slug: string;
37
+ readonly lang: string;
38
+ readonly title: string;
39
+ readonly body: string | null;
40
+ readonly folder: string | null;
41
+ readonly updatedAt: string;
42
+ };
43
+
44
+ type ByTenantResponse = {
45
+ readonly data: { readonly blocks: readonly BlockSummary[] };
46
+ };
47
+
48
+ // V.1.6a Multi-level Folder-Splitting: `folder` ist ein `/`-separierter
49
+ // Pfad (z.B. "page/marketing"). Der Tree splittet pro Segment und
50
+ // erzeugt nested Folder-Knoten:
51
+ // block.folder = null → root-leaf
52
+ // block.folder = "page" → 📁 page > leaf
53
+ // block.folder = "page/marketing" → 📁 page > 📁 marketing > leaf
54
+ //
55
+ // Walk-or-create-pattern: pro Block durch die Folder-Hierarchie laufen,
56
+ // Folder-Knoten anlegen wo nötig, Leaf am Ende anhängen. Folders pro
57
+ // Ebene alphabetisch sortiert (deterministisch).
58
+ //
59
+ // **Folder/Leaf-Collision** (advisor-Edge-Case): wenn ein Block
60
+ // `folder=null, slug="page"` UND ein anderer `folder="page", slug=...`
61
+ // existieren, leben beide an derselben Ebene — der Folder mit children
62
+ // + ein klickbarer Leaf "page" als root. Visual unterscheidbar (Folder
63
+ // hat Chevron + Folder-Icon, Leaf nicht). Kein Crash, keine Surprise.
64
+ //
65
+ // **V.1.5d Wrapper-Folder "Content"**: alle text-content-Blocks landen
66
+ // unter einem expliziten Top-Level-Folder. Empty-Tree → kein Wrapper.
67
+
68
+ type FolderNode = {
69
+ readonly leaves: TreeNode[];
70
+ readonly subFolders: Map<string, FolderNode>;
71
+ };
72
+
73
+ function newFolderNode(): FolderNode {
74
+ return { leaves: [], subFolders: new Map() };
75
+ }
76
+
77
+ function attachBlock(root: FolderNode, block: BlockSummary): void {
78
+ const leaf: TreeNode = {
79
+ label: block.title || block.slug,
80
+ target: {
81
+ featureId: "text-content",
82
+ action: "edit",
83
+ args: { slug: block.slug, lang: block.lang },
84
+ },
85
+ state: block.body ? "filled" : "stub",
86
+ };
87
+ if (block.folder === null) {
88
+ root.leaves.push(leaf);
89
+ return;
90
+ }
91
+ let cur = root;
92
+ for (const segment of block.folder.split("/")) {
93
+ let next = cur.subFolders.get(segment);
94
+ if (next === undefined) {
95
+ next = newFolderNode();
96
+ cur.subFolders.set(segment, next);
97
+ }
98
+ cur = next;
99
+ }
100
+ cur.leaves.push(leaf);
101
+ }
102
+
103
+ function renderFolderNode(node: FolderNode): TreeNode[] {
104
+ // Leaves first (root-items), dann Folders alphabetisch.
105
+ const result: TreeNode[] = [...node.leaves];
106
+ for (const folderName of [...node.subFolders.keys()].sort()) {
107
+ const subNode = node.subFolders.get(folderName);
108
+ if (subNode === undefined) continue;
109
+ result.push({
110
+ label: folderName,
111
+ icon: "folder",
112
+ state: "filled",
113
+ children: renderFolderNode(subNode),
114
+ });
115
+ }
116
+ return result;
117
+ }
118
+
119
+ export function groupBlocksByFolder(blocks: readonly BlockSummary[]): readonly TreeNode[] {
120
+ const root = newFolderNode();
121
+ for (const block of blocks) {
122
+ attachBlock(root, block);
123
+ }
124
+ const rendered = renderFolderNode(root);
125
+ if (rendered.length === 0) return [];
126
+ return [
127
+ {
128
+ label: "Content",
129
+ icon: "folder",
130
+ state: "filled",
131
+ children: rendered,
132
+ },
133
+ ];
134
+ }
135
+
136
+ const treeProvider: TreeChildrenSubscribe = () => (emit, emitError) => {
137
+ // CSRF-Header bei authenticated requests pflicht (auth-middleware
138
+ // double-submit pattern). Anonymous/Pre-Login wäre csrf-token=undefined
139
+ // → header weggelassen → server lässt die anonymous-Variante durch.
140
+ const headers: Record<string, string> = { "content-type": "application/json" };
141
+ const csrf = readCsrfToken();
142
+ if (csrf !== undefined) headers[CSRF_HEADER_NAME] = csrf;
143
+ fetch("/api/query", {
144
+ method: "POST",
145
+ headers,
146
+ body: JSON.stringify({
147
+ type: TextContentQueries.byTenant,
148
+ payload: {},
149
+ }),
150
+ })
151
+ .then(async (r) => {
152
+ if (!r.ok) {
153
+ // 403 (CSRF/auth) oder 5xx — explicit error statt silent empty.
154
+ const text = await r.text().catch(() => r.statusText);
155
+ throw new Error(`text-content load failed: ${r.status} ${text}`);
156
+ }
157
+ return r.json();
158
+ })
159
+ .then((data: ByTenantResponse) => {
160
+ const nodes = groupBlocksByFolder(data.data.blocks);
161
+ emit(nodes);
162
+ })
163
+ .catch((e) => {
164
+ // V.1.4: explicit error-Signal via emitError. ProviderBranch zeigt
165
+ // Banner + Retry-Button. Fallback auf emit([]) wenn der Consumer
166
+ // kein emitError unterstützt (Tests etc.).
167
+ if (emitError) {
168
+ emitError(e instanceof Error ? e : new Error(String(e)));
169
+ } else {
170
+ emit([]);
171
+ }
172
+ });
173
+ return () => {};
174
+ };
175
+
176
+ // V.1.3 echte Edit-Form: lädt aktuelle Werte via by-slug-query, lässt
177
+ // TenantAdmin/SystemAdmin title + body editieren, dispatcht set-write
178
+ // bei Submit. Non-Admin-User sehen die Form read-only mit Hint-Banner —
179
+ // das ist der Kumiko-Weg (Memory `[Sicherheit > Convenience]`: write-
180
+ // permission bleibt opinionated TenantAdmin-only, App-Roles erweitern
181
+ // per Dual-Role-Mapping wenn gewollt).
182
+ //
183
+ // **Stale-Tree-Caveat (V.1.4-Followup)**: TreeProvider ist fetch-once.
184
+ // Nach erfolgreichem Save flippt der visual state="stub"→"filled" in
185
+ // der Sidebar NICHT, bis der User den Workspace re-mountet. Editor selbst
186
+ // ist konsistent (lokaler Form-State trägt die neuen Werte). Echte
187
+ // Lösung: SSE-driven Tree-Refresh oder explicit cache-bust nach set-write.
188
+
189
+ type TextBlock = {
190
+ readonly slug: string;
191
+ readonly lang: string;
192
+ readonly title: string;
193
+ readonly body: string | null;
194
+ readonly folder: string | null;
195
+ readonly updatedAt: string;
196
+ };
197
+
198
+ type SetResponse = { readonly slug: string; readonly lang: string; readonly isNew: boolean };
199
+
200
+ function TextContentEditor({
201
+ target,
202
+ onClose,
203
+ }: {
204
+ readonly target: TargetRef;
205
+ readonly onClose: () => void;
206
+ }): ReactNode {
207
+ // @cast-boundary visual-tree-args — TargetRef.args ist erased zu
208
+ // Record<string, unknown>; der Resolver kennt das Action-Shape (siehe
209
+ // treeActions.edit-Definition unten) und de-erased pro Action analog
210
+ // zu Event-Payloads. Optional-Chain absorbiert fehlende Felder ohne
211
+ // throw, damit der Editor auch bei manuellem URL-Tampering nicht
212
+ // crasht (TargetRef könnte aus old localStorage / URL-State stammen).
213
+ const args = target.args as { slug?: string; lang?: string } | undefined;
214
+ const slug = args?.slug ?? "";
215
+ const lang = args?.lang ?? "";
216
+
217
+ const { Form, Field, Input, Button, Banner } = usePrimitives();
218
+ const dispatcher = useDispatcher();
219
+ const user = useShellUser();
220
+ const canWrite =
221
+ user?.roles.includes("TenantAdmin") === true || user?.roles.includes("SystemAdmin") === true;
222
+
223
+ // Load existing block via by-slug-query. Result ist entweder TextBlock
224
+ // oder null (slug existiert nicht — create-flow). useQuery returnt
225
+ // `data: T | null`, initial-loading: data=null + loading=true.
226
+ const {
227
+ data: loaded,
228
+ loading,
229
+ error: loadError,
230
+ } = useQuery<TextBlock | null>(
231
+ TextContentQueries.bySlug,
232
+ { slug, lang },
233
+ { enabled: slug !== "" && lang !== "" },
234
+ );
235
+
236
+ // Form-State unabhängig vom geladenen Block. Sync nur initial oder
237
+ // wenn target.slug+lang wechselt (User springt zwischen Knoten).
238
+ const [title, setTitle] = useState("");
239
+ const [body, setBody] = useState("");
240
+ const [folder, setFolder] = useState("");
241
+ const [submitting, setSubmitting] = useState(false);
242
+ const [saveError, setSaveError] = useState<string | null>(null);
243
+ const [savedMsg, setSavedMsg] = useState<string | null>(null);
244
+
245
+ // Sync loaded data → form state. Trigger sobald loaded-shape eindeutig
246
+ // ist (data ≠ undefined). loaded === null heißt "Block existiert noch
247
+ // nicht" — leere Form für create-flow.
248
+ useEffect(() => {
249
+ if (loading) return;
250
+ setTitle(loaded?.title ?? "");
251
+ setBody(loaded?.body ?? "");
252
+ setFolder(loaded?.folder ?? "");
253
+ setSaveError(null);
254
+ setSavedMsg(null);
255
+ }, [loading, loaded]);
256
+
257
+ const handleSave = async (): Promise<void> => {
258
+ setSubmitting(true);
259
+ setSaveError(null);
260
+ setSavedMsg(null);
261
+ try {
262
+ const result = await dispatcher.write<SetResponse>(TextContentHandlers.set, {
263
+ slug,
264
+ lang,
265
+ title,
266
+ body: body.length > 0 ? body : null,
267
+ folder: folder.length > 0 ? folder : null,
268
+ });
269
+ if (result.isSuccess) {
270
+ setSavedMsg(result.data.isNew ? "Neu angelegt." : "Gespeichert.");
271
+ } else {
272
+ setSaveError(result.error.message ?? result.error.code ?? "Speichern fehlgeschlagen.");
273
+ }
274
+ } catch (e) {
275
+ // Network-blip / dispatcher-throw — sonst bleibt submitting=true,
276
+ // Save-Button locked-forever, User klickt repeat ohne Feedback.
277
+ // Generic message reicht: konkreter Recovery-Pfad ist Retry.
278
+ setSaveError(e instanceof Error ? e.message : "Netzwerkfehler beim Speichern.");
279
+ } finally {
280
+ setSubmitting(false);
281
+ }
282
+ };
283
+
284
+ const onSubmit = (e?: FormEvent): void => {
285
+ e?.preventDefault();
286
+ void handleSave();
287
+ };
288
+
289
+ const disabled = submitting || loading || !canWrite;
290
+
291
+ return (
292
+ <div className="flex h-full flex-col">
293
+ <header className="flex items-center justify-between border-b px-6 py-4">
294
+ <div>
295
+ <h2 className="text-lg font-semibold">Text-Block bearbeiten</h2>
296
+ <p className="text-xs text-muted-foreground">
297
+ {slug || "—"} ({lang || "—"})
298
+ </p>
299
+ </div>
300
+ <Button variant="secondary" onClick={onClose}>
301
+ schlie&szlig;en
302
+ </Button>
303
+ </header>
304
+ <div className="flex-1 overflow-y-auto px-6 py-6">
305
+ <Form onSubmit={onSubmit}>
306
+ {loading && <Banner variant="loading">Lädt aktuellen Stand…</Banner>}
307
+ {loadError !== null && (
308
+ <Banner variant="error">Konnte Block nicht laden: {loadError.code}</Banner>
309
+ )}
310
+ {!canWrite && !loading && (
311
+ <Banner variant="info">
312
+ Read-only — TenantAdmin- oder SystemAdmin-Rolle f&uuml;r &Auml;nderungen erforderlich.
313
+ </Banner>
314
+ )}
315
+ <Field id="text-content-title" label="Titel" required>
316
+ <Input
317
+ kind="text"
318
+ id="text-content-title"
319
+ name="text-content-title"
320
+ value={title}
321
+ onChange={setTitle}
322
+ disabled={disabled}
323
+ required
324
+ />
325
+ </Field>
326
+ <Field id="text-content-folder" label="Ordner (optional)">
327
+ <Input
328
+ kind="text"
329
+ id="text-content-folder"
330
+ name="text-content-folder"
331
+ value={folder}
332
+ onChange={setFolder}
333
+ disabled={disabled}
334
+ placeholder="z.B. page oder legal"
335
+ />
336
+ </Field>
337
+ <Field id="text-content-body" label="Inhalt">
338
+ <Input
339
+ kind="textarea"
340
+ id="text-content-body"
341
+ name="text-content-body"
342
+ value={body}
343
+ onChange={setBody}
344
+ disabled={disabled}
345
+ rows={14}
346
+ />
347
+ </Field>
348
+ {saveError !== null && <Banner variant="error">{saveError}</Banner>}
349
+ {savedMsg !== null && <Banner variant="info">{savedMsg}</Banner>}
350
+ {canWrite && (
351
+ <Button type="submit" loading={submitting} disabled={disabled}>
352
+ {submitting ? "Speichern…" : "Speichern"}
353
+ </Button>
354
+ )}
355
+ </Form>
356
+ </div>
357
+ </div>
358
+ );
359
+ }
360
+
361
+ export function textContentClient(): ClientFeatureDefinition {
362
+ return {
363
+ name: "text-content",
364
+ treeProvider,
365
+ // V.1.5b: SSE-driven Tree-Refresh. Bei jedem text-block-Event
366
+ // (created/updated/deleted) ruft ProviderBranch den treeProvider
367
+ // neu auf → Tree-State spiegelt save sofort wider (Stale-Tree-Fix).
368
+ treeEntities: ["text-block"],
369
+ treeActions: {
370
+ edit: { args: { slug: "" as string, lang: "" as string } },
371
+ list: {},
372
+ create: { args: { folder: "" as string } },
373
+ },
374
+ resolvers: {
375
+ "text-content:edit": TextContentEditor,
376
+ },
377
+ };
378
+ }
@@ -1,113 +0,0 @@
1
- // @runtime client
2
- // Client-Feature-Factory für text-content Visual-Tree. Wird vom App-Code
3
- // in createKumikoApp({ clientFeatures: [textContentClient()] }) eingehängt
4
- // und liefert den treeProvider der Text-Blocks aus der by-tenant Query
5
- // lädt, nach Slug-Prefix gruppiert und als TreeNode[] emitted.
6
- //
7
- // **Slug-Gruppierung**: Slugs der Form `<prefix>:<rest>` oder `<prefix>/<rest>`
8
- // werden unter einem `<prefix>`-Container-Knoten gruppiert. Slugs ohne
9
- // Trenner landen als Top-Level-Knoten. Beispiele:
10
- // - "page:index:hero.title" → folder "page", label "index:hero.title"
11
- // - "imprint" → root-node, label "imprint"
12
- // V.1.3+ kann mehrstufige Hierarchien einführen wenn realer Bedarf zeigt.
13
- //
14
- // **State**: TreeNode.state = "filled" wenn body gesetzt ist, sonst
15
- // "stub" (hellgrau, Designer-Hinweis dass Slug existiert aber leer ist).
16
- //
17
- // **Fetch statt Subscribe**: V.1.1 ist Fetch-once beim Mount. Unsubscribe
18
- // ist no-op. V.1.3+ kann SSE-driven Re-Emit einbauen wenn text-block-
19
- // updated-Events propagiert werden.
20
-
21
- import type { TreeChildrenSubscribe, TreeNode } from "@cosmicdrift/kumiko-framework/engine";
22
- import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
23
- import { TextContentQueries } from "../constants";
24
-
25
- type BlockSummary = {
26
- readonly slug: string;
27
- readonly lang: string;
28
- readonly title: string;
29
- readonly body: string | null;
30
- readonly updatedAt: string;
31
- };
32
-
33
- type ByTenantResponse = {
34
- readonly data: { readonly blocks: readonly BlockSummary[] };
35
- };
36
-
37
- // Folder-Name = alles vor dem ersten ":" oder "/", oder undefined wenn
38
- // der Slug keinen Trenner enthält (dann landet er als Root-Node).
39
- function getFolderName(slug: string): string | undefined {
40
- const sepIdx = slug.search(/[:/]/);
41
- if (sepIdx === -1) return undefined;
42
- return slug.slice(0, sepIdx);
43
- }
44
-
45
- function groupBlocksBySlugPrefix(blocks: readonly BlockSummary[]): readonly TreeNode[] {
46
- const rootNodes: TreeNode[] = [];
47
- const folders = new Map<string, TreeNode[]>();
48
-
49
- for (const block of blocks) {
50
- const node: TreeNode = {
51
- label: block.title || block.slug,
52
- target: {
53
- featureId: "text-content",
54
- action: "edit",
55
- args: { slug: block.slug, lang: block.lang },
56
- },
57
- state: block.body ? "filled" : "stub",
58
- };
59
-
60
- const folderName = getFolderName(block.slug);
61
- if (folderName === undefined) {
62
- rootNodes.push(node);
63
- } else {
64
- const existing = folders.get(folderName) ?? [];
65
- existing.push(node);
66
- folders.set(folderName, existing);
67
- }
68
- }
69
-
70
- for (const [name, children] of folders) {
71
- rootNodes.push({
72
- label: name,
73
- icon: "folder",
74
- state: "filled",
75
- children,
76
- });
77
- }
78
-
79
- return rootNodes;
80
- }
81
-
82
- const treeProvider: TreeChildrenSubscribe = (_ctx) => (emit) => {
83
- fetch("/api/query", {
84
- method: "POST",
85
- headers: { "content-type": "application/json" },
86
- body: JSON.stringify({
87
- type: TextContentQueries.byTenant,
88
- payload: {},
89
- }),
90
- })
91
- .then((r) => r.json())
92
- .then((data: ByTenantResponse) => {
93
- const nodes = groupBlocksBySlugPrefix(data.data.blocks);
94
- emit(nodes);
95
- })
96
- .catch(() => {
97
- // V.1.3+ TODO: state="error"-Knoten + Reload-Action statt empty.
98
- emit([]);
99
- });
100
- return () => {};
101
- };
102
-
103
- export function textContentClient(): ClientFeatureDefinition {
104
- return {
105
- name: "text-content",
106
- treeProvider,
107
- treeActions: {
108
- edit: { args: { slug: "" as string, lang: "" as string } },
109
- list: {},
110
- create: { args: { folder: "" as string } },
111
- },
112
- };
113
- }