@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,313 @@
1
+ import { computeRevisionEtag } from "@cosmicdrift/kumiko-framework/api";
2
+ import {
3
+ defineFeature,
4
+ type FeatureDefinition,
5
+ SYSTEM_TENANT_ID,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
7
+ import { LEGAL_ROUTES } from "../legal-pages";
8
+ import { cachedSecurePageResponse } from "../page-render";
9
+ import { SEO_CONFIG_KEYS, SEO_DEFAULT_PATHS } from "./constants";
10
+ import { seoConfigQuery } from "./handlers/seo-config.query";
11
+ import { buildLlmsTxt } from "./llms-txt";
12
+ import { buildRobotsTxt, type RobotsPolicy } from "./robots-txt";
13
+ import { buildSitemapXml, type SitemapEntry } from "./sitemap";
14
+
15
+ // 300s-shared-cache: sitemap/llms.txt change rarely (new page publish, not
16
+ // per-request), longer than legal-pages/managed-pages' 60s content cache.
17
+ const SEO_CACHE = { kind: "revalidate", maxAgeSeconds: 300 } as const;
18
+
19
+ const MANAGED_PAGES_BY_TENANT_PUBLISHED_QN = "managed-pages:query:by-tenant-published";
20
+ const SEO_CONFIG_QUERY_QN = "seo:query:config";
21
+
22
+ // Minimal shape of the httpRoute handler's `{ app }` dep — just enough to
23
+ // make an internal /api/query call. Not importing HttpRouteHandlerDeps
24
+ // itself: it isn't part of the engine's public surface (only the
25
+ // httpRoute-registration types are).
26
+ type FetchApp = { readonly fetch: (req: Request) => Promise<Response> | Response };
27
+
28
+ export type ManagedPagesDiscoveryOptions = {
29
+ /** Same per-host tenant resolver the app already passes to
30
+ * createManagedPagesFeature — needed to X-Tenant-scope the anonymous
31
+ * by-tenant-published query. */
32
+ readonly resolveApexTenant: (host: string) => Promise<string | null> | string | null;
33
+ /** Must match the basePath passed to createManagedPagesFeature. Default "/p". */
34
+ readonly basePath?: string;
35
+ };
36
+
37
+ export type SeoOptions = {
38
+ /** Primary page inventory for sitemap.xml/llms.txt. App-authored, trusted
39
+ * (same trust boundary as ApexCta.href) — return fully-qualified absolute
40
+ * URLs. Called once per request with the resolved request Host. */
41
+ readonly sitemapEntries: (
42
+ host: string,
43
+ ) => Promise<readonly SitemapEntry[]> | readonly SitemapEntry[];
44
+ /** Merge legal-pages' 4 fixed routes into sitemap.xml/llms.txt. Set true
45
+ * only when the app ALSO mounts createLegalPagesFeature() — this does not
46
+ * detect it automatically (no such feature-presence hook exists at the
47
+ * httpRoute layer). Default false. */
48
+ readonly includeLegalPages?: boolean;
49
+ /** Merge managed-pages' published slugs (via its anonymous
50
+ * by-tenant-published query) into sitemap.xml/llms.txt. Omit if the app
51
+ * doesn't mount managed-pages, or already includes those URLs in
52
+ * `sitemapEntries` itself. */
53
+ readonly managedPages?: ManagedPagesDiscoveryOptions;
54
+ /** Serves GET /robots.txt with per-host policy. Default: no route — the
55
+ * static `public/robots.txt` dev-server already ships covers the common
56
+ * case (no per-host logic needed). Only opt in for staging/preview hosts
57
+ * that need a different (e.g. Disallow: /) policy at runtime. */
58
+ readonly robotsPolicy?: (host: string) => Promise<RobotsPolicy> | RobotsPolicy;
59
+ /** Override the 3 mount paths. Default: /sitemap.xml, /llms.txt, /robots.txt. */
60
+ readonly basePaths?: Partial<typeof SEO_DEFAULT_PATHS>;
61
+ };
62
+
63
+ type SeoConfigValues = {
64
+ readonly organizationName: string;
65
+ readonly organizationLogoUrl: string;
66
+ readonly twitterSite: string;
67
+ readonly llmsSummary: string;
68
+ readonly defaultOgImage: string;
69
+ };
70
+
71
+ const EMPTY_SEO_CONFIG: SeoConfigValues = {
72
+ organizationName: "",
73
+ organizationLogoUrl: "",
74
+ twitterSite: "",
75
+ llmsSummary: "",
76
+ defaultOgImage: "",
77
+ };
78
+
79
+ function queryRequest(origin: string, tenantId: string, type: string, payload: unknown): Request {
80
+ return new Request(`${origin}/api/query`, {
81
+ method: "POST",
82
+ headers: { "content-type": "application/json", "X-Tenant": tenantId },
83
+ body: JSON.stringify({ type, payload }),
84
+ });
85
+ }
86
+
87
+ // Config is decoration, not a hard dependency — a failed/unreachable read
88
+ // degrades to empty strings (same posture as managed-pages' readBrandingResponse).
89
+ async function readSeoConfig(
90
+ app: FetchApp,
91
+ origin: string,
92
+ tenantId: string,
93
+ ): Promise<SeoConfigValues> {
94
+ try {
95
+ const res = await app.fetch(queryRequest(origin, tenantId, SEO_CONFIG_QUERY_QN, {}));
96
+ if (!res.ok) return EMPTY_SEO_CONFIG;
97
+ const body: { data?: Partial<SeoConfigValues> } = await res.json();
98
+ return { ...EMPTY_SEO_CONFIG, ...body.data };
99
+ } catch {
100
+ return EMPTY_SEO_CONFIG;
101
+ }
102
+ }
103
+
104
+ // Merges the app callback with the optional legal-pages/managed-pages
105
+ // sources. legal-pages routes are static (no per-tenant data) — merged
106
+ // directly from the public LEGAL_ROUTES constant. managed-pages entries need
107
+ // a live query (per-tenant published slugs) — merged via app.fetch, same
108
+ // cross-feature decoupling as legal-pages' own text-content calls; a
109
+ // failed/unreachable read degrades to "no managed-pages entries" rather than
110
+ // failing the whole route.
111
+ async function gatherEntries(
112
+ opts: SeoOptions,
113
+ app: FetchApp,
114
+ origin: string,
115
+ host: string,
116
+ ): Promise<SitemapEntry[]> {
117
+ const entries: SitemapEntry[] = [...(await opts.sitemapEntries(host))];
118
+
119
+ if (opts.includeLegalPages) {
120
+ for (const route of LEGAL_ROUTES) {
121
+ entries.push({ loc: `${origin}${route.path}` });
122
+ }
123
+ }
124
+
125
+ if (opts.managedPages) {
126
+ const tenantId = await opts.managedPages.resolveApexTenant(host);
127
+ if (tenantId) {
128
+ try {
129
+ const res = await app.fetch(
130
+ queryRequest(origin, tenantId, MANAGED_PAGES_BY_TENANT_PUBLISHED_QN, {}),
131
+ );
132
+ if (res.ok) {
133
+ const body: {
134
+ data?: { pages?: readonly { slug: string; updatedAt: string }[] };
135
+ } = await res.json();
136
+ const basePath = opts.managedPages.basePath ?? "/p";
137
+ for (const page of body.data?.pages ?? []) {
138
+ entries.push({ loc: `${origin}${basePath}/${page.slug}`, lastmod: page.updatedAt });
139
+ }
140
+ }
141
+ } catch {
142
+ // managed-pages unreachable/not mounted — degrade to callback-only entries.
143
+ }
144
+ }
145
+ }
146
+
147
+ return entries;
148
+ }
149
+
150
+ function requestHost(c: { req: { header: (name: string) => string | undefined; url: string } }): {
151
+ origin: string;
152
+ host: string;
153
+ } {
154
+ const url = new URL(c.req.url);
155
+ return { origin: url.origin, host: c.req.header("host") ?? url.host };
156
+ }
157
+
158
+ // seo — site-discovery routes (sitemap.xml/llms.txt/robots.txt) plus the
159
+ // wrapInLayout OG/JSON-LD extension (page-render/layout.ts's `seo` opt) and
160
+ // the organizationSchema/webPageSchema/faqPageSchema JSON-LD builders
161
+ // (schema-builders.ts, imported directly by apps — not wired into this
162
+ // feature's own routes). Complements the apex ApexHead/renderApexPage
163
+ // renderer, which already ships full OG/JSON-LD/canonical head handling —
164
+ // this feature only fills the site-discovery + CMS-page-head gaps.
165
+ export function createSeoFeature(opts: SeoOptions): FeatureDefinition {
166
+ const paths = { ...SEO_DEFAULT_PATHS, ...opts.basePaths };
167
+
168
+ return defineFeature("seo", (r) => {
169
+ r.describe(
170
+ `Site-discovery + SEO/AEO/GEO surface for apex/content pages. Serves GET ${paths.sitemap} and GET ${paths.llmsTxt} (both anonymous, revalidate-cached), merging the app-supplied sitemapEntries() callback with legal-pages' fixed routes (includeLegalPages) and/or managed-pages' published slugs (managedPages.resolveApexTenant) when opted in. Serves GET ${paths.robots} only when robotsPolicy is supplied (default off — the static public/robots.txt already covers the common case). Tenant-scoped config keys (seo:config:seo-organization-{name,logo-url}, seo:config:seo-twitter-site, seo:config:seo-llms-summary, seo:config:seo-default-og-image) feed the Organization JSON-LD helper + llms.txt summary. Also exports pure schema.org JSON-LD builders (organizationSchema/webPageSchema/faqPageSchema) for apps to pass into ApexHead.schemaJson or wrapInLayout({ seo: { schemaJson } }) directly — this feature does not inject JSON-LD on its own routes.`,
171
+ );
172
+ r.uiHints({
173
+ displayLabel: "SEO / Site Discovery",
174
+ category: "content",
175
+ recommended: false,
176
+ });
177
+ // config keys need the config feature's write/read machinery to actually
178
+ // function (same hard dependency managed-pages has) — only legal-pages/
179
+ // managed-pages are genuinely optional (merged only when opted in).
180
+ r.requires("config");
181
+ r.optionalRequires("legal-pages", "managed-pages");
182
+
183
+ r.config({ keys: SEO_CONFIG_KEYS });
184
+ r.queryHandler(seoConfigQuery);
185
+
186
+ r.httpRoute({
187
+ method: "GET",
188
+ path: paths.sitemap,
189
+ anonymous: true,
190
+ handler: async (c, { app }) => {
191
+ const { origin, host } = requestHost(c);
192
+ const entries = await gatherEntries(opts, app, origin, host);
193
+ const xml = buildSitemapXml(entries);
194
+ const etag = computeRevisionEtag([host, xml]);
195
+ return cachedSecurePageResponse(c.req.raw, {
196
+ body: xml,
197
+ etag,
198
+ cache: SEO_CACHE,
199
+ extra: { "content-type": "application/xml; charset=utf-8" },
200
+ });
201
+ },
202
+ });
203
+
204
+ r.httpRoute({
205
+ method: "GET",
206
+ path: paths.llmsTxt,
207
+ anonymous: true,
208
+ handler: async (c, { app }) => {
209
+ const { origin, host } = requestHost(c);
210
+ const tenantId = opts.managedPages
211
+ ? ((await opts.managedPages.resolveApexTenant(host)) ?? SYSTEM_TENANT_ID)
212
+ : SYSTEM_TENANT_ID;
213
+ const [entries, seoConfig] = await Promise.all([
214
+ gatherEntries(opts, app, origin, host),
215
+ readSeoConfig(app, origin, tenantId),
216
+ ]);
217
+ const sections =
218
+ entries.length > 0
219
+ ? [{ heading: "Pages", links: entries.map((e) => ({ title: e.loc, url: e.loc })) }]
220
+ : [];
221
+ const text = buildLlmsTxt({
222
+ title: seoConfig.organizationName || host,
223
+ summary: seoConfig.llmsSummary,
224
+ sections,
225
+ });
226
+ const etag = computeRevisionEtag([host, text]);
227
+ return cachedSecurePageResponse(c.req.raw, {
228
+ body: text,
229
+ etag,
230
+ cache: SEO_CACHE,
231
+ extra: { "content-type": "text/plain; charset=utf-8" },
232
+ });
233
+ },
234
+ });
235
+
236
+ if (opts.robotsPolicy) {
237
+ const robotsPolicy = opts.robotsPolicy;
238
+ r.httpRoute({
239
+ method: "GET",
240
+ path: paths.robots,
241
+ anonymous: true,
242
+ handler: async (c) => {
243
+ const { host } = requestHost(c);
244
+ const policy = await robotsPolicy(host);
245
+ const text = buildRobotsTxt(policy);
246
+ const etag = computeRevisionEtag([host, text]);
247
+ return cachedSecurePageResponse(c.req.raw, {
248
+ body: text,
249
+ etag,
250
+ cache: SEO_CACHE,
251
+ extra: { "content-type": "text/plain; charset=utf-8" },
252
+ });
253
+ },
254
+ });
255
+ }
256
+
257
+ // Boot-Check body als named function extrahiert — direkt unit-testbar,
258
+ // gleiches "thin job-shell ruft testable function"-Pattern wie
259
+ // legal-pages' runLegalPagesBootCheck.
260
+ r.job(
261
+ "seo-boot-check",
262
+ { trigger: { manual: true }, runOnBoot: true, runIn: "api" },
263
+ async (_payload, ctx) =>
264
+ runSeoBootCheck({
265
+ sitemapEntries: opts.sitemapEntries,
266
+ includeLegalPages: opts.includeLegalPages ?? false,
267
+ hasManagedPages: opts.managedPages !== undefined,
268
+ log: ctx.log,
269
+ }),
270
+ );
271
+
272
+ return {};
273
+ });
274
+ }
275
+
276
+ export type SeoBootCheckCtx = {
277
+ readonly sitemapEntries: (
278
+ host: string,
279
+ ) => Promise<readonly SitemapEntry[]> | readonly SitemapEntry[];
280
+ readonly includeLegalPages: boolean;
281
+ /** managedPages entries are only resolvable per-request (need a live
282
+ * tenant + query) — the boot-check can't probe them, so their mere
283
+ * presence counts as "has a source" rather than sampling actual rows. */
284
+ readonly hasManagedPages: boolean;
285
+ readonly log?: {
286
+ readonly info?: (msg: string) => void;
287
+ readonly warn?: (msg: string) => void;
288
+ };
289
+ };
290
+
291
+ // Exported for direct tests. Throws in NODE_ENV=production when NONE of the
292
+ // three entry sources (sitemapEntries() probe, includeLegalPages,
293
+ // managedPages) can supply anything — the routes would otherwise serve a
294
+ // permanently empty document. Otherwise log.warn. Logs log.info when at
295
+ // least one source is present.
296
+ export async function runSeoBootCheck(ctx: SeoBootCheckCtx): Promise<void> {
297
+ const sample = await ctx.sitemapEntries("boot-check.invalid");
298
+ const hasSource = sample.length > 0 || ctx.includeLegalPages || ctx.hasManagedPages;
299
+
300
+ if (hasSource) {
301
+ ctx.log?.info?.("seo boot-check: sitemap/llms.txt have at least one entry source");
302
+ } else {
303
+ const message =
304
+ "seo: sitemapEntries() returned no entries and neither includeLegalPages " +
305
+ "nor managedPages is set — sitemap.xml/llms.txt will serve an empty " +
306
+ "document. Wire real entries, set includeLegalPages:true, or pass managedPages.";
307
+
308
+ if (process.env["NODE_ENV"] === "production") {
309
+ throw new Error(`Boot-Validation failed: ${message}`);
310
+ }
311
+ ctx.log?.warn?.(message);
312
+ }
313
+ }
@@ -0,0 +1,35 @@
1
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { z } from "zod";
3
+ import { SEO_CONFIG_QN } from "../constants";
4
+
5
+ // Public read of the seo feature's own tenant-config values, for the
6
+ // anonymous sitemap.xml/llms.txt routes (a Response is per-request; those
7
+ // routes only get `{ app }`, not `ctx.config` directly, so they reach this
8
+ // the same way managed-pages' render route reaches its branding query — an
9
+ // internal app.fetch with X-Tenant set to the resolved tenant).
10
+ //
11
+ // config:query:values (the generic config feature query) is NOT usable here:
12
+ // it requires an authenticated caller (any role) even though its per-key
13
+ // access defaults to read:all — a genuinely anonymous visitor gets 403 before
14
+ // per-key filtering ever runs. This mirrors managed-pages' branding query
15
+ // instead (`access: { roles: ["anonymous", ...] }`), the pattern actually
16
+ // proven to work for anonymous-served pages.
17
+ export const seoConfigQuery = defineQueryHandler({
18
+ name: "config",
19
+ schema: z.object({}),
20
+ access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
21
+ handler: async (_query, ctx) => {
22
+ const read = async (qualifiedKey: string): Promise<string> => {
23
+ if (!ctx.config) return "";
24
+ const value = await ctx.config(qualifiedKey);
25
+ return typeof value === "string" ? value : "";
26
+ };
27
+ return {
28
+ organizationName: await read(SEO_CONFIG_QN.organizationName),
29
+ organizationLogoUrl: await read(SEO_CONFIG_QN.organizationLogoUrl),
30
+ twitterSite: await read(SEO_CONFIG_QN.twitterSite),
31
+ llmsSummary: await read(SEO_CONFIG_QN.llmsSummary),
32
+ defaultOgImage: await read(SEO_CONFIG_QN.defaultOgImage),
33
+ };
34
+ },
35
+ });
@@ -0,0 +1,19 @@
1
+ export { SEO_CONFIG_KEYS, SEO_CONFIG_QN, SEO_DEFAULT_PATHS, SEO_FEATURE } from "./constants";
2
+ export {
3
+ createSeoFeature,
4
+ type ManagedPagesDiscoveryOptions,
5
+ runSeoBootCheck,
6
+ type SeoBootCheckCtx,
7
+ type SeoOptions,
8
+ } from "./feature";
9
+ export { buildLlmsTxt, type LlmsTxtInput, type LlmsTxtLink, type LlmsTxtSection } from "./llms-txt";
10
+ export { buildRobotsTxt, type RobotsPolicy } from "./robots-txt";
11
+ export {
12
+ type FaqItem,
13
+ faqPageSchema,
14
+ type OrganizationSchemaInput,
15
+ organizationSchema,
16
+ type WebPageSchemaInput,
17
+ webPageSchema,
18
+ } from "./schema-builders";
19
+ export { buildSitemapXml, type SitemapEntry } from "./sitemap";
@@ -0,0 +1,33 @@
1
+ export type LlmsTxtLink = {
2
+ readonly title: string;
3
+ readonly url: string;
4
+ readonly desc?: string;
5
+ };
6
+
7
+ export type LlmsTxtSection = {
8
+ readonly heading: string;
9
+ readonly links: readonly LlmsTxtLink[];
10
+ };
11
+
12
+ export type LlmsTxtInput = {
13
+ readonly title: string;
14
+ readonly summary: string;
15
+ readonly sections?: readonly LlmsTxtSection[];
16
+ };
17
+
18
+ // Plain-text builder for the community llms.txt convention (H1 title,
19
+ // blockquote summary, `## `-headed link lists). No HTML escaping needed —
20
+ // Markdown link syntax, plain-text output.
21
+ export function buildLlmsTxt(input: LlmsTxtInput): string {
22
+ const sections = (input.sections ?? [])
23
+ .filter((s) => s.links.length > 0)
24
+ .map((s) => {
25
+ const links = s.links
26
+ .map((l) => `- [${l.title}](${l.url})${l.desc ? `: ${l.desc}` : ""}`)
27
+ .join("\n");
28
+ return `## ${s.heading}\n\n${links}`;
29
+ })
30
+ .join("\n\n");
31
+ const summary = input.summary ? `\n\n> ${input.summary}` : "";
32
+ return `# ${input.title}${summary}${sections ? `\n\n${sections}` : ""}\n`;
33
+ }
@@ -0,0 +1,13 @@
1
+ export type RobotsPolicy = {
2
+ readonly allow: boolean;
3
+ readonly sitemapUrl?: string;
4
+ };
5
+
6
+ // Only used when an app opts into GET /robots.txt (SeoOptions.robotsPolicy) —
7
+ // the common case (no per-host logic) is already covered by dev-server's
8
+ // static public/robots.txt passthrough.
9
+ export function buildRobotsTxt(policy: RobotsPolicy): string {
10
+ const rule = policy.allow ? "Disallow:" : "Disallow: /";
11
+ const sitemap = policy.sitemapUrl ? `\nSitemap: ${policy.sitemapUrl}` : "";
12
+ return `User-agent: *\n${rule}${sitemap}\n`;
13
+ }
@@ -0,0 +1,57 @@
1
+ // Pure schema.org JSON-LD builders — plain data, no HTML/escaping (the
2
+ // serialization boundary is `renderApexHeadTags`'s `scriptSafeJsonHtml`,
3
+ // which already escapes `<` so a value can't break out of the
4
+ // `<script type="application/ld+json">` block). Feed the result into
5
+ // `ApexHead.schemaJson` (renderApexPage) or `wrapInLayout({ seo: { schemaJson
6
+ // } })` (CMS pages) — this feature does not inject JSON-LD on its own routes.
7
+
8
+ export type OrganizationSchemaInput = {
9
+ readonly name: string;
10
+ readonly url?: string;
11
+ readonly logoUrl?: string;
12
+ readonly sameAs?: readonly string[];
13
+ };
14
+
15
+ export function organizationSchema(input: OrganizationSchemaInput): Record<string, unknown> {
16
+ return {
17
+ "@context": "https://schema.org",
18
+ "@type": "Organization",
19
+ name: input.name,
20
+ ...(input.url ? { url: input.url } : {}),
21
+ ...(input.logoUrl ? { logo: input.logoUrl } : {}),
22
+ ...(input.sameAs && input.sameAs.length > 0 ? { sameAs: input.sameAs } : {}),
23
+ };
24
+ }
25
+
26
+ export type WebPageSchemaInput = {
27
+ readonly name: string;
28
+ readonly url: string;
29
+ readonly description?: string;
30
+ };
31
+
32
+ export function webPageSchema(input: WebPageSchemaInput): Record<string, unknown> {
33
+ return {
34
+ "@context": "https://schema.org",
35
+ "@type": "WebPage",
36
+ name: input.name,
37
+ url: input.url,
38
+ ...(input.description ? { description: input.description } : {}),
39
+ };
40
+ }
41
+
42
+ export type FaqItem = { readonly question: string; readonly answer: string };
43
+
44
+ // Feeds Google's FAQ rich-result + is the most directly LLM-answer-engine-
45
+ // legible JSON-LD shape (question/answer pairs) — the "AEO/GEO" half of the
46
+ // schema-builder set, same emission path as organizationSchema/webPageSchema.
47
+ export function faqPageSchema(items: readonly FaqItem[]): Record<string, unknown> {
48
+ return {
49
+ "@context": "https://schema.org",
50
+ "@type": "FAQPage",
51
+ mainEntity: items.map((item) => ({
52
+ "@type": "Question",
53
+ name: item.question,
54
+ acceptedAnswer: { "@type": "Answer", text: item.answer },
55
+ })),
56
+ };
57
+ }
@@ -0,0 +1,30 @@
1
+ import { escapeXml } from "@cosmicdrift/kumiko-headless";
2
+
3
+ export type SitemapEntry = {
4
+ readonly loc: string;
5
+ /** ISO-8601 date/datetime. */
6
+ readonly lastmod?: string;
7
+ readonly changefreq?: "always" | "hourly" | "daily" | "weekly" | "monthly" | "yearly" | "never";
8
+ /** hreflang alternates for the same logical page (multilingual sitemaps). */
9
+ readonly alternates?: readonly { readonly hreflang: string; readonly href: string }[];
10
+ };
11
+
12
+ // Pure XML builder — sitemaps.org urlset + xhtml:link alternates. No
13
+ // dedup/sort: callers control ordering, entries are trusted app/feature data
14
+ // (see feature.ts's trust boundary — same posture as ApexCta.href).
15
+ export function buildSitemapXml(entries: readonly SitemapEntry[]): string {
16
+ const urls = entries
17
+ .map((e) => {
18
+ const lastmod = e.lastmod ? `\n <lastmod>${escapeXml(e.lastmod)}</lastmod>` : "";
19
+ const changefreq = e.changefreq ? `\n <changefreq>${e.changefreq}</changefreq>` : "";
20
+ const alternates = (e.alternates ?? [])
21
+ .map(
22
+ (a) =>
23
+ `\n <xhtml:link rel="alternate" hreflang="${escapeXml(a.hreflang)}" href="${escapeXml(a.href)}" />`,
24
+ )
25
+ .join("");
26
+ return ` <url>\n <loc>${escapeXml(e.loc)}</loc>${lastmod}${changefreq}${alternates}\n </url>`;
27
+ })
28
+ .join("\n");
29
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">\n${urls}\n</urlset>\n`;
30
+ }