@cosmicdrift/kumiko-bundled-features 0.174.1 → 0.176.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 (45) hide show
  1. package/package.json +8 -10
  2. package/src/__tests__/boot-seed-contract.integration.test.ts +18 -14
  3. package/src/legal-pages/README.md +26 -25
  4. package/src/legal-pages/__tests__/legal-pages.integration.test.ts +93 -61
  5. package/src/legal-pages/feature.ts +40 -31
  6. package/src/legal-pages/web/__tests__/client-plugin.test.ts +2 -2
  7. package/src/legal-pages/web/client-plugin.ts +3 -3
  8. package/src/seo/__tests__/robots-txt.test.ts +21 -0
  9. package/src/seo/__tests__/seo.integration.test.ts +13 -13
  10. package/src/seo/robots-txt.ts +21 -1
  11. package/src/template-resolver/README.md +30 -1
  12. package/src/{text-content → template-resolver}/__tests__/seed-legal-content.integration.test.ts +32 -21
  13. package/src/template-resolver/__tests__/text-blocks.integration.test.ts +563 -0
  14. package/src/template-resolver/api.ts +11 -7
  15. package/src/template-resolver/constants.ts +22 -0
  16. package/src/template-resolver/feature.ts +16 -1
  17. package/src/template-resolver/handlers/by-slug.query.ts +54 -0
  18. package/src/template-resolver/handlers/by-tenant.query.ts +57 -0
  19. package/src/template-resolver/handlers/set.write.ts +110 -0
  20. package/src/template-resolver/handlers/shared.ts +13 -2
  21. package/src/template-resolver/handlers/upsert-system.write.ts +2 -0
  22. package/src/template-resolver/handlers/upsert-tenant.write.ts +2 -0
  23. package/src/template-resolver/index.ts +7 -0
  24. package/src/template-resolver/qualified-names.ts +3 -0
  25. package/src/template-resolver/seeding.ts +126 -2
  26. package/src/template-resolver/table.ts +11 -2
  27. package/src/{text-content → template-resolver}/web/__tests__/client-plugin.test.tsx +26 -19
  28. package/src/{text-content → template-resolver}/web/__tests__/editor-read-only.test.tsx +10 -10
  29. package/src/{text-content → template-resolver}/web/__tests__/group-blocks.test.ts +34 -34
  30. package/src/template-resolver/web/client-plugin.tsx +352 -0
  31. package/src/template-resolver/web/index.ts +8 -0
  32. package/src/text-content/README.md +0 -197
  33. package/src/text-content/__tests__/text-content.integration.test.ts +0 -503
  34. package/src/text-content/api.ts +0 -86
  35. package/src/text-content/changes.json +0 -14
  36. package/src/text-content/constants.ts +0 -21
  37. package/src/text-content/feature.ts +0 -53
  38. package/src/text-content/handlers/by-slug.query.ts +0 -55
  39. package/src/text-content/handlers/by-tenant.query.ts +0 -60
  40. package/src/text-content/handlers/set.write.ts +0 -141
  41. package/src/text-content/index.ts +0 -14
  42. package/src/text-content/seeding.ts +0 -139
  43. package/src/text-content/table.ts +0 -53
  44. package/src/text-content/web/client-plugin.tsx +0 -391
  45. package/src/text-content/web/index.ts +0 -8
