@cosmicdrift/kumiko-bundled-features 0.145.0 → 0.146.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 (37) hide show
  1. package/package.json +7 -6
  2. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +15 -55
  3. package/src/custom-fields/__tests__/parse-serialized-field.test.ts +11 -3
  4. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +24 -79
  5. package/src/custom-fields/db/queries/user-data-rights.ts +0 -20
  6. package/src/custom-fields/events.ts +4 -9
  7. package/src/custom-fields/feature.ts +8 -0
  8. package/src/custom-fields/handlers/set-custom-field.write.ts +3 -44
  9. package/src/custom-fields/handlers/update-tenant-field.write.ts +0 -27
  10. package/src/custom-fields/lib/parse-serialized-field.ts +12 -4
  11. package/src/custom-fields/lib/value-schema.ts +1 -1
  12. package/src/custom-fields/schemas.ts +9 -9
  13. package/src/custom-fields/wire-for-entity.ts +8 -15
  14. package/src/custom-fields/wire-user-data-rights.ts +5 -54
  15. package/src/inbound-mail-foundation/__tests__/retention.integration.test.ts +199 -0
  16. package/src/inbound-mail-foundation/feature.ts +58 -0
  17. package/src/inbound-mail-foundation/retention-sweep.ts +155 -0
  18. package/src/managed-pages/__tests__/managed-pages.integration.test.ts +26 -0
  19. package/src/managed-pages/feature.ts +3 -1
  20. package/src/managed-pages/handlers/by-tenant-published.query.ts +52 -0
  21. package/src/page-render/__tests__/layout.test.ts +53 -0
  22. package/src/page-render/index.ts +1 -1
  23. package/src/page-render/layout.ts +15 -1
  24. package/src/seo/__tests__/llms-txt.test.ts +38 -0
  25. package/src/seo/__tests__/robots-txt.test.ts +18 -0
  26. package/src/seo/__tests__/schema-builders.test.ts +57 -0
  27. package/src/seo/__tests__/seo.integration.test.ts +258 -0
  28. package/src/seo/__tests__/sitemap.test.ts +38 -0
  29. package/src/seo/constants.ts +60 -0
  30. package/src/seo/feature.ts +313 -0
  31. package/src/seo/handlers/seo-config.query.ts +35 -0
  32. package/src/seo/index.ts +19 -0
  33. package/src/seo/llms-txt.ts +33 -0
  34. package/src/seo/robots-txt.ts +13 -0
  35. package/src/seo/schema-builders.ts +57 -0
  36. package/src/seo/sitemap.ts +30 -0
  37. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +39 -62
@@ -0,0 +1,53 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { wrapInLayout } from "../layout";
3
+
4
+ describe("wrapInLayout :: seo (opt-in OG/JSON-LD extension)", () => {
5
+ test("without `seo` — unchanged minimal title+description head (no regression)", () => {
6
+ const html = wrapInLayout({
7
+ title: "About",
8
+ bodyHtml: "<p>x</p>",
9
+ lang: "en",
10
+ description: "About us",
11
+ });
12
+ expect(html).toContain("<title>About</title>");
13
+ expect(html).toContain('<meta name="description" content="About us">');
14
+ expect(html).not.toContain("og:title");
15
+ expect(html).not.toContain("application/ld+json");
16
+ });
17
+
18
+ test("with `seo` — emits OG/canonical/JSON-LD via the shared apex head renderer", () => {
19
+ const html = wrapInLayout({
20
+ title: "About",
21
+ bodyHtml: "<p>x</p>",
22
+ lang: "en",
23
+ description: "About us",
24
+ seo: {
25
+ canonicalUrl: "https://acme.test/about",
26
+ ogImage: "https://acme.test/og.png",
27
+ siteName: "Acme",
28
+ schemaJson: { "@context": "https://schema.org", "@type": "WebPage", name: "About" },
29
+ },
30
+ });
31
+ expect(html).toContain("<title>About</title>");
32
+ expect(html).toContain('<meta property="og:title" content="About" />');
33
+ expect(html).toContain('<meta property="og:description" content="About us" />');
34
+ expect(html).toContain('<link rel="canonical" href="https://acme.test/about" />');
35
+ expect(html).toContain('<meta property="og:image" content="https://acme.test/og.png" />');
36
+ expect(html).toContain('<meta property="og:site_name" content="Acme" />');
37
+ expect(html).toContain('<script type="application/ld+json">');
38
+ expect(html).toContain('"@type":"WebPage"');
39
+ // no-`seo` minimal-description meta must NOT also appear (headTags replaces it)
40
+ expect(html).not.toContain('<meta name="description" content="About us">');
41
+ });
42
+
43
+ test("with `seo` — escapes title/description same as the non-seo path", () => {
44
+ const html = wrapInLayout({
45
+ title: "<script>alert(1)</script>",
46
+ bodyHtml: "x",
47
+ lang: "en",
48
+ seo: {},
49
+ });
50
+ expect(html).not.toContain("<script>alert(1)</script>");
51
+ expect(html).toContain("&lt;script&gt;");
52
+ });
53
+ });
@@ -12,6 +12,6 @@ export {
12
12
  cachedSecurePageResponse,
13
13
  } from "./cached-page-response";
