@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.
- package/package.json +7 -6
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +15 -55
- package/src/custom-fields/__tests__/parse-serialized-field.test.ts +11 -3
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +24 -79
- package/src/custom-fields/db/queries/user-data-rights.ts +0 -20
- package/src/custom-fields/events.ts +4 -9
- package/src/custom-fields/feature.ts +8 -0
- package/src/custom-fields/handlers/set-custom-field.write.ts +3 -44
- package/src/custom-fields/handlers/update-tenant-field.write.ts +0 -27
- package/src/custom-fields/lib/parse-serialized-field.ts +12 -4
- package/src/custom-fields/lib/value-schema.ts +1 -1
- package/src/custom-fields/schemas.ts +9 -9
- package/src/custom-fields/wire-for-entity.ts +8 -15
- package/src/custom-fields/wire-user-data-rights.ts +5 -54
- package/src/inbound-mail-foundation/__tests__/retention.integration.test.ts +199 -0
- package/src/inbound-mail-foundation/feature.ts +58 -0
- package/src/inbound-mail-foundation/retention-sweep.ts +155 -0
- package/src/managed-pages/__tests__/managed-pages.integration.test.ts +26 -0
- package/src/managed-pages/feature.ts +3 -1
- package/src/managed-pages/handlers/by-tenant-published.query.ts +52 -0
- package/src/page-render/__tests__/layout.test.ts +53 -0
- package/src/page-render/index.ts +1 -1
- package/src/page-render/layout.ts +15 -1
- package/src/seo/__tests__/llms-txt.test.ts +38 -0
- package/src/seo/__tests__/robots-txt.test.ts +18 -0
- package/src/seo/__tests__/schema-builders.test.ts +57 -0
- package/src/seo/__tests__/seo.integration.test.ts +258 -0
- package/src/seo/__tests__/sitemap.test.ts +38 -0
- package/src/seo/constants.ts +60 -0
- package/src/seo/feature.ts +313 -0
- package/src/seo/handlers/seo-config.query.ts +35 -0
- package/src/seo/index.ts +19 -0
- package/src/seo/llms-txt.ts +33 -0
- package/src/seo/robots-txt.ts +13 -0
- package/src/seo/schema-builders.ts +57 -0
- package/src/seo/sitemap.ts +30 -0
- 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
|
+
});
|
package/src/seo/index.ts
ADDED
|
@@ -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
|
+
}
|