@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.
- package/apps/storefront/src/components/blog/BlogIndexView.astro +10 -9
- package/apps/storefront/src/components/blog/PostGrid.astro +6 -5
- package/apps/storefront/src/components/the-build/TheBuildIndexView.astro +9 -8
- package/apps/storefront/src/config/publicationSpaceConfig.ts +6 -1
- package/apps/storefront/src/config/publicationSpaceRegistry.ts +1 -0
- package/apps/storefront/src/config/publicationSpaceTopology.ts +22 -1
- package/apps/storefront/src/lib/blog/collectionPaths.ts +46 -0
- package/apps/storefront/src/lib/blog/pagination.ts +6 -16
- package/apps/storefront/src/lib/publication/contentProvider.ts +21 -4
- package/apps/storefront/src/lib/publication/mountedDiscovery.ts +4 -1
- package/apps/storefront/src/lib/publication/rootDiscovery.ts +7 -4
- package/apps/storefront/src/lib/the-build/pagination.ts +3 -8
- package/apps/storefront/src/middleware/index.ts +19 -7
- package/apps/storefront/src/middleware/renderChain.ts +19 -0
- package/apps/storefront/src/pages/blog/[slug].astro +14 -12
- package/apps/storefront/src/pages/blog/author/[author].astro +8 -6
- package/apps/storefront/src/pages/blog/category/[category].astro +6 -4
- package/apps/storefront/src/pages/blog/tag/[tag].astro +6 -4
- package/apps/storefront/src/pages/llms.txt.ts +6 -4
- package/apps/storefront/src/pages/sitemap.xml.ts +12 -9
- package/apps/storefront/src/pages/tenants/[id]/[...path].ts +58 -0
- package/apps/storefront/src/pages/the-build/[slug].astro +9 -7
- package/package.json +1 -1
- package/packages/public-runtime/package.json +1 -0
- package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +25 -0
- package/packages/public-runtime/src/blog-visibility.d.mts +20 -0
- package/packages/public-runtime/src/blog-visibility.mjs +87 -0
- package/packages/public-runtime/src/publication-artifact.ts +278 -46
- package/packages/public-runtime/src/publication-content-redirects.ts +61 -0
- package/packages/public-runtime/src/publication-reader.ts +89 -77
- package/packages/public-runtime/src/publication-routing.ts +15 -1
- 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}
|
|
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}
|
|
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(
|
|
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}
|
|
69
|
+
return { label: tag, href: slug && archivedTags.has(slug) ? `${basePath}${blogPaths.tag(slug)}` : null };
|
|
68
70
|
});
|
|
69
71
|
const authorUrl = authorHasArchive
|
|
70
|
-
? new URL(
|
|
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:
|
|
81
|
-
{ label: post.title, href:
|
|
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}
|
|
86
|
+
const blogHref = `${basePath}${blogPaths.index}`;
|
|
85
87
|
const authorHref = authorHasArchive
|
|
86
|
-
? `${basePath}
|
|
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(
|
|
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={
|
|
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}
|
|
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(
|
|
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(
|
|
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:
|
|
59
|
-
{ label: authorName, href:
|
|
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={
|
|
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(
|
|
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 =
|
|
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(
|
|
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:
|
|
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}
|
|
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 =
|
|
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(
|
|
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:
|
|
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}
|
|
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,
|
|
59
|
+
const rootOwnsBlogIndex = !index || rootOwnsDiscoveryPathV1(index, blogPaths.index);
|
|
58
60
|
const blogPosts = index
|
|
59
|
-
? rootBlogPosts.filter((post) => rootOwnsDiscoveryPathV1(index,
|
|
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}
|
|
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}
|
|
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}
|
|
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}
|
|
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}
|
|
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}
|
|
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}
|
|
113
|
-
for (const slug of tagSlugs) urls.push({ loc: `${base}
|
|
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}
|
|
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}
|
|
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}
|
|
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}
|
|
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(
|
|
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:
|
|
53
|
-
{ label: post.title, href:
|
|
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}
|
|
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(
|
|
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={
|
|
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.
|
|
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;
|