@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.
- package/README.md +30 -0
- package/bin/site-runtime.mjs +46 -0
- package/docs/runtime-contract.md +324 -0
- package/package.json +84 -0
- package/scripts/scan-bespoke-siblings.mjs +191 -0
- package/scripts/scan-copy.mjs +204 -0
- package/scripts/scan-island-imports.mjs +207 -0
- package/scripts/verify.mjs +283 -0
- package/src/components/seo-json.tsx +157 -0
- package/src/components/seo.tsx +294 -0
- package/src/config/preset.ts +198 -0
- package/src/content/collections.ts +71 -0
- package/src/content/schema.ts +239 -0
- package/src/index.ts +22 -0
- package/src/integrations/iterant-plugins.mjs +83 -0
- package/src/integrations/new-file-reload.mjs +95 -0
- package/src/integrations/preview-error-shell.mjs +145 -0
- package/src/layouts/LayoutCore.astro +182 -0
- package/src/layouts/layout-core.ts +141 -0
- package/src/lib/bespoke-pages.ts +60 -0
- package/src/lib/chrome-schemas.ts +266 -0
- package/src/lib/chrome.ts +23 -0
- package/src/lib/content-paths.ts +16 -0
- package/src/lib/content-values.ts +201 -0
- package/src/lib/hreflang.ts +123 -0
- package/src/lib/locales.ts +92 -0
- package/src/lib/sitemap/get-sitemap-paths.ts +65 -0
- package/src/lib/sitemap/index.ts +19 -0
- package/src/lib/sitemap/routes.ts +141 -0
- package/src/lib/sitemap/shared.ts +95 -0
- package/src/lib/sitemap/sitemap-with-custom-pages-plugin.ts +74 -0
- package/src/routes/UnderConstruction.astro +43 -0
- package/src/routes/index.ts +11 -0
- package/src/routes/llms-txt.ts +68 -0
- package/src/routes/robots-txt.ts +56 -0
- 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
|
+
}
|