14
14
  export { sanitizeTenantCss } from "./css-sanitize";
15
- export { TENANT_CONTENT_ATTR, tenantStyleBlock, wrapInLayout } from "./layout";
15
+ export { type SeoHeadInput, TENANT_CONTENT_ATTR, tenantStyleBlock, wrapInLayout } from "./layout";
16
16
  export { renderSafeMarkdown } from "./markdown";
17
17
  export { securePageHeaders } from "./security-headers";
@@ -1,7 +1,13 @@
1
1
  import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
2
+ import { type ApexHead, renderApexHeadTags } from "@cosmicdrift/kumiko-headless/apex";
2
3
  import { type BrandingTokens, brandingHeaderHtml, brandingStyleBlock } from "./branding";
3
4
  import { sanitizeTenantCss } from "./css-sanitize";
4
5
 
6
+ // Optional OG/JSON-LD/canonical extension for wrapInLayout — the same field
7
+ // set renderApexPage accepts, minus title/description/lang (those come from
8
+ // wrapInLayout's own opts, so callers don't repeat them).
9
+ export type SeoHeadInput = Omit<ApexHead, "title" | "description" | "lang">;
10
+
5
11
  // Attribute marking the content container. The page body lives in
6
12
  // `<main data-tenant-content>`; tenant custom CSS is scoped to its descendants
7
13
  // and host containment clips its paint to this box. A custom wrapLayout that
