@cosmicdrift/kumiko-bundled-features 0.3.0 → 0.4.1

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 (54) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/package.json +7 -5
  3. package/src/auth-email-password/i18n.ts +8 -0
  4. package/src/auth-email-password/web/__tests__/login-screen.test.tsx +128 -1
  5. package/src/auth-email-password/web/login-screen.tsx +73 -8
  6. package/src/config/__tests__/cascade.integration.ts +419 -0
  7. package/src/config/__tests__/config.integration.ts +109 -2
  8. package/src/config/constants.ts +1 -0
  9. package/src/config/feature.ts +2 -0
  10. package/src/config/handlers/cascade.query.ts +70 -0
  11. package/src/config/handlers/values.query.ts +14 -4
  12. package/src/config/index.ts +17 -0
  13. package/src/config/resolver.ts +273 -1
  14. package/src/delivery/__tests__/delivery.integration.ts +6 -0
  15. package/src/delivery/delivery-service.ts +4 -12
  16. package/src/delivery/feature.ts +6 -4
  17. package/src/delivery/index.ts +0 -1
  18. package/src/legal-pages/web/client-plugin.ts +50 -10
  19. package/src/renderer-foundation/README.md +86 -0
  20. package/src/renderer-foundation/__tests__/api.test.ts +188 -0
  21. package/src/renderer-foundation/__tests__/collect-plugins.integration.ts +101 -0
  22. package/src/renderer-foundation/api.ts +106 -0
  23. package/src/renderer-foundation/constants.ts +21 -0
  24. package/src/renderer-foundation/feature.ts +47 -0
  25. package/src/renderer-foundation/index.ts +25 -0
  26. package/src/renderer-foundation/types.ts +109 -0
  27. package/src/renderer-simple/__tests__/adapter.test.ts +50 -0
  28. package/src/renderer-simple/feature.ts +28 -3
  29. package/src/template-resolver/README.md +89 -0
  30. package/src/template-resolver/__tests__/handlers.integration.ts +403 -0
  31. package/src/template-resolver/__tests__/template-resolver.integration.ts +570 -0
  32. package/src/template-resolver/api.ts +205 -0
  33. package/src/template-resolver/constants.ts +28 -0
  34. package/src/template-resolver/feature.ts +36 -0
  35. package/src/template-resolver/handlers/archive.write.ts +42 -0
  36. package/src/template-resolver/handlers/find-by-id.query.ts +45 -0
  37. package/src/template-resolver/handlers/list.query.ts +71 -0
  38. package/src/template-resolver/handlers/publish.write.ts +45 -0
  39. package/src/template-resolver/handlers/shared.ts +41 -0
  40. package/src/template-resolver/handlers/upsert-system.write.ts +81 -0
  41. package/src/template-resolver/handlers/upsert-tenant.write.ts +105 -0
  42. package/src/template-resolver/index.ts +28 -0
  43. package/src/template-resolver/qualified-names.ts +24 -0
  44. package/src/template-resolver/table.ts +67 -0
  45. package/src/text-content/__tests__/text-content.integration.ts +54 -0
  46. package/src/text-content/handlers/by-slug.query.ts +1 -0
  47. package/src/text-content/handlers/by-tenant.query.ts +2 -0
  48. package/src/text-content/handlers/set.write.ts +23 -0
  49. package/src/text-content/seeding.ts +9 -1
  50. package/src/text-content/table.ts +6 -0
  51. package/src/text-content/web/__tests__/editor-read-only.test.tsx +125 -0
  52. package/src/text-content/web/__tests__/group-blocks.test.ts +221 -0
  53. package/src/text-content/web/client-plugin.tsx +378 -0
  54. package/src/text-content/web/client-plugin.ts +0 -113