@@ -1,391 +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 `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, tenantIdOverride?: string): void {
78
- const leaf: TreeNode = {
79
- label: block.title || block.slug,
80
- icon: "file",
81
- target: {
82
- featureId: "text-content",
83
- action: "edit",
84
- // tenantIdOverride travels with the node → the editor's by-slug read +
85
- // set write target the same tenant the tree was loaded from (SystemAdmin
86
- // editing SYSTEM-tenant content). Omitted → session tenant.
87
- args: {
88
- slug: block.slug,
89
- lang: block.lang,
90
- ...(tenantIdOverride !== undefined && { tenantIdOverride }),
91
- },
92
- },
93
- state: block.body ? "filled" : "stub",
94
- };
95
- if (block.folder === null) {
96
- root.leaves.push(leaf);
97
- return;
98
- }
99
- let cur = root;
100
- for (const segment of block.folder.split("/")) {
101
- let next = cur.subFolders.get(segment);
102
- if (next === undefined) {
103
- next = newFolderNode();
104
- cur.subFolders.set(segment, next);
105
- }
106
- cur = next;
107
- }
108
- cur.leaves.push(leaf);
109
- }
110
-
111
- function renderFolderNode(node: FolderNode): TreeNode[] {
112
- // Leaves first (root-items), dann Folders alphabetisch.
113
- const result: TreeNode[] = [...node.leaves];
114
- for (const folderName of [...node.subFolders.keys()].sort()) {
115
- const subNode = node.subFolders.get(folderName);
116
- if (subNode === undefined) continue;
117
- result.push({
118
- label: folderName,
119
- icon: "folder",
120
- state: "filled",
121
- children: renderFolderNode(subNode),
122
- });
123
- }
124
- return result;
125
- }
126
-
127
- export function groupBlocksByFolder(
128
- blocks: readonly BlockSummary[],
129
- tenantIdOverride?: string,
130
- ): readonly TreeNode[] {
131
- const root = newFolderNode();
132
- for (const block of blocks) {
133
- attachBlock(root, block, tenantIdOverride);
134
- }
135
- const rendered = renderFolderNode(root);
136
- if (rendered.length === 0) return [];
137
- return [
138
- {
139
- label: "Content",
140
- icon: "folder",
141
- state: "filled",
142
- children: rendered,
143
- },
144
- ];
145
- }
146
-
147
- // tenantIdOverride (SystemAdmin-only) lets an app point the Content tree at a
148
- // tenant other than the session's — publicstatus seeds marketing/legal blocks
149
- // on SYSTEM_TENANT_ID, so its SystemAdmin must read that tenant, not their own.
150
- function makeTreeProvider(tenantIdOverride?: string): TreeChildrenSubscribe {
151
- return () => (emit, emitError) => {
152
- // CSRF-Header bei authenticated requests pflicht (auth-middleware
153
- // double-submit pattern). Anonymous/Pre-Login wäre csrf-token=undefined
154
- // → header weggelassen → server lässt die anonymous-Variante durch.
155
- const headers: Record<string, string> = { "content-type": "application/json" };
156
- const csrf = readCsrfToken();
157
- if (csrf !== undefined) headers[CSRF_HEADER_NAME] = csrf;
158
- fetch("/api/query", {
159
- method: "POST",
160
- headers,
161
- body: JSON.stringify({
162
- type: TextContentQueries.byTenant,
163
- payload: tenantIdOverride !== undefined ? { tenantIdOverride } : {},
164
- }),
165
- })
166
- .then(async (r) => {
167
- if (!r.ok) {
168
- // 403 (CSRF/auth) oder 5xx — explicit error statt silent empty.
169
- const text = await r.text().catch(() => r.statusText);
170
- throw new Error(`text-content load failed: ${r.status} ${text}`);
171
- }
172
- return r.json();
173
- })
174
- .then((data: ByTenantResponse) => {
175
- // Der App-seitige r.nav-Knoten IST der "Content"-Container → die
176
- // Provider-Kinder sind die Folder/Leaves darunter, nicht der Wrapper.
177
- const content = groupBlocksByFolder(data.data.blocks, tenantIdOverride)[0];
178
- emit(content !== undefined && Array.isArray(content.children) ? content.children : []);
179
- })
180
- .catch((e) => {
181
- // V.1.4: explicit error-Signal via emitError. ProviderBranch zeigt
182
- // Banner + Retry-Button. Fallback auf emit([]) wenn der Consumer
183
- // kein emitError unterstützt (Tests etc.).
184
- if (emitError) {
185
- emitError(e instanceof Error ? e : new Error(String(e)));
186
- } else {
187
- emit([]);
188
- }
189
- });
190
- return () => {};
191
- };
192
- }
193
-
194
- // V.1.3 echte Edit-Form: lädt aktuelle Werte via by-slug-query, lässt
195
- // TenantAdmin/SystemAdmin title + body editieren, dispatcht set-write
196
- // bei Submit. Non-Admin-User sehen die Form read-only mit Hint-Banner —
197
- // das ist der Kumiko-Weg (Memory `[Sicherheit > Convenience]`: write-
198
- // permission bleibt opinionated TenantAdmin-only, App-Roles erweitern
199
- // per Dual-Role-Mapping wenn gewollt).
200
- //
201
- // **Stale-Tree-Caveat (V.1.4-Followup)**: TreeProvider ist fetch-once.
202
- // Nach erfolgreichem Save flippt der visual state="stub"→"filled" in
203
- // der Sidebar NICHT, bis der User den Workspace re-mountet. Editor selbst
204
- // ist konsistent (by-slug-Re-Read würde aktuelle Werte liefern, hier
205
- // aber ohne extra-Re-Query — savedAt-Banner signalisiert success und
206
- // der lokale Form-State trägt die neuen Werte). Echte Lösung: SSE-
207
- // driven Tree-Refresh oder explicit cache-bust nach set-write.
208
-
209
- type TextBlock = {
210
- readonly slug: string;
211
- readonly lang: string;
212
- readonly title: string;
213
- readonly body: string | null;
214
- readonly folder: string | null;
215
- readonly updatedAt: string;
216
- };
217
-
218
- type SetResponse = { readonly slug: string; readonly lang: string; readonly isNew: boolean };
219
-
220
- function TextContentEditor({
221
- target,
222
- }: {
223
- readonly target: TargetRef;
224
- // onClose bleibt Teil des Resolver-Contracts (Visual-Panel liefert es),
225
- // wird aber nicht mehr gebraucht: die Tree-Auswahl navigiert, kein
226
- // Schließen-Button mehr. Nicht destrukturiert → kein unused binding.
227
- readonly onClose: () => void;
228
- }): ReactNode {
229
- // @cast-boundary visual-tree-args — TargetRef.args ist erased zu
230
- // Record<string, unknown>; der Resolver kennt das Action-Shape (siehe
231
- // treeActions.edit-Definition unten) und de-erased pro Action analog
232
- // zu Event-Payloads. Optional-Chain absorbiert fehlende Felder ohne
233
- // throw, damit der Editor auch bei manuellem URL-Tampering nicht
234
- // crasht (TargetRef könnte aus old localStorage / URL-State stammen).
235
- const args = target.args as
236
- | { slug?: string; lang?: string; tenantIdOverride?: string }
237
- | undefined;
238
- const slug = args?.slug ?? "";
239
- const lang = args?.lang ?? "";
240
- const tenantIdOverride = args?.tenantIdOverride;
241
-
242
- const { Form, Field, Input, Button, Banner } = usePrimitives();
243
- const dispatcher = useDispatcher();
244
- const user = useShellUser();
245
- const canWrite =
246
- user?.roles.includes("TenantAdmin") === true || user?.roles.includes("SystemAdmin") === true;
247
-
248
- // Load existing block via by-slug-query. Result ist entweder TextBlock
249
- // oder null (slug existiert nicht). useQuery returnt `data: T | null`,
250
- // initial-loading: data=null + loading=true.
251
- const {
252
- data: loaded,
253
- loading,
254
- error: loadError,
255
- } = useQuery<TextBlock | null>(
256
- TextContentQueries.bySlug,
257
- { slug, lang, ...(tenantIdOverride !== undefined && { tenantIdOverride }) },
258
- { enabled: slug !== "" && lang !== "" },
259
- );
260
-
261
- // Form-State unabhängig vom geladenen Block (User-Edits sollen
262
- // erhalten bleiben wenn loaded-data sich ändert). sync nur initial
263
- // oder wenn target.slug+lang wechselt.
264
- const [title, setTitle] = useState("");
265
- const [body, setBody] = useState("");
266
- const [submitting, setSubmitting] = useState(false);
267
- const [saveError, setSaveError] = useState<string | null>(null);
268
- const [savedMsg, setSavedMsg] = useState<string | null>(null);
269
-
270
- // Sync loaded data → form state. Trigger sobald loaded-shape eindeutig
271
- // ist (data ≠ undefined). loaded === null heißt "Block existiert noch
272
- // nicht" — leere Form für create-flow.
273
- useEffect(() => {
274
- if (loading) return;
275
- setTitle(loaded?.title ?? "");
276
- setBody(loaded?.body ?? "");
277
- setSaveError(null);
278
- setSavedMsg(null);
279
- }, [loading, loaded]);
280
-
281
- const handleSave = async (): Promise<void> => {
282
- setSubmitting(true);
283
- setSaveError(null);
284
- setSavedMsg(null);
285
- try {
286
- const result = await dispatcher.write<SetResponse>(TextContentHandlers.set, {
287
- slug,
288
- lang,
289
- title,
290
- body: body.length > 0 ? body : null,
291
- folder: loaded?.folder ?? null,
292
- ...(tenantIdOverride !== undefined && { tenantIdOverride }),
293
- });
294
- if (result.isSuccess) {
295
- setSavedMsg(result.data.isNew ? "Neu angelegt." : "Gespeichert.");
296
- return;
297
- }
298
- setSaveError(result.error.message ?? result.error.code ?? "Speichern fehlgeschlagen.");
299
- } catch (e) {
300
- // Network-blip / dispatcher-throw — sonst bleibt submitting=true,
301
- // Save-Button locked-forever, User klickt repeat ohne Feedback.
302
- // Generic message reicht: konkreter Recovery-Pfad ist Retry.
303
- setSaveError(e instanceof Error ? e.message : "Netzwerkfehler beim Speichern.");
304
- } finally {
305
- setSubmitting(false);
306
- }
307
- };
308
-
309
- const onSubmit = (e?: FormEvent): void => {
310
- e?.preventDefault();
311
- void handleSave();
312
- };
313
-
314
- const disabled = submitting || loading || !canWrite;
315
-
316
- return (
317
- <Form
318
- onSubmit={onSubmit}
319
- testId="text-content-editor"
320
- title={title || slug || "—"}
321
- subtitle={lang !== "" ? `${slug} · (${lang})` : slug}
322
- actions={
323
- canWrite ? (
324
- <Button type="submit" loading={submitting} disabled={disabled}>
325
- {submitting ? "Speichern…" : "Speichern"}
326
- </Button>
327
- ) : undefined
328
- }
329
- >
330
- {loading && <Banner variant="loading">Lädt aktuellen Stand…</Banner>}
331
- {loadError !== null && (
332
- <Banner variant="error">Konnte Block nicht laden: {loadError.code}</Banner>
333
- )}
334
- {!canWrite && !loading && (
335
- <Banner variant="info">
336
- Read-only — TenantAdmin- oder SystemAdmin-Rolle f&uuml;r &Auml;nderungen erforderlich.
337
- </Banner>
338
- )}
339
- <Field id="text-content-title" label="Titel" required>
340
- <Input
341
- kind="text"
342
- id="text-content-title"
343
- name="text-content-title"
344
- value={title}
345
- onChange={setTitle}
346
- disabled={disabled}
347
- required
348
- />
349
- </Field>
350
- <Field id="text-content-body" label="Inhalt">
351
- <Input
352
- kind="textarea"
353
- id="text-content-body"
354
- name="text-content-body"
355
- value={body}
356
- onChange={setBody}
357
- disabled={disabled}
358
- rows={14}
359
- />
360
- </Field>
361
- {saveError !== null && <Banner variant="error">{saveError}</Banner>}
362
- {savedMsg !== null && <Banner variant="info">{savedMsg}</Banner>}
363
- </Form>
364
- );
365
- }
366
-
367
- // `navId` = die QN des r.nav({ provider: true })-Knotens, den die App für den
368
- // Content-Tree registriert (z.B. "publicstatus:nav:content"). Die App besitzt
369
- // Label/Icon/Access des Knotens (managed-pages-Konvention) — das Feature
370
- // liefert nur die Kinder + den Editor. Ohne navId: nur der Resolver, kein
371
- // Sidebar-Knoten (server-only-Consumer wie money-horse leaken nichts).
372
- // `tenantId` (SystemAdmin-only): welchen Tenant der Content-Tree + Editor
373
- // bedienen — SYSTEM_TENANT_ID für Apps, die Marketing/Legal dort seeden.
374
- // Weglassen → Session-Tenant (Default; kein Cross-Tenant-Zugriff).
375
- export function textContentClient(opts?: {
376
- readonly navId?: string;
377
- readonly tenantId?: string;
378
- }): ClientFeatureDefinition {
379
- const navId = opts?.navId;
380
- return {
381
- name: "text-content",
382
- ...(navId !== undefined && {
383
- navProviders: { [navId]: makeTreeProvider(opts?.tenantId) },
384
- // SSE-Refresh: jedes text-block-Event re-fired den Provider → Tree live.
385
- navEntities: { [navId]: ["text-block"] },
386
- }),
387
- resolvers: {
388
- "text-content:edit": TextContentEditor,
389
- },
390
- };
391
- }
@@ -1,8 +0,0 @@
1
- // @runtime client
2
- // Public exports für die Browser-Seite des text-content Features.
3
- // Wird über den Sub-Path-Export `@cosmicdrift/kumiko-bundled-features/text-content/web`
4
- // konsumiert — die Server-Seite (defineFeature) lebt in
5
- // `@cosmicdrift/kumiko-bundled-features/text-content` und hat keine
6
- // React-/DOM-Deps. Trennung bleibt sauber so wie renderer vs renderer-web.
7
-
8
- export { textContentClient } from "./client-plugin";