@@ -52,6 +58,9 @@ export function wrapInLayout(opts: {
52
58
  lang: string;
53
59
  description?: string | null;
54
60
  branding?: BrandingTokens;
61
+ /** Adds Open Graph/Twitter/canonical/JSON-LD tags via the shared apex head
62
+ * renderer. Omitted → unchanged minimal title+description behavior. */
63
+ seo?: SeoHeadInput;
55
64
  }): string {
56
65
  const themeStyleHtml = opts.branding ? brandingStyleBlock(opts.branding) : "";
57
66
  const header = opts.branding ? brandingHeaderHtml(opts.branding) : "";
@@ -69,12 +78,17 @@ export function wrapInLayout(opts: {
69
78
  const metaDescription = description
70
79
  ? `\n<meta name="description" content="${escapeHtmlAttr(description)}">`
71
80
  : "";
81
+ // Without `seo`: identical title+description emission as before (no
82
+ // behavior change for legal-pages/managed-pages callers that don't pass it).
83
+ const headHtml = opts.seo
84
+ ? renderApexHeadTags({ title: opts.title, description, lang: opts.lang, ...opts.seo })
85
+ : `<title>${escapeHtml(opts.title)}</title>${metaDescription}`;
72
86
  return `<!doctype html>
73
87
  <html lang="${escapeHtmlAttr(opts.lang)}">
74
88
  <head>
75
89
  <meta charset="utf-8">
76
90
  <meta name="viewport" content="width=device-width, initial-scale=1">
77
- <title>${escapeHtml(opts.title)}</title>${metaDescription}
91
+ ${headHtml}
78
92
  <style>
79
93
  :root { --accent: #0066cc; --page-max-width: 720px; }
80
94
  body { font-family: system-ui, -apple-system, sans-serif; max-width: var(--page-max-width);
@@ -0,0 +1,38 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildLlmsTxt } from "../llms-txt";
3
+
4
+ describe("buildLlmsTxt", () => {
5
+ test("emits H1 title + blockquote summary", () => {
6
+ const text = buildLlmsTxt({ title: "Acme", summary: "Acme does things." });
7
+ expect(text).toStartWith("# Acme\n\n> Acme does things.");
8
+ });
9
+
10
+ test("emits ## sections with markdown links", () => {
11
+ const text = buildLlmsTxt({
12
+ title: "Acme",
13
+ summary: "s",
14
+ sections: [
15
+ {
16
+ heading: "Pages",
17
+ links: [{ title: "About", url: "https://acme.test/about", desc: "About us" }],
18
+ },
19
+ ],
20
+ });
21
+ expect(text).toContain("## Pages");
22
+ expect(text).toContain("- [About](https://acme.test/about): About us");
23
+ });
24
+
25
+ test("empty sections are omitted, no trailing section markers", () => {
26
+ const text = buildLlmsTxt({
27
+ title: "Acme",
28
+ summary: "s",
29
+ sections: [{ heading: "Empty", links: [] }],
30
+ });
31
+ expect(text).not.toContain("## Empty");
32
+ });
33
+
34
+ test("no summary → no blockquote line", () => {
35
+ const text = buildLlmsTxt({ title: "Acme", summary: "" });
36
+ expect(text).toBe("# Acme\n");
37
+ });
38
+ });
@@ -0,0 +1,18 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildRobotsTxt } from "../robots-txt";
3
+
4
+ describe("buildRobotsTxt", () => {
5
+ test("allow: true → no Disallow rule", () => {
6
+ expect(buildRobotsTxt({ allow: true })).toBe("User-agent: *\nDisallow:\n");
7
+ });
8
+
9
+ test("allow: false → Disallow: /", () => {
10
+ expect(buildRobotsTxt({ allow: false })).toBe("User-agent: *\nDisallow: /\n");
11
+ });
12
+
13
+ test("sitemapUrl appends a Sitemap line", () => {
14
+ expect(buildRobotsTxt({ allow: true, sitemapUrl: "https://acme.test/sitemap.xml" })).toBe(
15
+ "User-agent: *\nDisallow:\nSitemap: https://acme.test/sitemap.xml\n",
16
+ );
17
+ });
18
+ });
@@ -0,0 +1,57 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { faqPageSchema, organizationSchema, webPageSchema } from "../schema-builders";
3
+
4
+ describe("organizationSchema", () => {
5
+ test("minimal input", () => {
6
+ expect(organizationSchema({ name: "Acme" })).toEqual({
7
+ "@context": "https://schema.org",
8
+ "@type": "Organization",
9
+ name: "Acme",
10
+ });
11
+ });
12
+
13
+ test("full input", () => {
14
+ expect(
15
+ organizationSchema({
16
+ name: "Acme",
17
+ url: "https://acme.test",
18
+ logoUrl: "https://acme.test/logo.png",
19
+ sameAs: ["https://x.com/acme"],
20
+ }),
21
+ ).toEqual({
22
+ "@context": "https://schema.org",
23
+ "@type": "Organization",
24
+ name: "Acme",
25
+ url: "https://acme.test",
26
+ logo: "https://acme.test/logo.png",
27
+ sameAs: ["https://x.com/acme"],
28
+ });
29
+ });
30
+ });
31
+
32
+ describe("webPageSchema", () => {
33
+ test("emits WebPage type", () => {
34
+ expect(webPageSchema({ name: "About", url: "https://acme.test/about" })).toMatchObject({
35
+ "@type": "WebPage",
36
+ name: "About",
37
+ url: "https://acme.test/about",
38
+ });
39
+ });
40
+ });
41
+
42
+ describe("faqPageSchema", () => {
43
+ test("maps question/answer pairs to mainEntity", () => {
44
+ const schema = faqPageSchema([{ question: "Q1?", answer: "A1." }]);
45
+ expect(schema).toEqual({
46
+ "@context": "https://schema.org",
47
+ "@type": "FAQPage",
48
+ mainEntity: [
49
+ {
50
+ "@type": "Question",
51
+ name: "Q1?",
52
+ acceptedAnswer: { "@type": "Answer", text: "A1." },
53
+ },
54
+ ],
55
+ });
56
+ });
57
+ });
@@ -0,0 +1,258 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { createLegalPagesFeature } from "@cosmicdrift/kumiko-bundled-features/legal-pages";
3
+ import {
4
+ createManagedPagesFeature,
5
+ pageEntity,
6
+ } from "@cosmicdrift/kumiko-bundled-features/managed-pages";
7
+ import { seedPage } from "@cosmicdrift/kumiko-bundled-features/managed-pages/seeding";
8
+ import {
9
+ createTextContentApi,
10
+ createTextContentFeature,
11
+ textBlockEntity,
12
+ } from "@cosmicdrift/kumiko-bundled-features/text-content";
13
+ import { seedTextBlock } from "@cosmicdrift/kumiko-bundled-features/text-content/seeding";
14
+ import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
15
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
16
+ import {
17
+ createTestUser,
18
+ setupTestStack,
19
+ type TestStack,
20
+ unsafeCreateEntityTable,
21
+ unsafePushTables,
22
+ } from "@cosmicdrift/kumiko-framework/stack";
23
+ import { createConfigAccessorFactory, createConfigFeature } from "../../config/feature";
24
+ import { createConfigResolver } from "../../config/resolver";
25
+ import { configValuesTable } from "../../config/table";
26
+ import { SEO_CONFIG_QN } from "../constants";
27
+ import { createSeoFeature, runSeoBootCheck } from "../feature";
28
+
29
+ const TENANT_A = "11111111-1111-4111-8111-111111111111";
30
+
31
+ let stack: TestStack;
32
+
33
+ const seo = createSeoFeature({
34
+ sitemapEntries: (host) => [{ loc: `https://${host}/`, changefreq: "daily" }],
35
+ includeLegalPages: true,
36
+ managedPages: {
37
+ resolveApexTenant: (host) => (host.startsWith("a.") ? TENANT_A : null),
38
+ },
39
+ });
40
+ const managed = createManagedPagesFeature({
41
+ resolveApexTenant: (host) => (host.startsWith("a.") ? TENANT_A : null),
42
+ });
43
+ const legal = createLegalPagesFeature();
44
+ const configFeature = createConfigFeature();
45
+ const textContent = createTextContentFeature();
46
+
47
+ beforeAll(async () => {
48
+ const resolver = createConfigResolver();
49
+ stack = await setupTestStack({
50
+ features: [configFeature, textContent, managed, legal, seo],
51
+ anonymousAccess: {
52
+ tenantExists: async (id) => id === TENANT_A || id === SYSTEM_TENANT_ID,
53
+ },
54
+ extraContext: ({ registry, db }) => ({
55
+ configResolver: resolver,
56
+ _configAccessorFactory: createConfigAccessorFactory(registry, resolver),
57
+ textContent: createTextContentApi(db),
58
+ }),
59
+ });
60
+ await unsafeCreateEntityTable(stack.db, pageEntity);
61
+ await unsafeCreateEntityTable(stack.db, textBlockEntity);
62
+ await unsafePushTables(stack.db, { configValuesTable });
63
+ await createEventsTable(stack.db);
64
+
65
+ await seedTextBlock(stack.db, {
66
+ tenantId: SYSTEM_TENANT_ID,
67
+ slug: "imprint",
68
+ lang: "de",
69
+ title: "Impressum",
70
+ body: "## Test\n\nAcme",
71
+ });
72
+ await seedTextBlock(stack.db, {
73
+ tenantId: SYSTEM_TENANT_ID,
74
+ slug: "privacy",
75
+ lang: "de",
76
+ title: "Datenschutz",
77
+ body: "## Test\n\nAcme",
78
+ });
79
+
80
+ await seedPage(stack.db, {
81
+ tenantId: TENANT_A,
82
+ slug: "about",
83
+ lang: "en",
84
+ title: "About",
85
+ body: "# About",
86
+ published: true,
87
+ });
88
+ await seedPage(stack.db, {
89
+ tenantId: TENANT_A,
90
+ slug: "draft",
91
+ lang: "en",
92
+ title: "Draft",
93
+ body: "# Draft",
94
+ published: false,
95
+ });
96
+
97
+ const admin = createTestUser({ id: 1, roles: ["TenantAdmin"], tenantId: TENANT_A });
98
+ await stack.http.writeOk(
99
+ "config:write:set",
100
+ { key: SEO_CONFIG_QN.organizationName, value: "Acme" },
101
+ admin,
102
+ );
103
+ await stack.http.writeOk(
104
+ "config:write:set",
105
+ { key: SEO_CONFIG_QN.llmsSummary, value: "Acme builds things." },
106
+ admin,
107
+ );
108
+ });
109
+
110
+ afterAll(async () => {
111
+ await stack.cleanup();
112
+ });
113
+
114
+ describe("seo :: GET /sitemap.xml", () => {
115
+ test("merges the app callback + legal-pages routes + managed-pages published slugs", async () => {
116
+ const res = await stack.app.request("http://a.example.com/sitemap.xml");
117
+ expect(res.status).toBe(200);
118
+ expect(res.headers.get("content-type")).toContain("application/xml");
119
+ const xml = await res.text();
120
+ expect(xml).toContain("<loc>https://a.example.com/</loc>");
121
+ expect(xml).toContain("<loc>http://a.example.com/legal/impressum</loc>");
122
+ expect(xml).toContain("<loc>http://a.example.com/p/about</loc>");
123
+ expect(xml).not.toContain("/p/draft");
124
+ });
125
+
126
+ test("cache-control revalidate + etag", async () => {
127
+ const res = await stack.app.request("http://a.example.com/sitemap.xml");
128
+ expect(res.headers.get("cache-control")).toBe("public, max-age=300, must-revalidate");
129
+ expect(res.headers.get("etag")).toBeTruthy();
130
+ });
131
+
132
+ test("host without a managed-pages tenant → callback + legal-pages entries only", async () => {
133
+ const res = await stack.app.request("http://unknown.example.com/sitemap.xml");
134
+ expect(res.status).toBe(200);
135
+ const xml = await res.text();
136
+ expect(xml).toContain("<loc>https://unknown.example.com/</loc>");
137
+ expect(xml).toContain("/legal/impressum");
138
+ expect(xml).not.toContain("/p/about");
139
+ });
140
+ });
141
+
142
+ describe("seo :: GET /llms.txt", () => {
143
+ test("emits org name + summary + a Pages section with all entries", async () => {
144
+ const res = await stack.app.request("http://a.example.com/llms.txt");
145
+ expect(res.status).toBe(200);
146
+ expect(res.headers.get("content-type")).toContain("text/plain");
147
+ const text = await res.text();
148
+ expect(text).toStartWith("# Acme");
149
+ expect(text).toContain("> Acme builds things.");
150
+ expect(text).toContain("## Pages");
151
+ expect(text).toContain("http://a.example.com/p/about");
152
+ });
153
+ });
154
+
155
+ describe("seo :: GET /robots.txt (not registered without robotsPolicy)", () => {
156
+ test("404 — no route registered", async () => {
157
+ const res = await stack.app.request("http://a.example.com/robots.txt");
158
+ expect(res.status).toBe(404);
159
+ });
160
+ });
161
+
162
+ describe("seo :: GET /robots.txt (opted in)", () => {
163
+ let robotsStack: TestStack;
164
+
165
+ beforeAll(async () => {
166
+ const seoWithRobots = createSeoFeature({
167
+ sitemapEntries: () => [],
168
+ includeLegalPages: true,
169
+ robotsPolicy: (host) => ({
170
+ allow: host !== "staging.example.com",
171
+ sitemapUrl: `https://${host}/sitemap.xml`,
172
+ }),
173
+ });
174
+ robotsStack = await setupTestStack({
175
+ features: [createConfigFeature(), seoWithRobots],
176
+ anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID },
177
+ });
178
+ });
179
+
180
+ afterAll(async () => {
181
+ await robotsStack.cleanup();
182
+ });
183
+
184
+ test("allow host → no Disallow rule + Sitemap line", async () => {
185
+ const res = await robotsStack.app.request("http://prod.example.com/robots.txt");
186
+ expect(res.status).toBe(200);
187
+ const text = await res.text();
188
+ expect(text).toBe("User-agent: *\nDisallow:\nSitemap: https://prod.example.com/sitemap.xml\n");
189
+ });
190
+
191
+ test("disallowed host → Disallow: /", async () => {
192
+ const res = await robotsStack.app.request("http://staging.example.com/robots.txt");
193
+ const text = await res.text();
194
+ expect(text).toContain("Disallow: /");
195
+ });
196
+ });
197
+
198
+ describe("seo :: runSeoBootCheck (direct unit-tests)", () => {
199
+ test("has a source (non-empty callback) → log.info, no throw", async () => {
200
+ const infos: string[] = [];
201
+ await expect(
202
+ runSeoBootCheck({
203
+ sitemapEntries: () => [{ loc: "https://x.test/" }],
204
+ includeLegalPages: false,
205
+ hasManagedPages: false,
206
+ log: { info: (m) => infos.push(m) },
207
+ }),
208
+ ).resolves.toBeUndefined();
209
+ expect(infos).toHaveLength(1);
210
+ });
211
+
212
+ test("no source + includeLegalPages true → counts as a source", async () => {
213
+ const infos: string[] = [];
214
+ await runSeoBootCheck({
215
+ sitemapEntries: () => [],
216
+ includeLegalPages: true,
217
+ hasManagedPages: false,
218
+ log: { info: (m) => infos.push(m) },
219
+ });
220
+ expect(infos).toHaveLength(1);
221
+ });
222
+
223
+ test("no source at all + NODE_ENV=production → throws", async () => {
224
+ const originalEnv = process.env["NODE_ENV"];
225
+ process.env["NODE_ENV"] = "production";
226
+ try {
227
+ await expect(
228
+ runSeoBootCheck({
229
+ sitemapEntries: () => [],
230
+ includeLegalPages: false,
231
+ hasManagedPages: false,
232
+ }),
233
+ ).rejects.toThrow(/Boot-Validation failed/);
234
+ } finally {
235
+ if (originalEnv === undefined) delete process.env["NODE_ENV"];
236
+ else process.env["NODE_ENV"] = originalEnv;
237
+ }
238
+ });
239
+
240
+ test("no source at all + NODE_ENV!=production → log.warn, no throw", async () => {
241
+ const warns: string[] = [];
242
+ const originalEnv = process.env["NODE_ENV"];
243
+ process.env["NODE_ENV"] = "development";
244
+ try {
245
+ await runSeoBootCheck({
246
+ sitemapEntries: () => [],
247
+ includeLegalPages: false,
248
+ hasManagedPages: false,
249
+ log: { warn: (m) => warns.push(m) },
250
+ });
251
+ expect(warns).toHaveLength(1);
252
+ expect(warns[0]).toContain("sitemap.xml/llms.txt will serve an empty document");
253
+ } finally {
254
+ if (originalEnv === undefined) delete process.env["NODE_ENV"];
255
+ else process.env["NODE_ENV"] = originalEnv;
256
+ }
257
+ });
258
+ });
@@ -0,0 +1,38 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildSitemapXml } from "../sitemap";
3
+
4
+ describe("buildSitemapXml", () => {
5
+ test("emits urlset with loc/lastmod/changefreq", () => {
6
+ const xml = buildSitemapXml([
7
+ { loc: "https://example.com/", lastmod: "2026-01-01", changefreq: "weekly" },
8
+ ]);
9
+ expect(xml).toContain('<?xml version="1.0" encoding="UTF-8"?>');
10
+ expect(xml).toContain("<loc>https://example.com/</loc>");
11
+ expect(xml).toContain("<lastmod>2026-01-01</lastmod>");
12
+ expect(xml).toContain("<changefreq>weekly</changefreq>");
13
+ });
14
+
15
+ test("emits hreflang alternates", () => {
16
+ const xml = buildSitemapXml([
17
+ {
18
+ loc: "https://example.com/",
19
+ alternates: [{ hreflang: "de", href: "https://example.com/de" }],
20
+ },
21
+ ]);
22
+ expect(xml).toContain(
23
+ '<xhtml:link rel="alternate" hreflang="de" href="https://example.com/de" />',
24
+ );
25
+ });
26
+
27
+ test("escapes XML-unsafe characters in loc", () => {
28
+ const xml = buildSitemapXml([{ loc: "https://example.com/?a=1&b=2" }]);
29
+ expect(xml).toContain("https://example.com/?a=1&amp;b=2");
30
+ expect(xml).not.toContain("&b=2<");
31
+ });
32
+
33
+ test("empty entries → valid empty urlset", () => {
34
+ const xml = buildSitemapXml([]);
35
+ expect(xml).toContain("<urlset");
36
+ expect(xml).not.toContain("<url>");
37
+ });
38
+ });
@@ -0,0 +1,60 @@
1
+ import {
2
+ access,
3
+ type ConfigKeyDefinition,
4
+ createTenantConfig,
5
+ } from "@cosmicdrift/kumiko-framework/engine";
6
+
7
+ export const SEO_FEATURE = "seo" as const;
8
+
9
+ // Default mount paths — override per-app via SeoOptions.basePaths for
10
+ // non-standard mounts (e.g. a reverse-proxy that already serves /robots.txt).
11
+ export const SEO_DEFAULT_PATHS = {
12
+ sitemap: "/sitemap.xml",
13
+ llmsTxt: "/llms.txt",
14
+ robots: "/robots.txt",
15
+ } as const;
16
+
17
+ // Tenant-scoped, admin-writable metadata feeding the Organization JSON-LD
18
+ // helper + llms.txt summary + OG default image. No `seo-site-url` key —
19
+ // absolute URLs are derived from the request's own Host (mirrors managed-
20
+ // pages' resolveApexTenant pattern), so a single seo mount works correctly
21
+ // across multiple apex hosts without a static site-url config drifting.
22
+ const TEXT_PATTERN = { regex: "^[\\s\\S]{0,500}$" } as const;
23
+ const HTTPS_PATTERN = { regex: "^$|^https://[^\\s\"'<>]{1,2000}$" } as const;
24
+ const SEO_WRITE = access.withSystem(access.admin);
25
+
26
+ export const SEO_CONFIG_KEYS = {
27
+ seoOrganizationName: createTenantConfig("text", {
28
+ default: "",
29
+ pattern: TEXT_PATTERN,
30
+ write: SEO_WRITE,
31
+ }),
32
+ seoOrganizationLogoUrl: createTenantConfig("text", {
33
+ default: "",
34
+ pattern: HTTPS_PATTERN,
35
+ write: SEO_WRITE,
36
+ }),
37
+ seoTwitterSite: createTenantConfig("text", {
38
+ default: "",
39
+ pattern: TEXT_PATTERN,
40
+ write: SEO_WRITE,
41
+ }),
42
+ seoLlmsSummary: createTenantConfig("text", {
43
+ default: "",
44
+ pattern: TEXT_PATTERN,
45
+ write: SEO_WRITE,
46
+ }),
47
+ seoDefaultOgImage: createTenantConfig("text", {
48
+ default: "",
49
+ pattern: HTTPS_PATTERN,
50
+ write: SEO_WRITE,
51
+ }),
52
+ } satisfies Record<string, ConfigKeyDefinition>;
53
+
54
+ export const SEO_CONFIG_QN = {
55
+ organizationName: "seo:config:seo-organization-name",
56
+ organizationLogoUrl: "seo:config:seo-organization-logo-url",
57
+ twitterSite: "seo:config:seo-twitter-site",
58
+ llmsSummary: "seo:config:seo-llms-summary",
59
+ defaultOgImage: "seo:config:seo-default-og-image",
60
+ } as const;