@tokenoftrust/storefront-runner 2.4.23 → 2.4.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/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 +4 -3
- 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/the-build/[slug].astro +9 -7
- package/package.json +1 -1
- package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +12 -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-routing.ts +15 -1
- package/packages/public-runtime/src/publication-space.ts +70 -2
|
@@ -7,9 +7,9 @@ import Layout from "@/layouts/Layout.astro";
|
|
|
7
7
|
import PostAccessBadge from "@/components/blog/PostAccessBadge.astro";
|
|
8
8
|
import type { BlogPostSummary } from "@/lib/blog/types.js";
|
|
9
9
|
import type { BlogIndexCopy } from "@/lib/blog/pagination.js";
|
|
10
|
-
import { blogPagePath } from "@/lib/blog/pagination.js";
|
|
11
10
|
import type { BlogPresentation } from "@/lib/blog/presentation.js";
|
|
12
11
|
import { categorySlug } from "@/lib/blog/collection.js";
|
|
12
|
+
import { collectionPathsV1 } from "@/lib/blog/collectionPaths.js";
|
|
13
13
|
|
|
14
14
|
interface Props {
|
|
15
15
|
basePath: string;
|
|
@@ -23,17 +23,18 @@ interface Props {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
const { basePath, canonicalBase, copy, presentation, tags, pagePosts, page, totalPages } = Astro.props;
|
|
26
|
+
const blogPaths = collectionPathsV1(Astro.locals, "blog");
|
|
26
27
|
const showTopics = presentation.topics && tags.length > 0;
|
|
27
28
|
|
|
28
29
|
const href = (path: string) => `${basePath}${path}` || "/";
|
|
29
|
-
const path =
|
|
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:
|
|
36
|
-
...(page < totalPages ? { next:
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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. `
|
|
4
|
-
//
|
|
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
|
-
|
|
12
|
+
paths: CollectionPathsV1;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
const { posts, basePath,
|
|
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(
|
|
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 {
|
|
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 =
|
|
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:
|
|
33
|
-
...(page < totalPages ? { next:
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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:
|
|
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
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import type {
|
|
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
|
-
/**
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Posts per blog index page when a tenant declares no `pageSize` (or an invalid one). The route
|
|
6
|
+
* builders count index pages with the same default, so a page the renderer serves is a page they list.
|
|
7
|
+
*/
|
|
8
|
+
export const DEFAULT_BLOG_PAGE_SIZE = DEFAULT_COLLECTION_PAGE_SIZE_V1;
|
|
5
9
|
|
|
6
10
|
/**
|
|
7
11
|
* Resolved `/blog/` index copy + page size. A declared `BlogSettings` field wins;
|
|
@@ -91,20 +95,6 @@ export function paginateBlogPosts<T>(
|
|
|
91
95
|
return { posts: posts.slice(start, start + pageSize), page, totalPages };
|
|
92
96
|
}
|
|
93
97
|
|
|
94
|
-
/**
|
|
95
|
-
* Generic engine behind `blogPagePath` and every other collection's page path
|
|
96
|
-
* (e.g. `theBuildPagePath`): page 1 is the bare index; page N≥2 is
|
|
97
|
-
* `<basePath>/page/<n>/`. `basePath` carries no trailing slash (e.g. `/blog`).
|
|
98
|
-
*/
|
|
99
|
-
export function pagedRoutePath(basePath: string, page: number): string {
|
|
100
|
-
return page <= 1 ? `${basePath}/` : `${basePath}/page/${page}/`;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/** `/blog/` is page 1; `/blog/page/<n>/` is page N for N≥2. */
|
|
104
|
-
export function blogPagePath(page: number): string {
|
|
105
|
-
return pagedRoutePath("/blog", page);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
98
|
/**
|
|
109
99
|
* Generic engine behind `parseBlogPageParam` and every other collection's
|
|
110
100
|
* `page/[n]` route param: not a bare positive-integer string (rejects "01",
|
|
@@ -195,7 +195,10 @@ export async function readMountedPublicationDiscoveryV1(input: {
|
|
|
195
195
|
export function mountedDiscoveryUrlV1(canonicalBase: string, entry: Pick<MountedDiscoveryEntryV1,
|
|
196
196
|
"canonicalPath" | "kind">): string {
|
|
197
197
|
if (entry.kind === "raw-html") return rawPageCanonicalUrl(canonicalBase, entry.canonicalPath.slice(1));
|
|
198
|
-
|
|
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,
|
|
41
|
-
...buildPosts.map((post) => ({ post,
|
|
42
|
-
].map(({ post,
|
|
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:
|
|
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
|
|
14
|
-
export const DEFAULT_THE_BUILD_PAGE_SIZE =
|
|
13
|
+
/** Posts per the-build index page when a tenant declares no `pageSize` (or an invalid one). */
|
|
14
|
+
export const DEFAULT_THE_BUILD_PAGE_SIZE = DEFAULT_COLLECTION_PAGE_SIZE_V1;
|
|
15
15
|
|
|
16
16
|
export type TheBuildIndexCopy = BlogIndexCopy;
|
|
17
17
|
|
|
@@ -32,11 +32,6 @@ export function resolveTheBuildIndexCopy(
|
|
|
32
32
|
);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
/** `/the-build/` is page 1; `/the-build/page/<n>/` is page N for N≥2. */
|
|
36
|
-
export function theBuildPagePath(page: number): string {
|
|
37
|
-
return pagedRoutePath("/the-build", page);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
35
|
/**
|
|
41
36
|
* Parse a `/the-build/page/<n>/` route param into a page number, or null when it
|
|
42
37
|
* is not a valid page-N route (see `parsePagedRouteParam`).
|
|
@@ -237,10 +237,11 @@ async function serveRequest(
|
|
|
237
237
|
}
|
|
238
238
|
|
|
239
239
|
// A render alias dispatches the same existing Astro route as its canonical
|
|
240
|
-
// mount
|
|
241
|
-
//
|
|
240
|
+
// mount, and a collection path dispatches its platform collection route
|
|
241
|
+
// (`renderPath`). Content lookup keeps its separate mount-relative
|
|
242
|
+
// `contentPath` on the plan; no downstream layer decodes or strips the request again.
|
|
242
243
|
const publicationRenderPath = publication.action === "render"
|
|
243
|
-
? publication.
|
|
244
|
+
? publication.renderPath
|
|
244
245
|
: renderPath;
|
|
245
246
|
|
|
246
247
|
// Workers Cache API: backs the full-page edge cache in the render chain, and
|
|
@@ -14,6 +14,7 @@ import { readViewerSession, safeNext } from "@/lib/auth/route.js";
|
|
|
14
14
|
import { buildAlternates } from "@/lib/seo/alternates.js";
|
|
15
15
|
import { languageLabel } from "@/lib/i18n.js";
|
|
16
16
|
import { breadcrumbLd, blogPostingLd } from "@/lib/jsonld.js";
|
|
17
|
+
import { collectionPathsV1 } from "@/lib/blog/collectionPaths.js";
|
|
17
18
|
|
|
18
19
|
// These redirects also exist in the published artifact: the publication composition lifts this
|
|
19
20
|
// file into the space's `routes.aliases`, which reach the CDN plane through the live pointer. The
|
|
@@ -24,13 +25,14 @@ import { breadcrumbLd, blogPostingLd } from "@/lib/jsonld.js";
|
|
|
24
25
|
export const MATERIALIZED_REDIRECT_SOURCE = "content/blog/redirects.json";
|
|
25
26
|
|
|
26
27
|
const { tenant, canonicalBase, basePath, content, publicationTimestamp } = Astro.locals;
|
|
28
|
+
const blogPaths = collectionPathsV1(Astro.locals, "blog");
|
|
27
29
|
const slug = Astro.params.slug ?? "";
|
|
28
30
|
const post = await getBlogPost(tenant.tenant_id, slug, content, publicationTimestamp);
|
|
29
31
|
if (!post) {
|
|
30
32
|
const redirect = await resolveBlogRedirect(tenant.tenant_id, slug, content);
|
|
31
33
|
// 308, matching the `redirect-308` alias contract the published plane serves, so a renamed slug
|
|
32
34
|
// behaves identically on both substrates.
|
|
33
|
-
if (redirect) return Astro.redirect(`${basePath}
|
|
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
|
|
|
@@ -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.24",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
|
|
6
6
|
"packageManager": "pnpm@11.9.0",
|
|
@@ -77,6 +77,14 @@
|
|
|
77
77
|
}
|
|
78
78
|
]
|
|
79
79
|
}
|
|
80
|
+
},
|
|
81
|
+
"collections": {
|
|
82
|
+
"type": "object",
|
|
83
|
+
"additionalProperties": false,
|
|
84
|
+
"properties": {
|
|
85
|
+
"blog": { "$ref": "#/$defs/collectionBase" },
|
|
86
|
+
"the-build": { "$ref": "#/$defs/collectionBase" }
|
|
87
|
+
}
|
|
80
88
|
}
|
|
81
89
|
}
|
|
82
90
|
},
|
|
@@ -167,6 +175,10 @@
|
|
|
167
175
|
}
|
|
168
176
|
],
|
|
169
177
|
"$defs": {
|
|
178
|
+
"collectionBase": {
|
|
179
|
+
"type": "string",
|
|
180
|
+
"pattern": "^(?:[a-z0-9]+(?:-[a-z0-9]+)*(?:/[a-z0-9]+(?:-[a-z0-9]+)*)*)?$"
|
|
181
|
+
},
|
|
170
182
|
"identifier": {
|
|
171
183
|
"type": "string",
|
|
172
184
|
"pattern": "^[a-z0-9](?:[a-z0-9._-]{0,126}[a-z0-9])?$"
|
|
@@ -7,3 +7,23 @@ export declare function exactPublicationTimestamp(value: unknown): string | null
|
|
|
7
7
|
export declare function publicationAggregateClockV1(spaces: unknown): string | null;
|
|
8
8
|
export declare function isBlogPostVisibleOnLive(post: BlogVisibilityInput, now?: string | null): boolean;
|
|
9
9
|
export declare function isBlogPostListableOnLive(post: BlogVisibilityInput, now?: string | null): boolean;
|
|
10
|
+
|
|
11
|
+
export type PublicationCollectionV1 = "blog" | "the-build";
|
|
12
|
+
export declare const PUBLICATION_COLLECTIONS_V1: readonly PublicationCollectionV1[];
|
|
13
|
+
export declare const DEFAULT_COLLECTION_PAGE_SIZE_V1: number;
|
|
14
|
+
export type PublicationCollectionRouteV1 =
|
|
15
|
+
| { kind: "index" }
|
|
16
|
+
| { kind: "page"; page: number }
|
|
17
|
+
| { kind: "post"; slug: string }
|
|
18
|
+
| { kind: "author" | "category" | "tag"; slug: string }
|
|
19
|
+
| { kind: "rss" };
|
|
20
|
+
export declare function publicationCollectionBaseV1(
|
|
21
|
+
manifest: { routes?: { collections?: Partial<Record<PublicationCollectionV1, string>> } } | null | undefined,
|
|
22
|
+
collection: PublicationCollectionV1,
|
|
23
|
+
): string;
|
|
24
|
+
export declare function publicationCollectionPathV1(mount: string, base: string, route: PublicationCollectionRouteV1): string;
|
|
25
|
+
export declare function publicationCollectionOfV1(
|
|
26
|
+
manifest: { routes?: { collections?: Partial<Record<PublicationCollectionV1, string>> } } | null | undefined,
|
|
27
|
+
contentPath: string,
|
|
28
|
+
): { collection: PublicationCollectionV1; rest: string } | null;
|
|
29
|
+
export declare function isPublicationCollectionBaseV1(value: unknown): value is string;
|
|
@@ -47,3 +47,90 @@ export function isBlogPostListableOnLive(post, now) {
|
|
|
47
47
|
const status = post.status ?? "published";
|
|
48
48
|
return status === "published" || scheduledIsDue({ ...post, status }, now);
|
|
49
49
|
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A space's post collections: `blog` and `the-build`. Each serves at a base path inside its
|
|
53
|
+
* space's mount. The base defaults to the collection's own name; a space may declare another,
|
|
54
|
+
* or `""` for the mount root (`routes.collections` on the publication-space manifest).
|
|
55
|
+
*/
|
|
56
|
+
export const PUBLICATION_COLLECTIONS_V1 = Object.freeze(/** @type {const} */ (["blog", "the-build"]));
|
|
57
|
+
|
|
58
|
+
/** Posts per collection index page when a collection declares no valid `pageSize`. */
|
|
59
|
+
export const DEFAULT_COLLECTION_PAGE_SIZE_V1 = 12;
|
|
60
|
+
|
|
61
|
+
const SEGMENT = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
62
|
+
|
|
63
|
+
/** @param {string} value */
|
|
64
|
+
function segments(value) {
|
|
65
|
+
return value.split("/").filter(Boolean);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The base path a space declares for `collection`, or the collection's default.
|
|
70
|
+
* @param {{routes?: {collections?: Record<string, string>}} | null | undefined} manifest
|
|
71
|
+
* @param {"blog" | "the-build"} collection
|
|
72
|
+
* @returns {string}
|
|
73
|
+
*/
|
|
74
|
+
export function publicationCollectionBaseV1(manifest, collection) {
|
|
75
|
+
const declared = manifest?.routes?.collections?.[collection];
|
|
76
|
+
return typeof declared === "string" ? declared : collection;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* THE constructor of every collection URL: a tenant-relative canonical path, with the trailing
|
|
81
|
+
* slash the collection pages declare as their canonical (`rss` excepted). Every route builder,
|
|
82
|
+
* discovery emitter and renderer builds collection paths only through this.
|
|
83
|
+
* @param {string} mount the space's canonical mount (`/` for the root space)
|
|
84
|
+
* @param {string} base the collection's base path inside the mount (`""` for the mount root)
|
|
85
|
+
* @param {{kind: "index"} | {kind: "page", page: number} | {kind: "post", slug: string} |
|
|
86
|
+
* {kind: "author" | "category" | "tag", slug: string} | {kind: "rss"}} route
|
|
87
|
+
* @returns {string}
|
|
88
|
+
*/
|
|
89
|
+
export function publicationCollectionPathV1(mount, base, route) {
|
|
90
|
+
const parts = [...segments(mount), ...segments(base)];
|
|
91
|
+
if (route.kind === "page") {
|
|
92
|
+
if (!Number.isSafeInteger(route.page) || route.page < 1) throw new Error("collection page is malformed");
|
|
93
|
+
if (route.page > 1) parts.push("page", String(route.page));
|
|
94
|
+
} else if (route.kind === "post" || route.kind === "author" || route.kind === "category" || route.kind === "tag") {
|
|
95
|
+
if (typeof route.slug !== "string" || !route.slug || route.slug.includes("/") || route.slug.includes("..")) {
|
|
96
|
+
throw new Error("collection slug is malformed");
|
|
97
|
+
}
|
|
98
|
+
if (route.kind !== "post") parts.push(route.kind);
|
|
99
|
+
parts.push(route.slug);
|
|
100
|
+
} else if (route.kind === "rss") {
|
|
101
|
+
return `/${[...parts, "rss.xml"].join("/")}`;
|
|
102
|
+
} else if (route.kind !== "index") {
|
|
103
|
+
throw new Error("collection route kind is unknown");
|
|
104
|
+
}
|
|
105
|
+
return parts.length ? `/${parts.join("/")}/` : "/";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Which collection a mount-relative content path belongs to, and the path inside it: the
|
|
110
|
+
* declared base with the longest match wins, so a collection at the mount root (`""`) owns only
|
|
111
|
+
* what no other collection claims. Null when the path is in no collection.
|
|
112
|
+
* @param {{routes?: {collections?: Record<string, string>}} | null | undefined} manifest
|
|
113
|
+
* @param {string} contentPath mount-relative, starting with `/`
|
|
114
|
+
* @returns {{collection: "blog" | "the-build", rest: string} | null}
|
|
115
|
+
*/
|
|
116
|
+
export function publicationCollectionOfV1(manifest, contentPath) {
|
|
117
|
+
const path = segments(contentPath);
|
|
118
|
+
/** @type {{collection: "blog" | "the-build", rest: string, depth: number} | null} */
|
|
119
|
+
let best = null;
|
|
120
|
+
for (const collection of PUBLICATION_COLLECTIONS_V1) {
|
|
121
|
+
const base = segments(publicationCollectionBaseV1(manifest, collection));
|
|
122
|
+
if (base.length > path.length || base.some((part, index) => part !== path[index])) continue;
|
|
123
|
+
if (!best || base.length > best.depth) {
|
|
124
|
+
best = { collection, rest: path.slice(base.length).map((part) => `/${part}`).join(""), depth: base.length };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return best ? { collection: best.collection, rest: best.rest } : null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Validate a declared collection base: `""` (the mount root) or `/`-separated lowercase segments.
|
|
132
|
+
* @param {unknown} value
|
|
133
|
+
*/
|
|
134
|
+
export function isPublicationCollectionBaseV1(value) {
|
|
135
|
+
return typeof value === "string" && (value === "" || value.split("/").every((part) => SEGMENT.test(part)));
|
|
136
|
+
}
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
type RouteTierIndexV1,
|
|
23
23
|
} from "./route-tier.js";
|
|
24
24
|
import type { PublicationSpaceManifestV1 } from "./publication-space.js";
|
|
25
|
+
import { publicationCollectionOfV1 } from "./blog-visibility.mjs";
|
|
25
26
|
|
|
26
27
|
export const PUBLICATION_ROUTE_PLAN_VERSION = 1 as const;
|
|
27
28
|
|
|
@@ -42,8 +43,14 @@ export interface PublicationRouteRenderPlanV1 {
|
|
|
42
43
|
requestPath: string;
|
|
43
44
|
/** Route relative to the selected publication source. Always absolute. */
|
|
44
45
|
contentPath: string;
|
|
45
|
-
/** Canonical tenant-relative route used by links
|
|
46
|
+
/** Canonical tenant-relative route used by links. */
|
|
46
47
|
canonicalPath: string;
|
|
48
|
+
/**
|
|
49
|
+
* The platform route Astro dispatches: a path in one of the space's declared collections maps
|
|
50
|
+
* to that collection's platform route (`/blog/...`, `/the-build/...`), whatever its base inside
|
|
51
|
+
* the mount; any other path dispatches at its canonical path.
|
|
52
|
+
*/
|
|
53
|
+
renderPath: string;
|
|
47
54
|
assetNamespace: string;
|
|
48
55
|
}
|
|
49
56
|
|
|
@@ -91,6 +98,12 @@ function appendSuffix(mount: string, suffix: string): string {
|
|
|
91
98
|
return `${mount}${suffix}`;
|
|
92
99
|
}
|
|
93
100
|
|
|
101
|
+
/** The platform collection route for a mount-relative path, or null when it is in no collection. */
|
|
102
|
+
function collectionRenderPath(manifest: PublicationSpaceManifestV1, contentPath: string): string | null {
|
|
103
|
+
const match = publicationCollectionOfV1(manifest, contentPath);
|
|
104
|
+
return match ? `/${match.collection}${match.rest}` : null;
|
|
105
|
+
}
|
|
106
|
+
|
|
94
107
|
/** Resolve one request through the already-compiled tenant mount authority. */
|
|
95
108
|
export function resolvePublicationRoutePlanV1(
|
|
96
109
|
index: RouteTierIndexV1,
|
|
@@ -143,6 +156,7 @@ export function resolvePublicationRoutePlanV1(
|
|
|
143
156
|
requestPath: route.path,
|
|
144
157
|
contentPath: suffix || "/",
|
|
145
158
|
canonicalPath: appendSuffix(mount.canonicalMount, suffix),
|
|
159
|
+
renderPath: collectionRenderPath(manifest, suffix || "/") ?? appendSuffix(mount.canonicalMount, suffix),
|
|
146
160
|
assetNamespace: manifest.assets.namespace,
|
|
147
161
|
};
|
|
148
162
|
}
|
|
@@ -6,6 +6,12 @@
|
|
|
6
6
|
* normalized mounts emitted here rather than decode or canonicalize paths again.
|
|
7
7
|
*/
|
|
8
8
|
import type { TenantConfig } from "./tenant.js";
|
|
9
|
+
import {
|
|
10
|
+
isPublicationCollectionBaseV1,
|
|
11
|
+
PUBLICATION_COLLECTIONS_V1,
|
|
12
|
+
publicationCollectionBaseV1,
|
|
13
|
+
type PublicationCollectionV1,
|
|
14
|
+
} from "./blog-visibility.mjs";
|
|
9
15
|
import {
|
|
10
16
|
ANNOTATION_KEYS,
|
|
11
17
|
type ContractResult,
|
|
@@ -115,6 +121,12 @@ export interface PublicationSpaceManifestV1 {
|
|
|
115
121
|
routes: {
|
|
116
122
|
canonicalMount: string;
|
|
117
123
|
aliases?: PublicationSpaceAliasV1[];
|
|
124
|
+
/**
|
|
125
|
+
* Base path of each post collection inside the mount. Absent means the collection's own name
|
|
126
|
+
* (`blog`, `the-build`); `""` serves it at the mount root. Every collection URL is built from
|
|
127
|
+
* it by `publicationCollectionPathV1`.
|
|
128
|
+
*/
|
|
129
|
+
collections?: Partial<Record<PublicationCollectionV1, string>>;
|
|
118
130
|
};
|
|
119
131
|
visibility: PublicationSpaceVisibilityV1;
|
|
120
132
|
authorization:
|
|
@@ -450,6 +462,44 @@ function validateRenderer(
|
|
|
450
462
|
return contractVersion && themeContractVersion ? { contractVersion, themeContractVersion } : undefined;
|
|
451
463
|
}
|
|
452
464
|
|
|
465
|
+
/**
|
|
466
|
+
* A space's declared collection bases: known collections only, each `""` or lowercase segments,
|
|
467
|
+
* no two collections sharing a base, and none at the site root (the root space's home owns `/`).
|
|
468
|
+
*/
|
|
469
|
+
function validateCollections(
|
|
470
|
+
raw: unknown,
|
|
471
|
+
canonicalMount: string | undefined,
|
|
472
|
+
v: Violations,
|
|
473
|
+
path: string,
|
|
474
|
+
): PublicationSpaceManifestV1["routes"]["collections"] {
|
|
475
|
+
if (raw === undefined) return undefined;
|
|
476
|
+
const collections = reqObject(raw, v, path);
|
|
477
|
+
if (!collections) return undefined;
|
|
478
|
+
rejectPublicationUnknownKeys(collections, [...PUBLICATION_COLLECTIONS_V1], v, path);
|
|
479
|
+
const out: Partial<Record<PublicationCollectionV1, string>> = {};
|
|
480
|
+
for (const collection of PUBLICATION_COLLECTIONS_V1) {
|
|
481
|
+
const value = collections[collection];
|
|
482
|
+
if (value === undefined) continue;
|
|
483
|
+
if (!isPublicationCollectionBaseV1(value)) {
|
|
484
|
+
v.add(`${path}.${collection}`, "publication_collection_base_malformed",
|
|
485
|
+
"A collection base is empty (the mount root) or lowercase path segments.");
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
if (value === "" && canonicalMount === "/") {
|
|
489
|
+
v.add(`${path}.${collection}`, "publication_collection_at_site_root",
|
|
490
|
+
"A collection cannot take the site root; the root space's home serves it.");
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
out[collection] = value;
|
|
494
|
+
}
|
|
495
|
+
const bases = PUBLICATION_COLLECTIONS_V1.map((collection) => publicationCollectionBaseV1({ routes: { collections: out } },
|
|
496
|
+
collection));
|
|
497
|
+
if (new Set(bases).size !== bases.length) {
|
|
498
|
+
v.add(path, "publication_collection_base_conflict", "Two collections cannot share a base path.");
|
|
499
|
+
}
|
|
500
|
+
return Object.keys(out).length ? out : undefined;
|
|
501
|
+
}
|
|
502
|
+
|
|
453
503
|
/** Validate, close, and normalize one serialized publication-space manifest. */
|
|
454
504
|
export function validatePublicationSpaceManifestV1(raw: unknown): ContractResult<PublicationSpaceManifestV1> {
|
|
455
505
|
const v = new Violations();
|
|
@@ -468,8 +518,9 @@ export function validatePublicationSpaceManifestV1(raw: unknown): ContractResult
|
|
|
468
518
|
const routes = reqObject(root["routes"], v, "routes");
|
|
469
519
|
let canonicalMount: string | undefined;
|
|
470
520
|
let aliases: PublicationSpaceAliasV1[] | undefined;
|
|
521
|
+
let collections: PublicationSpaceManifestV1["routes"]["collections"];
|
|
471
522
|
if (routes) {
|
|
472
|
-
rejectPublicationUnknownKeys(routes, ["canonicalMount", "aliases"], v, "routes");
|
|
523
|
+
rejectPublicationUnknownKeys(routes, ["canonicalMount", "aliases", "collections"], v, "routes");
|
|
473
524
|
const rawMount = reqString(routes, "canonicalMount", v, "routes");
|
|
474
525
|
canonicalMount = rawMount
|
|
475
526
|
? append(v, "routes.canonicalMount", normalizePublicationPath(rawMount, { mode: "mount" }))
|
|
@@ -478,6 +529,7 @@ export function validatePublicationSpaceManifestV1(raw: unknown): ContractResult
|
|
|
478
529
|
v.add("routes.canonicalMount", "publication_reserved_mount", "Canonical mount is platform-reserved.");
|
|
479
530
|
}
|
|
480
531
|
aliases = validateAliases(routes["aliases"], canonicalMount, v, "routes.aliases");
|
|
532
|
+
collections = validateCollections(routes["collections"], canonicalMount, v, "routes.collections");
|
|
481
533
|
}
|
|
482
534
|
const visibility = reqEnum<PublicationSpaceVisibilityV1>(root, "visibility", ["public", "internal"], v, "");
|
|
483
535
|
const authorization = validateAuthorization(root["authorization"], v, "authorization");
|
|
@@ -525,7 +577,11 @@ export function validatePublicationSpaceManifestV1(raw: unknown): ContractResult
|
|
|
525
577
|
id,
|
|
526
578
|
tenant,
|
|
527
579
|
source,
|
|
528
|
-
routes:
|
|
580
|
+
routes: {
|
|
581
|
+
canonicalMount,
|
|
582
|
+
...(aliases === undefined ? {} : { aliases }),
|
|
583
|
+
...(collections === undefined ? {} : { collections }),
|
|
584
|
+
},
|
|
529
585
|
visibility,
|
|
530
586
|
authorization,
|
|
531
587
|
discovery,
|
|
@@ -787,6 +843,18 @@ export function validatePublicationSpaceRegistryV1(
|
|
|
787
843
|
);
|
|
788
844
|
}
|
|
789
845
|
}
|
|
846
|
+
// Precedence: a mount owns every path under it. An alias one space declares inside ANOTHER
|
|
847
|
+
// space's mount (the root's `/blog/page` once a space is mounted at `/blog`) is refused, so no
|
|
848
|
+
// route-tier or edge redirect can shadow the mounted space's own pages.
|
|
849
|
+
for (const alias of entries.filter((entry) => entry.kind === "alias")) {
|
|
850
|
+
const owner = entries.find((entry) => entry.kind === "canonical" && entry.tenant === alias.tenant &&
|
|
851
|
+
entry.spaceId !== alias.spaceId && entry.mount !== "/" &&
|
|
852
|
+
(alias.mount === entry.mount || alias.mount.startsWith(`${entry.mount}/`)));
|
|
853
|
+
if (owner) {
|
|
854
|
+
addRegistryViolation(violations, `spaces.${alias.spaceId}.routes.aliases`, "publication_alias_under_mount",
|
|
855
|
+
`Alias ${alias.mount} lies under the mount ${owner.mount} that space ${owner.spaceId} owns.`);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
790
858
|
detectAliasLoops(entries, violations);
|
|
791
859
|
if (violations.length > 0) return { ok: false, violations };
|
|
792
860
|
|