@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
@@ -0,0 +1,188 @@
1
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { describe, expect, test } from "vitest";
3
+ import { createRendererFoundationApi } from "../api";
4
+ import {
5
+ type RendererContext,
6
+ RendererError,
7
+ type RendererPlugin,
8
+ type RenderRequest,
9
+ type RenderResponse,
10
+ } from "../types";
11
+
12
+ // Stub-Context für Plugin-Render-Calls in Unit-Tests. makePlugin ignoriert
13
+ // ctx; db+registry sind hier null-cast weil Unit-Tests keinen echten
14
+ // Stack haben — Integration-Tests (collect-plugins.integration.ts) nutzen
15
+ // echte stack.db / stack.registry. tenantId ist valid UUID (Memory-Lesson
16
+ // feedback_system_tenant_id_is_uuid).
17
+ const STUB_CTX: RendererContext = {
18
+ db: null as never,
19
+ registry: null as never,
20
+ tenantId: "11111111-1111-4111-8111-111111111111" as TenantId,
21
+ };
22
+
23
+ // Test-Helper: minimal Plugin mit fix-Response. Mehrere im Pool für
24
+ // Multi-Kind- + Tenant-Override-Tests.
25
+ function makePlugin(name: string, kinds: RendererPlugin["kinds"]): RendererPlugin {
26
+ return {
27
+ name,
28
+ kinds,
29
+ render: async (req: RenderRequest): Promise<RenderResponse> => {
30
+ // shape-by-kind, gibt name in der response damit Tests sehen welcher plugin lief
31
+ switch (req.kind) {
32
+ case "notification":
33
+ return { kind: "notification", html: `from:${name}` };
34
+ case "mail-html":
35
+ return { kind: "mail-html", html: `from:${name}`, text: `from:${name}` };
36
+ case "document-pdf":
37
+ return {
38
+ kind: "document-pdf",
39
+ pdfBytes: new Uint8Array([1, 2, 3]),
40
+ pageCount: 1,
41
+ sizeBytes: 3,
42
+ };
43
+ case "image-snapshot":
44
+ return {
45
+ kind: "image-snapshot",
46
+ imageBytes: new Uint8Array([1]),
47
+ format: "png",
48
+ width: 1,
49
+ height: 1,
50
+ };
51
+ }
52
+ },
53
+ };
54
+ }
55
+
56
+ const TENANT: TenantId = "22222222-2222-4222-8222-222222222222" as TenantId;
57
+
58
+ describe("renderer-foundation :: Plugin-Selection", () => {
59
+ test("default-plugin für notification = 'simple'", async () => {
60
+ const api = createRendererFoundationApi([
61
+ makePlugin("simple", ["notification"]),
62
+ makePlugin("other", ["notification"]),
63
+ ]);
64
+ const plugin = api.createRendererForTenant({ tenantId: TENANT, kind: "notification" });
65
+ expect(plugin.name).toBe("simple");
66
+ });
67
+
68
+ test("default-plugin für mail-html = 'mail-html'", async () => {
69
+ const api = createRendererFoundationApi([
70
+ makePlugin("simple", ["notification", "mail-html"]),
71
+ makePlugin("mail-html", ["mail-html"]),
72
+ ]);
73
+ const plugin = api.createRendererForTenant({ tenantId: TENANT, kind: "mail-html" });
74
+ expect(plugin.name).toBe("mail-html");
75
+ });
76
+
77
+ test("default-plugin für document-pdf = 'puppeteer'", async () => {
78
+ const api = createRendererFoundationApi([
79
+ makePlugin("puppeteer", ["document-pdf", "image-snapshot"]),
80
+ ]);
81
+ const plugin = api.createRendererForTenant({ tenantId: TENANT, kind: "document-pdf" });
82
+ expect(plugin.name).toBe("puppeteer");
83
+ });
84
+
85
+ test("Tenant-Override gewinnt vor Default", async () => {
86
+ const api = createRendererFoundationApi(
87
+ [makePlugin("simple", ["notification"]), makePlugin("custom-notif", ["notification"])],
88
+ (tid) => (tid === TENANT ? { notification: "custom-notif" } : null),
89
+ );
90
+ const plugin = api.createRendererForTenant({ tenantId: TENANT, kind: "notification" });
91
+ expect(plugin.name).toBe("custom-notif");
92
+ });
93
+
94
+ test("Tenant-Override auf nicht-registriertes Plugin → fällt durch auf Default", async () => {
95
+ const api = createRendererFoundationApi([makePlugin("simple", ["notification"])], () => ({
96
+ notification: "ghost-plugin",
97
+ }));
98
+ const plugin = api.createRendererForTenant({ tenantId: TENANT, kind: "notification" });
99
+ expect(plugin.name).toBe("simple");
100
+ });
101
+
102
+ test("Fallback auf erstes passendes Plugin wenn weder Tenant-Config noch Default-Name matchen", async () => {
103
+ const api = createRendererFoundationApi([makePlugin("nonstandard-name", ["notification"])]);
104
+ const plugin = api.createRendererForTenant({ tenantId: TENANT, kind: "notification" });
105
+ expect(plugin.name).toBe("nonstandard-name");
106
+ });
107
+
108
+ test("kein Plugin für kind → RendererError", () => {
109
+ const api = createRendererFoundationApi([makePlugin("simple", ["notification"])]);
110
+ expect(() => api.createRendererForTenant({ tenantId: TENANT, kind: "document-pdf" })).toThrow(
111
+ RendererError,
112
+ );
113
+ });
114
+
115
+ test("leerer Plugin-Pool → RendererError für jeden kind", () => {
116
+ const api = createRendererFoundationApi([]);
117
+ expect(() => api.createRendererForTenant({ tenantId: TENANT, kind: "notification" })).toThrow(
118
+ RendererError,
119
+ );
120
+ expect(() => api.createRendererForTenant({ tenantId: TENANT, kind: "mail-html" })).toThrow(
121
+ RendererError,
122
+ );
123
+ expect(() => api.createRendererForTenant({ tenantId: TENANT, kind: "document-pdf" })).toThrow(
124
+ RendererError,
125
+ );
126
+ expect(() => api.createRendererForTenant({ tenantId: TENANT, kind: "image-snapshot" })).toThrow(
127
+ RendererError,
128
+ );
129
+ });
130
+
131
+ test("RendererError code='no_plugin_for_kind' wenn kein Plugin", () => {
132
+ const api = createRendererFoundationApi([]);
133
+ try {
134
+ api.createRendererForTenant({ tenantId: TENANT, kind: "notification" });
135
+ expect.fail("expected RendererError");
136
+ } catch (e) {
137
+ expect(e).toBeInstanceOf(RendererError);
138
+ expect((e as RendererError).code).toBe("no_plugin_for_kind");
139
+ }
140
+ });
141
+
142
+ test("Plugin mit mehreren kinds wird für jeden passend gewählt", async () => {
143
+ const multi = makePlugin("multi", ["notification", "mail-html", "document-pdf"]);
144
+ const api = createRendererFoundationApi([multi]);
145
+ expect(api.createRendererForTenant({ tenantId: TENANT, kind: "notification" }).name).toBe(
146
+ "multi",
147
+ );
148
+ expect(api.createRendererForTenant({ tenantId: TENANT, kind: "mail-html" }).name).toBe("multi");
149
+ expect(api.createRendererForTenant({ tenantId: TENANT, kind: "document-pdf" }).name).toBe(
150
+ "multi",
151
+ );
152
+ });
153
+
154
+ test("Plugin mit leeren kinds wird nie ausgewählt", () => {
155
+ const api = createRendererFoundationApi([
156
+ makePlugin("empty-kinds", []),
157
+ makePlugin("simple", ["notification"]),
158
+ ]);
159
+ const plugin = api.createRendererForTenant({ tenantId: TENANT, kind: "notification" });
160
+ expect(plugin.name).toBe("simple");
161
+ });
162
+
163
+ test("Tenant-Override mit falschem kind-Plugin → ignoriert, Fallback", async () => {
164
+ // Tenant config sagt "puppeteer" für notification, aber puppeteer kann
165
+ // nur document-pdf. Foundation muss den falsche-kind-Eintrag ignorieren.
166
+ const api = createRendererFoundationApi(
167
+ [makePlugin("simple", ["notification"]), makePlugin("puppeteer", ["document-pdf"])],
168
+ () => ({ notification: "puppeteer" }),
169
+ );
170
+ const plugin = api.createRendererForTenant({ tenantId: TENANT, kind: "notification" });
171
+ expect(plugin.name).toBe("simple");
172
+ });
173
+ });
174
+
175
+ describe("renderer-foundation :: Plugin executes render", () => {
176
+ test("Plugin.render returnt RenderResponse mit gleichem kind", async () => {
177
+ const api = createRendererFoundationApi([makePlugin("simple", ["notification"])]);
178
+ const plugin = api.createRendererForTenant({ tenantId: TENANT, kind: "notification" });
179
+ const response = await plugin.render(
180
+ { kind: "notification", payload: { content: "hello", contentFormat: "markdown" } },
181
+ STUB_CTX,
182
+ );
183
+ expect(response.kind).toBe("notification");
184
+ if (response.kind === "notification") {
185
+ expect(response.html).toBe("from:simple");
186
+ }
187
+ });
188
+ });
@@ -0,0 +1,101 @@
1
+ import { defineFeature, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { setupTestStack, type TestStack } from "@cosmicdrift/kumiko-framework/stack";
3
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
4
+ import { createTemplateResolverFeature } from "../../template-resolver/feature";
5
+ import { createRendererFoundationApi } from "../api";
6
+ import { collectRendererPlugins, createRendererFoundationFeature } from "../feature";
7
+ import type { RenderRequest, RenderResponse } from "../types";
8
+
9
+ const TEST_TENANT = "11111111-1111-4111-8111-111111111111" as TenantId;
10
+
11
+ let stack: TestStack;
12
+
13
+ // Mini-Plugin via defineFeature + r.useExtension — wie ein echter
14
+ // renderer-plugin (renderer-simple, renderer-mail-html) sich registriert.
15
+ function createTestPluginFeature(name: string, kinds: ReadonlyArray<string>) {
16
+ return defineFeature(`renderer-${name}`, (r) => {
17
+ r.requires("renderer-foundation");
18
+ r.useExtension("renderer", name, {
19
+ kinds,
20
+ render: async (req: RenderRequest): Promise<RenderResponse> => {
21
+ if (req.kind === "notification") return { kind: "notification", html: `via:${name}` };
22
+ if (req.kind === "mail-html")
23
+ return { kind: "mail-html", html: `via:${name}`, text: `via:${name}` };
24
+ if (req.kind === "document-pdf")
25
+ return {
26
+ kind: "document-pdf",
27
+ pdfBytes: new Uint8Array([1]),
28
+ pageCount: 1,
29
+ sizeBytes: 1,
30
+ };
31
+ return {
32
+ kind: "image-snapshot",
33
+ imageBytes: new Uint8Array([1]),
34
+ format: "png",
35
+ width: 1,
36
+ height: 1,
37
+ };
38
+ },
39
+ });
40
+ });
41
+ }
42
+
43
+ beforeAll(async () => {
44
+ stack = await setupTestStack({
45
+ features: [
46
+ createTemplateResolverFeature(),
47
+ createRendererFoundationFeature(),
48
+ createTestPluginFeature("simple", ["notification"]),
49
+ createTestPluginFeature("mail-html", ["mail-html"]),
50
+ createTestPluginFeature("puppeteer", ["document-pdf", "image-snapshot"]),
51
+ ],
52
+ });
53
+ });
54
+
55
+ afterAll(async () => {
56
+ await stack.cleanup();
57
+ });
58
+
59
+ describe("renderer-foundation :: Plugin-Pool aus Registry", () => {
60
+ test("collectRendererPlugins findet alle registrierten Plugins", () => {
61
+ const plugins = collectRendererPlugins(stack.registry);
62
+ const names = plugins.map((p) => p.name).sort();
63
+ expect(names).toEqual(["mail-html", "puppeteer", "simple"]);
64
+ });
65
+
66
+ test("jeder Plugin behält seine kinds-Deklaration", () => {
67
+ const plugins = collectRendererPlugins(stack.registry);
68
+ const byName = new Map(plugins.map((p) => [p.name, p]));
69
+ expect([...byName.get("simple")!.kinds]).toEqual(["notification"]);
70
+ expect([...byName.get("mail-html")!.kinds]).toEqual(["mail-html"]);
71
+ expect([...byName.get("puppeteer")!.kinds].sort()).toEqual(["document-pdf", "image-snapshot"]);
72
+ });
73
+
74
+ test("API findet Default-Plugin pro kind aus echtem Pool", () => {
75
+ const plugins = collectRendererPlugins(stack.registry);
76
+ const api = createRendererFoundationApi(plugins);
77
+ expect(api.createRendererForTenant({ tenantId: TEST_TENANT, kind: "notification" }).name).toBe(
78
+ "simple",
79
+ );
80
+ expect(api.createRendererForTenant({ tenantId: TEST_TENANT, kind: "mail-html" }).name).toBe(
81
+ "mail-html",
82
+ );
83
+ expect(api.createRendererForTenant({ tenantId: TEST_TENANT, kind: "document-pdf" }).name).toBe(
84
+ "puppeteer",
85
+ );
86
+ });
87
+
88
+ test("Plugin.render mit echtem Pool fließt end-to-end durch", async () => {
89
+ const plugins = collectRendererPlugins(stack.registry);
90
+ const api = createRendererFoundationApi(plugins);
91
+ const plugin = api.createRendererForTenant({ tenantId: TEST_TENANT, kind: "notification" });
92
+ const result = await plugin.render(
93
+ { kind: "notification", payload: { content: "hello", contentFormat: "markdown" } },
94
+ { db: stack.db, registry: stack.registry, tenantId: TEST_TENANT },
95
+ );
96
+ expect(result.kind).toBe("notification");
97
+ if (result.kind === "notification") {
98
+ expect(result.html).toBe("via:simple");
99
+ }
100
+ });
101
+ });
@@ -0,0 +1,106 @@
1
+ // RendererFoundationApi — Cross-Feature-Schnittstelle. Konsumenten
2
+ // (delivery, mail-Sender, Solon NKA-PDF-Generator) holen sich pro
3
+ // Render-Call den passenden Plugin via `createRendererForTenant`.
4
+ // Pattern symmetrisch zu ai-foundation's `createLLMProviderForTenant`.
5
+
6
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
7
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
8
+ import { DEFAULT_PLUGIN_BY_KIND, type RenderKind } from "./constants";
9
+ import { RendererError, type RendererPlugin } from "./types";
10
+
11
+ export type RendererFoundationApi = {
12
+ /**
13
+ * Wählt + returnt ein RendererPlugin für (tenantId × kind). Caller
14
+ * rufen anschließend `plugin.render(req, ctx)`.
15
+ *
16
+ * **Auswahl-Reihenfolge:**
17
+ * 1. Tenant-spezifische Config (config-Bundle: `rendererPluginByKind`)
18
+ * 2. DEFAULT_PLUGIN_BY_KIND aus constants
19
+ * 3. Erstes Plugin im Pool das das kind bedient
20
+ * 4. `RendererError("no_plugin_for_kind")` wenn nichts passt
21
+ *
22
+ * **Caller-Pattern:** `tenantId` MUSS vom Server kommen (typisch
23
+ * `ctx.user.tenantId`). Plugins, die Service-Access brauchen (z.B.
24
+ * `renderer-mail-html` für Layout-Resolve via `template-resolver`),
25
+ * erhalten `RendererContext` als zweites Argument zu `render()` —
26
+ * matcht das Pattern von `DeliveryChannel.send(addr, msg, ctx)`.
27
+ * Plugins ohne Service-Deps (z.B. `renderer-simple`) ignorieren ctx.
28
+ */
29
+ readonly createRendererForTenant: (args: {
30
+ readonly tenantId: TenantId;
31
+ readonly kind: RenderKind;
32
+ }) => RendererPlugin;
33
+ };
34
+
35
+ // Plugin-Pool wird zur Boot-Zeit aus Extension-Usages aufgebaut.
36
+ // Aus dem Registry kommen alle registrierten Plugins. Foundation-Feature
37
+ // baut den Pool via `collectRendererPlugins(registry)` und steckt das
38
+ // Result in extraContext.
39
+ export function createRendererFoundationApi(
40
+ plugins: ReadonlyArray<RendererPlugin>,
41
+ tenantConfigLookup: (tenantId: TenantId) => Record<string, string> | null = () => null,
42
+ ): RendererFoundationApi {
43
+ const byKind = indexByKind(plugins);
44
+ return {
45
+ createRendererForTenant: ({ tenantId, kind }) => {
46
+ const tenantConfig = tenantConfigLookup(tenantId) ?? {};
47
+
48
+ // 1. Tenant-Override
49
+ const tenantPluginName = tenantConfig[kind];
50
+ if (tenantPluginName) {
51
+ const plugin = plugins.find((p) => p.name === tenantPluginName && p.kinds.includes(kind));
52
+ if (plugin) return plugin;
53
+ // Tenant-Config zeigt auf nicht-registriertes Plugin — fällt durch zu Default
54
+ }
55
+
56
+ // 2. Default-Plugin
57
+ const defaultName = DEFAULT_PLUGIN_BY_KIND[kind];
58
+ if (defaultName) {
59
+ const plugin = plugins.find((p) => p.name === defaultName && p.kinds.includes(kind));
60
+ if (plugin) return plugin;
61
+ }
62
+
63
+ // 3. Erstes Plugin im Pool das das kind bedient
64
+ const first = byKind.get(kind)?.[0];
65
+ if (first) return first;
66
+
67
+ // 4. Kein Plugin → Error
68
+ throw new RendererError(
69
+ `[renderer-foundation] no plugin registered for kind="${kind}". Mount at least one plugin (renderer-simple, renderer-mail-html, renderer-puppeteer-client) for this kind.`,
70
+ "no_plugin_for_kind",
71
+ );
72
+ },
73
+ };
74
+ }
75
+
76
+ function indexByKind(plugins: ReadonlyArray<RendererPlugin>): Map<RenderKind, RendererPlugin[]> {
77
+ const map = new Map<RenderKind, RendererPlugin[]>();
78
+ for (const plugin of plugins) {
79
+ for (const kind of plugin.kinds) {
80
+ const list = map.get(kind) ?? [];
81
+ list.push(plugin);
82
+ map.set(kind, list);
83
+ }
84
+ }
85
+ return map;
86
+ }
87
+
88
+ // Single point of truth für "dieser Handler braucht renderer-foundation".
89
+ // Pattern symmetrisch zu requireTemplateResolver + requireTextContent.
90
+ export function requireRendererFoundation(
91
+ ctx: { readonly rendererFoundation?: RendererFoundationApi } | object,
92
+ callerName: string,
93
+ ): RendererFoundationApi {
94
+ // @cast-boundary engine-bridge — rendererFoundation kommt per extraContext
95
+ // aus App-Bootstrap.
96
+ const api = (ctx as { rendererFoundation?: RendererFoundationApi }).rendererFoundation;
97
+ if (!api) {
98
+ throw new InternalError({
99
+ message:
100
+ `[${callerName}] ctx.rendererFoundation missing — App-Bootstrap muss ` +
101
+ `extraContext: { rendererFoundation: createRendererFoundationApi(plugins) } setzen ` +
102
+ `(siehe renderer-foundation/README.md).`,
103
+ });
104
+ }
105
+ return api;
106
+ }
@@ -0,0 +1,21 @@
1
+ // Re-export aus template-resolver — gleiche RenderKind-Domäne. Beide
2
+ // Bundles teilen sich die Enum, damit Plugins (renderer-mail-html etc.)
3
+ // nicht zwei Quellen importieren müssen. Cross-feature-Imports gehen
4
+ // über das Barrel (../template-resolver), nicht via deep-import.
5
+ import type { RenderKind as RenderKindLocal } from "../template-resolver";
6
+
7
+ export {
8
+ CONTENT_FORMATS,
9
+ type ContentFormat,
10
+ RENDER_KINDS,
11
+ type RenderKind,
12
+ } from "../template-resolver";
13
+
14
+ // Standard-Default-Plugin pro Kind, wenn Tenant keine explizite Config
15
+ // gesetzt hat. App-Bootstrap kann das via TenantConfigKey überschreiben.
16
+ export const DEFAULT_PLUGIN_BY_KIND: Readonly<Record<RenderKindLocal, string>> = {
17
+ notification: "simple",
18
+ "mail-html": "mail-html",
19
+ "document-pdf": "puppeteer",
20
+ "image-snapshot": "puppeteer",
21
+ };
@@ -0,0 +1,47 @@
1
+ import { defineFeature, type Registry } from "@cosmicdrift/kumiko-framework/engine";
2
+ import type { RendererPlugin } from "./types";
3
+
4
+ // renderer-foundation — Plugin-Foundation für Renderer (Notification,
5
+ // HTML-Mail, PDF, Image). Plan-Doc:
6
+ // kumiko-platform/docs/plans/features/renderer-foundation.md
7
+ //
8
+ // Pattern symmetrisch zu ai-foundation: Foundation definiert den
9
+ // Extension-Point `renderer`, Plugins (renderer-simple, renderer-mail-html,
10
+ // renderer-puppeteer-client) registrieren sich via `r.useExtension`.
11
+ // Konsumenten holen sich Plugin runtime via createRendererForTenant.
12
+ export function createRendererFoundationFeature() {
13
+ return defineFeature("renderer-foundation", (r) => {
14
+ r.requires("template-resolver");
15
+
16
+ r.extendsRegistrar("renderer", {
17
+ onRegister: () => {
18
+ // Plugin-Konformitäts-Check könnte hier: shape-validation der
19
+ // options (kinds, render-Funktion present). Aktuell kein
20
+ // shape-check — Caller's TypeScript-Type-Sicherheit greift.
21
+ },
22
+ });
23
+
24
+ return {};
25
+ });
26
+ }
27
+
28
+ // Plugin-Pool-Aufbau zur Boot-Zeit. App-Bootstrap ruft das nach
29
+ // Feature-Registration auf, baut den Pool, gibt ihn via extraContext
30
+ // an die Handlers weiter.
31
+ //
32
+ // Symmetrisch zu collectChannels / collectRenderers in delivery-service.
33
+ export function collectRendererPlugins(registry: Registry): RendererPlugin[] {
34
+ const usages = registry.getExtensionUsages("renderer");
35
+ return usages.map((usage) => {
36
+ // @cast-boundary engine-payload — extension-usage carries unknown options
37
+ const opts = usage.options as {
38
+ kinds: RendererPlugin["kinds"];
39
+ render: RendererPlugin["render"];
40
+ };
41
+ return {
42
+ name: usage.entityName,
43
+ kinds: opts.kinds,
44
+ render: opts.render,
45
+ };
46
+ });
47
+ }
@@ -0,0 +1,25 @@
1
+ export {
2
+ createRendererFoundationApi,
3
+ type RendererFoundationApi,
4
+ requireRendererFoundation,
5
+ } from "./api";
6
+ export {
7
+ CONTENT_FORMATS,
8
+ type ContentFormat,
9
+ DEFAULT_PLUGIN_BY_KIND,
10
+ RENDER_KINDS,
11
+ type RenderKind,
12
+ } from "./constants";
13
+ export { collectRendererPlugins, createRendererFoundationFeature } from "./feature";
14
+ export {
15
+ type DocumentPayload,
16
+ type ImageOptions,
17
+ type MailHtmlPayload,
18
+ type NotificationPayload,
19
+ type PdfOptions,
20
+ type RendererContext,
21
+ RendererError,
22
+ type RendererPlugin,
23
+ type RenderRequest,
24
+ type RenderResponse,
25
+ } from "./types";
@@ -0,0 +1,109 @@
1
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
2
+ import type { Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
+ import type { ContentFormat, RenderKind } from "./constants";
4
+
5
+ // RenderRequest — Plugins erhalten das. Resource-Mode wählt der Caller
6
+ // (renderer-foundation api) je nach kind oder explizit. Plugin selbst
7
+ // entscheidet nicht — es bekommt eine schon aufgelöste Form.
8
+ export type RenderRequest =
9
+ | { kind: "notification"; payload: NotificationPayload }
10
+ | { kind: "mail-html"; payload: MailHtmlPayload }
11
+ | { kind: "document-pdf"; payload: DocumentPayload; options?: PdfOptions }
12
+ | { kind: "image-snapshot"; payload: DocumentPayload; options?: ImageOptions };
13
+
14
+ export type RenderResponse =
15
+ | { kind: "notification"; html: string }
16
+ | { kind: "mail-html"; html: string; text: string }
17
+ | { kind: "document-pdf"; pdfBytes: Uint8Array; pageCount: number; sizeBytes: number }
18
+ | {
19
+ kind: "image-snapshot";
20
+ imageBytes: Uint8Array;
21
+ format: "png" | "jpg";
22
+ width: number;
23
+ height: number;
24
+ };
25
+
26
+ export type NotificationPayload = {
27
+ readonly template?: string;
28
+ readonly content?: string;
29
+ readonly contentFormat?: ContentFormat;
30
+ readonly variables?: Readonly<Record<string, unknown>>;
31
+ readonly locale?: string;
32
+ };
33
+
34
+ export type MailHtmlPayload = {
35
+ readonly template?: string;
36
+ readonly content?: string;
37
+ readonly contentFormat?: ContentFormat;
38
+ readonly variables?: Readonly<Record<string, unknown>>;
39
+ readonly locale?: string;
40
+ readonly subject?: string;
41
+ };
42
+
43
+ export type DocumentPayload = {
44
+ readonly template?: string;
45
+ readonly content?: string;
46
+ readonly contentFormat?: ContentFormat;
47
+ readonly variables?: Readonly<Record<string, unknown>>;
48
+ readonly locale?: string;
49
+ };
50
+
51
+ export type PdfOptions = {
52
+ readonly format?: "A4" | "Letter";
53
+ readonly marginMm?: { top?: number; right?: number; bottom?: number; left?: number };
54
+ readonly headerTemplate?: string;
55
+ readonly footerTemplate?: string;
56
+ readonly printBackground?: boolean;
57
+ readonly displayHeaderFooter?: boolean;
58
+ };
59
+
60
+ export type ImageOptions = {
61
+ readonly width?: number;
62
+ readonly height?: number;
63
+ readonly format?: "png" | "jpg";
64
+ readonly quality?: number;
65
+ };
66
+
67
+ // RendererContext — schmale Surface (Kumiko-Style, matcht FileProviderContext
68
+ // + ChannelContext). Plugins, die Service-Access brauchen, holen sich
69
+ // templateResolver/etc. via direkten Bundle-Import + ctx.db (cross-feature-
70
+ // public-API). KEIN extraContext-Pass-Through — Plugin importiert pure-
71
+ // Function-Factories statt App-ctx zu casten.
72
+ //
73
+ // **Beispiel renderer-mail-html:**
74
+ // import { createTemplateResolverApi } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
75
+ // const tplApi = createTemplateResolverApi(ctx.db);
76
+ // const layout = await tplApi.resolveTemplate({ tenantId: ctx.tenantId, slug, kind });
77
+ export type RendererContext = {
78
+ readonly db: DbConnection;
79
+ readonly registry: Registry;
80
+ readonly tenantId: TenantId;
81
+ };
82
+
83
+ // RendererPlugin-Contract — pro Plugin (renderer-simple, renderer-mail-html,
84
+ // renderer-puppeteer-client). Plugin deklariert welche kinds es bedienen
85
+ // kann; Foundation-Resolver wählt basierend auf Tenant-Config + kind.
86
+ //
87
+ // **Plugin-Invarianten:**
88
+ // - `kinds` muss min. 1 Element haben (sonst nie ausgewählt)
89
+ // - `render(req, ctx)`-Response.kind MUSS req.kind matchen
90
+ // - Plugin ist zustandslos: kein internal-state zwischen render-Calls
91
+ // - Plugin wirft `RendererError` für domain-Fehler (nicht bare Error)
92
+ // - `ctx` ist required. Plugins ohne Service-Deps (z.B. renderer-simple)
93
+ // ignorieren ihn einfach — TS function-arg variance erlaubt `(req) => ...`-
94
+ // Implementations weiterhin (Implementation-Args ≤ Contract-Args ist OK).
95
+ export type RendererPlugin = {
96
+ readonly name: string;
97
+ readonly kinds: ReadonlyArray<RenderKind>;
98
+ render(req: RenderRequest, ctx: RendererContext): Promise<RenderResponse>;
99
+ };
100
+
101
+ export class RendererError extends Error {
102
+ constructor(
103
+ message: string,
104
+ public readonly code: "no_plugin_for_kind" | "invalid_payload" | "other",
105
+ ) {
106
+ super(message);
107
+ this.name = "RendererError";
108
+ }
109
+ }
@@ -0,0 +1,50 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { RendererError } from "../../renderer-foundation";
3
+ import { adaptToFoundation } from "../feature";
4
+
5
+ describe("renderer-simple :: adaptToFoundation", () => {
6
+ test("kind='notification' rendert via simpleRenderer und gibt RenderResponse zurück", async () => {
7
+ const res = await adaptToFoundation({
8
+ kind: "notification",
9
+ payload: {
10
+ template: "welcome",
11
+ variables: { title: "Welcome!", body: "Hello there" },
12
+ },
13
+ });
14
+ expect(res.kind).toBe("notification");
15
+ if (res.kind === "notification") {
16
+ // simpleRenderer baut HTML mit title als header + body als section
17
+ expect(res.html).toContain("Welcome!");
18
+ expect(res.html).toContain("Hello there");
19
+ expect(res.html).toContain("<!DOCTYPE html>");
20
+ }
21
+ });
22
+
23
+ test("leere variables → leerer body, kein crash", async () => {
24
+ const res = await adaptToFoundation({
25
+ kind: "notification",
26
+ payload: { template: "", variables: {} },
27
+ });
28
+ expect(res.kind).toBe("notification");
29
+ });
30
+
31
+ test("non-notification kind → RendererError mit code 'invalid_payload'", async () => {
32
+ await expect(
33
+ adaptToFoundation({
34
+ kind: "mail-html",
35
+ payload: { content: "test", contentFormat: "markdown" },
36
+ }),
37
+ ).rejects.toThrow(RendererError);
38
+
39
+ try {
40
+ await adaptToFoundation({
41
+ kind: "document-pdf",
42
+ payload: { content: "test", contentFormat: "markdown" },
43
+ });
44
+ expect.fail("expected RendererError");
45
+ } catch (e) {
46
+ expect(e).toBeInstanceOf(RendererError);
47
+ expect((e as RendererError).code).toBe("invalid_payload");
48
+ }
49
+ });
50
+ });