@tokenoftrust/storefront-runner 2.4.23 → 2.4.25

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 (32) 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/contentProvider.ts +21 -4
  10. package/apps/storefront/src/lib/publication/mountedDiscovery.ts +4 -1
  11. package/apps/storefront/src/lib/publication/rootDiscovery.ts +7 -4
  12. package/apps/storefront/src/lib/the-build/pagination.ts +3 -8
  13. package/apps/storefront/src/middleware/index.ts +19 -7
  14. package/apps/storefront/src/middleware/renderChain.ts +19 -0
  15. package/apps/storefront/src/pages/blog/[slug].astro +14 -12
  16. package/apps/storefront/src/pages/blog/author/[author].astro +8 -6
  17. package/apps/storefront/src/pages/blog/category/[category].astro +6 -4
  18. package/apps/storefront/src/pages/blog/tag/[tag].astro +6 -4
  19. package/apps/storefront/src/pages/llms.txt.ts +6 -4
  20. package/apps/storefront/src/pages/sitemap.xml.ts +12 -9
  21. package/apps/storefront/src/pages/tenants/[id]/[...path].ts +58 -0
  22. package/apps/storefront/src/pages/the-build/[slug].astro +9 -7
  23. package/package.json +1 -1
  24. package/packages/public-runtime/package.json +1 -0
  25. package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +25 -0
  26. package/packages/public-runtime/src/blog-visibility.d.mts +20 -0
  27. package/packages/public-runtime/src/blog-visibility.mjs +87 -0
  28. package/packages/public-runtime/src/publication-artifact.ts +278 -46
  29. package/packages/public-runtime/src/publication-content-redirects.ts +61 -0
  30. package/packages/public-runtime/src/publication-reader.ts +89 -77
  31. package/packages/public-runtime/src/publication-routing.ts +15 -1
  32. package/packages/public-runtime/src/publication-space.ts +145 -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",
