@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.
- package/CHANGELOG.md +81 -0
- package/package.json +7 -5
- package/src/auth-email-password/i18n.ts +8 -0
- package/src/auth-email-password/web/__tests__/login-screen.test.tsx +128 -1
- package/src/auth-email-password/web/login-screen.tsx +73 -8
- package/src/config/__tests__/cascade.integration.ts +419 -0
- package/src/config/__tests__/config.integration.ts +109 -2
- package/src/config/constants.ts +1 -0
- package/src/config/feature.ts +2 -0
- package/src/config/handlers/cascade.query.ts +70 -0
- package/src/config/handlers/values.query.ts +14 -4
- package/src/config/index.ts +17 -0
- package/src/config/resolver.ts +273 -1
- package/src/delivery/__tests__/delivery.integration.ts +6 -0
- package/src/delivery/delivery-service.ts +4 -12
- package/src/delivery/feature.ts +6 -4
- package/src/delivery/index.ts +0 -1
- package/src/legal-pages/web/client-plugin.ts +50 -10
- package/src/renderer-foundation/README.md +86 -0
- package/src/renderer-foundation/__tests__/api.test.ts +188 -0
- package/src/renderer-foundation/__tests__/collect-plugins.integration.ts +101 -0
- package/src/renderer-foundation/api.ts +106 -0
- package/src/renderer-foundation/constants.ts +21 -0
- package/src/renderer-foundation/feature.ts +47 -0
- package/src/renderer-foundation/index.ts +25 -0
- package/src/renderer-foundation/types.ts +109 -0
- package/src/renderer-simple/__tests__/adapter.test.ts +50 -0
- package/src/renderer-simple/feature.ts +28 -3
- package/src/template-resolver/README.md +89 -0
- package/src/template-resolver/__tests__/handlers.integration.ts +403 -0
- package/src/template-resolver/__tests__/template-resolver.integration.ts +570 -0
- package/src/template-resolver/api.ts +205 -0
- package/src/template-resolver/constants.ts +28 -0
- package/src/template-resolver/feature.ts +36 -0
- package/src/template-resolver/handlers/archive.write.ts +42 -0
- package/src/template-resolver/handlers/find-by-id.query.ts +45 -0
- package/src/template-resolver/handlers/list.query.ts +71 -0
- package/src/template-resolver/handlers/publish.write.ts +45 -0
- package/src/template-resolver/handlers/shared.ts +41 -0
- package/src/template-resolver/handlers/upsert-system.write.ts +81 -0
- package/src/template-resolver/handlers/upsert-tenant.write.ts +105 -0
- package/src/template-resolver/index.ts +28 -0
- package/src/template-resolver/qualified-names.ts +24 -0
- package/src/template-resolver/table.ts +67 -0
- package/src/text-content/__tests__/text-content.integration.ts +54 -0
- package/src/text-content/handlers/by-slug.query.ts +1 -0
- package/src/text-content/handlers/by-tenant.query.ts +2 -0
- package/src/text-content/handlers/set.write.ts +23 -0
- package/src/text-content/seeding.ts +9 -1
- package/src/text-content/table.ts +6 -0
- package/src/text-content/web/__tests__/editor-read-only.test.tsx +125 -0
- package/src/text-content/web/__tests__/group-blocks.test.ts +221 -0
- package/src/text-content/web/client-plugin.tsx +378 -0
- package/src/text-content/web/client-plugin.ts +0 -113
package/src/config/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/config/resolver.ts
CHANGED
|
@@ -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
|
-
//
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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 {
|
package/src/delivery/feature.ts
CHANGED
|
@@ -48,13 +48,15 @@ export function createDeliveryFeature(): FeatureDefinition {
|
|
|
48
48
|
},
|
|
49
49
|
});
|
|
50
50
|
|
|
51
|
-
// Extension
|
|
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),
|
package/src/delivery/index.ts
CHANGED
|
@@ -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 = (
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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`
|