@iterant/site-runtime 3.0.2

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 (36) hide show
  1. package/README.md +30 -0
  2. package/bin/site-runtime.mjs +46 -0
  3. package/docs/runtime-contract.md +324 -0
  4. package/package.json +84 -0
  5. package/scripts/scan-bespoke-siblings.mjs +191 -0
  6. package/scripts/scan-copy.mjs +204 -0
  7. package/scripts/scan-island-imports.mjs +207 -0
  8. package/scripts/verify.mjs +283 -0
  9. package/src/components/seo-json.tsx +157 -0
  10. package/src/components/seo.tsx +294 -0
  11. package/src/config/preset.ts +198 -0
  12. package/src/content/collections.ts +71 -0
  13. package/src/content/schema.ts +239 -0
  14. package/src/index.ts +22 -0
  15. package/src/integrations/iterant-plugins.mjs +83 -0
  16. package/src/integrations/new-file-reload.mjs +95 -0
  17. package/src/integrations/preview-error-shell.mjs +145 -0
  18. package/src/layouts/LayoutCore.astro +182 -0
  19. package/src/layouts/layout-core.ts +141 -0
  20. package/src/lib/bespoke-pages.ts +60 -0
  21. package/src/lib/chrome-schemas.ts +266 -0
  22. package/src/lib/chrome.ts +23 -0
  23. package/src/lib/content-paths.ts +16 -0
  24. package/src/lib/content-values.ts +201 -0
  25. package/src/lib/hreflang.ts +123 -0
  26. package/src/lib/locales.ts +92 -0
  27. package/src/lib/sitemap/get-sitemap-paths.ts +65 -0
  28. package/src/lib/sitemap/index.ts +19 -0
  29. package/src/lib/sitemap/routes.ts +141 -0
  30. package/src/lib/sitemap/shared.ts +95 -0
  31. package/src/lib/sitemap/sitemap-with-custom-pages-plugin.ts +74 -0
  32. package/src/routes/UnderConstruction.astro +43 -0
  33. package/src/routes/index.ts +11 -0
  34. package/src/routes/llms-txt.ts +68 -0
  35. package/src/routes/robots-txt.ts +56 -0
  36. package/src/version.ts +9 -0
