@tokenoftrust/storefront-runner 4.6.4 → 4.7.1

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.
@@ -0,0 +1,113 @@
1
+ // @ts-check
2
+ /**
3
+ * Which editorial page slugs the hosted storefront router can serve.
4
+ *
5
+ * A page under `content/pages/<file>.json` is served at `/<slug>/`, where `<slug>` is the page's
6
+ * own JSON `slug` (never the file name), and a raw page `content/pages-html/<path>.html` is served
7
+ * at `/<path>/`. Both reach the platform's catch-all route, which every first-class platform route
8
+ * outranks. So a slug is servable only when:
9
+ *
10
+ * - it is a string of one or more `/`-separated segments, each starting with a letter or digit
11
+ * and holding only letters, digits, `-` and `_` (no leading/trailing or doubled slash, no `.`:
12
+ * both CDNs treat a dotted last segment as a file, never a page);
13
+ * - its first segment is not a route root the platform owns (`products`, `collections`, `blog`,
14
+ * `api`, …) — the platform route answers first and the page is never reached;
15
+ * - no other page, JSON or raw, claims the same slug — the router serves one of them.
16
+ *
17
+ * Pure: callers pass what they read, so the kit validator and `tot validate` apply one rule.
18
+ * `packages/cli/src/page-slugs.mjs` is a byte-identical copy (a test pins it).
19
+ */
20
+
21
+ /**
22
+ * First path segments the platform's own routes answer before the editorial catch-all.
23
+ * Pinned against the storefront's route tree by scripts/lib/page-slug-reserved.test.mjs.
24
+ */
25
+ export const RESERVED_PAGE_ROOTS = Object.freeze([
26
+ "_sitemap",
27
+ "account",
28
+ "admin",
29
+ "api",
30
+ "auth",
31
+ "blog",
32
+ "capabilities",
33
+ "cart",
34
+ "cockpit",
35
+ "collections",
36
+ "dashboard",
37
+ "diag",
38
+ "preview",
39
+ "products",
40
+ "saved",
41
+ "search",
42
+ "shared",
43
+ "style-guide",
44
+ "tenants",
45
+ "the-build",
46
+ ]);
47
+
48
+ const SEGMENT_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
49
+
50
+ /**
51
+ * Why `slug` cannot be served by the hosted router, or null when it can.
52
+ * @param {unknown} slug
53
+ * @returns {string | null}
54
+ */
55
+ export function unservableSlugReason(slug) {
56
+ if (typeof slug !== "string" || slug.length === 0) return "has no string `slug`";
57
+ const segments = slug.split("/");
58
+ if (segments.some((segment) => !SEGMENT_RE.test(segment))) {
59
+ return `slug "${slug}" is not a page path: each "/"-separated segment must start with a letter or digit and hold only letters, digits, "-" and "_" (no leading, trailing or doubled "/", no ".")`;
60
+ }
61
+ const root = /** @type {string} */ (segments[0]);
62
+ if (RESERVED_PAGE_ROOTS.includes(root)) {
63
+ return `slug "${slug}" starts with "${root}/", a route the platform serves itself — the page is never reached`;
64
+ }
65
+ return null;
66
+ }
67
+
68
+ /**
69
+ * @typedef {Object} PageSlugFinding
70
+ * @property {string} code `page-slug-unservable` | `page-slug-duplicate`
71
+ * @property {string} path the declaring file, store-relative
72
+ * @property {string} message
73
+ * @property {string} remedy
74
+ */
75
+
76
+ /**
77
+ * Every slug the hosted router cannot serve, across a store's JSON and raw pages.
78
+ * @param {{ pages?: ReadonlyArray<{ path: string, slug: unknown }>, rawPages?: ReadonlyArray<{ path: string, slug: string }> }} input
79
+ * `pages`: each `content/pages/*.json` with its parsed `slug`; `rawPages`: each
80
+ * `content/pages-html/**.html` with the slug its path encodes.
81
+ * @returns {PageSlugFinding[]}
82
+ */
83
+ export function pageSlugFindings({ pages = [], rawPages = [] }) {
84
+ /** @type {PageSlugFinding[]} */
85
+ const findings = [];
86
+ /** @type {Map<string, string>} */
87
+ const claimed = new Map();
88
+ for (const { path, slug } of [...pages, ...rawPages]) {
89
+ const reason = unservableSlugReason(slug);
90
+ if (reason) {
91
+ findings.push({
92
+ code: "page-slug-unservable",
93
+ path,
94
+ message: `${reason}, so the hosted store answers 404 for it`,
95
+ remedy: "give the page a slug of plain path segments outside the platform's routes, and add a content/redirects.json entry from any old URL",
96
+ });
97
+ continue;
98
+ }
99
+ const key = /** @type {string} */ (slug);
100
+ const first = claimed.get(key);
101
+ if (first !== undefined) {
102
+ findings.push({
103
+ code: "page-slug-duplicate",
104
+ path,
105
+ message: `slug "${slug}" is also claimed by ${first} — the hosted store serves only one of them`,
106
+ remedy: "give each page its own slug",
107
+ });
108
+ continue;
109
+ }
110
+ claimed.set(key, path);
111
+ }
112
+ return findings;
113
+ }
@@ -261,6 +261,23 @@ async function readRawDevFile(scope: string, relative: string): Promise<string |
261
261
  }
262
262
  }
263
263
 
