@tokenoftrust/storefront-runner 2.4.23 → 2.4.24

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 (25) hide show
  1. package/apps/storefront/src/components/blog/BlogIndexView.astro +10 -9
  2. package/apps/storefront/src/components/blog/PostGrid.astro +6 -5
  3. package/apps/storefront/src/components/the-build/TheBuildIndexView.astro +9 -8
  4. package/apps/storefront/src/config/publicationSpaceConfig.ts +6 -1
  5. package/apps/storefront/src/config/publicationSpaceRegistry.ts +1 -0
  6. package/apps/storefront/src/config/publicationSpaceTopology.ts +22 -1
  7. package/apps/storefront/src/lib/blog/collectionPaths.ts +46 -0
  8. package/apps/storefront/src/lib/blog/pagination.ts +6 -16
  9. package/apps/storefront/src/lib/publication/mountedDiscovery.ts +4 -1
  10. package/apps/storefront/src/lib/publication/rootDiscovery.ts +7 -4
  11. package/apps/storefront/src/lib/the-build/pagination.ts +3 -8
  12. package/apps/storefront/src/middleware/index.ts +4 -3
  13. package/apps/storefront/src/pages/blog/[slug].astro +14 -12
  14. package/apps/storefront/src/pages/blog/author/[author].astro +8 -6
  15. package/apps/storefront/src/pages/blog/category/[category].astro +6 -4
  16. package/apps/storefront/src/pages/blog/tag/[tag].astro +6 -4
  17. package/apps/storefront/src/pages/llms.txt.ts +6 -4
  18. package/apps/storefront/src/pages/sitemap.xml.ts +12 -9
  19. package/apps/storefront/src/pages/the-build/[slug].astro +9 -7
  20. package/package.json +1 -1
  21. package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +12 -0
  22. package/packages/public-runtime/src/blog-visibility.d.mts +20 -0
  23. package/packages/public-runtime/src/blog-visibility.mjs +87 -0
  24. package/packages/public-runtime/src/publication-routing.ts +15 -1
  25. package/packages/public-runtime/src/publication-space.ts +70 -2
@@ -7,9 +7,9 @@ import Layout from "@/layouts/Layout.astro";
7
7
  import PostAccessBadge from "@/components/blog/PostAccessBadge.astro";
8
8
  import type { BlogPostSummary } from "@/lib/blog/types.js";
9
9
  import type { BlogIndexCopy } from "@/lib/blog/pagination.js";
10
- import { blogPagePath } from "@/lib/blog/pagination.js";
11
10
  import type { BlogPresentation } from "@/lib/blog/presentation.js";
12
11
  import { categorySlug } from "@/lib/blog/collection.js";
12
+ import { collectionPathsV1 } from "@/lib/blog/collectionPaths.js";
13
13
 
14
14
  interface Props {
15
15
  basePath: string;
@@ -23,17 +23,18 @@ interface Props {
23
23
  }
24
24
 
25
25
  const { basePath, canonicalBase, copy, presentation, tags, pagePosts, page, totalPages } = Astro.props;
26
+ const blogPaths = collectionPathsV1(Astro.locals, "blog");
26
27
  const showTopics = presentation.topics && tags.length > 0;
27
28
 
28
29
  const href = (path: string) => `${basePath}${path}` || "/";
29
- const path = blogPagePath(page);
30
+ const path = blogPaths.page(page);
30
31
  // Page 1 keeps the tenant's declared title verbatim (unchanged default copy); page
31
32
  // N>1 is distinguished in the <title> tag only — the on-page <h1> stays identical
32
33
  // across pages, matching how a reader experiences "the same blog, page 2".
33
34
  const documentTitle = page > 1 ? `${copy.title} — Page ${page}` : copy.title;
34
35
  const pagination = {
35
- ...(page > 1 ? { prev: blogPagePath(page - 1) } : {}),
36
- ...(page < totalPages ? { next: blogPagePath(page + 1) } : {}),
36
+ ...(page > 1 ? { prev: blogPaths.page(page - 1) } : {}),
37
+ ...(page < totalPages ? { next: blogPaths.page(page + 1) } : {}),
37
38
  };
38
39
 
39
40
  const itemList = {
@@ -44,7 +45,7 @@ const itemList = {
44
45
  "@type": "ListItem",
45
46
  position: index + 1,
46
47
  name: post.title,
47
- url: new URL(`/blog/${post.slug}/`, canonicalBase + "/").toString(),
48
+ url: new URL(blogPaths.post(post.slug), canonicalBase + "/").toString(),
48
49
  })),
49
50
  };
50
51
  ---
@@ -70,7 +71,7 @@ const itemList = {
70
71
  <aside aria-label="Blog topics">
71
72
  <p class="filter-label">Explore topics</p>
72
73
  <div class="tag-list">
73
- {tags.map((tag) => <a href={href(`/blog/tag/${categorySlug(tag)}/`)}>{tag}</a>)}
74
+ {tags.map((tag) => <a href={href(blogPaths.tag(categorySlug(tag)))}>{tag}</a>)}
74
75
  </div>
75
76
  </aside>
76
77
  )}