@@ -62,8 +62,23 @@ export class PublicationSpaceContentProvider implements ContentProvider {
62
62
  constructor(
63
63
  private readonly sharedRoot: ContentProvider,
64
64
  private readonly publication: OpenPublicationArtifactV1,
65
+ /**
66
+ * A discovery document (sitemap, feeds, search index, llms.txt) lists every page of the space,
67
+ * so it reads any of them; a page request reads only the page its plan selected.
68
+ */
69
+ private readonly wholeSpace = false,
65
70
  ) {}
66
71
 
72
+ /** The space-relative slug a page request may read, or null. `""` is the space's home. */
73
+ private readableSlug(slug: string): string | null {
74
+ if (!this.wholeSpace) return selectedPageSlug(this.plan, slug);
75
+ const requested = stripSlashes(slug);
76
+ const mount = stripSlashes(this.plan.manifest.routes.canonicalMount);
77
+ if (!mount) return requested;
78
+ if (requested === mount) return "";
79
+ return requested.startsWith(`${mount}/`) ? requested.slice(mount.length + 1) : null;
80
+ }
81
+
67
82
  private get plan(): PublicationRouteRenderPlanV1 {
68
83
  return this.publication.selection;
69
84
  }
@@ -86,7 +101,7 @@ export class PublicationSpaceContentProvider implements ContentProvider {
86
101
  }
87
102
 
88
103
  async getPageHtml(slug: string): Promise<string | null> {
89
- const selected = selectedPageSlug(this.plan, slug);
104
+ const selected = this.readableSlug(slug);
90
105
  if (selected === null) return null;
91
106
  const raw = this.publication.readContent(
92
107
  selected ? `pages-html/${selected}.html` : "home.html",
@@ -122,10 +137,11 @@ export class PublicationSpaceContentProvider implements ContentProvider {
122
137
  }
123
138
 
124
139
  async getPage(slug: string): Promise<EditorialPage | null> {
125
- const selected = selectedPageSlug(this.plan, slug);
140
+ const selected = this.readableSlug(slug);
126
141
  if (!selected) return null;
127
142
  const page = (await this.sourcePages()).find((candidate) => candidate.slug === selected);
128
- return page ? rebasePage(this.plan, page, stripSlashes(this.plan.canonicalPath)) : null;
143
+ if (!page) return null;
144
+ return this.wholeSpace ? rebasePage(this.plan, page) : rebasePage(this.plan, page, stripSlashes(this.plan.canonicalPath));
129
145
  }
130
146
 
131
147
  async listPages(): Promise<EditorialPage[]> {
@@ -152,8 +168,9 @@ export class PublicationSpaceContentProvider implements ContentProvider {
152
168
  export function bindPublicationSpaceContentProviderV1(
153
169
  sharedRoot: ContentProvider,
154
170
  publication?: OpenPublicationArtifactV1,
171
+ options: { wholeSpace?: boolean } = {},
155
172
  ): ContentProvider {
156
173
  return publication
157
- ? new PublicationSpaceContentProvider(sharedRoot, publication)
174
+ ? new PublicationSpaceContentProvider(sharedRoot, publication, options.wholeSpace ?? false)
158
175
  : sharedRoot;
159
176
  }
@@ -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`).
@@ -25,7 +25,7 @@ import { getTenantResolver } from "@/config/resolver";
25
25
  import { readEnv, readKv, readR2 } from "@/lib/runtimeEnv";
26
26
  import { resolveCdnForwardRouting, resolveTenantRouting } from "@/lib/tenantRouting";
27
27
  import { crossSiteFormRefusal, verifyCdnForward } from "@/lib/cdnForward";
28
- import { compileRouteTierIndexV1, type RouteTierIndexV1 } from "@tot/public-runtime";
28
+ import { compileRouteTierIndexV1, resolvePublicationRoutePlanV1, type RouteTierIndexV1 } from "@tot/public-runtime";
29
29
  import type { PublicationArtifactBucketV1, TenantConfig } from "@tot/public-runtime";
30
30
  import { DEV_DRAFT_COOKIE } from "@/lib/dev/devDraft";
31
31
  import { applySecurityHeaders } from "@/lib/securityHeaders";
@@ -39,6 +39,7 @@ import { HEALTH_PATH, healthResponse } from "@/lib/health";
39
39
  import { observeDeployVersion } from "@/lib/activity/deployVersion";
40
40
  import { primeActivityKillSwitch } from "@/lib/activity/killSwitch";
41
41
  import {
42
+ isPlatformPublicDiscoveryPath,
42
43
  platformStaticRootLocationV1,
43
44
  publicationRedirectLocationV1,
44
45
  resolveMiddlewareBranchV1,
@@ -237,10 +238,11 @@ async function serveRequest(
237
238
  }
238
239
 
239
240
  // 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.
241
+ // mount, and a collection path dispatches its platform collection route
242
+ // (`renderPath`). Content lookup keeps its separate mount-relative
243
+ // `contentPath` on the plan; no downstream layer decodes or strips the request again.
242
244
  const publicationRenderPath = publication.action === "render"
243
- ? publication.canonicalPath
245
+ ? publication.renderPath
244
246
  : renderPath;
245
247
 
246
248
  // Workers Cache API: backs the full-page edge cache in the render chain, and
@@ -313,6 +315,14 @@ async function serveRequest(
313
315
  return asset;
314
316
  }
315
317
 
318
+ // A tenant's discovery documents (sitemap, feeds, search index, llms.txt, robots) are the root's:
319
+ // when the root is a publication space, they render from its selected version, as its pages do.
320
+ const discoveryRoot = isPlatformPublicDiscoveryPath(renderPath)
321
+ ? resolvePublicationRoutePlanV1(routeTierIndexFor(tenant), "/")
322
+ : null;
323
+ const publicationSourceRoute = discoveryRoot?.action === "render" ? discoveryRoot : publication;
324
+ const publicationSourcePath = discoveryRoot?.action === "render" ? "/" : renderPath;
325
+
316
326
  if (branch === "public-render") {
317
327
  return await servePublicRender({
318
328
  context,
@@ -325,9 +335,11 @@ async function serveRequest(
325
335
  cache,
326
336
  kv,
327
337
  routeTierIndex: routeTierIndexFor(tenant),
328
- publicationRequestPath: renderPath,
329
- publicationRoute: publication,
330
- publicationBucket,
338
+ publicationRequestPath: publicationSourcePath,
339
+ publicationRoute: publicationSourceRoute,
340
+ publicationBucket: discoveryRoot?.action === "render"
341
+ ? await readR2("TENANT_ASSETS") as unknown as PublicationArtifactBucketV1 | undefined
342
+ : publicationBucket,
331
343
  });
332
344
  }
333
345
 
@@ -14,6 +14,7 @@ import { mergeTheme, themeToCssVars } from "@/themes/schema";
14
14
  import { createToTClient } from "@/lib/tot/ToTClient";
15
15
  import { readEnv, readR2 } from "@/lib/runtimeEnv";
16
16
  import { tryServeStaticBundle } from "./staticBundle.js";
17
+ import { isPlatformPublicDiscoveryPath } from "./routeBranch.js";
17
18
  import { resolveCandidateContentPin, type CandidateContentPin } from "./candidatePin.js";
18
19
  import { createContentProvider, DevDraftContentProvider } from "@/lib/storyblok/provider";
19
20
  import { bindPublicationSpaceContentProviderV1 } from "@/lib/publication/contentProvider.js";
@@ -82,6 +83,7 @@ import {
82
83
  HTML_CACHE_POLICY_CAPTURE_HEADER,
83
84
  HTML_CACHE_POLICY_CAPTURE_PROBE,
84
85
  } from "@tot/public-runtime";
86
+ import { publicationContentRedirectLocationV1 } from "@tot/public-runtime/publication-content-redirects";
85
87
  import { loadTenantIntegrationDeclarations } from "@/lib/integration/tenantDeclarations";
86
88
  import { resolveTenantCommerce } from "@/lib/checkoutCommerce";
87
89
  import {
@@ -236,6 +238,21 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
236
238
  if (isInternalPublication) applyInternalContentResponsePolicy(response.headers);
237
239
  return response;
238
240
  }
241
+ // The owning space's content-declared redirects answer before anything renders, as the CloudFront
242
+ // redirect plane does for the same declarations: a same-origin target keeps the path-routing prefix
243
+ // and the query; an off-site target (the root space only) is sent as declared.
244
+ if (publicationSource.status === "selected") {
245
+ const location = publicationContentRedirectLocationV1(publicationSource.artifact, input.publicationRequestPath);
246
+ if (location) {
247
+ const response = new Response(null, {
248
+ status: 308,
249
+ headers: { location: location.startsWith("/") ? `${url.origin}${basePath}${location}${url.search}` : location },
250
+ });
251
+ applySecurityHeaders(response.headers);
252
+ if (isInternalPublication) applyInternalContentResponsePolicy(response.headers);
253
+ return response;
254
+ }
255
+ }
239
256
 
240
257
  // The tenant as it renders in THIS environment: its record with the declared
241
258
  // `.tot/config.json` fields applied (compliance, siteType, capabilities,
@@ -613,6 +630,8 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
613
630
  locals.content = bindPublicationSpaceContentProviderV1(
614
631
  sharedRootContent,
615
632
  publicationSource.status === "selected" ? publicationSource.artifact : undefined,
633
+ // A discovery document lists the whole space it is bound to, not one selected page.
634
+ { wholeSpace: isPlatformPublicDiscoveryPath(renderPath) },
616
635
  );
617
636
  // Developer-draft content overlay — serves the AI-edited artifacts to the draft
618
637
  // owner only; every other viewer keeps the published/base chain above.