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