@@ -78,7 +79,7 @@ const itemList = {
78
79
  <section class="post-grid" aria-label="Blog posts" id="blog-posts">
79
80
  {pagePosts.map((post, index) => (
80
81
  <article class="post-card">
81
- <a href={href(`/blog/${post.slug}/`)} aria-label={`Read ${post.title}`}>
82
+ <a href={href(blogPaths.post(post.slug))} aria-label={`Read ${post.title}`}>
82
83
  {post.featuredImage ? (
83
84
  <img
84
85
  src={post.featuredImage.src}
@@ -105,11 +106,11 @@ const itemList = {
105
106
  {totalPages > 1 && (
106
107
  <nav class="blog-shell blog-pagination" aria-label="Blog pagination">
107
108
  {page > 1
108
- ? <a class="blog-pagination-link" rel="prev" href={href(blogPagePath(page - 1))}>← Newer posts</a>
109
+ ? <a class="blog-pagination-link" rel="prev" href={href(blogPaths.page(page - 1))}>← Newer posts</a>
109
110
  : <span />}
110
111
  <span class="blog-pagination-status">Page {page} of {totalPages}</span>
111
112
  {page < totalPages
112
- ? <a class="blog-pagination-link" rel="next" href={href(blogPagePath(page + 1))}>Older posts →</a>
113
+ ? <a class="blog-pagination-link" rel="next" href={href(blogPaths.page(page + 1))}>Older posts →</a>
113
114
  : <span />}
114
115
  </nav>
115
116
  )}
@@ -1,24 +1,25 @@
1
1
  ---
2
2
  // Reader-facing card grid for a list of blog posts, shared by the collection's
3
- // category and tag index pages. `base` is the collection route base (e.g.
4
- // "/blog"); `basePath` is the tenant/preview path prefix from Astro.locals.
3
+ // category and tag index pages. `paths` are the collection's URLs for the space that serves
4
+ // the page (collectionPathsV1); `basePath` is the tenant/preview path prefix from Astro.locals.
5
5
  import type { BlogPostSummary } from "@/lib/blog/types.js";
6
+ import type { CollectionPathsV1 } from "@/lib/blog/collectionPaths.js";
6
7
  import PostAccessBadge from "@/components/blog/PostAccessBadge.astro";
7
8
 
8
9
  interface Props {
9
10
  posts: BlogPostSummary[];
10
11
  basePath: string;
11
- base: string;
12
+ paths: CollectionPathsV1;
12
13
  }
13
14
 
14
- const { posts, basePath, base } = Astro.props;
15
+ const { posts, basePath, paths } = Astro.props;
15
16
  const href = (path: string) => `${basePath}${path}` || "/";
16
17
  ---
17
18
 
18
19
  <section class="post-grid" aria-label="Posts">
19
20
  {posts.map((post, index) => (
20
21
  <article class="post-card">
21
- <a href={href(`${base}/${post.slug}/`)} aria-label={`Read ${post.title}`}>
22
+ <a href={href(paths.post(post.slug))} aria-label={`Read ${post.title}`}>
22
23
  {post.featuredImage ? (
23
24
  <img
24
25
  src={post.featuredImage.src}
@@ -9,7 +9,7 @@ import PostAccessBadge from "@/components/blog/PostAccessBadge.astro";
9
9
  import type { BlogPostSummary } from "@/lib/blog/types.js";
10
10
  import type { TheBuildIndexCopy } from "@/lib/the-build/pagination.js";
11
11
  import type { BlogPresentation } from "@/lib/blog/presentation.js";
12
- import { theBuildPagePath } from "@/lib/the-build/pagination.js";
12
+ import { collectionPathsV1 } from "@/lib/blog/collectionPaths.js";
13
13
 
14
14
  interface Props {
15
15
  basePath: string;
@@ -22,15 +22,16 @@ interface Props {
22
22
  }
23
23
 
24
24
  const { basePath, canonicalBase, copy, presentation, pagePosts, page, totalPages } = Astro.props;
25
+ const buildPaths = collectionPathsV1(Astro.locals, "the-build");
25
26
 
26
27
  const href = (path: string) => `${basePath}${path}` || "/";
27
- const path = theBuildPagePath(page);
28
+ const path = buildPaths.page(page);
28
29
  // Page 1 keeps the tenant's declared title verbatim; page N>1 is distinguished
29
30
  // in the <title> tag only — the on-page <h1> stays identical across pages.
30
31
  const documentTitle = page > 1 ? `${copy.title} — Page ${page}` : copy.title;
31
32
  const pagination = {
32
- ...(page > 1 ? { prev: theBuildPagePath(page - 1) } : {}),
33
- ...(page < totalPages ? { next: theBuildPagePath(page + 1) } : {}),
33
+ ...(page > 1 ? { prev: buildPaths.page(page - 1) } : {}),
34
+ ...(page < totalPages ? { next: buildPaths.page(page + 1) } : {}),
34
35
  };
35
36
 
36
37
  const itemList = {
@@ -41,7 +42,7 @@ const itemList = {
41
42
  "@type": "ListItem",
42
43
  position: index + 1,
43
44
  name: post.title,
44
- url: new URL(`/the-build/${post.slug}/`, canonicalBase + "/").toString(),
45
+ url: new URL(buildPaths.post(post.slug), canonicalBase + "/").toString(),
45
46
  })),
46
47
  };
47
48
  ---
@@ -66,7 +67,7 @@ const itemList = {
66
67
  <section class="post-grid" aria-label="The Build posts" id="the-build-posts">
67
68
  {pagePosts.map((post, index) => (
68
69
  <article class="post-card">
69
- <a href={href(`/the-build/${post.slug}/`)} aria-label={`Read ${post.title}`}>
70
+ <a href={href(buildPaths.post(post.slug))} aria-label={`Read ${post.title}`}>
70
71
  {post.featuredImage ? (
71
72
  <img
72
73
  src={post.featuredImage.src}
@@ -92,11 +93,11 @@ const itemList = {
92
93
  {totalPages > 1 && (
93
94
  <nav class="blog-pagination" aria-label="The Build pagination">
94
95
  {page > 1
95
- ? <a class="blog-pagination-link" rel="prev" href={href(theBuildPagePath(page - 1))}>← Newer posts</a>
96
+ ? <a class="blog-pagination-link" rel="prev" href={href(buildPaths.page(page - 1))}>← Newer posts</a>
96
97
  : <span />}
97
98
  <span class="blog-pagination-status">Page {page} of {totalPages}</span>
98
99
  {page < totalPages
99
- ? <a class="blog-pagination-link" rel="next" href={href(theBuildPagePath(page + 1))}>Older posts →</a>
100
+ ? <a class="blog-pagination-link" rel="next" href={href(buildPaths.page(page + 1))}>Older posts →</a>
100
101
  : <span />}
101
102
  </nav>
102
103
  )}
@@ -51,6 +51,7 @@ export function mountedPublicationSpaceConfig(
51
51
  mount: string,
52
52
  visibility: PublicationSpaceVisibilityV1 = "public",
53
53
  aliases: readonly PublicationSpaceAliasV1[] = [],
54
+ collections?: PublicationSpaceManifestV1["routes"]["collections"],
54
55
  ): PublicationSpaceManifestV1 {
55
56
  const internal = visibility === "internal";
56
57
  return {
@@ -58,7 +59,11 @@ export function mountedPublicationSpaceConfig(
58
59
  id,
59
60
  tenant,
60
61
  source,
61
- routes: aliases.length ? { canonicalMount: mount, aliases: [...aliases] } : { canonicalMount: mount },
62
+ routes: {
63
+ canonicalMount: mount,
64
+ ...(aliases.length ? { aliases: [...aliases] } : {}),
65
+ ...(collections && Object.keys(collections).length ? { collections: { ...collections } } : {}),
66
+ },
62
67
  visibility,
63
68
  authorization: internal ? { policy: "capability", capability: "internal-content-reader" } : { policy: "public" },
64
69
  discovery: internal
@@ -33,6 +33,7 @@ export function publicationSpacesFromTopology(
33
33
  mounted.mount,
34
34
  mounted.visibility ?? "public",
35
35
  mounted.aliases ?? [],
36
+ mounted.collections,
36
37
  )),
37
38
  ];
38
39
  const foreign = spaces.find((space) => !sourceBelongsToTenant(tenant, space.source));
@@ -1,4 +1,9 @@
1
- import type { PublicationSpaceAliasV1, PublicationSpaceSourceV1, PublicationSpaceVisibilityV1 } from "@tot/public-runtime";
1
+ import type {
2
+ PublicationSpaceAliasV1,
3
+ PublicationSpaceManifestV1,
4
+ PublicationSpaceSourceV1,
5
+ PublicationSpaceVisibilityV1,
6
+ } from "@tot/public-runtime";
2
7
 
3
8
  /**
4
9
  * One declared non-root publication space: a mount, its source, and its
@@ -11,6 +16,8 @@ export interface MountedPublicationSpaceDeclarationV1 {
11
16
  mount: string;
12
17
  visibility?: PublicationSpaceVisibilityV1;
13
18
  aliases?: readonly PublicationSpaceAliasV1[];
19
+ /** Collection base paths inside the mount; `""` serves the collection at the mount root. */
20
+ collections?: PublicationSpaceManifestV1["routes"]["collections"];
14
21
  }
15
22
 
16
23
  /** A tenant's complete declared publication topology: exactly one root, plus any mounted spaces. */
@@ -38,6 +45,8 @@ export const PUBLICATION_SPACE_TOPOLOGY: Readonly<Record<string, PublicationTopo
38
45
  source: { forge: "gitea", repo: "tokenoftrust.com-publications", defaultBranch: "main" },
39
46
  mount: "/blog",
40
47
  visibility: "public",
48
+ // Decision m1b-mount-layout-2026-10-12: posts, index and archives at /blog/..., as today.
49
+ collections: { blog: "" },
41
50
  },
42
51
  ],
43
52
  },
@@ -70,6 +79,12 @@ export const PUBLICATION_SPACE_TOPOLOGY: Readonly<Record<string, PublicationTopo
70
79
  mount: "/internal-docs",
71
80
  visibility: "internal",
72
81
  },
82
+ {
83
+ id: "fixture-articles",
84
+ source: { forge: "fixture", repo: "fixture.e2e.test-articles", defaultBranch: "main" },
85
+ mount: "/blog",
86
+ collections: { blog: "" },
87
+ },
73
88
  ],
74
89
  },
75
90
  // The same three-space shape, served by CloudFront: certifies the s3-overwrite publication path.
@@ -88,6 +103,12 @@ export const PUBLICATION_SPACE_TOPOLOGY: Readonly<Record<string, PublicationTopo
88
103
  mount: "/internal-docs",
89
104
  visibility: "internal",
90
105
  },
106
+ {
107
+ id: "fixture-cloudfront-articles",
108
+ source: { forge: "fixture", repo: "fixture-cloudfront.e2e.test-articles", defaultBranch: "main" },
109
+ mount: "/blog",
110
+ collections: { blog: "" },
111
+ },
91
112
  ],
92
113
  },
93
114
  "chrome.e2e.test": {
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The collection URLs a page renders, for the publication space that serves it. Every one comes
3
+ * from `publicationCollectionPathV1`, the one constructor the route builders and the discovery
4
+ * emitter use, with the space's mount and its declared collection base: a space mounted at
5
+ * `/blog` whose blog collection sits at the mount root links its posts as `/blog/<slug>/`, and
6
+ * the root space keeps `/blog/<slug>/`. Paths are tenant-relative (no preview base path).
7
+ */
8
+ import {
9
+ publicationCollectionBaseV1,
10
+ publicationCollectionPathV1,
11
+ type PublicationCollectionV1,
12
+ type PublicationRoutePlanV1,
13
+ } from "@tot/public-runtime";
14
+
15
+ export interface CollectionPathsV1 {
16
+ /** The collection index, page 1. */
17
+ index: string;
18
+ page(page: number): string;
19
+ post(slug: string): string;
20
+ author(slug: string): string;
21
+ category(slug: string): string;
22
+ tag(slug: string): string;
23
+ rss: string;
24
+ }
25
+
26
+ /** The collection paths for the space the request resolved to (the root space otherwise). */
27
+ export function collectionPathsV1(
28
+ locals: { publicationRoute?: PublicationRoutePlanV1 },
29
+ collection: PublicationCollectionV1,
30
+ ): CollectionPathsV1 {
31
+ const plan = locals.publicationRoute;
32
+ const manifest = plan && plan.action === "render" ? plan.manifest : null;
33
+ const mount = manifest?.routes.canonicalMount ?? "/";
34
+ const base = publicationCollectionBaseV1(manifest, collection);
35
+ const path = (route: Parameters<typeof publicationCollectionPathV1>[2]) =>
36
+ publicationCollectionPathV1(mount, base, route);
37
+ return {
38
+ index: path({ kind: "index" }),
39
+ page: (page) => path({ kind: "page", page }),
40
+ post: (slug) => path({ kind: "post", slug }),
41
+ author: (slug) => path({ kind: "author", slug }),
42
+ category: (slug) => path({ kind: "category", slug }),
43
+ tag: (slug) => path({ kind: "tag", slug }),
44
+ rss: path({ kind: "rss" }),
45
+ };
46
+ }
@@ -1,7 +1,11 @@
1
+ import { DEFAULT_COLLECTION_PAGE_SIZE_V1 } from "@tot/public-runtime";
1
2
  import type { BlogSettings } from "./types.js";
2
3
 
3
- /** Posts per `/blog/` page when a tenant declares no `pageSize` (or an invalid one). */
4
- export const DEFAULT_BLOG_PAGE_SIZE = 12;
4
+ /**
5
+ * Posts per blog index page when a tenant declares no `pageSize` (or an invalid one). The route
6
+ * builders count index pages with the same default, so a page the renderer serves is a page they list.
7
+ */
8
+ export const DEFAULT_BLOG_PAGE_SIZE = DEFAULT_COLLECTION_PAGE_SIZE_V1;
5
9
 
6
10
  /**
7
11
  * Resolved `/blog/` index copy + page size. A declared `BlogSettings` field wins;
@@ -91,20 +95,6 @@ export function paginateBlogPosts<T>(
91
95
  return { posts: posts.slice(start, start + pageSize), page, totalPages };
92
96
  }
93
97
 
94
- /**
95
- * Generic engine behind `blogPagePath` and every other collection's page path
96
- * (e.g. `theBuildPagePath`): page 1 is the bare index; page N≥2 is
97
- * `<basePath>/page/<n>/`. `basePath` carries no trailing slash (e.g. `/blog`).
98
- */
99
- export function pagedRoutePath(basePath: string, page: number): string {
100
- return page <= 1 ? `${basePath}/` : `${basePath}/page/${page}/`;
101
- }
102
-
103
- /** `/blog/` is page 1; `/blog/page/<n>/` is page N for N≥2. */
104
- export function blogPagePath(page: number): string {
105
- return pagedRoutePath("/blog", page);
106
- }
107
-
108
98
  /**
109
99
  * Generic engine behind `parseBlogPageParam` and every other collection's
110
100
  * `page/[n]` route param: not a bare positive-integer string (rejects "01",
@@ -195,7 +195,10 @@ export async function readMountedPublicationDiscoveryV1(input: {
195
195
  export function mountedDiscoveryUrlV1(canonicalBase: string, entry: Pick<MountedDiscoveryEntryV1,
196
196
  "canonicalPath" | "kind">): string {
197
197
  if (entry.kind === "raw-html") return rawPageCanonicalUrl(canonicalBase, entry.canonicalPath.slice(1));
198
- return `${canonicalBase.replace(/\/$/, "")}${entry.canonicalPath}`;
198
+ // Collection posts and archives declare the constructor's form (publicationCollectionPathV1):
199
+ // the route with its trailing slash.
200
+ const slash = entry.kind === "post" || entry.kind === "archive" ? "/" : "";
201
+ return `${canonicalBase.replace(/\/$/, "")}${entry.canonicalPath}${slash}`;
199
202
  }
200
203
 
201
204
  /** An RFC 3339 instant for a feed, or undefined when the value is not a date. */
@@ -8,6 +8,7 @@ import type { APIContext } from "astro";
8
8
  import { listBlogPosts } from "@/lib/blog/provider.js";
9
9
  import { listBuildPosts } from "@/lib/the-build/provider.js";
10
10
  import { isIndexableHtml, rawPageCanonicalUrl } from "@/lib/rawMarketingHtml.js";
11
+ import { collectionPathsV1 } from "@/lib/blog/collectionPaths.js";
11
12
 
12
13
  export interface RootDiscoveryDocumentV1 {
13
14
  kind: "home" | "page" | "post";
@@ -36,14 +37,16 @@ export async function rootPostDocumentsV1(locals: APIContext["locals"]): Promise
36
37
  listBlogPosts(tenant.tenant_id, content, publicationTimestamp),
37
38
  listBuildPosts(tenant.tenant_id, { content, now: publicationTimestamp }),
38
39
  ]);
40
+ const blogPaths = collectionPathsV1(locals, "blog");
41
+ const buildPaths = collectionPathsV1(locals, "the-build");
39
42
  return [
40
- ...blogPosts.map((post) => ({ post, base: "/blog" })),
41
- ...buildPosts.map((post) => ({ post, base: "/the-build" })),
42
- ].map(({ post, base }) => {
43
+ ...blogPosts.map((post) => ({ post, paths: blogPaths })),
44
+ ...buildPosts.map((post) => ({ post, paths: buildPaths })),
45
+ ].map(({ post, paths }) => {
43
46
  const summary = plain(post.description || post.excerpt);
44
47
  return {
45
48
  kind: "post" as const,
46
- path: `${base}/${post.slug}/`,
49
+ path: paths.post(post.slug),
47
50
  title: plain(post.title) || post.slug,
48
51
  summary,
49
52
  searchText: [plain(post.title), summary].filter(Boolean).join(" "),
@@ -3,15 +3,15 @@
3
3
  // drift from it (fb-1791301493691-brzl6f's sibling: /the-build hardcoded
4
4
  // Token of Trust's own branding and never paginated, exactly like /blog did).
5
5
  import type { BlogSettings } from "../blog/types.js";
6
+ import { DEFAULT_COLLECTION_PAGE_SIZE_V1 } from "@tot/public-runtime";
6
7
  import {
7
8
  type BlogIndexCopy,
8
- pagedRoutePath,
9
9
  parsePagedRouteParam,
10
10
  resolveIndexCopy,
11
11
  } from "../blog/pagination.js";
12
12
 
13
- /** Posts per `/the-build/` page when a tenant declares no `pageSize` (or an invalid one). */
14
- export const DEFAULT_THE_BUILD_PAGE_SIZE = 12;
13
+ /** Posts per the-build index page when a tenant declares no `pageSize` (or an invalid one). */
14
+ export const DEFAULT_THE_BUILD_PAGE_SIZE = DEFAULT_COLLECTION_PAGE_SIZE_V1;
15
15
 
16
16
  export type TheBuildIndexCopy = BlogIndexCopy;
17
17
 
@@ -32,11 +32,6 @@ export function resolveTheBuildIndexCopy(
32
32
  );
33
33
  }
34
34
 
35
- /** `/the-build/` is page 1; `/the-build/page/<n>/` is page N for N≥2. */
36
- export function theBuildPagePath(page: number): string {
37
- return pagedRoutePath("/the-build", page);
38
- }
39
-
40
35
  /**
41
36
  * Parse a `/the-build/page/<n>/` route param into a page number, or null when it
42
37
  * is not a valid page-N route (see `parsePagedRouteParam`).
@@ -237,10 +237,11 @@ async function serveRequest(
237
237
  }
238
238
 
239
239
  // A render alias dispatches the same existing Astro route as its canonical
240
- // mount. Content lookup keeps its separate mount-relative `contentPath` on the
241
- // plan; no downstream layer decodes or strips the request again.
240
+ // mount, and a collection path dispatches its platform collection route
241
+ // (`renderPath`). Content lookup keeps its separate mount-relative
242
+ // `contentPath` on the plan; no downstream layer decodes or strips the request again.
242
243
  const publicationRenderPath = publication.action === "render"
243
- ? publication.canonicalPath
244
+ ? publication.renderPath
244
245
  : renderPath;
245
246
 
246
247
  // Workers Cache API: backs the full-page edge cache in the render chain, and
@@ -14,6 +14,7 @@ import { readViewerSession, safeNext } from "@/lib/auth/route.js";
14
14
  import { buildAlternates } from "@/lib/seo/alternates.js";
15
15
  import { languageLabel } from "@/lib/i18n.js";
16
16
  import { breadcrumbLd, blogPostingLd } from "@/lib/jsonld.js";
17
+ import { collectionPathsV1 } from "@/lib/blog/collectionPaths.js";
17
18
 
18
19
  // These redirects also exist in the published artifact: the publication composition lifts this
19
20
  // file into the space's `routes.aliases`, which reach the CDN plane through the live pointer. The
@@ -24,13 +25,14 @@ import { breadcrumbLd, blogPostingLd } from "@/lib/jsonld.js";
24
25
  export const MATERIALIZED_REDIRECT_SOURCE = "content/blog/redirects.json";
25
26
 
26
27
  const { tenant, canonicalBase, basePath, content, publicationTimestamp } = Astro.locals;
28
+ const blogPaths = collectionPathsV1(Astro.locals, "blog");
27
29
  const slug = Astro.params.slug ?? "";
28
30
  const post = await getBlogPost(tenant.tenant_id, slug, content, publicationTimestamp);
29
31
  if (!post) {
30
32
  const redirect = await resolveBlogRedirect(tenant.tenant_id, slug, content);
31
33
  // 308, matching the `redirect-308` alias contract the published plane serves, so a renamed slug
32
34
  // behaves identically on both substrates.
33
- if (redirect) return Astro.redirect(`${basePath}/blog/${redirect}/`, 308);
35
+ if (redirect) return Astro.redirect(`${basePath}${blogPaths.post(redirect)}`, 308);
34
36
  return new Response(null, { status: 404 });
35
37
  }
36
38
 
@@ -41,10 +43,10 @@ const viewer = await readViewerSession(Astro);
41
43
  const gated = (post.access ?? "public") !== "public";
42
44
  const unlock = viewerHasBlogAccess(viewer, post);
43
45
  const bodyHtml = articleBodyHtml(post.contentHtml, { gated, unlock });
44
- const next = safeNext(`${basePath}/blog/${post.slug}/`);
46
+ const next = safeNext(`${basePath}${blogPaths.post(post.slug)}`);
45
47
  const loginHref = `/auth/login?next=${encodeURIComponent(next)}`;
46
48
 
47
- const canonical = new URL(`/blog/${post.slug}/`, canonicalBase + "/").toString();
49
+ const canonical = new URL(blogPaths.post(post.slug), canonicalBase + "/").toString();
48
50
  const presentation = resolveBlogPresentation(await getBlogSettings(tenant.tenant_id, content));
49
51
  // A post that names no author has no byline: never attribute it to anyone (the blog's title, the
50
52
  // store) its content does not name.
@@ -64,10 +66,10 @@ const authorHasArchive = authorArchiveSlug !== "" && listed.some(
64
66
  const archivedTags = new Set(listed.flatMap((p) => p.tags.map(categorySlug)));
65
67
  const tagLinks = post.tags.map((tag) => {
66
68
  const slug = categorySlug(tag);
67
- return { label: tag, href: slug && archivedTags.has(slug) ? `${basePath}/blog/tag/${slug}/` : null };
69
+ return { label: tag, href: slug && archivedTags.has(slug) ? `${basePath}${blogPaths.tag(slug)}` : null };
68
70
  });
69
71
  const authorUrl = authorHasArchive
70
- ? new URL(`/blog/author/${authorArchiveSlug}/`, canonicalBase + "/").toString()
72
+ ? new URL(blogPaths.author(authorArchiveSlug), canonicalBase + "/").toString()
71
73
  : undefined;
72
74
  const articleLd = blogPostingLd(post, {
73
75
  url: canonical,
@@ -77,13 +79,13 @@ const articleLd = blogPostingLd(post, {
77
79
  });
78
80
  const trail = siteTrail(
79
81
  homeCrumbLabel(await content.getChrome()),
80
- { label: "Blog", href: "/blog/" },
81
- { label: post.title, href: `/blog/${post.slug}/` },
82
+ { label: "Blog", href: blogPaths.index },
83
+ { label: post.title, href: blogPaths.post(post.slug) },
82
84
  );
83
85
  const crumbs = breadcrumbLd(trail, canonicalBase);
84
- const blogHref = `${basePath}/blog/`;
86
+ const blogHref = `${basePath}${blogPaths.index}`;
85
87
  const authorHref = authorHasArchive
86
- ? `${basePath}/blog/author/${authorArchiveSlug}/`
88
+ ? `${basePath}${blogPaths.author(authorArchiveSlug)}`
87
89
  : null;
88
90
  const share = shareLinks(presentation.share, {
89
91
  url: canonical,
@@ -97,7 +99,7 @@ const translations = post.translationGroupId
97
99
  ? await getTranslations(tenant.tenant_id, "blog", post.slug, { content, now: publicationTimestamp })
98
100
  : [];
99
101
  const alternates = buildAlternates(translations, (s) =>
100
- new URL(`/blog/${s}/`, canonicalBase + "/").toString(),
102
+ new URL(blogPaths.post(s), canonicalBase + "/").toString(),
101
103
  );
102
104
  const lang = alternates.length ? (post.inLanguage ?? "en") : undefined;
103
105
  ---
@@ -105,7 +107,7 @@ const lang = alternates.length ? (post.inLanguage ?? "en") : undefined;
105
107
  <Layout
106
108
  title={post.seoTitle || post.title}
107
109
  description={post.description}
108
- path={`/blog/${post.slug}/`}
110
+ path={blogPaths.post(post.slug)}
109
111
  ogType="article"
110
112
  ogImage={post.featuredImage?.src}
111
113
  noindex={post.status === "unlisted"}
@@ -145,7 +147,7 @@ const lang = alternates.length ? (post.inLanguage ?? "en") : undefined;
145
147
  return (
146
148
  <a
147
149
  hreflang={code}
148
- href={`${basePath}/blog/${variant.slug}/`}
150
+ href={`${basePath}${blogPaths.post(variant.slug)}`}
149
151
  aria-current={current ? "true" : undefined}
150
152
  >{languageLabel(code)}</a>
151
153
  );
@@ -7,11 +7,13 @@ import { resolveBlogPresentation } from "@/lib/blog/presentation.js";
7
7
  import { homeCrumbLabel, siteTrail } from "@/lib/breadcrumbs.js";
8
8
  import { authorSlug, getAuthors } from "@/lib/blog/collection.js";
9
9
  import { breadcrumbLd } from "@/lib/jsonld.js";
10
+ import { collectionPathsV1 } from "@/lib/blog/collectionPaths.js";
10
11
 
11
12
  // Author archive for the `/blog` collection. Lists exactly the posts whose
12
13
  // bylines link here (same source as /blog/index.astro + /blog/[slug].astro),
13
14
  // so a byline never points at a page missing its post.
14
15
  const { tenant, basePath, canonicalBase, content, publicationTimestamp } = Astro.locals;
16
+ const blogPaths = collectionPathsV1(Astro.locals, "blog");
15
17
  const slug = Astro.params.author ?? "";
16
18
 
17
19
  const allPosts = await listBlogPosts(tenant.tenant_id, content, publicationTimestamp);
@@ -22,7 +24,7 @@ const authorName = posts[0]!.author;
22
24
  const bio = (await getAuthors(tenant.tenant_id, "blog", content))[slug];
23
25
 
24
26
  const href = (path: string) => `${basePath}${path}` || "/";
25
- const canonical = new URL(`/blog/author/${slug}/`, canonicalBase + "/").toString();
27
+ const canonical = new URL(blogPaths.author(slug), canonicalBase + "/").toString();
26
28
  const avatarUrl = bio?.avatar ? new URL(bio.avatar, canonicalBase + "/").toString() : undefined;
27
29
 
28
30
  const personLd = {
@@ -49,14 +51,14 @@ const itemListLd = {
49
51
  "@type": "ListItem",
50
52
  position: index + 1,
51
53
  name: post.title,
52
- url: new URL(`/blog/${post.slug}/`, canonicalBase + "/").toString(),
54
+ url: new URL(blogPaths.post(post.slug), canonicalBase + "/").toString(),
53
55
  })),
54
56
  };
55
57
  const presentation = resolveBlogPresentation(await getBlogSettings(tenant.tenant_id, content));
56
58
  const trail = siteTrail(
57
59
  homeCrumbLabel(await content.getChrome()),
58
- { label: "Blog", href: "/blog/" },
59
- { label: authorName, href: `/blog/author/${slug}/` },
60
+ { label: "Blog", href: blogPaths.index },
61
+ { label: authorName, href: blogPaths.author(slug) },
60
62
  );
61
63
  const crumbs = breadcrumbLd(trail, canonicalBase);
62
64
  ---
@@ -64,7 +66,7 @@ const crumbs = breadcrumbLd(trail, canonicalBase);
64
66
  <Layout
65
67
  title={`${authorName} — Blog`}
66
68
  description={bio?.bio ?? `Articles by ${authorName}.`}
67
- path={`/blog/author/${slug}/`}
69
+ path={blogPaths.author(slug)}
68
70
  fullBleed
69
71
  jsonLd={[crumbs, profileLd, itemListLd]}
70
72
  >
@@ -92,7 +94,7 @@ const crumbs = breadcrumbLd(trail, canonicalBase);
92
94
  <section class="post-grid" aria-label={`Posts by ${authorName}`}>
93
95
  {posts.map((post, index) => (
94
96
  <article class="post-card">
95
- <a href={href(`/blog/${post.slug}/`)} aria-label={`Read ${post.title}`}>
97
+ <a href={href(blogPaths.post(post.slug))} aria-label={`Read ${post.title}`}>
96
98
  {post.featuredImage ? (
97
99
  <img
98
100
  src={post.featuredImage.src}
@@ -7,8 +7,10 @@ import { collectPostsByTerm } from "@/lib/blog/collection.js";
7
7
  import { resolveBlogPresentation } from "@/lib/blog/presentation.js";
8
8
  import { homeCrumbLabel, siteTrail } from "@/lib/breadcrumbs.js";
9
9
  import { breadcrumbLd } from "@/lib/jsonld.js";
10
+ import { collectionPathsV1 } from "@/lib/blog/collectionPaths.js";
10
11
 
11
12
  const { tenant, basePath, canonicalBase, content, publicationTimestamp } = Astro.locals;
13
+ const blogPaths = collectionPathsV1(Astro.locals, "blog");
12
14
  const param = Astro.params.category ?? "";
13
15
 
14
16
  // Same source as the /blog index (the collection's curated list), filtered to
@@ -19,7 +21,7 @@ if (!match) return new Response(null, { status: 404 });
19
21
  const { label, posts } = match;
20
22
  const presentation = resolveBlogPresentation(await getBlogSettings(tenant.tenant_id, content));
21
23
 
22
- const path = `/blog/category/${param}/`;
24
+ const path = blogPaths.category(param);
23
25
  const itemList = {
24
26
  "@context": "https://schema.org",
25
27
  "@type": "ItemList",
@@ -28,12 +30,12 @@ const itemList = {
28
30
  "@type": "ListItem",
29
31
  position: index + 1,
30
32
  name: post.title,
31
- url: new URL(`/blog/${post.slug}/`, canonicalBase + "/").toString(),
33
+ url: new URL(blogPaths.post(post.slug), canonicalBase + "/").toString(),
32
34
  })),
33
35
  };
34
36
  const trail = siteTrail(
35
37
  homeCrumbLabel(await content.getChrome()),
36
- { label: "Blog", href: "/blog/" },
38
+ { label: "Blog", href: blogPaths.index },
37
39
  { label, href: path },
38
40
  );
39
41
  const crumbs = breadcrumbLd(trail, canonicalBase);
@@ -56,7 +58,7 @@ const crumbs = breadcrumbLd(trail, canonicalBase);
56
58
  </header>
57
59
 
58
60
  <div class="blog-shell blog-layout">
59
- <PostGrid posts={posts} basePath={basePath} base="/blog" />
61
+ <PostGrid posts={posts} basePath={basePath} paths={blogPaths} />
60
62
  </div>
61
63
  </Layout>
62
64
 
@@ -7,8 +7,10 @@ import { collectPostsByTerm } from "@/lib/blog/collection.js";
7
7
  import { resolveBlogPresentation } from "@/lib/blog/presentation.js";
8
8
  import { homeCrumbLabel, siteTrail } from "@/lib/breadcrumbs.js";
9
9
  import { breadcrumbLd } from "@/lib/jsonld.js";
10
+ import { collectionPathsV1 } from "@/lib/blog/collectionPaths.js";
10
11
 
11
12
  const { tenant, basePath, canonicalBase, content, publicationTimestamp } = Astro.locals;
13
+ const blogPaths = collectionPathsV1(Astro.locals, "blog");
12
14
  const param = Astro.params.tag ?? "";
13
15
 
14
16
  const all = await listBlogPosts(tenant.tenant_id, content, publicationTimestamp);
@@ -17,7 +19,7 @@ if (!match) return new Response(null, { status: 404 });
17
19
  const { label, posts } = match;
18
20
  const presentation = resolveBlogPresentation(await getBlogSettings(tenant.tenant_id, content));
19
21
 
20
- const path = `/blog/tag/${param}/`;
22
+ const path = blogPaths.tag(param);
21
23
  const itemList = {
22
24
  "@context": "https://schema.org",
23
25
  "@type": "ItemList",
@@ -26,12 +28,12 @@ const itemList = {
26
28
  "@type": "ListItem",
27
29
  position: index + 1,
28
30
  name: post.title,
29
- url: new URL(`/blog/${post.slug}/`, canonicalBase + "/").toString(),
31
+ url: new URL(blogPaths.post(post.slug), canonicalBase + "/").toString(),
30
32
  })),
31
33
  };
32
34
  const trail = siteTrail(
33
35
  homeCrumbLabel(await content.getChrome()),
34
- { label: "Blog", href: "/blog/" },
36
+ { label: "Blog", href: blogPaths.index },
35
37
  { label: `#${label}`, href: path },
36
38
  );
37
39
  const crumbs = breadcrumbLd(trail, canonicalBase);
@@ -54,7 +56,7 @@ const crumbs = breadcrumbLd(trail, canonicalBase);
54
56
  </header>
55
57
 
56
58
  <div class="blog-shell blog-layout">
57
- <PostGrid posts={posts} basePath={basePath} base="/blog" />
59
+ <PostGrid posts={posts} basePath={basePath} paths={blogPaths} />
58
60
  </div>
59
61
  </Layout>
60
62
 
@@ -20,6 +20,7 @@ import {
20
20
  readMountedPublicationDiscoveryV1,
21
21
  rootOwnsDiscoveryPathV1,
22
22
  } from "@/lib/publication/mountedDiscovery.js";
23
+ import { collectionPathsV1 } from "@/lib/blog/collectionPaths.js";
23
24
 
24
25
  /** Collapse HTML/whitespace to a single trimmed line for a link description. */
25
26
  function oneLine(s: string | undefined, max = 140): string | undefined {
@@ -31,6 +32,7 @@ function oneLine(s: string | undefined, max = 140): string | undefined {
31
32
 
32
33
  export async function GET(context: APIContext): Promise<Response> {
33
34
  const { tot, tenant, canonicalBase, content, publicationTimestamp } = context.locals;
35
+ const blogPaths = collectionPathsV1(context.locals, "blog");
34
36
  const base = canonicalBase.replace(/\/$/, "");
35
37
 
36
38
  // Use the request-resolved ContentProvider (locals.content) so any published /
@@ -54,9 +56,9 @@ export async function GET(context: APIContext): Promise<Response> {
54
56
  const pages = index
55
57
  ? rootPages.filter((pg) => rootOwnsDiscoveryPathV1(index, `/${pg.slug}`))
56
58
  : rootPages;
57
- const rootOwnsBlogIndex = !index || rootOwnsDiscoveryPathV1(index, "/blog/");
59
+ const rootOwnsBlogIndex = !index || rootOwnsDiscoveryPathV1(index, blogPaths.index);
58
60
  const blogPosts = index
59
- ? rootBlogPosts.filter((post) => rootOwnsDiscoveryPathV1(index, `/blog/${post.slug}/`))
61
+ ? rootBlogPosts.filter((post) => rootOwnsDiscoveryPathV1(index, blogPaths.post(post.slug)))
60
62
  : rootBlogPosts;
61
63
  const mounted = index
62
64
  ? (await readMountedPublicationDiscoveryV1({ tenant, store: await readKv("TENANT_CACHE"), index }))
@@ -108,10 +110,10 @@ export async function GET(context: APIContext): Promise<Response> {
108
110
 
109
111
  if (blogPosts.length) {
110
112
  lines.push("## Blog");
111
- if (rootOwnsBlogIndex) lines.push(`- [All articles](${base}/blog/)`);
113
+ if (rootOwnsBlogIndex) lines.push(`- [All articles](${base}${blogPaths.index})`);
112
114
  for (const post of blogPosts) {
113
115
  const desc = oneLine(post.description || post.excerpt);
114
- lines.push(`- [${post.title}](${base}/blog/${post.slug}/)${desc ? `: ${desc}` : ""}`);
116
+ lines.push(`- [${post.title}](${base}${blogPaths.post(post.slug)})${desc ? `: ${desc}` : ""}`);
115
117
  }
116
118
  lines.push("");
117
119
  }
@@ -24,6 +24,7 @@ import {
24
24
  readMountedPublicationDiscoveryV1,
25
25
  rootOwnsDiscoveryPathV1,
26
26
  } from "@/lib/publication/mountedDiscovery.js";
27
+ import { collectionPathsV1 } from "@/lib/blog/collectionPaths.js";
27
28
 
28
29
  interface SitemapUrl {
29
30
  loc: string;
@@ -43,6 +44,8 @@ function escapeXml(value: string): string {
43
44
  export async function GET(context: APIContext): Promise<Response> {
44
45
  const { tot, tenant, canonicalBase, content, features, publicationTimestamp } = context.locals;
45
46
  const base = canonicalBase.replace(/\/$/, "");
47
+ const blogPaths = collectionPathsV1(context.locals, "blog");
48
+ const buildPaths = collectionPathsV1(context.locals, "the-build");
46
49
 
47
50
  const urls: SitemapUrl[] = [{ loc: `${base}/` }];
48
51
 
@@ -85,7 +88,7 @@ export async function GET(context: APIContext): Promise<Response> {
85
88
 
86
89
  const blogPosts = await listBlogPosts(tenant.tenant_id, content, publicationTimestamp);
87
90
  if (blogPosts.length > 0) {
88
- urls.push({ loc: `${base}/blog/` });
91
+ urls.push({ loc: `${base}${blogPaths.index}` });
89
92
  // Paginated pages (N≥2) — the static materializer discovers these new routes
90
93
  // straight off this list (withSitemapRoutes in scripts/publish/lib/routes.mjs),
91
94
  // so listing them here is what makes /blog/page/N/ exist in BOTH serving
@@ -93,9 +96,9 @@ export async function GET(context: APIContext): Promise<Response> {
93
96
  const blogSettings = await getBlogSettings(tenant.tenant_id, content);
94
97
  const { pageSize } = resolveBlogIndexCopy(blogSettings, tenant.displayName);
95
98
  const totalBlogPages = Math.max(1, Math.ceil(blogPosts.length / pageSize));
96
- for (let n = 2; n <= totalBlogPages; n++) urls.push({ loc: `${base}/blog/page/${n}/` });
99
+ for (let n = 2; n <= totalBlogPages; n++) urls.push({ loc: `${base}${blogPaths.page(n)}` });
97
100
  for (const post of blogPosts) {
98
- urls.push({ loc: `${base}/blog/${post.slug}/`, lastmod: post.modifiedAt || post.publishedAt });
101
+ urls.push({ loc: `${base}${blogPaths.post(post.slug)}`, lastmod: post.modifiedAt || post.publishedAt });
99
102
  }
100
103
  // Author archives (blog/author/[author].astro): one per distinct byline,
101
104
  // same source as the pages themselves so the sitemap never lists an
@@ -105,25 +108,25 @@ export async function GET(context: APIContext): Promise<Response> {
105
108
  ...new Set(blogPosts.map((post) => authorSlug(post.author)).filter(Boolean)),
106
109
  ].sort();
107
110
  for (const slug of authorSlugs) {
108
- urls.push({ loc: `${base}/blog/author/${slug}/` });
111
+ urls.push({ loc: `${base}${blogPaths.author(slug)}` });
109
112
  }
110
113
  const categorySlugs = [...new Set(blogPosts.flatMap((post) => post.categories.map(categorySlug)).filter(Boolean))].sort();
111
114
  const tagSlugs = [...new Set(blogPosts.flatMap((post) => post.tags.map(categorySlug)).filter(Boolean))].sort();
112
- for (const slug of categorySlugs) urls.push({ loc: `${base}/blog/category/${slug}/` });
113
- for (const slug of tagSlugs) urls.push({ loc: `${base}/blog/tag/${slug}/` });
115
+ for (const slug of categorySlugs) urls.push({ loc: `${base}${blogPaths.category(slug)}` });
116
+ for (const slug of tagSlugs) urls.push({ loc: `${base}${blogPaths.tag(slug)}` });
114
117
  }
115
118
 
116
119
  const buildPosts = await listBuildPosts(tenant.tenant_id, { content, now: publicationTimestamp });
117
120
  if (buildPosts.length > 0) {
118
- urls.push({ loc: `${base}/the-build/` });
121
+ urls.push({ loc: `${base}${buildPaths.index}` });
119
122
  // Paginated pages (N≥2), same reasoning as /blog/page/N/ above: the static
120
123
  // materializer discovers these new routes straight off this list.
121
124
  const buildSettings = await getTheBuildSettings(tenant.tenant_id, content);
122
125
  const { pageSize } = resolveTheBuildIndexCopy(buildSettings, tenant.displayName);
123
126
  const totalBuildPages = Math.max(1, Math.ceil(buildPosts.length / pageSize));
124
- for (let n = 2; n <= totalBuildPages; n++) urls.push({ loc: `${base}/the-build/page/${n}/` });
127
+ for (let n = 2; n <= totalBuildPages; n++) urls.push({ loc: `${base}${buildPaths.page(n)}` });
125
128
  for (const post of buildPosts) {
126
- urls.push({ loc: `${base}/the-build/${post.slug}/`, lastmod: post.modifiedAt || post.publishedAt });
129
+ urls.push({ loc: `${base}${buildPaths.post(post.slug)}`, lastmod: post.modifiedAt || post.publishedAt });
127
130
  }
128
131
  }
129
132
 
@@ -14,6 +14,7 @@ import { homeCrumbLabel, siteTrail } from "@/lib/breadcrumbs.js";
14
14
  import { getTranslations } from "@/lib/blog/collection.js";
15
15
  import { buildAlternates } from "@/lib/seo/alternates.js";
16
16
  import { breadcrumbLd } from "@/lib/jsonld.js";
17
+ import { collectionPathsV1 } from "@/lib/blog/collectionPaths.js";
17
18
 
18
19
  // Same contract as blog/[slug].astro: the publication composition lifts this file into the space's
19
20
  // `routes.aliases`, so these redirects reach the CDN plane through the live pointer. The redirect
@@ -21,16 +22,17 @@ import { breadcrumbLd } from "@/lib/jsonld.js";
21
22
  export const MATERIALIZED_REDIRECT_SOURCE = "content/the-build/redirects.json";
22
23
 
23
24
  const { tenant, canonicalBase, basePath, content, publicationTimestamp } = Astro.locals;
25
+ const buildPaths = collectionPathsV1(Astro.locals, "the-build");
24
26
  const slug = Astro.params.slug ?? "";
25
27
  const post = await getBuildPost(tenant.tenant_id, slug, { content, now: publicationTimestamp });
26
28
  if (!post) {
27
29
  const redirect = await resolveBuildRedirect(tenant.tenant_id, slug, content);
28
30
  // 308, matching the `redirect-308` alias contract the published plane serves.
29
- if (redirect) return Astro.redirect(`${basePath}/the-build/${redirect}/`, 308);
31
+ if (redirect) return Astro.redirect(`${basePath}${buildPaths.post(redirect)}`, 308);
30
32
  return new Response(null, { status: 404 });
31
33
  }
32
34
 
33
- const canonical = new URL(`/the-build/${post.slug}/`, canonicalBase + "/").toString();
35
+ const canonical = new URL(buildPaths.post(post.slug), canonicalBase + "/").toString();
34
36
  const presentation = resolveBlogPresentation(await getTheBuildSettings(tenant.tenant_id, content));
35
37
  // A post that names no author has no byline and no structured-data author.
36
38
  const author = bylineAuthor(post.author);
@@ -49,8 +51,8 @@ const articleLd = {
49
51
  };
50
52
  const trail = siteTrail(
51
53
  homeCrumbLabel(await content.getChrome()),
52
- { label: "The Build", href: "/the-build/" },
53
- { label: post.title, href: `/the-build/${post.slug}/` },
54
+ { label: "The Build", href: buildPaths.index },
55
+ { label: post.title, href: buildPaths.post(post.slug) },
54
56
  );
55
57
  const crumbs = breadcrumbLd(trail, canonicalBase);
56
58
  const share = shareLinks(presentation.share, {
@@ -58,7 +60,7 @@ const share = shareLinks(presentation.share, {
58
60
  title: post.title,
59
61
  image: post.featuredImage ? new URL(post.featuredImage.src, canonicalBase + "/").toString() : undefined,
60
62
  });
61
- const buildHref = `${basePath}/the-build/`;
63
+ const buildHref = `${basePath}${buildPaths.index}`;
62
64
 
63
65
  // I2 — hreflang alternates + per-variant <html lang>. `getTranslations` returns
64
66
  // the live-visible variant set (self + siblings); `buildAlternates` yields [] for
@@ -69,7 +71,7 @@ const translations = await getTranslations(tenant.tenant_id, THE_BUILD.id, post.
69
71
  now: publicationTimestamp,
70
72
  });
71
73
  const alternates = buildAlternates(translations, (s) =>
72
- new URL(`/the-build/${s}/`, canonicalBase + "/").toString(),
74
+ new URL(buildPaths.post(s), canonicalBase + "/").toString(),
73
75
  );
74
76
  const lang = alternates.length ? (post.inLanguage ?? "en") : undefined;
75
77
  ---
@@ -77,7 +79,7 @@ const lang = alternates.length ? (post.inLanguage ?? "en") : undefined;
77
79
  <Layout
78
80
  title={post.seoTitle || post.title}
79
81
  description={post.description}
80
- path={`/the-build/${post.slug}/`}
82
+ path={buildPaths.post(post.slug)}
81
83
  ogType="article"
82
84
  ogImage={post.featuredImage?.src}
83
85
  noindex={post.status === "unlisted"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.4.23",
3
+ "version": "2.4.24",
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",
@@ -77,6 +77,14 @@
77
77
  }
78
78
  ]
79
79
  }
80
+ },
81
+ "collections": {
82
+ "type": "object",
83
+ "additionalProperties": false,
84
+ "properties": {
85
+ "blog": { "$ref": "#/$defs/collectionBase" },
86
+ "the-build": { "$ref": "#/$defs/collectionBase" }
87
+ }
80
88
  }
81
89
  }
82
90
  },
@@ -167,6 +175,10 @@
167
175
  }
168
176
  ],
169
177
  "$defs": {
178
+ "collectionBase": {
179
+ "type": "string",
180
+ "pattern": "^(?:[a-z0-9]+(?:-[a-z0-9]+)*(?:/[a-z0-9]+(?:-[a-z0-9]+)*)*)?$"
181
+ },
170
182
  "identifier": {
171
183
  "type": "string",
172
184
  "pattern": "^[a-z0-9](?:[a-z0-9._-]{0,126}[a-z0-9])?$"
@@ -7,3 +7,23 @@ export declare function exactPublicationTimestamp(value: unknown): string | null
7
7
  export declare function publicationAggregateClockV1(spaces: unknown): string | null;
8
8
  export declare function isBlogPostVisibleOnLive(post: BlogVisibilityInput, now?: string | null): boolean;
9
9
  export declare function isBlogPostListableOnLive(post: BlogVisibilityInput, now?: string | null): boolean;
10
+
11
+ export type PublicationCollectionV1 = "blog" | "the-build";
12
+ export declare const PUBLICATION_COLLECTIONS_V1: readonly PublicationCollectionV1[];
13
+ export declare const DEFAULT_COLLECTION_PAGE_SIZE_V1: number;
14
+ export type PublicationCollectionRouteV1 =
15
+ | { kind: "index" }
16
+ | { kind: "page"; page: number }
17
+ | { kind: "post"; slug: string }
18
+ | { kind: "author" | "category" | "tag"; slug: string }
19
+ | { kind: "rss" };
20
+ export declare function publicationCollectionBaseV1(
21
+ manifest: { routes?: { collections?: Partial<Record<PublicationCollectionV1, string>> } } | null | undefined,
22
+ collection: PublicationCollectionV1,
23
+ ): string;
24
+ export declare function publicationCollectionPathV1(mount: string, base: string, route: PublicationCollectionRouteV1): string;
25
+ export declare function publicationCollectionOfV1(
26
+ manifest: { routes?: { collections?: Partial<Record<PublicationCollectionV1, string>> } } | null | undefined,
27
+ contentPath: string,
28
+ ): { collection: PublicationCollectionV1; rest: string } | null;
29
+ export declare function isPublicationCollectionBaseV1(value: unknown): value is string;
@@ -47,3 +47,90 @@ export function isBlogPostListableOnLive(post, now) {
47
47
  const status = post.status ?? "published";
48
48
  return status === "published" || scheduledIsDue({ ...post, status }, now);
49
49
  }
50
+
51
+ /**
52
+ * A space's post collections: `blog` and `the-build`. Each serves at a base path inside its
53
+ * space's mount. The base defaults to the collection's own name; a space may declare another,
54
+ * or `""` for the mount root (`routes.collections` on the publication-space manifest).
55
+ */
56
+ export const PUBLICATION_COLLECTIONS_V1 = Object.freeze(/** @type {const} */ (["blog", "the-build"]));
57
+
58
+ /** Posts per collection index page when a collection declares no valid `pageSize`. */
59
+ export const DEFAULT_COLLECTION_PAGE_SIZE_V1 = 12;
60
+
61
+ const SEGMENT = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
62
+
63
+ /** @param {string} value */
64
+ function segments(value) {
65
+ return value.split("/").filter(Boolean);
66
+ }
67
+
68
+ /**
69
+ * The base path a space declares for `collection`, or the collection's default.
70
+ * @param {{routes?: {collections?: Record<string, string>}} | null | undefined} manifest
71
+ * @param {"blog" | "the-build"} collection
72
+ * @returns {string}
73
+ */
74
+ export function publicationCollectionBaseV1(manifest, collection) {
75
+ const declared = manifest?.routes?.collections?.[collection];
76
+ return typeof declared === "string" ? declared : collection;
77
+ }
78
+
79
+ /**
80
+ * THE constructor of every collection URL: a tenant-relative canonical path, with the trailing
81
+ * slash the collection pages declare as their canonical (`rss` excepted). Every route builder,
82
+ * discovery emitter and renderer builds collection paths only through this.
83
+ * @param {string} mount the space's canonical mount (`/` for the root space)
84
+ * @param {string} base the collection's base path inside the mount (`""` for the mount root)
85
+ * @param {{kind: "index"} | {kind: "page", page: number} | {kind: "post", slug: string} |
86
+ * {kind: "author" | "category" | "tag", slug: string} | {kind: "rss"}} route
87
+ * @returns {string}
88
+ */
89
+ export function publicationCollectionPathV1(mount, base, route) {
90
+ const parts = [...segments(mount), ...segments(base)];
91
+ if (route.kind === "page") {
92
+ if (!Number.isSafeInteger(route.page) || route.page < 1) throw new Error("collection page is malformed");
93
+ if (route.page > 1) parts.push("page", String(route.page));
94
+ } else if (route.kind === "post" || route.kind === "author" || route.kind === "category" || route.kind === "tag") {
95
+ if (typeof route.slug !== "string" || !route.slug || route.slug.includes("/") || route.slug.includes("..")) {
96
+ throw new Error("collection slug is malformed");
97
+ }
98
+ if (route.kind !== "post") parts.push(route.kind);
99
+ parts.push(route.slug);
100
+ } else if (route.kind === "rss") {
101
+ return `/${[...parts, "rss.xml"].join("/")}`;
102
+ } else if (route.kind !== "index") {
103
+ throw new Error("collection route kind is unknown");
104
+ }
105
+ return parts.length ? `/${parts.join("/")}/` : "/";
106
+ }
107
+
108
+ /**
109
+ * Which collection a mount-relative content path belongs to, and the path inside it: the
110
+ * declared base with the longest match wins, so a collection at the mount root (`""`) owns only
111
+ * what no other collection claims. Null when the path is in no collection.
112
+ * @param {{routes?: {collections?: Record<string, string>}} | null | undefined} manifest
113
+ * @param {string} contentPath mount-relative, starting with `/`
114
+ * @returns {{collection: "blog" | "the-build", rest: string} | null}
115
+ */
116
+ export function publicationCollectionOfV1(manifest, contentPath) {
117
+ const path = segments(contentPath);
118
+ /** @type {{collection: "blog" | "the-build", rest: string, depth: number} | null} */
119
+ let best = null;
120
+ for (const collection of PUBLICATION_COLLECTIONS_V1) {
121
+ const base = segments(publicationCollectionBaseV1(manifest, collection));
122
+ if (base.length > path.length || base.some((part, index) => part !== path[index])) continue;
123
+ if (!best || base.length > best.depth) {
124
+ best = { collection, rest: path.slice(base.length).map((part) => `/${part}`).join(""), depth: base.length };
125
+ }
126
+ }
127
+ return best ? { collection: best.collection, rest: best.rest } : null;
128
+ }
129
+
130
+ /**
131
+ * Validate a declared collection base: `""` (the mount root) or `/`-separated lowercase segments.
132
+ * @param {unknown} value
133
+ */
134
+ export function isPublicationCollectionBaseV1(value) {
135
+ return typeof value === "string" && (value === "" || value.split("/").every((part) => SEGMENT.test(part)));
136
+ }
@@ -22,6 +22,7 @@ import {
22
22
  type RouteTierIndexV1,
23
23
  } from "./route-tier.js";
24
24
  import type { PublicationSpaceManifestV1 } from "./publication-space.js";
25
+ import { publicationCollectionOfV1 } from "./blog-visibility.mjs";
25
26
 
26
27
  export const PUBLICATION_ROUTE_PLAN_VERSION = 1 as const;
27
28
 
@@ -42,8 +43,14 @@ export interface PublicationRouteRenderPlanV1 {
42
43
  requestPath: string;
43
44
  /** Route relative to the selected publication source. Always absolute. */
44
45
  contentPath: string;
45
- /** Canonical tenant-relative route used by links and Astro dispatch. */
46
+ /** Canonical tenant-relative route used by links. */
46
47
  canonicalPath: string;
48
+ /**
49
+ * The platform route Astro dispatches: a path in one of the space's declared collections maps
50
+ * to that collection's platform route (`/blog/...`, `/the-build/...`), whatever its base inside
51
+ * the mount; any other path dispatches at its canonical path.
52
+ */
53
+ renderPath: string;
47
54
  assetNamespace: string;
48
55
  }
49
56
 
@@ -91,6 +98,12 @@ function appendSuffix(mount: string, suffix: string): string {
91
98
  return `${mount}${suffix}`;
92
99
  }
93
100
 
101
+ /** The platform collection route for a mount-relative path, or null when it is in no collection. */
102
+ function collectionRenderPath(manifest: PublicationSpaceManifestV1, contentPath: string): string | null {
103
+ const match = publicationCollectionOfV1(manifest, contentPath);
104
+ return match ? `/${match.collection}${match.rest}` : null;
105
+ }
106
+
94
107
  /** Resolve one request through the already-compiled tenant mount authority. */
95
108
  export function resolvePublicationRoutePlanV1(
96
109
  index: RouteTierIndexV1,
@@ -143,6 +156,7 @@ export function resolvePublicationRoutePlanV1(
143
156
  requestPath: route.path,
144
157
  contentPath: suffix || "/",
145
158
  canonicalPath: appendSuffix(mount.canonicalMount, suffix),
159
+ renderPath: collectionRenderPath(manifest, suffix || "/") ?? appendSuffix(mount.canonicalMount, suffix),
146
160
  assetNamespace: manifest.assets.namespace,
147
161
  };
148
162
  }
@@ -6,6 +6,12 @@
6
6
  * normalized mounts emitted here rather than decode or canonicalize paths again.
7
7
  */
8
8
  import type { TenantConfig } from "./tenant.js";
9
+ import {
10
+ isPublicationCollectionBaseV1,
11
+ PUBLICATION_COLLECTIONS_V1,
12
+ publicationCollectionBaseV1,
13
+ type PublicationCollectionV1,
14
+ } from "./blog-visibility.mjs";
9
15
  import {
10
16
  ANNOTATION_KEYS,
11
17
  type ContractResult,
@@ -115,6 +121,12 @@ export interface PublicationSpaceManifestV1 {
115
121
  routes: {
116
122
  canonicalMount: string;
117
123
  aliases?: PublicationSpaceAliasV1[];
124
+ /**
125
+ * Base path of each post collection inside the mount. Absent means the collection's own name
126
+ * (`blog`, `the-build`); `""` serves it at the mount root. Every collection URL is built from
127
+ * it by `publicationCollectionPathV1`.
128
+ */
129
+ collections?: Partial<Record<PublicationCollectionV1, string>>;
118
130
  };
119
131
  visibility: PublicationSpaceVisibilityV1;
120
132
  authorization:
@@ -450,6 +462,44 @@ function validateRenderer(
450
462
  return contractVersion && themeContractVersion ? { contractVersion, themeContractVersion } : undefined;
451
463
  }
452
464
 
465
+ /**
466
+ * A space's declared collection bases: known collections only, each `""` or lowercase segments,
467
+ * no two collections sharing a base, and none at the site root (the root space's home owns `/`).
468
+ */
469
+ function validateCollections(
470
+ raw: unknown,
471
+ canonicalMount: string | undefined,
472
+ v: Violations,
473
+ path: string,
474
+ ): PublicationSpaceManifestV1["routes"]["collections"] {
475
+ if (raw === undefined) return undefined;
476
+ const collections = reqObject(raw, v, path);
477
+ if (!collections) return undefined;
478
+ rejectPublicationUnknownKeys(collections, [...PUBLICATION_COLLECTIONS_V1], v, path);
479
+ const out: Partial<Record<PublicationCollectionV1, string>> = {};
480
+ for (const collection of PUBLICATION_COLLECTIONS_V1) {
481
+ const value = collections[collection];
482
+ if (value === undefined) continue;
483
+ if (!isPublicationCollectionBaseV1(value)) {
484
+ v.add(`${path}.${collection}`, "publication_collection_base_malformed",
485
+ "A collection base is empty (the mount root) or lowercase path segments.");
486
+ continue;
487
+ }
488
+ if (value === "" && canonicalMount === "/") {
489
+ v.add(`${path}.${collection}`, "publication_collection_at_site_root",
490
+ "A collection cannot take the site root; the root space's home serves it.");
491
+ continue;
492
+ }
493
+ out[collection] = value;
494
+ }
495
+ const bases = PUBLICATION_COLLECTIONS_V1.map((collection) => publicationCollectionBaseV1({ routes: { collections: out } },
496
+ collection));
497
+ if (new Set(bases).size !== bases.length) {
498
+ v.add(path, "publication_collection_base_conflict", "Two collections cannot share a base path.");
499
+ }
500
+ return Object.keys(out).length ? out : undefined;
501
+ }
502
+
453
503
  /** Validate, close, and normalize one serialized publication-space manifest. */
454
504
  export function validatePublicationSpaceManifestV1(raw: unknown): ContractResult<PublicationSpaceManifestV1> {
455
505
  const v = new Violations();
@@ -468,8 +518,9 @@ export function validatePublicationSpaceManifestV1(raw: unknown): ContractResult
468
518
  const routes = reqObject(root["routes"], v, "routes");
469
519
  let canonicalMount: string | undefined;
470
520
  let aliases: PublicationSpaceAliasV1[] | undefined;
521
+ let collections: PublicationSpaceManifestV1["routes"]["collections"];
471
522
  if (routes) {
472
- rejectPublicationUnknownKeys(routes, ["canonicalMount", "aliases"], v, "routes");
523
+ rejectPublicationUnknownKeys(routes, ["canonicalMount", "aliases", "collections"], v, "routes");
473
524
  const rawMount = reqString(routes, "canonicalMount", v, "routes");
474
525
  canonicalMount = rawMount
475
526
  ? append(v, "routes.canonicalMount", normalizePublicationPath(rawMount, { mode: "mount" }))
@@ -478,6 +529,7 @@ export function validatePublicationSpaceManifestV1(raw: unknown): ContractResult
478
529
  v.add("routes.canonicalMount", "publication_reserved_mount", "Canonical mount is platform-reserved.");
479
530
  }
480
531
  aliases = validateAliases(routes["aliases"], canonicalMount, v, "routes.aliases");
532
+ collections = validateCollections(routes["collections"], canonicalMount, v, "routes.collections");
481
533
  }
482
534
  const visibility = reqEnum<PublicationSpaceVisibilityV1>(root, "visibility", ["public", "internal"], v, "");
483
535
  const authorization = validateAuthorization(root["authorization"], v, "authorization");
@@ -525,7 +577,11 @@ export function validatePublicationSpaceManifestV1(raw: unknown): ContractResult
525
577
  id,
526
578
  tenant,
527
579
  source,
528
- routes: aliases === undefined ? { canonicalMount } : { canonicalMount, aliases },
580
+ routes: {
581
+ canonicalMount,
582
+ ...(aliases === undefined ? {} : { aliases }),
583
+ ...(collections === undefined ? {} : { collections }),
584
+ },
529
585
  visibility,
530
586
  authorization,
531
587
  discovery,
@@ -787,6 +843,18 @@ export function validatePublicationSpaceRegistryV1(
787
843
  );
788
844
  }
789
845
  }
846
+ // Precedence: a mount owns every path under it. An alias one space declares inside ANOTHER
847
+ // space's mount (the root's `/blog/page` once a space is mounted at `/blog`) is refused, so no
848
+ // route-tier or edge redirect can shadow the mounted space's own pages.
849
+ for (const alias of entries.filter((entry) => entry.kind === "alias")) {
850
+ const owner = entries.find((entry) => entry.kind === "canonical" && entry.tenant === alias.tenant &&
851
+ entry.spaceId !== alias.spaceId && entry.mount !== "/" &&
852
+ (alias.mount === entry.mount || alias.mount.startsWith(`${entry.mount}/`)));
853
+ if (owner) {
854
+ addRegistryViolation(violations, `spaces.${alias.spaceId}.routes.aliases`, "publication_alias_under_mount",
855
+ `Alias ${alias.mount} lies under the mount ${owner.mount} that space ${owner.spaceId} owns.`);
856
+ }
857
+ }
790
858
  detectAliasLoops(entries, violations);
791
859
  if (violations.length > 0) return { ok: false, violations };
792
860