@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.174.1",
3
+ "version": "0.176.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -104,12 +104,10 @@
104
104
  "./personal-access-tokens/web": "./src/personal-access-tokens/web/index.ts",
105
105
  "./feature-toggles": "./src/feature-toggles/index.ts",
106
106
  "./feature-toggles/web": "./src/feature-toggles/web/index.ts",
107
- "./text-content": "./src/text-content/index.ts",
108
- "./text-content/seeding": "./src/text-content/seeding.ts",
109
- "./text-content/web": "./src/text-content/web/index.ts",
110
107
  "./template-resolver": "./src/template-resolver/index.ts",
111
108
  "./template-resolver/seeding": "./src/template-resolver/seeding.ts",
112
109
  "./template-resolver/testing": "./src/template-resolver/testing.ts",
110
+ "./template-resolver/web": "./src/template-resolver/web/index.ts",
113
111
  "./renderer-foundation": "./src/renderer-foundation/index.ts",
114
112
  "./legal-pages": "./src/legal-pages/index.ts",
115
113
  "./legal-pages/web": "./src/legal-pages/web/index.ts",
@@ -122,12 +120,12 @@
122
120
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
123
121
  },
124
122
  "dependencies": {
125
- "@cosmicdrift/kumiko-dispatcher-live": "0.174.1",
126
- "@cosmicdrift/kumiko-framework": "0.174.1",
127
- "@cosmicdrift/kumiko-headless": "0.174.1",
128
- "@cosmicdrift/kumiko-renderer": "0.174.1",
129
- "@cosmicdrift/kumiko-renderer-web": "0.174.1",
130
- "@cosmicdrift/kumiko-types": "0.174.1",
123
+ "@cosmicdrift/kumiko-dispatcher-live": "0.176.0",
124
+ "@cosmicdrift/kumiko-framework": "0.176.0",
125
+ "@cosmicdrift/kumiko-headless": "0.176.0",
126
+ "@cosmicdrift/kumiko-renderer": "0.176.0",
127
+ "@cosmicdrift/kumiko-renderer-web": "0.176.0",
128
+ "@cosmicdrift/kumiko-types": "0.176.0",
131
129
  "@mollie/api-client": "^4.5.0",
132
130
  "imapflow": "^1.3.3",
133
131
  "mailparser": "^3.9.8",
@@ -17,17 +17,21 @@ import {
17
17
  tenantComplianceProfileTable,
18
18
  } from "../compliance-profiles/schema/profile-selection";
19
19
  import { seedComplianceProfile } from "../compliance-profiles/seeding";
20
- import { createTextContentFeature } from "../text-content/feature";
21
- import { seedTextBlock } from "../text-content/seeding";
22
- import { type TextBlockRow, textBlockEntity, textBlocksTable } from "../text-content/table";
20
+ import { createTemplateResolverFeature } from "../template-resolver/feature";
21
+ import { seedTextBlock } from "../template-resolver/seeding";
22
+ import {
23
+ type TemplateResourceRow,
24
+ templateResourceEntity,
25
+ templateResourcesTable,
26
+ } from "../template-resolver/table";
23
27
 
24
28
  let stack: TestStack;
25
29
 
26
30
  beforeAll(async () => {
27
31
  stack = await setupTestStack({
28
- features: [createTextContentFeature(), createComplianceProfilesFeature()],
32
+ features: [createTemplateResolverFeature(), createComplianceProfilesFeature()],
29
33
  });
30
- await unsafeCreateEntityTable(stack.db, textBlockEntity);
34
+ await unsafeCreateEntityTable(stack.db, templateResourceEntity);
31
35
  await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
32
36
  await createEventsTable(stack.db);
33
37
  });
@@ -43,32 +47,32 @@ describe("boot-seed contract", () => {
43
47
  await seedTextBlock(stack.db, {
44
48
  tenantId,
45
49
  slug: "imprint",
46
- lang: "de",
50
+ locale: "de",
47
51
  title: "Impressum",
48
- body: "seed body",
52
+ content: "seed body",
49
53
  });
50
54
  await seedTextBlock(stack.db, {
51
55
  tenantId,
52
56
  slug: "imprint",
53
- lang: "de",
57
+ locale: "de",
54
58
  title: "Impressum (edited)",
55
- body: "admin body",
59
+ content: "admin body",
56
60
  ifExists: "update",
57
61
  });
58
62
  await seedTextBlock(stack.db, {
59
63
  tenantId,
60
64
  slug: "imprint",
61
- lang: "de",
65
+ locale: "de",
62
66
  title: "Impressum",
63
- body: "seed body",
67
+ content: "seed body",
64
68
  });
65
69
 
66
- const row = await fetchOne<TextBlockRow>(stack.db, textBlocksTable, {
70
+ const row = await fetchOne<TemplateResourceRow>(stack.db, templateResourcesTable, {
67
71
  tenantId,
68
72
  slug: "imprint",
69
- lang: "de",
73
+ locale: "de",
70
74
  });
71
- expect(row).toMatchObject({ title: "Impressum (edited)", body: "admin body", version: 2 });
75
+ expect(row).toMatchObject({ title: "Impressum (edited)", content: "admin body", version: 2 });
72
76
 
73
77
  const events = await selectMany(stack.db, eventsTable, { aggregateId: String(row!.id) });
74
78
  expect(events).toHaveLength(2);
@@ -1,6 +1,6 @@
1
1
  # legal-pages
2
2
 
3
- Opt-in wrapper around [`text-content`](../text-content/) for
3
+ Opt-in wrapper around [`template-resolver`](../template-resolver/) text-blocks for
4
4
  DACH compliance. Ships four fixed public HTML routes
5
5
  (`/legal/impressum`, `/legal/datenschutz`, `/legal/imprint`,
6
6
  `/legal/privacy`) with Markdown→HTML rendering and a boot check that
@@ -17,25 +17,25 @@ feature.
17
17
  ```typescript
18
18
  import { createLegalPagesFeature } from "@cosmicdrift/kumiko-bundled-features/legal-pages";
19
19
  import {
20
- createTextContentApi,
21
- createTextContentFeature,
22
- } from "@cosmicdrift/kumiko-bundled-features/text-content";
20
+ createTemplateResolverApi,
21
+ createTemplateResolverFeature,
22
+ } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
23
23
  import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
24
24
 
25
25
  runProdApp({
26
26
  features: [
27
- createTextContentFeature(), // legal-pages requires text-content
27
+ createTemplateResolverFeature(), // legal-pages requires template-resolver
28
28
  createLegalPagesFeature(),
29
29
  /* ... */
30
30
  ],
31
31
  // Two wirings are required:
32
32
  // 1. anonymousAccess for /legal/* routes (run without a JWT)
33
- // 2. extraContext.textContent for the boot check (cross-feature
34
- // decoupling — legal-pages imports no code from text-content,
33
+ // 2. extraContext.templateResolver for the boot check (cross-feature
34
+ // decoupling — legal-pages imports no code from template-resolver,
35
35
  // only uses the API via ctx)
36
36
  anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID },
37
37
  extraContext: ({ db }) => ({
38
- textContent: createTextContentApi(db),
38
+ templateResolver: createTemplateResolverApi(db),
39
39
  }),
40
40
  });
41
41
  ```
@@ -44,19 +44,20 @@ runProdApp({
44
44
 
45
45
  ### Production table setup
46
46
 
47
- legal-pages doesn't have its own table — it uses text-content's
48
- `read_text_blocks`. Table setup therefore goes through text-content:
47
+ legal-pages doesn't have its own table — it stores its blocks as
48
+ `kind: "text-block"` rows in template-resolver's `read_template_resources`.
49
+ Table setup therefore goes through template-resolver:
49
50
 
50
51
  ```bash
51
- bun kumiko migrate generate # text-block entity is detected
52
- bun kumiko migrate apply
52
+ bun kumiko schema generate <name> # template-resource entity is detected
53
+ bun kumiko schema apply
53
54
  ```
54
55
 
55
- See [text-content/README.md](../text-content/README.md#production-table-setup).
56
+ See [template-resolver/README.md](../template-resolver/README.md).
56
57
 
57
58
  ## Routes
58
59
 
59
- | Path | Slug + lang | Title fallback (when block empty) |
60
+ | Path | Slug + locale | Title fallback (when block empty) |
60
61
  |---|---|---|
61
62
  | `GET /legal/impressum` | `imprint` / `de` | "Impressum" |
62
63
  | `GET /legal/datenschutz` | `privacy` / `de` | "Datenschutzerklärung" |
@@ -64,12 +65,12 @@ See [text-content/README.md](../text-content/README.md#production-table-setup).
64
65
  | `GET /legal/privacy` | `privacy` / `en` | "Privacy Policy" |
65
66
 
66
67
  Response:
67
- - `200 text/html` — block exists + has body. Cache header `public, max-age=300`.
68
+ - `200 text/html` — block exists + has content. Cache header `public, max-age=300`.
68
69
  - `404 text/plain` — block missing. Hint: "Tenant admin must set this text block".
69
70
  - `503 text/plain` — `app.fetch` to `/api/query` failed (anonymousAccess missing?).
70
71
 
71
72
  Layout: a minimal HTML5 skeleton with inline CSS — apps that want to
72
- integrate into their own layout use `text-content:query:by-slug`
73
+ integrate into their own layout use `template-resolver:query:by-slug`
73
74
  directly and render themselves.
74
75
 
75
76
  ---
@@ -79,7 +80,7 @@ directly and render themselves.
79
80
  `r.job` with `runOnBoot: true` checks at app start whether the DE
80
81
  required blocks exist in SYSTEM_TENANT:
81
82
 
82
- | Slug + lang | What happens when missing |
83
+ | Slug + locale | What happens when missing |
83
84
  |---|---|
84
85
  | `imprint` / `de` | **Production:** `throw new Error(...)` blocks app start. **Dev:** `ctx.log.warn(...)` |
85
86
  | `privacy` / `de` | as above |
@@ -104,18 +105,18 @@ await fetch("/api/write", {
104
105
  method: "POST",
105
106
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${jwt}` },
106
107
  body: JSON.stringify({
107
- type: "text-content:write:set",
108
+ type: "template-resolver:write:set",
108
109
  payload: {
109
110
  slug: "imprint",
110
- lang: "de",
111
+ locale: "de",
111
112
  title: "Impressum",
112
- body: "## Angaben gemäß § 5 TMG\n\n...",
113
+ content: "## Angaben gemäß § 5 TMG\n\n...",
113
114
  },
114
115
  }),
115
116
  });
116
117
  ```
117
118
 
118
- → Idempotent: a second call with the same `(slug, lang)` updates the block.
119
+ → Idempotent: a second call with the same `(slug, locale)` updates the block.
119
120
  ACL: `roles: ["TenantAdmin", "SystemAdmin"]` — SystemAdmin (a global
120
121
  role) may set SYSTEM_TENANT texts, TenantAdmin only tenant-owned ones.
121
122
 
@@ -128,15 +129,15 @@ instant visibility, you can help things along with a CDN purge.
128
129
  On first app boot or via migration:
129
130
 
130
131
  ```typescript
131
- import { seedTextBlock } from "@cosmicdrift/kumiko-bundled-features/text-content/seeding";
132
+ import { seedTextBlock } from "@cosmicdrift/kumiko-bundled-features/template-resolver/seeding";
132
133
  import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
133
134
 
134
135
  await seedTextBlock(db, {
135
136
  tenantId: SYSTEM_TENANT_ID,
136
137
  slug: "imprint",
137
- lang: "de",
138
+ locale: "de",
138
139
  title: "Impressum",
139
- body: `## Angaben gemäß § 5 TMG
140
+ content: `## Angaben gemäß § 5 TMG
140
141
 
141
142
  **Marc Frost**
142
143
 
@@ -181,7 +182,7 @@ no DOMPurify dependency needed:
181
182
  Kumiko app share the SYSTEM_TENANT version of the legal pages. If you
182
183
  need per-tenant imprints (rare — typical case: the platform operator
183
184
  is the responsible party, not the tenant customer), call
184
- text-content's by-slug query directly with a tenant-specific TenantId
185
+ the by-slug query directly with a tenant-specific TenantId
185
186
  and put your own routes in front.
186
187
 
187
188
  ---
@@ -1,11 +1,16 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
- import type { TextContentApi } from "@cosmicdrift/kumiko-bundled-features/text-content";
2
+ import type {
3
+ TemplateResolverApi,
4
+ TemplateResource,
5
+ } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
3
6
  import {
4
- createTextContentApi,
5
- createTextContentFeature,
6
- textBlockEntity,
7
- } from "@cosmicdrift/kumiko-bundled-features/text-content";
8
- import { seedTextBlock } from "@cosmicdrift/kumiko-bundled-features/text-content/seeding";
7
+ createTemplateResolverApi,
8
+ createTemplateResolverFeature,
9
+ TEXT_BLOCK_KIND,
10
+ TemplateNotFoundError,
11
+ templateResourceEntity,
12
+ } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
13
+ import { seedTextBlock } from "@cosmicdrift/kumiko-bundled-features/template-resolver/seeding";
9
14
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
10
15
  import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
11
16
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
@@ -20,46 +25,46 @@ import { renderMarkdownToHtml, wrapInLayout } from "../markdown";
20
25
  let stack: TestStack;
21
26
  let db: DbConnection;
22
27
 
23
- const textFeature = createTextContentFeature();
28
+ const textFeature = createTemplateResolverFeature();
24
29
  const legalFeature = createLegalPagesFeature();
25
30
 
26
31
  beforeAll(async () => {
27
32
  // legal-pages braucht zwei wirings:
28
33
  // 1. anonymousAccess für die /legal/*-Routes (laufen ohne JWT)
29
- // 2. extraContext.textContent damit der Boot-Check + interner
34
+ // 2. extraContext.templateResolver damit der Boot-Check + interner
30
35
  // Cross-Feature-Lookup ohne direct DB-Coupling funktioniert
31
36
  stack = await setupTestStack({
32
37
  features: [textFeature, legalFeature],
33
38
  anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID },
34
39
  extraContext: ({ db }) => ({
35
- textContent: createTextContentApi(db),
40
+ templateResolver: createTemplateResolverApi(db),
36
41
  }),
37
42
  });
38
43
  db = stack.db;
39
- await unsafeCreateEntityTable(db, textBlockEntity);
44
+ await unsafeCreateEntityTable(db, templateResourceEntity);
40
45
  await createEventsTable(db);
41
46
 
42
47
  // Seed legal blocks für SYSTEM_TENANT in DE
43
48
  await seedTextBlock(db, {
44
49
  tenantId: SYSTEM_TENANT_ID,
45
50
  slug: "imprint",
46
- lang: "de",
51
+ locale: "de",
47
52
  title: "Impressum",
48
- body: "## Angaben gemäß § 5 TMG\n\n**Marc Frost**\n\nSlevogtstr. 10, Leipzig",
53
+ content: "## Angaben gemäß § 5 TMG\n\n**Marc Frost**\n\nSlevogtstr. 10, Leipzig",
49
54
  });
50
55
  await seedTextBlock(db, {
51
56
  tenantId: SYSTEM_TENANT_ID,
52
57
  slug: "privacy",
53
- lang: "de",
58
+ locale: "de",
54
59
  title: "Datenschutzerklärung",
55
- body: "## 1. Überblick\n\nWir verarbeiten **keine Tracking-Cookies**.",
60
+ content: "## 1. Überblick\n\nWir verarbeiten **keine Tracking-Cookies**.",
56
61
  });
57
62
  await seedTextBlock(db, {
58
63
  tenantId: SYSTEM_TENANT_ID,
59
64
  slug: "imprint",
60
- lang: "en",
65
+ locale: "en",
61
66
  title: "Imprint",
62
- body: "## Provider\n\n**Marc Frost**\n\nLeipzig, Germany",
67
+ content: "## Provider\n\n**Marc Frost**\n\nLeipzig, Germany",
63
68
  });
64
69
  });
65
70
 
@@ -120,9 +125,9 @@ describe("legal-pages :: edge-cases", () => {
120
125
  await seedTextBlock(db, {
121
126
  tenantId: SYSTEM_TENANT_ID,
122
127
  slug: "imprint",
123
- lang: "fr",
128
+ locale: "fr",
124
129
  title: "Mentions légales",
125
- body: null,
130
+ content: null,
126
131
  });
127
132
  // Keine /legal/imprint-fr-Route registriert (LEGAL_ROUTES ist
128
133
  // de+en) — wir adden nicht extra. Stattdessen testen wir das
@@ -131,9 +136,9 @@ describe("legal-pages :: edge-cases", () => {
131
136
  await seedTextBlock(db, {
132
137
  tenantId: SYSTEM_TENANT_ID,
133
138
  slug: "privacy",
134
- lang: "en",
139
+ locale: "en",
135
140
  title: "Privacy Policy",
136
- body: null,
141
+ content: null,
137
142
  });
138
143
  const res = await stack.app.request("/legal/privacy");
139
144
  expect(res.status).toBe(404);
@@ -147,9 +152,9 @@ describe("legal-pages :: edge-cases", () => {
147
152
  await seedTextBlock(db, {
148
153
  tenantId: SYSTEM_TENANT_ID,
149
154
  slug: "imprint",
150
- lang: "de",
155
+ locale: "de",
151
156
  title: "Impressum",
152
- body: "## XSS-Test\n\n<script>window.x=1</script>\n\nDanach.",
157
+ content: "## XSS-Test\n\n<script>window.x=1</script>\n\nDanach.",
153
158
  ifExists: "update",
154
159
  });
155
160
  const res = await stack.app.request("/legal/impressum");
@@ -233,7 +238,7 @@ describe("legal-pages :: SYSTEM_TENANT-routing (production-bug-regression)", ()
233
238
  // (leeren) imprint-Block abfragen → 404. Mit Fix immer SYSTEM_TENANT.
234
239
  const otherTenantId = "22222222-2222-4222-8222-222222222222";
235
240
  const hostScopedStack = await setupTestStack({
236
- features: [createTextContentFeature(), createLegalPagesFeature()],
241
+ features: [createTemplateResolverFeature(), createLegalPagesFeature()],
237
242
  anonymousAccess: {
238
243
  // Resolver gibt IMMER einen anderen Tenant zurück — wenn legal-
239
244
  // pages den respektieren würde, wäre der DB-Lookup leer.
@@ -245,20 +250,20 @@ describe("legal-pages :: SYSTEM_TENANT-routing (production-bug-regression)", ()
245
250
  tenantExists: async (id) => id === otherTenantId || id === SYSTEM_TENANT_ID,
246
251
  },
247
252
  extraContext: ({ db }) => ({
248
- textContent: createTextContentApi(db),
253
+ templateResolver: createTemplateResolverApi(db),
249
254
  }),
250
255
  });
251
256
  try {
252
- await unsafeCreateEntityTable(hostScopedStack.db, textBlockEntity);
257
+ await unsafeCreateEntityTable(hostScopedStack.db, templateResourceEntity);
253
258
  await createEventsTable(hostScopedStack.db);
254
259
 
255
260
  // Block NUR im SYSTEM_TENANT seeden — NICHT im otherTenantId
256
261
  await seedTextBlock(hostScopedStack.db, {
257
262
  tenantId: SYSTEM_TENANT_ID,
258
263
  slug: "imprint",
259
- lang: "de",
264
+ locale: "de",
260
265
  title: "System-Impressum",
261
- body: "## Plattform\n\nMarc Frost",
266
+ content: "## Plattform\n\nMarc Frost",
262
267
  });
263
268
 
264
269
  const res = await hostScopedStack.app.request("/legal/impressum");
@@ -277,7 +282,7 @@ describe("legal-pages :: wrapLayout erhält route.slug (alt-lang-switch-regressi
277
282
  const seenSlugs: (string | undefined)[] = [];
278
283
  const customStack = await setupTestStack({
279
284
  features: [
280
- createTextContentFeature(),
285
+ createTemplateResolverFeature(),
281
286
  createLegalPagesFeature({
282
287
  wrapLayout: (opts) => {
283
288
  seenSlugs.push(opts.slug);
@@ -287,25 +292,25 @@ describe("legal-pages :: wrapLayout erhält route.slug (alt-lang-switch-regressi
287
292
  ],
288
293
  anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID },
289
294
  extraContext: ({ db }) => ({
290
- textContent: createTextContentApi(db),
295
+ templateResolver: createTemplateResolverApi(db),
291
296
  }),
292
297
  });
293
298
  try {
294
- await unsafeCreateEntityTable(customStack.db, textBlockEntity);
299
+ await unsafeCreateEntityTable(customStack.db, templateResourceEntity);
295
300
  await createEventsTable(customStack.db);
296
301
  await seedTextBlock(customStack.db, {
297
302
  tenantId: SYSTEM_TENANT_ID,
298
303
  slug: "imprint",
299
- lang: "de",
304
+ locale: "de",
300
305
  title: "Impressum",
301
- body: "Body",
306
+ content: "Body",
302
307
  });
303
308
  await seedTextBlock(customStack.db, {
304
309
  tenantId: SYSTEM_TENANT_ID,
305
310
  slug: "privacy",
306
- lang: "de",
311
+ locale: "de",
307
312
  title: "Datenschutzerklärung",
308
- body: "Body",
313
+ content: "Body",
309
314
  });
310
315
 
311
316
  const imprintRes = await customStack.app.request("/legal/impressum");
@@ -328,36 +333,61 @@ describe("legal-pages :: runLegalPagesBootCheck (direct unit-tests)", () => {
328
333
  // keine JobRunner-Coupling, keine Test-Stacks. Das ist die echte
329
334
  // Verhalten-Test-Surface; r.job() ist nur thin shell darum.
330
335
 
331
- type Block = { slug: string; lang: string; title: string; body: string | null };
336
+ type Block = { slug: string; lang: string; title: string; content: string | null };
332
337
 
333
- function fakeTextContent(blocks: readonly Block[]): {
334
- api: TextContentApi;
338
+ function fakeTemplateResolver(blocks: readonly Block[]): {
339
+ api: TemplateResolverApi;
335
340
  calls: { tenantId: string; slug: string; lang: string }[];
336
341
  } {
337
342
  const calls: { tenantId: string; slug: string; lang: string }[] = [];
343
+ const find = (slug: string, locale: string): TemplateResource | null => {
344
+ const block = blocks.find((b) => b.slug === slug && b.lang === locale);
345
+ if (!block) return null;
346
+ return {
347
+ id: `${block.slug}-${block.lang}`,
348
+ version: 1,
349
+ tenantId: SYSTEM_TENANT_ID,
350
+ slug: block.slug,
351
+ kind: TEXT_BLOCK_KIND,
352
+ locale: block.lang,
353
+ title: block.title,
354
+ folder: null,
355
+ content: block.content ?? "",
356
+ contentFormat: "markdown",
357
+ variableSchema: {},
358
+ linkedResources: {},
359
+ scope: "system",
360
+ parentTemplateId: null,
361
+ status: "active",
362
+ updatedAt: new Date(),
363
+ };
364
+ };
338
365
  return {
339
366
  calls,
340
367
  api: {
341
- getBlock: async ({ tenantId, slug, lang }) => {
342
- calls.push({ tenantId, slug, lang });
343
- const block = blocks.find((b) => b.slug === slug && b.lang === lang);
344
- if (!block) return null;
345
- return { ...block, updatedAt: new Date() };
368
+ findExact: async ({ tenantId, slug, locale }) => {
369
+ calls.push({ tenantId, slug, lang: locale });
370
+ return find(slug, locale);
371
+ },
372
+ resolveTemplate: async ({ slug, kind, locale }) => {
373
+ const found = find(slug, locale);
374
+ if (!found) throw new TemplateNotFoundError({ slug, kind, locale });
375
+ return found;
346
376
  },
347
377
  },
348
378
  };
349
379
  }
350
380
 
351
381
  test("alle Pflicht-Blocks vorhanden → log.info, kein throw", async () => {
352
- const { api } = fakeTextContent([
353
- { slug: "imprint", lang: "de", title: "I", body: "body" },
354
- { slug: "privacy", lang: "de", title: "P", body: "body" },
382
+ const { api } = fakeTemplateResolver([
383
+ { slug: "imprint", lang: "de", title: "I", content: "body" },
384
+ { slug: "privacy", lang: "de", title: "P", content: "body" },
355
385
  ]);
356
386
  const infos: string[] = [];
357
387
  const warns: string[] = [];
358
388
  await expect(
359
389
  runLegalPagesBootCheck({
360
- textContent: api,
390
+ templateResolver: api,
361
391
  log: { info: (m) => infos.push(m), warn: (m) => warns.push(m) },
362
392
  }),
363
393
  ).resolves.toBeUndefined();
@@ -367,11 +397,11 @@ describe("legal-pages :: runLegalPagesBootCheck (direct unit-tests)", () => {
367
397
  });
368
398
 
369
399
  test("missing blocks + NODE_ENV=production → throws mit slug-Liste", async () => {
370
- const { api } = fakeTextContent([]);
400
+ const { api } = fakeTemplateResolver([]);
371
401
  const originalEnv = process.env["NODE_ENV"];
372
402
  process.env["NODE_ENV"] = "production";
373
403
  try {
374
- await expect(runLegalPagesBootCheck({ textContent: api })).rejects.toThrow(
404
+ await expect(runLegalPagesBootCheck({ templateResolver: api })).rejects.toThrow(
375
405
  /Boot-Validation failed.*imprint\/de.*privacy\/de/s,
376
406
  );
377
407
  } finally {
@@ -381,14 +411,14 @@ describe("legal-pages :: runLegalPagesBootCheck (direct unit-tests)", () => {
381
411
  });
382
412
 
383
413
  test("missing blocks + NODE_ENV!=production → log.warn, kein throw", async () => {
384
- const { api } = fakeTextContent([]);
414
+ const { api } = fakeTemplateResolver([]);
385
415
  const warns: string[] = [];
386
416
  const originalEnv = process.env["NODE_ENV"];
387
417
  process.env["NODE_ENV"] = "development";
388
418
  try {
389
419
  await expect(
390
420
  runLegalPagesBootCheck({
391
- textContent: api,
421
+ templateResolver: api,
392
422
  log: { warn: (m) => warns.push(m) },
393
423
  }),
394
424
  ).resolves.toBeUndefined();
@@ -402,21 +432,23 @@ describe("legal-pages :: runLegalPagesBootCheck (direct unit-tests)", () => {
402
432
  }
403
433
  });
404
434
 
405
- test("ctx ohne textContent → InternalError mit Wiring-Hinweis", async () => {
406
- await expect(runLegalPagesBootCheck({})).rejects.toThrow(/textContent missing.*extraContext/s);
435
+ test("ctx ohne templateResolver → InternalError mit Wiring-Hinweis", async () => {
436
+ await expect(runLegalPagesBootCheck({})).rejects.toThrow(
437
+ /templateResolver missing.*extraContext/s,
438
+ );
407
439
  });
408
440
 
409
- test("Block existiert aber body ist null → wird als missing gezählt", async () => {
410
- const { api } = fakeTextContent([
411
- { slug: "imprint", lang: "de", title: "I", body: null },
412
- { slug: "privacy", lang: "de", title: "P", body: "body" },
441
+ test("Block existiert aber content ist null → wird als missing gezählt", async () => {
442
+ const { api } = fakeTemplateResolver([
443
+ { slug: "imprint", lang: "de", title: "I", content: null },
444
+ { slug: "privacy", lang: "de", title: "P", content: "body" },
413
445
  ]);
414
446
  const warns: string[] = [];
415
447
  const originalEnv = process.env["NODE_ENV"];
416
448
  process.env["NODE_ENV"] = "development";
417
449
  try {
418
450
  await runLegalPagesBootCheck({
419
- textContent: api,
451
+ templateResolver: api,
420
452
  log: { warn: (m) => warns.push(m) },
421
453
  });
422
454
  expect(warns[0]).toContain("missing 1 required text-block(s)");
@@ -429,11 +461,11 @@ describe("legal-pages :: runLegalPagesBootCheck (direct unit-tests)", () => {
429
461
  });
430
462
 
431
463
  test("alle Lookups erfolgen gegen SYSTEM_TENANT_ID (nie tenant-scoped)", async () => {
432
- const { api, calls } = fakeTextContent([
433
- { slug: "imprint", lang: "de", title: "I", body: "x" },
434
- { slug: "privacy", lang: "de", title: "P", body: "x" },
464
+ const { api, calls } = fakeTemplateResolver([
465
+ { slug: "imprint", lang: "de", title: "I", content: "x" },
466
+ { slug: "privacy", lang: "de", title: "P", content: "x" },
435
467
  ]);
436
- await runLegalPagesBootCheck({ textContent: api });
468
+ await runLegalPagesBootCheck({ templateResolver: api });
437
469
  expect(calls).toHaveLength(2);
438
470
  for (const call of calls) {
439
471
  expect(call.tenantId).toBe(SYSTEM_TENANT_ID);