@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
@@ -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
 
@@ -17,6 +17,10 @@
17
17
  * Preview sessions also consult the image-capture rewrite overlay so rewritten
18
18
  * `public/*.css` (and similar text assets) serve `/_a/<sha>` references.
19
19
  *
20
+ * A tenant whose ROOT is a publication space serves these paths from that space's selected version
21
+ * (its public asset lane, `<namespace>/<path>`), the same URL a whole-site tenant serves them at;
22
+ * otherwise the legacy asset pointer below answers.
23
+ *
20
24
  * Version selection, in order: a candidate/revision pin (`tot_preview_version` +
21
25
  * token cookie); else, for a shared-preview viewer (preview host, or a valid token
22
26
  * for this tenant), the version the content `preview` channel serves — what the
@@ -24,11 +28,18 @@
24
28
  */
25
29
  import type { APIContext } from "astro";
26
30
  import {
31
+ ASSET_CACHE_CONTROL,
27
32
  assetContentType,
33
+ compileRouteTierIndexV1,
34
+ parsePublicationVersionStateV1Json,
28
35
  parseTenantAssetPath,
36
+ publicationVersionStateKey,
37
+ readPublicationRootAssetV1,
29
38
  resolveChannelVersion,
30
39
  serveTenantAsset,
40
+ type PublicationArtifactReadBucketV1,
31
41
  type R2BucketLike,
42
+ type TenantConfig,
32
43
  } from "@tot/public-runtime";
33
44
  import { readWithImagePreviewRewriteOverlay } from "@tot/public-runtime/image-preview-rewrite-overlay";
34
45
  import { readR2, readKv, readEnv } from "@/lib/env";
@@ -68,6 +79,42 @@ async function tryServePreviewRewriteOverlay(input: {
68
79
  });
69
80
  }
70
81
 
82
+ /** The root publication space's asset, or null when the tenant's root is not a selected space. */
83
+ async function tryServePublicationRootAsset(input: {
84
+ /** The tenant the request resolved to (`Astro.locals.tenant`). */
85
+ tenant: TenantConfig | undefined;
86
+ bucket: PublicationArtifactReadBucketV1;
87
+ kvGet: (key: string) => Promise<string | null>;
88
+ tenantId: string;
89
+ pathname: string;
90
+ channel: "live" | "preview";
91
+ ifNoneMatch: string | null;
92
+ }): Promise<Response | null> {
93
+ const parsed = parseTenantAssetPath(input.pathname);
94
+ if (!parsed || parsed.tenantId !== input.tenantId) return null;
95
+ const tenant = input.tenant;
96
+ if (!tenant || tenant.tenant_id !== input.tenantId ||
97
+ !tenant.publicationSpaces?.some((space) => space.routes.canonicalMount === "/")) return null;
98
+ const stateJson = await input.kvGet(publicationVersionStateKey(input.tenantId));
99
+ if (stateJson === null) return null;
100
+ const state = parsePublicationVersionStateV1Json(stateJson);
101
+ if (!state.ok) return null;
102
+ const read = await readPublicationRootAssetV1({
103
+ index: compileRouteTierIndexV1(tenant), state: state.value, channel: input.channel,
104
+ bucket: input.bucket, relativePath: parsed.rel,
105
+ });
106
+ if (read.status === "legacy-root") return null;
107
+ if (read.status === "absent") {
108
+ return new Response("Not found", { status: 404, headers: { "cache-control": "no-store" } });
109
+ }
110
+ const etag = `"${read.sha256}"`;
111
+ const headers = { "content-type": assetContentType(parsed.rel), "cache-control": ASSET_CACHE_CONTROL, etag };
112
+ if (input.ifNoneMatch && input.ifNoneMatch.split(",").some((value) => value.trim().replace(/^W\//, "") === etag)) {
113
+ return new Response(null, { status: 304, headers });
114
+ }
115
+ return new Response(read.bytes as Uint8Array<ArrayBuffer>, { status: 200, headers });
116
+ }
117
+
71
118
  export async function GET(context: APIContext): Promise<Response> {
72
119
  const [bucket, kv] = await Promise.all([
73
120
  readR2("TENANT_ASSETS"),
@@ -106,6 +153,17 @@ export async function GET(context: APIContext): Promise<Response> {
106
153
  const previewVersionId = pinnedVersionId ?? sharedPreview.versionId;
107
154
 
108
155
  const pathname = url.pathname;
156
+ const fromPublication = await tryServePublicationRootAsset({
157
+ tenant: context.locals.tenant,
158
+ bucket: bucket as unknown as PublicationArtifactReadBucketV1,
159
+ kvGet: (key) => kv.get(key),
160
+ tenantId: urlTenant,
161
+ pathname,
162
+ channel: sharedPreview.preview || context.locals.customizationEnv === "test" ? "preview" : "live",
163
+ ifNoneMatch: context.request.headers.get("if-none-match"),
164
+ });
165
+ if (fromPublication) return fromPublication;
166
+
109
167
  if (previewVersionId) {
110
168
  const overlay = await tryServePreviewRewriteOverlay({
111
169
  kvGet: (key) => kv.get(key),
@@ -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.25",
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",
@@ -20,6 +20,7 @@
20
20
  "./catalog-files": "./src/catalog-files.ts",
21
21
  "./embed-catalog": "./src/embed-catalog.mjs",
22
22
  "./retention-holds": "./src/retention-holds.ts",
23
+ "./publication-content-redirects": "./src/publication-content-redirects.ts",
23
24
  "./tenant-embed-defaults": "./src/tenant-embed-defaults.mjs",
24
25
  "./declared-tenant-config": "./src/declared-tenant-config.mjs",
25
26
  "./compliance-coherence": "./src/compliance-coherence.mjs",
@@ -77,6 +77,27 @@
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
+ }
88
+ },
89
+ "externalRedirects": {
90
+ "description": "Exact paths the ROOT space sends to another origin; a mounted space declares none.",
91
+ "type": "array",
92
+ "items": {
93
+ "type": "object",
94
+ "additionalProperties": false,
95
+ "required": ["from", "to"],
96
+ "properties": {
97
+ "from": { "$ref": "#/$defs/path" },
98
+ "to": { "type": "string", "pattern": "^https://[^/?#@\\s]+(?:/[^?#\\s]*)?$" }
99
+ }
100
+ }
80
101
  }
81
102
  }
82
103
  },
@@ -167,6 +188,10 @@
167
188
  }
168
189
  ],
169
190
  "$defs": {
191
+ "collectionBase": {
192
+ "type": "string",
193
+ "pattern": "^(?:[a-z0-9]+(?:-[a-z0-9]+)*(?:/[a-z0-9]+(?:-[a-z0-9]+)*)*)?$"
194
+ },
170
195
  "identifier": {
171
196
  "type": "string",
172
197
  "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;