@@ -1,3 +1,8 @@
1
+ import type { DbConnection, EncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
2
+ import { seedConfigValues } from "@cosmicdrift/kumiko-framework/db";
3
+ import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
4
+ import { configValueEntity, configValuesTable } from "./table";
5
+
1
6
  export {
2
7
  CONFIG_FEATURE,
3
8
  ConfigErrors,
@@ -13,3 +18,15 @@ export {
13
18
  export type { AppConfigOverrides, ConfigResolver } from "./resolver";
14
19
  export { createConfigResolver, validateAppOverrides } from "./resolver";
15
20
  export { configValuesTable } from "./table";
21
+
22
+ // Boot helper for runDevApp / runProdApp: pulls every ConfigSeedDef from
23
+ // the registry and writes the matching system/tenant/user rows via the
24
+ // event-store executor. Idempotent across boots — see config-seeding.md.
25
+ export function seedAllConfigValues(
26
+ registry: Registry,
27
+ db: DbConnection,
28
+ encryption?: EncryptionProvider,
29
+ ): Promise<{ created: number; skipped: number }> {
30
+ const seeds = registry.getAllConfigSeeds();
31
+ return seedConfigValues(seeds, configValuesTable, configValueEntity, registry, db, encryption);
32
+ }
@@ -5,14 +5,17 @@ import {
5
5
  type TenantDb,
6
6
  } from "@cosmicdrift/kumiko-framework/db";
7
7
  import type {
8
+ ConfigCascade,
9
+ ConfigCascadeLevel,
8
10
  ConfigKeyDefinition,
9
11
  ConfigResolver,
12
+ ConfigStoredRowWithSource,
10
13
  ConfigValueSource,
11
14
  ConfigValueWithSource,
12
15
  } from "@cosmicdrift/kumiko-framework/engine";
13
16
  import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
14
17
  import { assertUnreachable, parseJsonOrThrow } from "@cosmicdrift/kumiko-framework/utils";
15
- import { and, eq, isNull, or } from "drizzle-orm";
18
+ import { and, eq, inArray, isNull, or } from "drizzle-orm";
16
19
  import { configValuesTable } from "./table";
17
20
 
18
21
  type ConfigRow = {
@@ -66,6 +69,148 @@ export type ConfigResolverOptions = {
66
69
  appOverrides?: AppConfigOverrides;
67
70
  };
68
71
 
72
+ // Shared cascade-builder. Single-key path passes a `findRow`-bound row
73
+ // fetcher (one SQL per lookup); batch path passes a closure over
74
+ // pre-loaded rows. The builder itself is unaware of which.
75
+ async function buildCascade(
76
+ qualifiedKey: string,
77
+ keyDef: ConfigKeyDefinition,
78
+ tenantId: string,
79
+ userId: string,
80
+ db: DbConnection | TenantDb,
81
+ fetchRow: (
82
+ tenantId: string,
83
+ userId: string | null,
84
+ ) => Promise<ConfigRow | null> | ConfigRow | null,
85
+ appOverrides: AppConfigOverrides | undefined,
86
+ encryption: EncryptionProvider | undefined,
87
+ ): Promise<ConfigCascade> {
88
+ type Lookup = {
89
+ tenantId: string;
90
+ userId: string | null;
91
+ source: ConfigValueSource;
92
+ label: string;
93
+ };
94
+ const lookups: Lookup[] = [];
95
+
96
+ switch (keyDef.scope) {
97
+ case "user":
98
+ lookups.push({ tenantId, userId, source: "user-row", label: "User" });
99
+ lookups.push({ tenantId, userId: null, source: "tenant-row", label: "Tenant" });
100
+ break;
101
+ case "tenant":
102
+ lookups.push({ tenantId, userId: null, source: "tenant-row", label: "Tenant" });
103
+ lookups.push({
104
+ tenantId: SYSTEM_TENANT_ID,
105
+ userId: null,
106
+ source: "system-row",
107
+ label: "System",
108
+ });
109
+ break;
110
+ case "system":
111
+ lookups.push({
112
+ tenantId: SYSTEM_TENANT_ID,
113
+ userId: null,
114
+ source: "system-row",
115
+ label: "System",
116
+ });
117
+ break;
118
+ default:
119
+ assertUnreachable(keyDef.scope, "config scope");
120
+ }
121
+
122
+ const levels: ConfigCascadeLevel[] = [];
123
+ let activeIndex = -1;
124
+
125
+ for (const lookup of lookups) {
126
+ const row = await fetchRow(lookup.tenantId, lookup.userId);
127
+ if (row?.value !== null && row?.value !== undefined) {
128
+ let raw = row.value;
129
+ if (keyDef.encrypted && encryption) {
130
+ raw = encryption.decrypt(raw);
131
+ }
132
+ if (activeIndex === -1) activeIndex = levels.length;
133
+ levels.push({
134
+ label: lookup.label,
135
+ value: deserializeValue(raw, keyDef.type),
136
+ source: lookup.source,
137
+ isActive: false,
138
+ hasValue: true,
139
+ });
140
+ } else {
141
+ levels.push({
142
+ label: lookup.label,
143
+ value: undefined,
144
+ source: lookup.source,
145
+ isActive: false,
146
+ hasValue: false,
147
+ });
148
+ }
149
+ }
150
+
151
+ const overrideValue = appOverrides?.get(qualifiedKey);
152
+ const hasOverride = overrideValue !== undefined;
153
+ if (activeIndex === -1 && hasOverride) activeIndex = levels.length;
154
+ levels.push({
155
+ label: "App-Override",
156
+ value: overrideValue,
157
+ source: "app-override",
158
+ isActive: false,
159
+ hasValue: hasOverride,
160
+ });
161
+
162
+ if (keyDef.computed) {
163
+ const value = await keyDef.computed({ tenantId, userId, db });
164
+ if (activeIndex === -1) activeIndex = levels.length;
165
+ levels.push({
166
+ label: "Computed",
167
+ value,
168
+ source: "computed",
169
+ isActive: false,
170
+ hasValue: true,
171
+ });
172
+ } else {
173
+ levels.push({
174
+ label: "Computed",
175
+ value: undefined,
176
+ source: "computed",
177
+ isActive: false,
178
+ hasValue: false,
179
+ });
180
+ }
181
+
182
+ if (keyDef.default !== undefined) {
183
+ if (activeIndex === -1) activeIndex = levels.length;
184
+ levels.push({
185
+ label: "Default",
186
+ value: keyDef.default,
187
+ source: "default",
188
+ isActive: false,
189
+ hasValue: true,
190
+ });
191
+ } else {
192
+ if (activeIndex === -1) activeIndex = levels.length;
193
+ levels.push({
194
+ label: "Default",
195
+ value: undefined,
196
+ source: "missing",
197
+ isActive: false,
198
+ hasValue: false,
199
+ });
200
+ }
201
+
202
+ const active = activeIndex >= 0 ? levels[activeIndex] : undefined;
203
+ if (active !== undefined) {
204
+ levels[activeIndex] = { ...active, isActive: true };
205
+ }
206
+
207
+ return {
208
+ value: active?.value,
209
+ source: active?.source ?? "missing",
210
+ levels,
211
+ };
212
+ }
213
+
69
214
  export function createConfigResolver(options: ConfigResolverOptions = {}): ConfigResolver {
70
215
  const { encryption, appOverrides } = options;
71
216
  async function findRow(
@@ -193,6 +338,133 @@ export function createConfigResolver(options: ConfigResolverOptions = {}): Confi
193
338
 
194
339
  return result;
195
340
  },
341
+
342
+ async getAllWithSource(tenantId, userId, db) {
343
+ // Load ALL potentially relevant rows (user + tenant + system)
344
+ const rows = await db
345
+ .select()
346
+ .from(configValuesTable)
347
+ .where(
348
+ or(
349
+ and(eq(configValuesTable.tenantId, SYSTEM_TENANT_ID), isNull(configValuesTable.userId)),
350
+ and(eq(configValuesTable.tenantId, tenantId), isNull(configValuesTable.userId)),
351
+ and(eq(configValuesTable.tenantId, tenantId), eq(configValuesTable.userId, userId)),
352
+ ),
353
+ );
354
+
355
+ const result = new Map<string, ConfigStoredRowWithSource>();
356
+
357
+ // Group rows by key so we can determine the winner and its source
358
+ const groups = new Map<string, ConfigRow[]>();
359
+ for (const row of rows) {
360
+ const r = row as ConfigRow; // @cast-boundary db-row
361
+ const g = groups.get(r.key) ?? [];
362
+ g.push(r);
363
+ groups.set(r.key, g);
364
+ }
365
+
366
+ for (const [key, keyRows] of groups) {
367
+ const specificityOf = (candidate: ConfigRow) =>
368
+ (candidate.userId !== null ? 2 : 0) + (candidate.tenantId !== SYSTEM_TENANT_ID ? 1 : 0);
369
+
370
+ const first = keyRows[0];
371
+ if (!first) continue;
372
+ let winner: ConfigRow = first;
373
+ for (const r of keyRows) {
374
+ if (specificityOf(r) > specificityOf(winner)) {
375
+ winner = r;
376
+ }
377
+ }
378
+
379
+ let source: ConfigValueSource;
380
+ if (winner.userId !== null) {
381
+ source = "user-row";
382
+ } else if (winner.tenantId !== SYSTEM_TENANT_ID) {
383
+ source = "tenant-row";
384
+ } else {
385
+ source = "system-row";
386
+ }
387
+
388
+ result.set(key, { ...winner, source });
389
+ }
390
+
391
+ return result;
392
+ },
393
+
394
+ async getCascade(qualifiedKey, keyDef, tenantId, userId, db): Promise<ConfigCascade> {
395
+ // Single-key path uses findRow per cascade step. The batch path
396
+ // bulk-loads all rows up-front; both build identical levels arrays.
397
+ return buildCascade(
398
+ qualifiedKey,
399
+ keyDef,
400
+ tenantId,
401
+ userId,
402
+ db,
403
+ (tid, uid) => findRow(qualifiedKey, tid, uid, db),
404
+ appOverrides,
405
+ encryption,
406
+ );
407
+ },
408
+
409
+ async getCascadeBatch(
410
+ keys,
411
+ keyDefs,
412
+ tenantId,
413
+ userId,
414
+ db,
415
+ ): Promise<ReadonlyMap<string, ConfigCascade>> {
416
+ if (keys.length === 0) return new Map();
417
+
418
+ // One SQL query for all keys + every scope (user-row,
419
+ // tenant-row, system-row). The cascade-builder then matches
420
+ // per-key from this preloaded set instead of querying again.
421
+ const rows = await db
422
+ .select()
423
+ .from(configValuesTable)
424
+ .where(
425
+ and(
426
+ inArray(configValuesTable.key, [...keys]),
427
+ or(
428
+ and(
429
+ eq(configValuesTable.tenantId, SYSTEM_TENANT_ID),
430
+ isNull(configValuesTable.userId),
431
+ ),
432
+ and(eq(configValuesTable.tenantId, tenantId), isNull(configValuesTable.userId)),
433
+ and(eq(configValuesTable.tenantId, tenantId), eq(configValuesTable.userId, userId)),
434
+ ),
435
+ ),
436
+ );
437
+
438
+ const grouped = new Map<string, ConfigRow[]>();
439
+ for (const row of rows) {
440
+ const r = row as ConfigRow; // @cast-boundary db-row
441
+ const g = grouped.get(r.key) ?? [];
442
+ g.push(r);
443
+ grouped.set(r.key, g);
444
+ }
445
+
446
+ const result = new Map<string, ConfigCascade>();
447
+ for (const key of keys) {
448
+ const keyDef = keyDefs.get(key);
449
+ if (!keyDef) continue;
450
+
451
+ const keyRows = grouped.get(key) ?? [];
452
+ const cascade = await buildCascade(
453
+ key,
454
+ keyDef,
455
+ tenantId,
456
+ userId,
457
+ db,
458
+ (tid, uid) =>
459
+ keyRows.find((r) => r.tenantId === tid && (r.userId ?? null) === uid) ?? null,
460
+ appOverrides,
461
+ encryption,
462
+ );
463
+ result.set(key, cascade);
464
+ }
465
+
466
+ return result;
467
+ },
196
468
  };
197
469
  }
198
470
 
@@ -27,8 +27,10 @@ import { createChannelPushFeature } from "../../channel-push/feature";
27
27
  import { createInMemoryPushTransport } from "../../channel-push/types";
28
28
  import { createConfigFeature } from "../../config/feature";
29
29
  import { configValuesTable } from "../../config/table";
30
+ import { createRendererFoundationFeature } from "../../renderer-foundation/feature";
30
31
  import { createRendererSimpleFeature } from "../../renderer-simple/feature";
31
32
  import { simpleRenderer } from "../../renderer-simple/simple-renderer";
33
+ import { createTemplateResolverFeature } from "../../template-resolver/feature";
32
34
  import { TenantQueries } from "../../tenant/constants";
33
35
  import { createTenantFeature } from "../../tenant/feature";
34
36
  import { tenantMembershipsTable } from "../../tenant/membership-table";
@@ -241,6 +243,8 @@ const ticketFeature = defineFeature("tickets", (r) => {
241
243
 
242
244
  const configFeature = createConfigFeature();
243
245
  const tenantFeature = createTenantFeature();
246
+ const templateResolverFeature = createTemplateResolverFeature();
247
+ const rendererFoundationFeature = createRendererFoundationFeature();
244
248
  const deliveryFeature = createDeliveryFeature();
245
249
  const channelInAppFeature = createChannelInAppFeature();
246
250
  const rendererSimpleFeature = createRendererSimpleFeature();
@@ -256,6 +260,8 @@ const channelPushFeature = createChannelPushFeature({
256
260
  const features = [
257
261
  configFeature,
258
262
  tenantFeature,
263
+ templateResolverFeature,
264
+ rendererFoundationFeature,
259
265
  deliveryFeature,
260
266
  channelInAppFeature,
261
267
  rendererSimpleFeature,
@@ -18,7 +18,6 @@ import type {
18
18
  DeliveryChannel,
19
19
  DeliveryLogEntry,
20
20
  DeliveryService,
21
- NotificationRenderer,
22
21
  } from "./types";
23
22
 
24
23
  export type RateLimitConfig = {
@@ -56,17 +55,10 @@ export function collectChannels(registry: Registry): DeliveryChannel[] {
56
55
  });
57
56
  }
58
57
 
59
- // Build renderer map from registry extension usages
60
- export function collectRenderers(registry: Registry): Map<string, NotificationRenderer> {
61
- const usages = registry.getExtensionUsages("notificationRenderer");
62
- const map = new Map<string, NotificationRenderer>();
63
- for (const usage of usages) {
64
- // @cast-boundary engine-payload — extension-usage carries unknown options
65
- const opts = usage.options as { render: NotificationRenderer["render"] };
66
- map.set(usage.entityName, { name: usage.entityName, render: opts.render });
67
- }
68
- return map;
69
- }
58
+ // `collectRenderers` entfernt 2026-05-19: notificationRenderer-Extension-Point
59
+ // wurde nie konsumiert (channel-email nimmt renderer als Konstruktor-Option,
60
+ // nicht aus Extension-Usages). Multi-Kind-Plugin-Pool lebt jetzt im
61
+ // `renderer-foundation`-Bundle via `collectRendererPlugins`.
70
62
 
71
63
  export function createDeliveryService(options: DeliveryServiceOptions): DeliveryService {
72
64
  const {
@@ -48,13 +48,15 @@ export function createDeliveryFeature(): FeatureDefinition {
48
48
  },
49
49
  });
50
50
 
51
- // Extension points: channels and renderers register as features
51
+ // Extension point: delivery-channels (email/in-app/push). Renderer-
52
+ // Extension-Point lebt jetzt im `renderer-foundation`-Bundle als
53
+ // `renderer` (Multi-Kind-Plugin-Contract). delivery hostet keinen
54
+ // eigenen mehr — channel-email nimmt renderer als direkte
55
+ // Konstruktor-Option (siehe email-channel.ts), nicht via Extension-
56
+ // Usage. Migration 2026-05-19.
52
57
  r.extendsRegistrar("deliveryChannel", {
53
58
  onRegister: () => {},
54
59
  });
55
- r.extendsRegistrar("notificationRenderer", {
56
- onRegister: () => {},
57
- });
58
60
 
59
61
  const handlers = {
60
62
  setPreference: r.writeHandler(setPreferenceWrite),
@@ -8,7 +8,6 @@ export {
8
8
  } from "./constants";
9
9
  export {
10
10
  collectChannels,
11
- collectRenderers,
12
11
  createDeliveryService,
13
12
  type DeliveryServiceOptions,
14
13
  type KillSwitchResolver,
@@ -20,17 +20,57 @@ import type { TreeChildrenSubscribe, TreeNode } from "@cosmicdrift/kumiko-framew
20
20
  import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
21
21
  import { LEGAL_OPTIONAL_BLOCKS, LEGAL_REQUIRED_BLOCKS } from "../constants";
22
22
 
23
- const treeProvider: TreeChildrenSubscribe = (_ctx) => (emit) => {
24
- const allBlocks = [...LEGAL_REQUIRED_BLOCKS, ...LEGAL_OPTIONAL_BLOCKS];
25
- const nodes: readonly TreeNode[] = allBlocks.map((b) => ({
26
- label: `${b.slug} (${b.lang})`,
27
- target: {
28
- featureId: "text-content",
29
- action: "edit",
30
- args: { slug: b.slug, lang: b.lang },
23
+ const treeProvider: TreeChildrenSubscribe = () => (emit) => {
24
+ // V.1.5d Slug-first Verschachtelung (Variante C):
25
+ // 📁 Legal
26
+ // 📁 imprint
27
+ // de
28
+ // en
29
+ // 📁 privacy
30
+ // de
31
+ // en
32
+ //
33
+ // Slug ist der Übersetzungs-Anker — User pflegt DE+EN-Versionen
34
+ // desselben Inhalts zusammen statt nach Sprache zu gruppieren.
35
+ // Sub-Items sind reine Sprach-Leaves; Label = Sprache, target zeigt
36
+ // auf text-content:edit mit slug+lang.
37
+
38
+ // Group all blocks by slug, collect set of langs per slug.
39
+ const bySlug = new Map<string, string[]>();
40
+ for (const b of [...LEGAL_REQUIRED_BLOCKS, ...LEGAL_OPTIONAL_BLOCKS]) {
41
+ const langs = bySlug.get(b.slug) ?? [];
42
+ if (!langs.includes(b.lang)) langs.push(b.lang);
43
+ bySlug.set(b.slug, langs);
44
+ }
45
+
46
+ const slugFolders: TreeNode[] = [];
47
+ for (const slug of [...bySlug.keys()].sort()) {
48
+ const langs = bySlug.get(slug);
49
+ if (langs === undefined) continue;
50
+ const langLeaves: TreeNode[] = langs.sort().map((lang) => ({
51
+ label: lang,
52
+ target: {
53
+ featureId: "text-content",
54
+ action: "edit",
55
+ args: { slug, lang },
56
+ },
57
+ }));
58
+ slugFolders.push({
59
+ label: slug,
60
+ icon: "folder",
61
+ state: "filled",
62
+ children: langLeaves,
63
+ });
64
+ }
65
+
66
+ emit([
67
+ {
68
+ label: "Legal",
69
+ icon: "folder",
70
+ state: "filled",
71
+ children: slugFolders,
31
72
  },
32
- }));
33
- emit(nodes);
73
+ ]);
34
74
  return () => {};
35
75
  };
36
76
 
@@ -0,0 +1,86 @@
1
+ # renderer-foundation
2
+
3
+ Plugin-Foundation für Renderer (Notification, HTML-Mail, PDF, Image). Plugins (`renderer-simple`, `renderer-mail-html`, `renderer-puppeteer-client`) registrieren sich via `r.useExtension("renderer", "<name>", { kinds, render })`.
4
+
5
+ **Plan-Doc:** [`kumiko-platform/docs/plans/features/renderer-foundation.md`](../../../../../../kumiko-platform/docs/plans/features/renderer-foundation.md)
6
+
7
+ ## Mount
8
+
9
+ ```typescript
10
+ import {
11
+ collectRendererPlugins,
12
+ createRendererFoundationApi,
13
+ createRendererFoundationFeature,
14
+ } from "@cosmicdrift/kumiko-bundled-features/renderer-foundation";
15
+
16
+ const features = [
17
+ createTemplateResolverFeature(),
18
+ createRendererFoundationFeature(),
19
+ createRendererSimpleFeature(), // Plugin
20
+ createRendererMailHtmlFeature(), // Plugin (enterprise)
21
+ // weitere Plugins...
22
+ ];
23
+
24
+ const app = createKumikoApp({
25
+ features,
26
+ extraContext: ({ registry }) => ({
27
+ rendererFoundation: createRendererFoundationApi(
28
+ collectRendererPlugins(registry),
29
+ ),
30
+ }),
31
+ });
32
+ ```
33
+
34
+ ## Konsumtion
35
+
36
+ ```typescript
37
+ import { requireRendererFoundation } from "@cosmicdrift/kumiko-bundled-features/renderer-foundation";
38
+
39
+ async function sendMail(ctx, tenantId) {
40
+ const foundation = requireRendererFoundation(ctx, "sendMail");
41
+ const renderer = foundation.createRendererForTenant({ tenantId, kind: "mail-html" });
42
+ const result = await renderer.render(
43
+ {
44
+ kind: "mail-html",
45
+ payload: { content: "Hello {{name}}", contentFormat: "markdown", variables: { name: "Frau Schmidt" } },
46
+ },
47
+ { db: ctx.db, registry: ctx.registry, tenantId },
48
+ );
49
+ // result.html, result.text
50
+ }
51
+ ```
52
+
53
+ Plugins ohne Service-Deps (`renderer-simple`) ignorieren den zweiten `ctx`-Parameter.
54
+
55
+ ## Plugin-Auswahl-Reihenfolge
56
+
57
+ 1. Tenant-Override (Config-Key `rendererPluginByKind`, z.B. `{ "mail-html": "mail-html" }`)
58
+ 2. `DEFAULT_PLUGIN_BY_KIND` aus constants
59
+ 3. Erstes Plugin im Pool das das kind bedient
60
+ 4. `RendererError("no_plugin_for_kind")` wenn nichts passt
61
+
62
+ ## Eigenes Plugin schreiben
63
+
64
+ ```typescript
65
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
66
+
67
+ export const myRendererFeature = defineFeature("renderer-myown", (r) => {
68
+ r.requires("renderer-foundation");
69
+ r.useExtension("renderer", "myown", {
70
+ kinds: ["document-pdf"],
71
+ // ctx ist optional — nur nehmen wenn Service-Access nötig
72
+ render: async (req, ctx) => {
73
+ // ctx.db, ctx.registry, ctx.tenantId verfügbar
74
+ // eigene PDF-Logik
75
+ return { kind: "document-pdf", pdfBytes: ..., pageCount: 1, sizeBytes: ... };
76
+ },
77
+ });
78
+ });
79
+ ```
80
+
81
+ ## Out-of-Scope
82
+
83
+ - Template-Storage (kommt aus `template-resolver`)
84
+ - Resource-URL-Substitution (Caller-Verantwortung: signed-URL vs. data-URI je nach kind)
85
+ - Template-Authoring-UI — `designer`-Bundle (geplant)
86
+ - Mail-Versand — `delivery` + `mail-transport-smtp`