@@ -0,0 +1,201 @@
1
+ import { z } from "astro/zod";
2
+
3
+ // The content-value grammar: every piece of VISIBLE COPY in a brand site —
4
+ // headings, paragraphs, link labels, image alt text — lives in page/chrome
5
+ // JSON entries as a tagged wrapper, never as a literal string in TSX/JSX.
6
+ // The wrappers are what make copy machine-addressable: the visual editor
7
+ // binds to a wrapper by path, and translation rewrites exactly the wrapped
8
+ // copy fields (text.value, link.text, image.alt) while never touching
9
+ // hrefs, srcs, ids, or config values.
10
+ //
11
+ // { "type": "text", "value": "Visible prose" }
12
+ // { "type": "link", "text": "Start free", "href": "/signup" }
13
+ // { "type": "image", "src": "/images/x.webp", "alt": "Description" }
14
+ // { "type": "svg", "markup": "<svg…>", "label": "Accessible name" }
15
+ // { "type": "color", "value": "#0ea5e9" }
16
+ // { "type": "array", "items": [ { …wrapped fields per item… } ] }
17
+ //
18
+ // Non-copy config values stay bare: numbers, booleans, and short lowercase
19
+ // tokens (icon names, variants — "zap", "center", "inverse"). A bare string
20
+ // containing uppercase letters or spaces is rejected by the schema: that is
21
+ // the guard that keeps prose out of unwrapped strings.
22
+
23
+ export interface TextContent {
24
+ type: "text";
25
+ value: string;
26
+ }
27
+
28
+ export interface LinkContent {
29
+ type: "link";
30
+ text: string;
31
+ href: string;
32
+ target?: "_blank" | "_self";
33
+ }
34
+
35
+ /** A responsive-image candidate: a source URL paired with a width ("400w")
36
+ * or pixel-density ("2x") descriptor. */
37
+ export interface ImageCandidate {
38
+ src: string;
39
+ assetId?: string;
40
+ descriptor: string;
41
+ }
42
+
43
+ export interface ImageContent {
44
+ type: "image";
45
+ src: string;
46
+ alt: string;
47
+ assetId?: string;
48
+ /** Optional responsive candidates. The renderer composes the `srcset`
49
+ * attribute from these; `src` stays the always-valid fallback. */
50
+ srcset?: ImageCandidate[];
51
+ }
52
+
53
+ export interface SvgContent {
54
+ type: "svg";
55
+ markup: string;
56
+ label?: string;
57
+ }
58
+
59
+ export interface ColorContent {
60
+ type: "color";
61
+ value: string;
62
+ }
63
+
64
+ export interface ArrayContent {
65
+ type: "array";
66
+ items: ContentItem[];
67
+ }
68
+
69
+ export type ContentValue =
70
+ | TextContent
71
+ | LinkContent
72
+ | ImageContent
73
+ | SvgContent
74
+ | ColorContent
75
+ | ArrayContent;
76
+
77
+ /** Bare non-copy values: numbers, booleans, short lowercase config tokens. */
78
+ export type ConfigValue = string | number | boolean;
79
+
80
+ export type ContentLeaf = ContentValue | ConfigValue;
81
+ export type ContentItem = Record<string, ContentLeaf>;
82
+ export type ContentProps = Record<string, ContentLeaf>;
83
+
84
+ export const textContentSchema = z
85
+ .object({
86
+ type: z.literal("text"),
87
+ // A lone URL/path in a text wrapper is a modeling error: translation
88
+ // would try to rewrite it and the editor would offer a text input for
89
+ // a destination. Links live in link wrappers ({type:"link",text,href});
90
+ // image sources in image wrappers.
91
+ value: z
92
+ .string()
93
+ .refine(
94
+ (value) => !/^(?:\/|#|https?:\/\/)\S*$/.test(value.trim()),
95
+ 'this looks like a URL or path — use {"type":"link","text":…,"href":…} (or image src), not a text wrapper',
96
+ ),
97
+ })
98
+ .strict();
99
+
100
+ export const linkContentSchema = z
101
+ .object({
102
+ type: z.literal("link"),
103
+ text: z.string(),
104
+ href: z.string(),
105
+ target: z.enum(["_blank", "_self"]).optional(),
106
+ })
107
+ .strict();
108
+
109
+ // A srcset candidate descriptor is a width ("400w") or pixel density ("2x").
110
+ const descriptorSchema = z
111
+ .string()
112
+ .regex(
113
+ /^(?:[1-9]\d*w|\d+(?:\.\d+)?x)$/,
114
+ 'srcset descriptor must be a width ("400w") or density ("2x")',
115
+ );
116
+
117
+ export const imageCandidateSchema = z
118
+ .object({
119
+ src: z.string(),
120
+ assetId: z.string().optional(),
121
+ descriptor: descriptorSchema,
122
+ })
123
+ .strict();
124
+
125
+ export const imageContentSchema = z
126
+ .object({
127
+ type: z.literal("image"),
128
+ src: z.string(),
129
+ alt: z.string(),
130
+ assetId: z.string().optional(),
131
+ // Responsive candidates (optional): the renderer composes `srcset` from
132
+ // these; `src` remains the fallback. Absent for a single-resolution image.
133
+ srcset: z.array(imageCandidateSchema).optional(),
134
+ })
135
+ .strict();
136
+
137
+ export const svgContentSchema = z
138
+ .object({
139
+ type: z.literal("svg"),
140
+ markup: z.string(),
141
+ label: z.string().optional(),
142
+ })
143
+ .strict();
144
+
145
+ export const colorContentSchema = z
146
+ .object({ type: z.literal("color"), value: z.string() })
147
+ .strict();
148
+
149
+ export const configTokenSchema = z
150
+ .string()
151
+ .max(48)
152
+ .regex(
153
+ /^[a-z0-9][a-z0-9_./-]*$/,
154
+ 'bare strings are for short config tokens only ("zap", "center", "3xl"); visible copy must be wrapped: {"type":"text","value":"…"}',
155
+ );
156
+
157
+ const configValueSchema = z.union([z.number(), z.boolean(), configTokenSchema]);
158
+
159
+ // Prop keys become edit/translation paths (props.features.0.title), so they
160
+ // must be path-safe: alphanumeric camelCase, no dots or brackets.
161
+ const propKeySchema = z
162
+ .string()
163
+ .regex(
164
+ /^[a-zA-Z][a-zA-Z0-9]*$/,
165
+ "prop keys are alphanumeric camelCase (they become edit and translation paths)",
166
+ );
167
+
168
+ export const contentLeafSchema: z.ZodType<ContentLeaf> = z.lazy(() =>
169
+ z.union([
170
+ textContentSchema,
171
+ linkContentSchema,
172
+ imageContentSchema,
173
+ svgContentSchema,
174
+ colorContentSchema,
175
+ arrayContentSchema,
176
+ configValueSchema,
177
+ ]),
178
+ );
179
+
180
+ export const contentItemSchema: z.ZodType<ContentItem> = z.record(
181
+ propKeySchema,
182
+ contentLeafSchema,
183
+ );
184
+
185
+ export const arrayContentSchema = z
186
+ .object({ type: z.literal("array"), items: z.array(contentItemSchema) })
187
+ .strict();
188
+
189
+ export const contentPropsSchema: z.ZodType<ContentProps> = z.record(
190
+ propKeySchema,
191
+ contentLeafSchema,
192
+ );
193
+
194
+ /** Typed `{type:"array"}` schema for registered sections with a known item shape. */
195
+ export const arrayOf = <T extends z.ZodRawShape>(shape: T) =>
196
+ z
197
+ .object({
198
+ type: z.literal("array"),
199
+ items: z.array(z.object(shape).strict()),
200
+ })
201
+ .strict();
@@ -0,0 +1,123 @@
1
+ import { DEFAULT_LOCALE, normalizeBcp47, parseEntryId } from "./locales";
2
+
3
+ // hreflang alternates for locale sibling pages (starter 2.9.0). A page with
4
+ // locale siblings advertises one `<link rel="alternate" hreflang>` per
5
+ // NON-DRAFT sibling in the group, plus exactly one `x-default` pointing at the
6
+ // unsuffixed base entry. A page with no siblings emits nothing. Ported from the
7
+ // legacy `derive_hreflang_set` (backend translation_group.py): published/
8
+ // indexable rows only (starter: non-draft), one x-default = the default-locale
9
+ // row, deduped on a normalized locale key, hreflang values in canonical BCP-47.
10
+
11
+ /** One reciprocal hreflang alternate. `hreflang` is BCP-47 or "x-default". */
12
+ export interface HreflangAlternate {
13
+ hreflang: string;
14
+ href: string;
15
+ }
16
+
17
+ /** The minimal page-entry shape the derivation reads. */
18
+ export interface HreflangEntry {
19
+ /** Collection entry id (`example`, `example.es`). */
20
+ id: string;
21
+ /** The entry's `route` field (`/example`, `/es/example`). */
22
+ route: string;
23
+ draft: boolean;
24
+ }
25
+
26
+ /** A `pages` collection entry, as `pageLocaleHead` consumes it. */
27
+ export interface PagesCollectionEntry {
28
+ id: string;
29
+ data: { route: string; draft: boolean };
30
+ }
31
+
32
+ /**
33
+ * The `<html lang>` value for a page, from its entry id: base entries are the
34
+ * brand default (`en`); siblings carry their locale in canonical BCP-47
35
+ * casing (`example.pt-br` → `pt-BR`).
36
+ */
37
+ export function pageLang(entryId: string): string {
38
+ const { locale } = parseEntryId(entryId);
39
+ return locale ? normalizeBcp47(locale) : DEFAULT_LOCALE;
40
+ }
41
+
42
+ /**
43
+ * The locale head pair every page emits (starter 2.10.0): `<html lang>` plus
44
+ * the reciprocal hreflang alternates, from an entry id and the full `pages`
45
+ * collection. The one-liner bespoke shells use for head parity with the
46
+ * catch-all — base bespoke pages advertise their locale siblings through the
47
+ * same derivation, so siblings need no per-page head work.
48
+ */
49
+ export function pageLocaleHead(params: {
50
+ entries: PagesCollectionEntry[];
51
+ entryId: string;
52
+ site: URL | string | undefined;
53
+ }): { lang: string; hreflang: HreflangAlternate[] } {
54
+ return {
55
+ lang: pageLang(params.entryId),
56
+ hreflang: deriveHreflangAlternates({
57
+ entries: params.entries.map((entry) => ({
58
+ id: entry.id,
59
+ route: entry.data.route,
60
+ draft: entry.data.draft,
61
+ })),
62
+ baseName: parseEntryId(params.entryId).base,
63
+ site: params.site,
64
+ }),
65
+ };
66
+ }
67
+
68
+ function absolute(route: string, site: URL | string | undefined): string {
69
+ if (!site) return route; // dev without a configured `site` — relative href
70
+ return new URL(route, site.toString()).toString();
71
+ }
72
+
73
+ /**
74
+ * The reciprocal hreflang set for the group `baseName` belongs to, derived from
75
+ * ALL page entries (the caller passes the whole collection). Every non-draft
76
+ * entry in the group contributes one alternate; the base entry also seeds the
77
+ * single `x-default`. Draft siblings are never advertised (unpublished
78
+ * translations must not leak to crawlers). Deduped on the normalized locale key
79
+ * (first entry wins); hrefs absolute via `site` (`Astro.site`). Returns `[]`
80
+ * when the group has no non-draft siblings — a lone page emits no hreflang.
81
+ */
82
+ export function deriveHreflangAlternates(params: {
83
+ entries: HreflangEntry[];
84
+ baseName: string;
85
+ site: URL | string | undefined;
86
+ }): HreflangAlternate[] {
87
+ const { entries, baseName, site } = params;
88
+
89
+ const byKey = new Map<
90
+ string,
91
+ { tag: string; route: string; isBase: boolean }
92
+ >();
93
+ let base: { route: string } | undefined;
94
+ for (const entry of entries) {
95
+ if (entry.draft) continue; // never advertise a draft sibling/base
96
+ const { base: entryBase, locale } = parseEntryId(entry.id);
97
+ if (entryBase !== baseName) continue;
98
+ const isBase = !locale;
99
+ const tag = isBase ? DEFAULT_LOCALE : normalizeBcp47(locale);
100
+ const key = tag.toLowerCase();
101
+ if (isBase) base = { route: entry.route };
102
+ if (!byKey.has(key)) byKey.set(key, { tag, route: entry.route, isBase });
103
+ }
104
+
105
+ const hasSibling = [...byKey.values()].some((alt) => !alt.isBase);
106
+ if (!hasSibling) return [];
107
+
108
+ const ordered = [...byKey.values()].sort((a, b) => {
109
+ if (a.isBase !== b.isBase) return a.isBase ? -1 : 1; // base first
110
+ return a.tag.localeCompare(b.tag);
111
+ });
112
+ const alternates: HreflangAlternate[] = ordered.map((alt) => ({
113
+ hreflang: alt.tag,
114
+ href: absolute(alt.route, site),
115
+ }));
116
+ if (base) {
117
+ alternates.push({
118
+ hreflang: "x-default",
119
+ href: absolute(base.route, site),
120
+ });
121
+ }
122
+ return alternates;
123
+ }
@@ -0,0 +1,92 @@
1
+ import { z } from "astro/zod";
2
+
3
+ // Locale sibling entries (starter 2.8.0): a translated page is a first-class
4
+ // content entry next to its base — `src/content/pages/<page>.<locale>.json`
5
+ // with the lowercased locale as both the filename suffix and the root-level
6
+ // route prefix (`/es/pricing`); the unsuffixed base stays the x-default.
7
+ // Site chrome follows the same model (`src/content/chrome.<locale>.json`),
8
+ // resolved per request path with fallback to the base chrome. `<html lang>`
9
+ // and hreflang alternates are derived from the entry locale (see
10
+ // ./hreflang.ts).
11
+
12
+ /**
13
+ * Lowercased locale segment grammar (`es`, `pt-br`): language subtag plus
14
+ * optional extra subtags, all lowercase — the form used in entry filenames,
15
+ * entry ids, and route prefixes.
16
+ */
17
+ export const LOCALE_SEGMENT_RE = /^[a-z]{2,3}(?:-[a-z0-9]{2,8})*$/;
18
+
19
+ /** The platform x-default: base entries carry no suffix and never get one. */
20
+ export const DEFAULT_LOCALE = "en";
21
+
22
+ /**
23
+ * Split a collection entry id into its base page and optional locale:
24
+ * `home` → {base:"home"}, `home.es` → {base:"home", locale:"es"},
25
+ * `chrome.pt-br` → {base:"chrome", locale:"pt-br"}. Base names never contain
26
+ * dots, so any well-formed lowercased locale suffix IS a sibling id.
27
+ *
28
+ * KEEP IN SYNC with `apps/agent-mvp/src/locales.ts#parseEntryId` — the agent
29
+ * writes the sibling ids the site reads back (out-of-workspace duplication,
30
+ * same pattern as the special-input mirrors).
31
+ */
32
+ export function parseEntryId(id: string): { base: string; locale?: string } {
33
+ const dot = id.indexOf(".");
34
+ if (dot === -1) return { base: id };
35
+ const base = id.slice(0, dot);
36
+ const suffix = id.slice(dot + 1);
37
+ if (base && LOCALE_SEGMENT_RE.test(suffix)) return { base, locale: suffix };
38
+ return { base: id };
39
+ }
40
+
41
+ /**
42
+ * Canonical BCP-47 casing for a locale (`pt-br` → `pt-BR`, `ES` → `es`):
43
+ * language subtag lowercased, 4-alpha script subtags Titlecased, other
44
+ * subtags uppercased. Files/routes use the lowercased segment; this casing is
45
+ * for the `<html lang>` attribute and hreflang values (starter 2.9.0). KEEP IN
46
+ * SYNC with `apps/agent-mvp/src/locales.ts#normalizeBcp47`.
47
+ */
48
+ export function normalizeBcp47(locale: string): string {
49
+ const raw = locale.trim().replace(/_/g, "-");
50
+ if (!raw) return raw;
51
+ const [language, ...rest] = raw.split("-");
52
+ return [
53
+ language.toLowerCase(),
54
+ ...rest.map((part) =>
55
+ part.length === 4 && /^[a-z]+$/i.test(part)
56
+ ? part[0].toUpperCase() + part.slice(1).toLowerCase()
57
+ : part.toUpperCase(),
58
+ ),
59
+ ].join("-");
60
+ }
61
+
62
+ /**
63
+ * The locale segment a request path is prefixed with, if any: `/es/pricing`
64
+ * → `es`, `/pt-br` → `pt-br`, `/pricing` → undefined. Grammar-only — the
65
+ * caller decides whether a matching sibling (chrome.<locale>.json) actually
66
+ * exists and falls back to the base otherwise, so a page that merely LOOKS
67
+ * locale-shaped costs nothing.
68
+ */
69
+ export function localeFromPath(pathname: string): string | undefined {
70
+ const [first] = pathname.split("/").filter(Boolean);
71
+ return first && LOCALE_SEGMENT_RE.test(first) ? first : undefined;
72
+ }
73
+
74
+ /**
75
+ * The `_translation` provenance block locale siblings carry (per-leaf source
76
+ * hashes, human-owned paths, base-entry sha). It is written and maintained by
77
+ * the platform agent — a brand site only needs siblings to VALIDATE, so the
78
+ * shape is deliberately loose: a record the schema passes through and the
79
+ * renderer never reads.
80
+ */
81
+ export const translationProvenanceSchema = z.record(z.string(), z.unknown());
82
+
83
+ /**
84
+ * Collection entry id from a JSON filename, preserving dots:
85
+ * `home.es.json` → `home.es`. The glob loader's default generateId slugifies
86
+ * ids (github-slugger drops dots — `home.es` would become `homees`), which
87
+ * breaks the `<page>.<locale>` sibling grammar the platform addresses
88
+ * entries by.
89
+ */
90
+ export function entryIdFromFile(entry: string): string {
91
+ return entry.replace(/\.json$/, "");
92
+ }
@@ -0,0 +1,65 @@
1
+ // SSR routes are invisible to @astrojs/sitemap (it only sees prerendered
2
+ // pages), so list them here. This runs synchronously in Astro's config chain
3
+ // (plain Node) so there is no `astro:content` and no `@/` alias: we read the
4
+ // `pages` JSON collection from disk and use each entry's `route` field, the same
5
+ // value src/pages/[...slug].astro matches against.
6
+ //
7
+ // Locale siblings (`<page>.<locale>.json`) are ordinary entries here: each
8
+ // contributes its own prefixed route (`/es/example`) so every published locale
9
+ // is in the sitemap. Drafts (siblings inherit the base's `draft`) are skipped,
10
+ // so an unpublished translation never appears.
11
+ //
12
+ // `root` is the CONSUMING repo's project root, which the caller reads off
13
+ // Astro's resolved config rather than guessing from cwd: the package sits in
14
+ // node_modules, and a cwd-relative guess silently yields an empty list (and a
15
+ // sitemap missing every SSR route) whenever a build is driven from elsewhere.
16
+ import { readdirSync, readFileSync } from "node:fs";
17
+ import { isAbsolute, join } from "node:path";
18
+
19
+ import { DEFAULT_PAGES_DIR } from "../content-paths";
20
+
21
+ export interface SitemapPathsOptions {
22
+ /** Page entry directory (absolute, or relative to `root`). */
23
+ pagesDir?: string;
24
+ /** The project root. Required in practice; cwd is the last-resort fallback. */
25
+ root?: string;
26
+ }
27
+
28
+ function resolvePagesDir({ pagesDir, root }: SitemapPathsOptions): string {
29
+ const dir = pagesDir ?? DEFAULT_PAGES_DIR;
30
+ return isAbsolute(dir) ? dir : join(root ?? process.cwd(), dir);
31
+ }
32
+
33
+ /**
34
+ * Every non-draft page entry's route, home excluded (@astrojs/sitemap already
35
+ * emits the site root). Returns `{ paths, pagesDir }` so a caller can report
36
+ * WHERE it looked when the answer is empty.
37
+ */
38
+ export function getSitemapPaths(options: SitemapPathsOptions = {}): {
39
+ paths: string[];
40
+ entryCount: number;
41
+ pagesDir: string;
42
+ } {
43
+ const pagesDir = resolvePagesDir(options);
44
+ let entries: string[];
45
+ try {
46
+ entries = readdirSync(pagesDir, { recursive: true }) as string[];
47
+ } catch {
48
+ return { paths: [], pagesDir, entryCount: 0 }; // no page entries yet
49
+ }
50
+ const jsonEntries = entries.filter((entry) => entry.endsWith(".json"));
51
+ const entryCount = jsonEntries.length;
52
+ const paths = jsonEntries
53
+ .filter((rel) => /\.json$/i.test(rel))
54
+ .map((rel) => {
55
+ try {
56
+ const data = JSON.parse(readFileSync(join(pagesDir, rel), "utf8"));
57
+ if (data.draft || typeof data.route !== "string") return null;
58
+ return data.route as string;
59
+ } catch {
60
+ return null; // malformed entry; the build will surface the schema error
61
+ }
62
+ })
63
+ .filter((path): path is string => path !== null && path !== "/");
64
+ return { paths, pagesDir, entryCount };
65
+ }
@@ -0,0 +1,19 @@
1
+ // The sitemap machinery, as astro.config consumes it (the config preset wires
2
+ // `sitemapWithCustomPages` already). Route HANDLERS live in
3
+ // @iterant/site-runtime/routes so a route file never pulls @astrojs/sitemap
4
+ // into the server bundle.
5
+ export { DEFAULT_PAGES_DIR } from "../content-paths";
6
+ export { getSitemapPaths, type SitemapPathsOptions } from "./get-sitemap-paths";
7
+ export {
8
+ sitemapWithCustomPages,
9
+ type SitemapWithCustomPagesOptions,
10
+ } from "./sitemap-with-custom-pages-plugin";
11
+ export {
12
+ EMPTY_SITEMAP_HEADERS,
13
+ EMPTY_URLSET,
14
+ SITEMAP_HEADERS,
15
+ fetchUpstream,
16
+ normalizeSourceUrls,
17
+ parseSitemapIndex,
18
+ rewriteSitemapDomain,
19
+ } from "./shared";
@@ -0,0 +1,141 @@
1
+ import type { APIRoute } from "astro";
2
+
3
+ import {
4
+ EMPTY_SITEMAP_HEADERS,
5
+ EMPTY_URLSET,
6
+ fetchUpstream,
7
+ normalizeSourceUrls,
8
+ parseSitemapIndex,
9
+ rewriteSitemapDomain,
10
+ SITEMAP_HEADERS,
11
+ } from "./shared";
12
+
13
+ // The two sitemap-proxy handlers. They stay behind visible repo routes (the
14
+ // repo owns whether a URL exists; the package owns what it answers):
15
+ //
16
+ // src/pages/sitemap.xml.ts
17
+ // ---
18
+ // export const prerender = false;
19
+ // export const GET = createSitemapRoute({
20
+ // sourceSitemapUrl: SITE_CONFIG.sourceSitemapUrl,
21
+ // });
22
+ //
23
+ // src/pages/proxied-sitemap-[i].xml.ts
24
+ // ---
25
+ // export const prerender = false;
26
+ // export const GET = createProxiedSitemapRoute({
27
+ // sourceSitemapUrl: SITE_CONFIG.sourceSitemapUrl,
28
+ // });
29
+ //
30
+ // With no upstream configured both answer 404, which is what the routes did
31
+ // when they were conditionally injected: a brand with no mirrored sitemap
32
+ // serves only the generated sitemap-index.xml.
33
+
34
+ export interface SitemapRouteOptions {
35
+ /** The brand's `SITE_CONFIG.sourceSitemapUrl` (string or array, "" to disable). */
36
+ sourceSitemapUrl: string | string[];
37
+ }
38
+
39
+ /**
40
+ * /sitemap.xml: a sitemap proxied from the configured upstream with every URL's
41
+ * host rewritten to this site's domain. A `<sitemapindex>` upstream (or an array
42
+ * config) is served as an index whose entries are /proxied-sitemap-[i].xml.
43
+ */
44
+ export function createSitemapRoute({
45
+ sourceSitemapUrl,
46
+ }: SitemapRouteOptions): APIRoute {
47
+ return async ({ site }) => {
48
+ const urls = normalizeSourceUrls(sourceSitemapUrl);
49
+ if (urls.length === 0) {
50
+ return new Response("Not found", { status: 404 });
51
+ }
52
+ if (!site) {
53
+ return new Response(EMPTY_URLSET, { headers: EMPTY_SITEMAP_HEADERS });
54
+ }
55
+
56
+ // Array config: one /proxied-sitemap-N.xml slot per entry. The entry route
57
+ // fetches each upstream URL on demand.
58
+ if (urls.length > 1) {
59
+ return new Response(buildProxyIndex(urls.length, site.origin), {
60
+ headers: SITEMAP_HEADERS,
61
+ });
62
+ }
63
+
64
+ // Single URL: mirror the upstream's shape (urlset stays urlset; index becomes
65
+ // a proxied index).
66
+ const xml = await fetchUpstream(urls[0]);
67
+ if (xml === null) {
68
+ return new Response(EMPTY_URLSET, { headers: EMPTY_SITEMAP_HEADERS });
69
+ }
70
+ const entries = parseSitemapIndex(xml);
71
+ const body = entries
72
+ ? buildProxyIndex(entries.length, site.origin)
73
+ : rewriteSitemapDomain(xml, site.origin);
74
+ return new Response(body, { headers: SITEMAP_HEADERS });
75
+ };
76
+ }
77
+
78
+ /**
79
+ * /proxied-sitemap-<i>.xml: the i-th upstream urlset, host-rewritten. `i` only
80
+ * indexes the upstream's own entries or the operator-supplied array, never a
81
+ * caller-supplied URL.
82
+ */
83
+ export function createProxiedSitemapRoute({
84
+ sourceSitemapUrl,
85
+ }: SitemapRouteOptions): APIRoute {
86
+ return async ({ params, site }) => {
87
+ const urls = normalizeSourceUrls(sourceSitemapUrl);
88
+ if (urls.length === 0 || !/^\d+$/.test(params.i ?? "")) {
89
+ return new Response("Not found", { status: 404 });
90
+ }
91
+ if (!site) {
92
+ return new Response(EMPTY_URLSET, { headers: EMPTY_SITEMAP_HEADERS });
93
+ }
94
+ const idx = Number(params.i);
95
+
96
+ // Array config: index directly into the array. Single URL: fetch the upstream
97
+ // <sitemapindex> and take its i-th <loc>.
98
+ let entryUrl: string | undefined;
99
+ if (urls.length > 1) {
100
+ entryUrl = urls[idx];
101
+ } else {
102
+ const indexXml = await fetchUpstream(urls[0]);
103
+ const entries = indexXml ? parseSitemapIndex(indexXml) : null;
104
+ entryUrl = entries?.[idx];
105
+ }
106
+ if (!entryUrl) {
107
+ return new Response("Not found", { status: 404 });
108
+ }
109
+
110
+ const xml = await fetchUpstream(entryUrl);
111
+ if (xml === null) {
112
+ return new Response(EMPTY_URLSET, { headers: EMPTY_SITEMAP_HEADERS });
113
+ }
114
+ if (parseSitemapIndex(xml)) {
115
+ // Nested index: fail safe rather than emit URLs we don't serve.
116
+ console.warn(
117
+ `[sitemap-proxy] nested sitemap index not supported: ${entryUrl}`,
118
+ );
119
+ return new Response(EMPTY_URLSET, { headers: EMPTY_SITEMAP_HEADERS });
120
+ }
121
+
122
+ return new Response(rewriteSitemapDomain(xml, site.origin), {
123
+ headers: SITEMAP_HEADERS,
124
+ });
125
+ };
126
+ }
127
+
128
+ // A <sitemapindex> whose entries are our own /proxied-sitemap-<i>.xml URLs.
129
+ function buildProxyIndex(count: number, origin: string): string {
130
+ const entries = Array.from(
131
+ { length: count },
132
+ (_, i) =>
133
+ ` <sitemap><loc>${origin}/proxied-sitemap-${i}.xml</loc></sitemap>`,
134
+ ).join("\n");
135
+ return (
136
+ '<?xml version="1.0" encoding="UTF-8"?>\n' +
137
+ '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' +
138
+ `${entries}\n` +
139
+ "</sitemapindex>\n"
140
+ );
141
+ }