264
+ /**
265
+ * An editorial page is routed by its JSON `slug`, never by its file name (`pages/<name>.json`
266
+ * may hold `"slug": "policies/terms-of-service"`). The file named after the slug is read first
267
+ * because it is the common layout; any other match needs the enumeration.
268
+ */
269
+ async function pageBySlug(
270
+ slug: string,
271
+ read: (relative: string) => Promise<EditorialPage | null>,
272
+ list: () => Promise<EditorialPage[]>,
273
+ ): Promise<EditorialPage | null> {
274
+ const wanted = slug.replace(/^\/+|\/+$/g, "");
275
+ if (!wanted) return null;
276
+ const named = await read(`pages/${wanted}.json`);
277
+ if (named?.slug === wanted) return named;
278
+ return (await list()).find((page) => page.slug === wanted) ?? null;
279
+ }
280
+
264
281
  export class LocalContentProvider implements ContentProvider {
265
282
  constructor(private readonly scope: string = DEFAULT_SCOPE) {}
266
283
 
@@ -714,8 +731,11 @@ export class PublishedContentProvider implements ContentProvider {
714
731
  return config ?? ((await this.usesVersionedContent()) ? EMPTY_CHROME_CONFIG : this.base.getChrome());
715
732
  }
716
733
  async getPage(slug: string): Promise<EditorialPage | null> {
717
- const page = await this.published<EditorialPage>(`pages/${slug}.json`);
718
- return page ?? ((await this.usesVersionedContent()) ? null : this.base.getPage(slug));
734
+ if (!(await this.usesVersionedContent())) {
735
+ const legacy = await this.published<EditorialPage>(`pages/${slug}.json`);
736
+ return legacy?.slug === slug ? legacy : this.base.getPage(slug);
737
+ }
738
+ return pageBySlug(slug, (relative) => this.published<EditorialPage>(relative), () => this.listPages());
719
739
  }
720
740
  async listPages(): Promise<EditorialPage[]> {
721
741
  // Editorial enumeration follows its source, not the generic artifact fallback.
@@ -824,7 +844,7 @@ export class VersionPinnedContentProvider implements ContentProvider {
824
844
  return config ?? EMPTY_CHROME_CONFIG;
825
845
  }
826
846
  async getPage(slug: string): Promise<EditorialPage | null> {
827
- return this.pinned<EditorialPage>(`pages/${slug}.json`);
847
+ return pageBySlug(slug, (relative) => this.pinned<EditorialPage>(relative), () => this.listPages());
828
848
  }
829
849
  async listPages(): Promise<EditorialPage[]> {
830
850
  const paths = await this.listArtifactPaths("pages/");
@@ -70,6 +70,7 @@ import {
70
70
  MATERIALIZE_MODE_BINDING,
71
71
  verifyConsentSnapshotSig,
72
72
  resolveNativeGa4SandboxUrl,
73
+ CANDIDATE_CONTENT_PIN_PARAM,
73
74
  } from "@tot/public-runtime";
74
75
  import { readBakedManagedInstalls } from "@/lib/integrations/integration/bakedManagedInstalls";
75
76
  import { readWithImagePreviewRewriteOverlay } from "@tot/public-runtime/image-preview-rewrite-overlay";
@@ -274,13 +275,22 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
274
275
  // The owning space's content-declared redirects answer before anything renders, as the CloudFront
275
276
  // redirect plane does for the same declarations: a same-origin target keeps the path-routing prefix
276
277
  // and the query; an off-site target (the root space only) is sent as declared.
278
+ // A pinned render (staged/candidate revision) redirects inside the revision it reviews, and its
279
+ // one-shot pin token never reaches the browser.
280
+ const declaredRedirectHeaders = (location: string): HeadersInit => {
281
+ if (!location.startsWith("/")) return { location };
282
+ let search = url.search;
283
+ if (url.searchParams.has(CANDIDATE_CONTENT_PIN_PARAM)) {
284
+ const query = new URLSearchParams(url.search);
285
+ query.delete(CANDIDATE_CONTENT_PIN_PARAM);
286
+ search = query.toString() ? `?${query.toString()}` : "";
287
+ }
288
+ return { location: `${url.origin}${candidatePin ? candidatePin.basePath : basePath}${location}${search}` };
289
+ };
277
290
  if (publicationSource.status === "selected") {
278
291
  const location = publicationContentRedirectLocationV1(publicationSource.artifact, input.publicationRequestPath);
279
292
  if (location) {
280
- const response = new Response(null, {
281
- status: 308,
282
- headers: { location: location.startsWith("/") ? `${url.origin}${basePath}${location}${url.search}` : location },
283
- });
293
+ const response = new Response(null, { status: 308, headers: declaredRedirectHeaders(location) });
284
294
  applySecurityHeaders(response.headers);
285
295
  if (isInternalPublication) applyInternalContentResponsePolicy(response.headers);
286
296
  return response;
@@ -355,10 +365,7 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
355
365
  if (publicationSource.status === "legacy-root") {
356
366
  const location = await contentRedirectLocation(locals.content, renderPath);
357
367
  if (location) {
358
- const response = new Response(null, {
359
- status: 308,
360
- headers: { location: location.startsWith("/") ? `${url.origin}${basePath}${location}${url.search}` : location },
361
- });
368
+ const response = new Response(null, { status: 308, headers: declaredRedirectHeaders(location) });
362
369
  applySecurityHeaders(response.headers);
363
370
  return response;
364
371
  }
@@ -33,6 +33,7 @@ import {
33
33
  type RouteTierIndexV1,
34
34
  type ResolvedRouteTierV1,
35
35
  } from "@tot/public-runtime";
36
+ import { parseSitemapChildPathV1 } from "@tot/public-runtime/sitemap-index";
36
37
 
37
38
  /**
38
39
  * The three serving shapes.
@@ -103,9 +104,11 @@ export function isPlatformStaticAssetPath(renderPath: string): boolean {
103
104
  return PLATFORM_STATIC_ASSET_PREFIXES.some((entry) => matchesPathOrPrefix(renderPath, entry));
104
105
  }
105
106
 
107
+ /** Discovery documents, child sitemaps (`/sitemap-<group>-<page>.xml`) included. */
106
108
  export function isPlatformPublicDiscoveryPath(renderPath: string): boolean {
107
109
  const caseFolded = renderPath.toLowerCase();
108
- return PLATFORM_PUBLIC_DISCOVERY_PATHS.some((entry) => caseFolded === entry);
110
+ return PLATFORM_PUBLIC_DISCOVERY_PATHS.some((entry) => caseFolded === entry) ||
111
+ parseSitemapChildPathV1(caseFolded) !== null;
109
112
  }
110
113
 
111
114
  export function isPlatformPublicAppRoutePath(renderPath: string): boolean {
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Per-tenant XML sitemap: `/sitemap.xml` is an index of typed, paged child sitemaps
3
+ * (`/sitemap-<group>-<page>.xml`, the shared contract in `@tot/public-runtime/sitemap-index`).
4
+ *
5
+ * Built from the resolved tenant's canonical base + catalog. ACTIVE products only —
6
+ * getAllActiveProducts() excludes the draft canary (`trail-cap`), so drafts never leak into the
7
+ * sitemap. Collections are filtered to those with at least one active product
8
+ * (nonEmptyCollections), so empty synthesized collection shells never become crawlable dead ends.
9
+ * Editorial slugs come from the ContentProvider. The canonical base is resolved per-request (prod
10
+ * domain OR path-prefixed preview host), so every emitted loc, index and child alike, is
11
+ * prefix-correct without special-casing here.
12
+ *
13
+ * Static publication fetches the index, follows it to every child, materializes each child as a
14
+ * file at its own path, and takes the page set from the children (withSitemapRoutes in
15
+ * scripts/publish/lib/routes.mjs) — so listing a page here is what makes it exist on BOTH serving
16
+ * planes, not just the SSR Worker.
17
+ */
18
+ import type { APIContext } from "astro";
19
+ import {
20
+ chunkSitemapGroupsV1,
21
+ parseSitemapChildPathV1,
22
+ renderSitemapIndexV1,
23
+ renderSitemapUrlsetV1,
24
+ type SitemapChildV1,
25
+ type SitemapGroupV1,
26
+ type SitemapUrlV1,
27
+ } from "@tot/public-runtime/sitemap-index";
28
+ import { activeMemberCount, nonEmptyCollections } from "@/lib/catalog/tot/query.js";
29
+ import { collectionPageCount, collectionPagePath } from "@/lib/catalog/shared/collectionPaging.js";
30
+ import { getBlogSettings, listBlogPosts } from "@/lib/content/blog/provider.js";
31
+ import { authorSlug, categorySlug } from "@/lib/content/blog/collection.js";
32
+ import { resolveBlogIndexCopy } from "@/lib/content/blog/pagination.js";
33
+ import { getTheBuildSettings, listBuildPosts } from "@/lib/content/the-build/provider.js";
34
+ import { resolveTheBuildIndexCopy } from "@/lib/content/the-build/pagination.js";
35
+ import { isIndexableHtml, rawPageCanonicalUrl } from "@/lib/content/shared/rawMarketingHtml.js";
36
+ import { compileRouteTierIndexV1 } from "@tot/public-runtime";
37
+ import { readKv } from "@/lib/publication/shared/runtimeEnv.js";
38
+ import {
39
+ declaresMountedSpacesV1,
40
+ mountedDiscoveryUrlV1,
41
+ readMountedPublicationDiscoveryV1,
42
+ rootOwnsDiscoveryPathV1,
43
+ } from "@/lib/publication/mountedDiscovery.js";
44
+ import { collectionPathsV1, blogPathOptsV1 } from "@/lib/content/blog/collectionPaths.js";
45
+ import { pageUrlForm } from "@/lib/content/seo/urlForm.js";
46
+
47
+ type SitemapGroups = Record<SitemapGroupV1, SitemapUrlV1[]>;
48
+
49
+ const XML_HEADERS = {
50
+ "content-type": "application/xml; charset=utf-8",
51
+ "cache-control": "public, max-age=300, s-maxage=3600",
52
+ } as const;
53
+
54
+ /** Every indexable URL the tenant serves, sorted into the sitemap groups. */
55
+ async function collectSitemapGroups(locals: APIContext["locals"]): Promise<SitemapGroups> {
56
+ const { tot, tenant, canonicalBase, content, features, publicationTimestamp } = locals;
57
+ const base = canonicalBase.replace(/\/$/, "");
58
+ const blogSettings = await getBlogSettings(tenant.tenant_id, content);
59
+ const blogPaths = collectionPathsV1(locals, "blog", blogPathOptsV1(blogSettings));
60
+ const buildPaths = collectionPathsV1(locals, "the-build");
61
+ const groups: SitemapGroups = { pages: [], collections: [], products: [], blogs: [], build: [], spaces: [] };
62
+
63
+ groups.pages.push({ loc: `${base}/` });
64
+
65
+ // Commerce URLs only for tenants with a catalog sitemap. Marketing tenants
66
+ // emit homepage + content pages, keeping product/collection routes out.
67
+ if (features.commerceSitemap) {
68
+ const [products, allCollections] = await Promise.all([
69
+ tot.getAllActiveProducts(),
70
+ tot.listCollections(),
71
+ ]);
72
+ // Only collections with at least one active product — empty synthesized
73
+ // shells (all-draft/dropped membership) stay out of the sitemap.
74
+ const collections = nonEmptyCollections(allCollections, products);
75
+ groups.collections.push({ loc: `${base}/collections` });
76
+ for (const c of collections) {
77
+ groups.collections.push({ loc: `${base}/collections/${c.handle}` });
78
+ // Paginated pages (N≥2): listing them is what makes every product of a large
79
+ // collection reachable on both serving planes, which publish one document per path.
80
+ const pages = collectionPageCount(activeMemberCount(c, products));
81
+ for (let n = 2; n <= pages; n++) groups.collections.push({ loc: `${base}${collectionPagePath(c.handle, n)}` });
82
+ }
83
+ for (const p of products) {
84
+ const entry: SitemapUrlV1 = { loc: `${base}/products/${p.handle}` };
85
+ if (p.updated_at) entry.lastmod = p.updated_at;
86
+ groups.products.push(entry);
87
+ }
88
+ }
89
+
90
+ // Raw tenant-authored marketing subpages (content/<tenant>/pages-html/*.html).
91
+ const rawPageSlugs = await content.listPageHtmlSlugs();
92
+ const rawPages = await Promise.all(
93
+ rawPageSlugs.map(async (slug) => ({ slug, html: await content.getPageHtml(slug) })),
94
+ );
95
+ for (const { slug, html } of rawPages) {
96
+ if (html && isIndexableHtml(html)) groups.pages.push({ loc: rawPageCanonicalUrl(canonicalBase, slug) });
97
+ }
98
+
99
+ for (const page of await content.listPages()) groups.pages.push({ loc: `${base}/${page.slug}` });
100
+
101
+ const blogPosts = await listBlogPosts(tenant.tenant_id, content, publicationTimestamp);
102
+ if (blogPosts.length > 0) {
103
+ groups.blogs.push({ loc: `${base}${blogPaths.index}` });
104
+ // Paginated pages (N≥2) — the static materializer discovers these routes off this list.
105
+ const { pageSize } = resolveBlogIndexCopy(blogSettings, tenant.displayName);
106
+ const totalBlogPages = Math.max(1, Math.ceil(blogPosts.length / pageSize));
107
+ for (let n = 2; n <= totalBlogPages; n++) groups.blogs.push({ loc: `${base}${blogPaths.page(n)}` });
108
+ for (const post of blogPosts) {
109
+ groups.blogs.push({ loc: `${base}${blogPaths.post(post.slug)}`, lastmod: post.modifiedAt || post.publishedAt });
110
+ }
111
+ // Author archives (blog/author/[author].astro): one per distinct byline, same source as
112
+ // the pages themselves so the sitemap never lists an archive that would 404 (and vice
113
+ // versa — the static publish's T3 gate requires every rendered route to appear here).
114
+ const authorSlugs = [...new Set(blogPosts.map((post) => authorSlug(post.author)).filter(Boolean))].sort();
115
+ for (const slug of authorSlugs) groups.blogs.push({ loc: `${base}${blogPaths.author(slug)}` });
116
+ const categorySlugs = [...new Set(blogPosts.flatMap((post) => post.categories.map(categorySlug)).filter(Boolean))].sort();
117
+ const tagSlugs = [...new Set(blogPosts.flatMap((post) => post.tags.map(categorySlug)).filter(Boolean))].sort();
118
+ for (const slug of categorySlugs) groups.blogs.push({ loc: `${base}${blogPaths.category(slug)}` });
119
+ for (const slug of tagSlugs) groups.blogs.push({ loc: `${base}${blogPaths.tag(slug)}` });
120
+ }
121
+
122
+ const buildPosts = await listBuildPosts(tenant.tenant_id, { content, now: publicationTimestamp });
123
+ if (buildPosts.length > 0) {
124
+ groups.build.push({ loc: `${base}${buildPaths.index}` });
125
+ const buildSettings = await getTheBuildSettings(tenant.tenant_id, content);
126
+ const { pageSize } = resolveTheBuildIndexCopy(buildSettings, tenant.displayName);
127
+ const totalBuildPages = Math.max(1, Math.ceil(buildPosts.length / pageSize));
128
+ for (let n = 2; n <= totalBuildPages; n++) groups.build.push({ loc: `${base}${buildPaths.page(n)}` });
129
+ for (const post of buildPosts) {
130
+ groups.build.push({ loc: `${base}${buildPaths.post(post.slug)}`, lastmod: post.modifiedAt || post.publishedAt });
131
+ }
132
+ }
133
+
134
+ // Mounted publication spaces: root rows never list into a mount, and each
135
+ // public mount lists its own entries (an internal mount lists nothing).
136
+ if (declaresMountedSpacesV1(tenant)) {
137
+ const index = compileRouteTierIndexV1(tenant);
138
+ const listed = new Set<string>();
139
+ for (const group of Object.keys(groups) as SitemapGroupV1[]) {
140
+ groups[group] = groups[group].filter((u) => rootOwnsDiscoveryPathV1(index, u.loc.slice(base.length) || "/"));
141
+ for (const u of groups[group]) listed.add(u.loc);
142
+ }
143
+ const mounted = await readMountedPublicationDiscoveryV1({ tenant, store: await readKv("TENANT_CACHE"), index });
144
+ for (const entry of mounted) {
145
+ if (!entry.surfaces.sitemap) continue;
146
+ const loc = mountedDiscoveryUrlV1(canonicalBase, entry);
147
+ if (listed.has(loc)) continue;
148
+ listed.add(loc);
149
+ const lastmod = entry.updatedAt ?? entry.publishedAt;
150
+ groups.spaces.push(lastmod ? { loc, lastmod } : { loc });
151
+ }
152
+ }
153
+
154
+ for (const group of Object.keys(groups) as SitemapGroupV1[]) {
155
+ groups[group] = groups[group].map((u) => ({ ...u, loc: pageUrlForm(u.loc, tenant.seo) }));
156
+ }
157
+ return groups;
158
+ }
159
+
160
+ /** The tenant's child sitemaps, in index order; empty groups have none. */
161
+ export async function tenantSitemapChildren(locals: APIContext["locals"]): Promise<SitemapChildV1[]> {
162
+ const groups = await collectSitemapGroups(locals);
163
+ return chunkSitemapGroupsV1((Object.keys(groups) as SitemapGroupV1[]).map((group) => ({ group, urls: groups[group] })));
164
+ }
165
+
166
+ /** `/sitemap.xml`: the index of every child sitemap. */
167
+ export async function sitemapIndexResponse(context: APIContext): Promise<Response> {
168
+ const children = await tenantSitemapChildren(context.locals);
169
+ return new Response(renderSitemapIndexV1(context.locals.canonicalBase, children), { headers: XML_HEADERS });
170
+ }
171
+
172
+ /** `/sitemap-<group>-<page>.xml`: one child's `<urlset>`, or 404 when the index names no such child. */
173
+ export async function sitemapChildResponse(context: APIContext, childPath: string): Promise<Response> {
174
+ const named = parseSitemapChildPathV1(childPath);
175
+ if (!named) return new Response("Not found", { status: 404, headers: { "content-type": "text/plain; charset=utf-8" } });
176
+ const children = await tenantSitemapChildren(context.locals);
177
+ const child = children.find((c) => c.group === named.group && c.page === named.page);
178
+ if (!child) return new Response("Not found", { status: 404, headers: { "content-type": "text/plain; charset=utf-8" } });
179
+ return new Response(renderSitemapUrlsetV1(child.urls), { headers: XML_HEADERS });
180
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * One typed, paged child sitemap (`/sitemap-<group>-<page>.xml`) named by `/sitemap.xml`.
3
+ * A path the index does not name answers 404.
4
+ */
5
+ import type { APIContext } from "astro";
6
+ import { sitemapChildResponse } from "./_sitemap.js";
7
+
8
+ export function GET(context: APIContext): Promise<Response> {
9
+ return sitemapChildResponse(context, `/sitemap-${String(context.params.child ?? "")}.xml`);
10
+ }
@@ -1,178 +1,11 @@
1
1
  /**
2
- * Per-tenant XML sitemap. Built from the resolved tenant's canonical base +
3
- * catalog. ACTIVE products only — getAllActiveProducts() excludes the draft
4
- * canary (`trail-cap`), so drafts never leak into the sitemap. Collections are
5
- * filtered to those with at least one active product (nonEmptyCollections), so
6
- * empty synthesized collection shells never become crawlable dead ends.
7
- * Editorial slugs come from the ContentProvider. The canonical base is resolved
8
- * per-request (prod domain OR path-prefixed preview host), so every emitted
9
- * loc is prefix-correct without special-casing here.
2
+ * Per-tenant sitemap index. The children it names render from `sitemap-[child].xml.ts`; both
3
+ * come from one builder (`./_sitemap.ts`), so the index can never name a child that does not
4
+ * render.
10
5
  */
11
6
  import type { APIContext } from "astro";
12
- import { activeMemberCount, nonEmptyCollections } from "@/lib/catalog/tot/query.js";
13
- import { collectionPageCount, collectionPagePath } from "@/lib/catalog/shared/collectionPaging.js";
14
- import { getBlogSettings, listBlogPosts } from "@/lib/content/blog/provider.js";
15
- import { authorSlug, categorySlug } from "@/lib/content/blog/collection.js";
16
- import { resolveBlogIndexCopy } from "@/lib/content/blog/pagination.js";
17
- import { getTheBuildSettings, listBuildPosts } from "@/lib/content/the-build/provider.js";
18
- import { resolveTheBuildIndexCopy } from "@/lib/content/the-build/pagination.js";
19
- import { isIndexableHtml, rawPageCanonicalUrl } from "@/lib/content/shared/rawMarketingHtml.js";
20
- import { compileRouteTierIndexV1 } from "@tot/public-runtime";
21
- import { readKv } from "@/lib/publication/shared/runtimeEnv.js";
22
- import {
23
- declaresMountedSpacesV1,
24
- mountedDiscoveryUrlV1,
25
- readMountedPublicationDiscoveryV1,
26
- rootOwnsDiscoveryPathV1,
27
- } from "@/lib/publication/mountedDiscovery.js";
28
- import { collectionPathsV1, blogPathOptsV1 } from "@/lib/content/blog/collectionPaths.js";
29
- import { pageUrlForm } from "@/lib/content/seo/urlForm.js";
7
+ import { sitemapIndexResponse } from "./_sitemap.js";
30
8
 
31
- interface SitemapUrl {
32
- loc: string;
33
- lastmod?: string;
34
- }
35
-
36
- /** XML-escape text inside a tag (URLs may contain & in query, etc.). */
37
- function escapeXml(value: string): string {
38
- return value
39
- .replace(/&/g, "&amp;")
40
- .replace(/</g, "&lt;")
41
- .replace(/>/g, "&gt;")
42
- .replace(/"/g, "&quot;")
43
- .replace(/'/g, "&apos;");
44
- }
45
-
46
- export async function GET(context: APIContext): Promise<Response> {
47
- const { tot, tenant, canonicalBase, content, features, publicationTimestamp } = context.locals;
48
- const base = canonicalBase.replace(/\/$/, "");
49
- const blogSettings = await getBlogSettings(tenant.tenant_id, content);
50
- const blogPaths = collectionPathsV1(context.locals, "blog", blogPathOptsV1(blogSettings));
51
- const buildPaths = collectionPathsV1(context.locals, "the-build");
52
-
53
- const urls: SitemapUrl[] = [{ loc: `${base}/` }];
54
-
55
- // Commerce URLs only for tenants with a catalog sitemap. Marketing tenants
56
- // emit homepage + content pages, keeping product/collection routes out.
57
- if (features.commerceSitemap) {
58
- const [products, allCollections] = await Promise.all([
59
- tot.getAllActiveProducts(),
60
- tot.listCollections(),
61
- ]);
62
- // Only collections with at least one active product — empty synthesized
63
- // shells (all-draft/dropped membership) stay out of the sitemap.
64
- const collections = nonEmptyCollections(allCollections, products);
65
- urls.push({ loc: `${base}/collections` });
66
- for (const c of collections) {
67
- urls.push({ loc: `${base}/collections/${c.handle}` });
68
- // Paginated pages (N≥2), same reasoning as /blog/page/N/ below: listing them
69
- // is what makes every product of a large collection reachable on both
70
- // serving planes, which publish one document per path.
71
- const pages = collectionPageCount(activeMemberCount(c, products));
72
- for (let n = 2; n <= pages; n++) urls.push({ loc: `${base}${collectionPagePath(c.handle, n)}` });
73
- }
74
- for (const p of products) {
75
- const entry: SitemapUrl = { loc: `${base}/products/${p.handle}` };
76
- if (p.updated_at) entry.lastmod = p.updated_at;
77
- urls.push(entry);
78
- }
79
- }
80
-
81
- // Raw tenant-authored marketing subpages (content/<tenant>/pages-html/*.html).
82
- const rawPageSlugs = await content.listPageHtmlSlugs();
83
- const rawPages = await Promise.all(
84
- rawPageSlugs.map(async (slug) => ({ slug, html: await content.getPageHtml(slug) })),
85
- );
86
- for (const { slug, html } of rawPages) {
87
- if (html && isIndexableHtml(html)) {
88
- urls.push({ loc: rawPageCanonicalUrl(canonicalBase, slug) });
89
- }
90
- }
91
-
92
- const pages = await content.listPages();
93
- for (const page of pages) {
94
- urls.push({ loc: `${base}/${page.slug}` });
95
- }
96
-
97
- const blogPosts = await listBlogPosts(tenant.tenant_id, content, publicationTimestamp);
98
- if (blogPosts.length > 0) {
99
- urls.push({ loc: `${base}${blogPaths.index}` });
100
- // Paginated pages (N≥2) — the static materializer discovers these new routes
101
- // straight off this list (withSitemapRoutes in scripts/publish/lib/routes.mjs),
102
- // so listing them here is what makes /blog/page/N/ exist in BOTH serving
103
- // planes, not just the SSR Worker.
104
- const { pageSize } = resolveBlogIndexCopy(blogSettings, tenant.displayName);
105
- const totalBlogPages = Math.max(1, Math.ceil(blogPosts.length / pageSize));
106
- for (let n = 2; n <= totalBlogPages; n++) urls.push({ loc: `${base}${blogPaths.page(n)}` });
107
- for (const post of blogPosts) {
108
- urls.push({ loc: `${base}${blogPaths.post(post.slug)}`, lastmod: post.modifiedAt || post.publishedAt });
109
- }
110
- // Author archives (blog/author/[author].astro): one per distinct byline,
111
- // same source as the pages themselves so the sitemap never lists an
112
- // archive that would 404 (and vice versa — the static publish's T3 gate
113
- // requires every rendered route to appear here).
114
- const authorSlugs = [
115
- ...new Set(blogPosts.map((post) => authorSlug(post.author)).filter(Boolean)),
116
- ].sort();
117
- for (const slug of authorSlugs) {
118
- urls.push({ loc: `${base}${blogPaths.author(slug)}` });
119
- }
120
- const categorySlugs = [...new Set(blogPosts.flatMap((post) => post.categories.map(categorySlug)).filter(Boolean))].sort();
121
- const tagSlugs = [...new Set(blogPosts.flatMap((post) => post.tags.map(categorySlug)).filter(Boolean))].sort();
122
- for (const slug of categorySlugs) urls.push({ loc: `${base}${blogPaths.category(slug)}` });
123
- for (const slug of tagSlugs) urls.push({ loc: `${base}${blogPaths.tag(slug)}` });
124
- }
125
-
126
- const buildPosts = await listBuildPosts(tenant.tenant_id, { content, now: publicationTimestamp });
127
- if (buildPosts.length > 0) {
128
- urls.push({ loc: `${base}${buildPaths.index}` });
129
- // Paginated pages (N≥2), same reasoning as /blog/page/N/ above: the static
130
- // materializer discovers these new routes straight off this list.
131
- const buildSettings = await getTheBuildSettings(tenant.tenant_id, content);
132
- const { pageSize } = resolveTheBuildIndexCopy(buildSettings, tenant.displayName);
133
- const totalBuildPages = Math.max(1, Math.ceil(buildPosts.length / pageSize));
134
- for (let n = 2; n <= totalBuildPages; n++) urls.push({ loc: `${base}${buildPaths.page(n)}` });
135
- for (const post of buildPosts) {
136
- urls.push({ loc: `${base}${buildPaths.post(post.slug)}`, lastmod: post.modifiedAt || post.publishedAt });
137
- }
138
- }
139
-
140
- // Mounted publication spaces: root rows never list into a mount, and each
141
- // public mount lists its own entries (an internal mount lists nothing).
142
- if (declaresMountedSpacesV1(tenant)) {
143
- const index = compileRouteTierIndexV1(tenant);
144
- const rootRows = urls.filter((u) => rootOwnsDiscoveryPathV1(index, u.loc.slice(base.length) || "/"));
145
- urls.length = 0;
146
- urls.push(...rootRows);
147
- const listed = new Set(urls.map((u) => u.loc));
148
- const mounted = await readMountedPublicationDiscoveryV1({ tenant, store: await readKv("TENANT_CACHE"), index });
149
- for (const entry of mounted) {
150
- if (!entry.surfaces.sitemap) continue;
151
- const loc = mountedDiscoveryUrlV1(canonicalBase, entry);
152
- if (listed.has(loc)) continue;
153
- listed.add(loc);
154
- const lastmod = entry.updatedAt ?? entry.publishedAt;
155
- urls.push(lastmod ? { loc, lastmod } : { loc });
156
- }
157
- }
158
-
159
- const body = `<?xml version="1.0" encoding="UTF-8"?>
160
- <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
161
- ${urls
162
- .map(
163
- (u) =>
164
- ` <url>\n <loc>${escapeXml(pageUrlForm(u.loc, tenant.seo))}</loc>${
165
- u.lastmod ? `\n <lastmod>${escapeXml(u.lastmod)}</lastmod>` : ""
166
- }\n </url>`,
167
- )
168
- .join("\n")}
169
- </urlset>
170
- `;
171
-
172
- return new Response(body, {
173
- headers: {
174
- "content-type": "application/xml; charset=utf-8",
175
- "cache-control": "public, max-age=300, s-maxage=3600",
176
- },
177
- });
9
+ export function GET(context: APIContext): Promise<Response> {
10
+ return sitemapIndexResponse(context);
178
11
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "4.6.4",
3
+ "version": "4.7.1",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
6
6
  "packageManager": "pnpm@11.9.0",
@@ -10,6 +10,7 @@
10
10
  "exports": {
11
11
  ".": "./src/index.ts",
12
12
  "./blog-visibility": "./src/blog-visibility.mjs",
13
+ "./sitemap-index": "./src/sitemap-index.mjs",
13
14
  "./preview-url": "./src/preview-url.mjs",
14
15
  "./publication-redirects": "./src/publication-redirects.mjs",
15
16
  "./image-dependencies": "./src/image-dependencies.ts",
@@ -13,6 +13,7 @@ import {
13
13
  type PublicationCollectionV1,
14
14
  } from "./blog-visibility.mjs";
15
15
  import { normalizeRedirectPath } from "./publication-redirects.mjs";
16
+ import { parseSitemapChildPathV1 } from "./sitemap-index.mjs";
16
17
  import {
17
18
  ANNOTATION_KEYS,
18
19
  type ContractResult,
@@ -337,7 +338,8 @@ function validateIdentifier(value: string | undefined, v: Violations, path: stri
337
338
 
338
339
  function isReservedMount(mount: string): boolean {
339
340
  const caseFolded = mount.toLowerCase();
340
- return PUBLICATION_SPACE_RESERVED_MOUNTS.some((reserved) => publicationMountMatchesPath(reserved, caseFolded));
341
+ return PUBLICATION_SPACE_RESERVED_MOUNTS.some((reserved) => publicationMountMatchesPath(reserved, caseFolded)) ||
342
+ parseSitemapChildPathV1(caseFolded) !== null;
341
343
  }
342
344
 
343
345
  function validateSource(raw: unknown, v: Violations, path: string): PublicationSpaceSourceV1 | undefined {
@@ -16,6 +16,7 @@
16
16
  */
17
17
  import type { TenantConfig } from "./tenant.js";
18
18
  import type { ContractViolation } from "./integration/result.js";
19
+ import { parseSitemapChildPathV1 } from "./sitemap-index.mjs";
19
20
  import {
20
21
  PUBLICATION_SPACE_RESERVED_MOUNTS,
21
22
  type NormalizedPublicationMountEntryV1,
@@ -134,7 +135,7 @@ function isPlatformReserved(normalizedPath: string): boolean {
134
135
  const caseFolded = normalizedPath.toLowerCase();
135
136
  return PUBLICATION_SPACE_RESERVED_MOUNTS.some((reserved) =>
136
137
  publicationMountMatchesPath(reserved, caseFolded),
137
- );
138
+ ) || parseSitemapChildPathV1(caseFolded) !== null;
138
139
  }
139
140
 
140
141
  /**
@@ -0,0 +1,33 @@
1
+ export type SitemapGroupV1 = "pages" | "collections" | "products" | "blogs" | "build" | "spaces";
2
+
3
+ export interface SitemapUrlV1 {
4
+ loc: string;
5
+ lastmod?: string;
6
+ }
7
+
8
+ export interface SitemapChildV1 {
9
+ group: SitemapGroupV1;
10
+ page: number;
11
+ path: string;
12
+ urls: SitemapUrlV1[];
13
+ }
14
+
15
+ export declare const SITEMAP_INDEX_PATH_V1: "/sitemap.xml";
16
+ export declare const SITEMAP_MAX_URLS_PER_CHILD_V1: number;
17
+ export declare const SITEMAP_GROUPS_V1: readonly SitemapGroupV1[];
18
+ export declare function sitemapChildPathV1(group: SitemapGroupV1, page: number): string;
19
+ export declare function parseSitemapChildPathV1(path: unknown): { group: SitemapGroupV1; page: number } | null;
20
+ export declare function isSitemapDocumentPathV1(path: string): boolean;
21
+ export declare function chunkSitemapGroupsV1(
22
+ groups: ReadonlyArray<{ group: SitemapGroupV1; urls: readonly SitemapUrlV1[] }>,
23
+ maxPerChild?: number,
24
+ ): SitemapChildV1[];
25
+ export declare function newestLastmodV1(urls: readonly SitemapUrlV1[]): string | undefined;
26
+ export declare function renderSitemapUrlsetV1(urls: readonly SitemapUrlV1[]): string;
27
+ export declare function renderSitemapIndexV1(base: string, children: readonly SitemapChildV1[]): string;
28
+ export declare function isSitemapIndexXmlV1(xml: string): boolean;
29
+ export declare function sitemapIndexLocsV1(xml: string): string[];
30
+ export declare function countSitemapUrlsV1(
31
+ xml: string,
32
+ readChild: (loc: string) => Promise<string | null>,
33
+ ): Promise<number>;
@@ -0,0 +1,156 @@
1
+ // @ts-check
2
+ /**
3
+ * The tenant sitemap contract, shared by the Worker that renders it and the publish tooling
4
+ * that materializes and validates it on both serving substrates.
5
+ *
6
+ * `/sitemap.xml` is a `<sitemapindex>`. Its entries are typed, paged child sitemaps at
7
+ * `/sitemap-<group>-<page>.xml`, each a `<urlset>` of at most {@link SITEMAP_MAX_URLS_PER_CHILD_V1}
8
+ * URLs. A child path ends in `.xml`, so S3/CloudFront and the Cloudflare static bundle both serve
9
+ * it as a plain file with no rewrite, and the materializer writes it at the same bundle path.
10
+ *
11
+ * Pure: no I/O, no platform imports.
12
+ */
13
+
14
+ /** @typedef {{loc: string, lastmod?: string}} SitemapUrl */
15
+
16
+ /** The one discovery entry point robots.txt advertises. */
17
+ export const SITEMAP_INDEX_PATH_V1 = "/sitemap.xml";
18
+
19
+ /**
20
+ * URLs per child sitemap. The protocol allows 50,000; 5,000 keeps each child a small, fast,
21
+ * individually cacheable document and matches the paging large commerce platforms use.
22
+ */
23
+ export const SITEMAP_MAX_URLS_PER_CHILD_V1 = 5000;
24
+
25
+ /** Child sitemap groups, in the order the index lists them. */
26
+ export const SITEMAP_GROUPS_V1 = Object.freeze(["pages", "collections", "products", "blogs", "build", "spaces"]);
27
+
28
+ const CHILD_PATH = /^\/sitemap-([a-z]+)-([1-9][0-9]{0,5})\.xml$/;
29
+
30
+ /**
31
+ * `/sitemap-<group>-<page>.xml` for a declared group and a 1-based page.
32
+ * @param {string} group @param {number} page @returns {string}
33
+ */
34
+ export function sitemapChildPathV1(group, page) {
35
+ if (!SITEMAP_GROUPS_V1.includes(group)) throw new Error(`unknown sitemap group ${JSON.stringify(group)}`);
36
+ if (!Number.isInteger(page) || page < 1) throw new Error(`sitemap page must be a positive integer, got ${page}`);
37
+ return `/sitemap-${group}-${page}.xml`;
38
+ }
39
+
40
+ /**
41
+ * The group and page a child sitemap path names, or null when it names none.
42
+ * @param {unknown} path @returns {{group: string, page: number} | null}
43
+ */
44
+ export function parseSitemapChildPathV1(path) {
45
+ if (typeof path !== "string") return null;
46
+ const match = CHILD_PATH.exec(path);
47
+ if (!match || !SITEMAP_GROUPS_V1.includes(match[1])) return null;
48
+ return { group: match[1], page: Number(match[2]) };
49
+ }
50
+
51
+ /** True for `/sitemap.xml` and for every well-formed child sitemap path. @param {string} path */
52
+ export function isSitemapDocumentPathV1(path) {
53
+ return path === SITEMAP_INDEX_PATH_V1 || parseSitemapChildPathV1(path) !== null;
54
+ }
55
+
56
+ /**
57
+ * Page each group into children of at most {@link SITEMAP_MAX_URLS_PER_CHILD_V1} URLs, in
58
+ * {@link SITEMAP_GROUPS_V1} order. An empty group yields no child.
59
+ * @param {ReadonlyArray<{group: string, urls: readonly SitemapUrl[]}>} groups
60
+ * @param {number} [maxPerChild]
61
+ * @returns {{group: string, page: number, path: string, urls: SitemapUrl[]}[]}
62
+ */
63
+ export function chunkSitemapGroupsV1(groups, maxPerChild = SITEMAP_MAX_URLS_PER_CHILD_V1) {
64
+ /** @type {Map<string, SitemapUrl[]>} */
65
+ const byGroup = new Map();
66
+ for (const { group, urls } of groups) {
67
+ if (!SITEMAP_GROUPS_V1.includes(group)) throw new Error(`unknown sitemap group ${JSON.stringify(group)}`);
68
+ byGroup.set(group, [...(byGroup.get(group) ?? []), ...urls]);
69
+ }
70
+ /** @type {{group: string, page: number, path: string, urls: SitemapUrl[]}[]} */
71
+ const children = [];
72
+ for (const group of SITEMAP_GROUPS_V1) {
73
+ const urls = byGroup.get(group) ?? [];
74
+ for (let start = 0, page = 1; start < urls.length; start += maxPerChild, page += 1) {
75
+ children.push({ group, page, path: sitemapChildPathV1(group, page), urls: urls.slice(start, start + maxPerChild) });
76
+ }
77
+ }
78
+ return children;
79
+ }
80
+
81
+ /** @param {string} value */
82
+ function escapeXml(value) {
83
+ return String(value)
84
+ .replace(/&/g, "&amp;")
85
+ .replace(/</g, "&lt;")
86
+ .replace(/>/g, "&gt;")
87
+ .replace(/"/g, "&quot;")
88
+ .replace(/'/g, "&apos;");
89
+ }
90
+
91
+ /**
92
+ * The newest `lastmod` among `urls`, spelled as its source spelled it; undefined when none parses.
93
+ * @param {readonly SitemapUrl[]} urls @returns {string | undefined}
94
+ */
95
+ export function newestLastmodV1(urls) {
96
+ /** @type {string | undefined} */
97
+ let newest;
98
+ let newestTime = -Infinity;
99
+ for (const { lastmod } of urls) {
100
+ const time = lastmod ? Date.parse(lastmod) : NaN;
101
+ if (Number.isFinite(time) && time > newestTime) {
102
+ newest = lastmod;
103
+ newestTime = time;
104
+ }
105
+ }
106
+ return newest;
107
+ }
108
+
109
+ /** A `<urlset>` document. Locs are written exactly as given. @param {readonly SitemapUrl[]} urls */
110
+ export function renderSitemapUrlsetV1(urls) {
111
+ const rows = urls.map((u) =>
112
+ ` <url>\n <loc>${escapeXml(u.loc)}</loc>${u.lastmod ? `\n <lastmod>${escapeXml(u.lastmod)}</lastmod>` : ""}\n </url>`);
113
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${rows.join("\n")}\n</urlset>\n`;
114
+ }
115
+
116
+ /**
117
+ * A `<sitemapindex>` naming each child at `base` + its path. `base` is the tenant's canonical
118
+ * base without a trailing slash, path prefix included, so every loc keeps the prefix the page
119
+ * locs carry. Each entry's lastmod is the newest lastmod inside that child, when it has one.
120
+ * @param {string} base @param {readonly {path: string, urls: readonly SitemapUrl[]}[]} children
121
+ */
122
+ export function renderSitemapIndexV1(base, children) {
123
+ const root = base.replace(/\/+$/, "");
124
+ const rows = children.map((child) => {
125
+ const lastmod = newestLastmodV1(child.urls);
126
+ return ` <sitemap>\n <loc>${escapeXml(`${root}${child.path}`)}</loc>${lastmod ? `\n <lastmod>${escapeXml(lastmod)}</lastmod>` : ""}\n </sitemap>`;
127
+ });
128
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${rows.join("\n")}\n</sitemapindex>\n`;
129
+ }
130
+
131
+ /** True when `xml` is a `<sitemapindex>` rather than a `<urlset>`. @param {string} xml */
132
+ export function isSitemapIndexXmlV1(xml) {
133
+ return /<sitemapindex[\s>]/.test(xml);
134
+ }
135
+
136
+ /**
137
+ * The child sitemap locs a `<sitemapindex>` names, exactly as spelled; empty for a `<urlset>`.
138
+ * @param {string} xml @returns {string[]}
139
+ */
140
+ export function sitemapIndexLocsV1(xml) {
141
+ if (!isSitemapIndexXmlV1(xml)) return [];
142
+ return [...xml.matchAll(/<sitemap>\s*<loc>([^<]*)<\/loc>/g)].map((match) => match[1].trim());
143
+ }
144
+
145
+ /**
146
+ * Page URLs a sitemap lists, following an index to its children. `readChild` returns a child's
147
+ * body, or null when it cannot be read (which contributes nothing).
148
+ * @param {string} xml @param {(loc: string) => Promise<string | null>} readChild @returns {Promise<number>}
149
+ */
150
+ export async function countSitemapUrlsV1(xml, readChild) {
151
+ /** @param {string} body */
152
+ const urls = (body) => (body.match(/<url>/g) ?? []).length;
153
+ if (!isSitemapIndexXmlV1(xml)) return urls(xml);
154
+ const bodies = await Promise.all(sitemapIndexLocsV1(xml).map(readChild));
155
+ return bodies.reduce((count, body) => count + urls(body ?? ""), 0);
156
+ }
@@ -46,6 +46,7 @@ import {
46
46
  workspacePathForTenantRelative,
47
47
  } from "../../agency-kit/tools/lib/tot-repo-mappings.mjs";
48
48
  import { isForbiddenPublicPath, publicBinaryFindings } from "../../agency-kit/tools/lib/public-binaries.mjs";
49
+ import { pageSlugFindings } from "../../agency-kit/tools/lib/page-slugs.mjs";
49
50
  import { validateVisualParityClosure } from "../../packages/cli/src/visual-parity.mjs";
50
51
  import { declaredObligations, isRegulatedCompliance, validateDeclaredConfig } from "../../packages/cli/src/declared-config.mjs";
51
52
 
@@ -1200,6 +1201,18 @@ export function validateTenant(tenantDir, opts = {}) {
1200
1201
  }
1201
1202
  }
1202
1203
 
1204
+ // 3b. editorial page slugs — every page must be reachable at its slug on the hosted router
1205
+ // (agency-kit/tools/lib/page-slugs.mjs, the rule `tot validate` applies).
1206
+ {
1207
+ const pages = walk(join(contentDir, "pages"), (x) => x.endsWith(".json")).sort().map((p) => {
1208
+ const { value, error } = readJsonSafe(p);
1209
+ return { path: rel(p), slug: error ? undefined : value?.slug };
1210
+ });
1211
+ const rawPages = walk(pagesDir, (x) => x.endsWith(".html")).sort()
1212
+ .map((p) => ({ path: rel(p), slug: relative(pagesDir, p).split("\\").join("/").replace(/\.html$/, "") }));
1213
+ for (const f of pageSlugFindings({ pages, rawPages })) findings.push(mk(ERROR, f.code, f.path, f.message, f.remedy));
1214
+ }
1215
+
1203
1216
  // 4. raw HTML documents — validate + portability + resolution
1204
1217
  const hasChrome = providesChrome(contentDir);
1205
1218
  const htmlFiles = walk(contentDir, (p) => p.endsWith(".html"));