@tokenoftrust/storefront-runner 2.2.107 → 2.2.108

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.
@@ -0,0 +1,181 @@
1
+ ---
2
+ // Shared render for every `/the-build/` index page — page 1 (src/pages/the-build/index.astro)
3
+ // and every `/the-build/page/<n>/` (src/pages/the-build/page/[n].astro) render through this ONE
4
+ // component so the hero/grid/pagination markup, and the tenant-copy + pagination contract it
5
+ // implements, can't drift between the two routes (the-build sibling of fb-1791301493691-brzl6f —
6
+ // mirrors @/components/blog/BlogIndexView.astro).
7
+ import Layout from "@/layouts/Layout.astro";
8
+ import PostAccessBadge from "@/components/blog/PostAccessBadge.astro";
9
+ import type { BlogPostSummary } from "@/lib/blog/types.js";
10
+ import type { TheBuildIndexCopy } from "@/lib/the-build/pagination.js";
11
+ import { theBuildPagePath } from "@/lib/the-build/pagination.js";
12
+
13
+ interface Props {
14
+ basePath: string;
15
+ canonicalBase: string;
16
+ copy: TheBuildIndexCopy;
17
+ pagePosts: BlogPostSummary[];
18
+ page: number;
19
+ totalPages: number;
20
+ }
21
+
22
+ const { basePath, canonicalBase, copy, pagePosts, page, totalPages } = Astro.props;
23
+
24
+ const href = (path: string) => `${basePath}${path}` || "/";
25
+ const path = theBuildPagePath(page);
26
+ // Page 1 keeps the tenant's declared title verbatim; page N>1 is distinguished
27
+ // in the <title> tag only — the on-page <h1> stays identical across pages.
28
+ const documentTitle = page > 1 ? `${copy.title} — Page ${page}` : copy.title;
29
+ const pagination = {
30
+ ...(page > 1 ? { prev: theBuildPagePath(page - 1) } : {}),
31
+ ...(page < totalPages ? { next: theBuildPagePath(page + 1) } : {}),
32
+ };
33
+
34
+ const itemList = {
35
+ "@context": "https://schema.org",
36
+ "@type": "ItemList",
37
+ numberOfItems: pagePosts.length,
38
+ itemListElement: pagePosts.map((post, index) => ({
39
+ "@type": "ListItem",
40
+ position: index + 1,
41
+ name: post.title,
42
+ url: new URL(`/the-build/${post.slug}/`, canonicalBase + "/").toString(),
43
+ })),
44
+ };
45
+ ---
46
+
47
+ <Layout
48
+ title={documentTitle}
49
+ description={copy.description}
50
+ path={path}
51
+ fullBleed
52
+ jsonLd={itemList}
53
+ pagination={pagination}
54
+ >
55
+ <header class="blog-hero">
56
+ <div class="blog-shell">
57
+ <p class="eyebrow eyebrow--tick">{copy.eyebrow}</p>
58
+ <h1>{copy.title}</h1>
59
+ <p>{copy.tagline}</p>
60
+ </div>
61
+ </header>
62
+
63
+ <div class="blog-shell">
64
+ <section class="post-grid" aria-label="The Build posts" id="the-build-posts">
65
+ {pagePosts.map((post, index) => (
66
+ <article class="post-card">
67
+ <a href={href(`/the-build/${post.slug}/`)} aria-label={`Read ${post.title}`}>
68
+ {post.featuredImage ? (
69
+ <img
70
+ src={post.featuredImage.src}
71
+ alt={post.featuredImage.alt}
72
+ width={post.featuredImage.width}
73
+ height={post.featuredImage.height}
74
+ loading={index < 3 ? "eager" : "lazy"}
75
+ decoding="async"
76
+ />
77
+ ) : <div class="image-placeholder" aria-hidden="true"></div>}
78
+ <div class="post-card-copy">
79
+ <p class="post-meta"><time datetime={post.publishedAt}>{new Date(post.publishedAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" })}</time>{post.tags[0] && <> · {post.tags[0]}</>}</p>
80
+ <PostAccessBadge access={post.access} />
81
+ <h2>{post.title}</h2>
82
+ <p>{post.excerpt}</p>
83
+ <span>Read article <span aria-hidden="true">→</span></span>
84
+ </div>
85
+ </a>
86
+ </article>
87
+ ))}
88
+ </section>
89
+
90
+ {totalPages > 1 && (
91
+ <nav class="blog-pagination" aria-label="The Build pagination">
92
+ {page > 1
93
+ ? <a class="blog-pagination-link" rel="prev" href={href(theBuildPagePath(page - 1))}>← Newer posts</a>
94
+ : <span />}
95
+ <span class="blog-pagination-status">Page {page} of {totalPages}</span>
96
+ {page < totalPages
97
+ ? <a class="blog-pagination-link" rel="next" href={href(theBuildPagePath(page + 1))}>Older posts →</a>
98
+ : <span />}
99
+ </nav>
100
+ )}
101
+
102
+ <section class="news-signup" aria-labelledby="news-signup-title">
103
+ <h2 id="news-signup-title">Get {copy.title} in your inbox</h2>
104
+ <p>A short note when something worth reading lands. Double opt-in — we'll email a link to confirm.</p>
105
+ <form class="news-form" method="post" action={href("/api/newsletter/the-build/subscribe")}>
106
+ <label class="sr-only" for="news-email">Email address</label>
107
+ <input id="news-email" name="email" type="email" inputmode="email" autocomplete="email"
108
+ required placeholder="you@example.com" />
109
+ <button type="submit">Subscribe</button>
110
+ </form>
111
+ <p class="news-status" role="status" aria-live="polite" hidden></p>
112
+ </section>
113
+ </div>
114
+
115
+ <script>
116
+ // Progressive enhancement: the form POSTs natively without JS (the endpoint
117
+ // redirects back with ?subscribed=1). With JS we intercept for inline feedback.
118
+ const form = document.querySelector<HTMLFormElement>(".news-form");
119
+ const status = document.querySelector<HTMLParagraphElement>(".news-status");
120
+ function show(msg: string) {
121
+ if (!status) return;
122
+ status.textContent = msg;
123
+ status.hidden = false;
124
+ }
125
+ if (new URLSearchParams(location.search).get("subscribed") === "1") {
126
+ show("Almost there — check your inbox to confirm.");
127
+ }
128
+ form?.addEventListener("submit", async (e) => {
129
+ e.preventDefault();
130
+ const email = new FormData(form).get("email");
131
+ try {
132
+ const res = await fetch(form.action, {
133
+ method: "POST",
134
+ headers: { "content-type": "application/json", accept: "application/json" },
135
+ body: JSON.stringify({ email }),
136
+ });
137
+ if (res.status === 429) show("You've tried a few times — give it a minute and retry.");
138
+ else if (res.ok) {
139
+ form.reset();
140
+ show("Almost there — check your inbox to confirm.");
141
+ } else show("That address didn't look right — try again.");
142
+ } catch {
143
+ show("Something went wrong — try again in a moment.");
144
+ }
145
+ });
146
+ </script>
147
+ </Layout>
148
+
149
+ <style>
150
+ .blog-shell { width: min(1180px, calc(100% - 2rem)); margin-inline: auto; }
151
+ .blog-hero { padding: clamp(4.5rem, 9vw, 8rem) 0 clamp(3rem, 6vw, 5rem); background: linear-gradient(145deg, var(--color-primary), color-mix(in srgb, var(--color-primary) 82%, var(--color-accent))); color: var(--color-primary-contrast); }
152
+ .blog-hero h1 { max-width: 12ch; margin-top: .75rem; color: inherit; font-size: clamp(2.9rem, 7vw, 5.6rem); }
153
+ .blog-hero p:last-child { max-width: 58ch; margin-top: 1.25rem; color: color-mix(in srgb, var(--color-primary-contrast) 82%, transparent); font-size: 1.12rem; }
154
+ .blog-hero .eyebrow { color: color-mix(in srgb, var(--color-primary-contrast) 75%, transparent); }
155
+ .post-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1.35rem; align-items: start; padding-block: clamp(3rem, 7vw, 6rem); }
156
+ .post-card { height: 100%; overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-surface); box-shadow: var(--shadow-sm); transition: transform .2s ease, box-shadow .2s ease; }
157
+ .post-card:hover { transform: translateY(-4px); box-shadow: var(--shadow-md); }
158
+ .post-card a { display: flex; height: 100%; flex-direction: column; }
159
+ .post-card img, .image-placeholder { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; background: linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 12%, white), color-mix(in srgb, var(--color-accent) 24%, white)); }
160
+ .post-card-copy { display: flex; flex: 1; flex-direction: column; padding: 1.25rem; }
161
+ .post-meta { margin: 0 0 .7rem; color: var(--color-muted); font: 600 .72rem/1.45 var(--font-mono); text-transform: uppercase; letter-spacing: .05em; }
162
+ .post-card h2 { font-size: 1.18rem; line-height: 1.25; }
163
+ .post-card-copy > p:not(.post-meta) { display: -webkit-box; margin: .75rem 0 1rem; overflow: hidden; color: var(--color-muted); font-size: .92rem; -webkit-line-clamp: 3; -webkit-box-orient: vertical; }
164
+ .post-card-copy > span { margin-top: auto; color: var(--color-accent-ink); font-weight: 700; font-size: .88rem; }
165
+ @media (max-width: 980px) { .post-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
166
+ @media (max-width: 520px) { .post-grid { grid-template-columns: 1fr; } }
167
+
168
+ .blog-pagination { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding-bottom: clamp(2rem, 5vw, 3rem); }
169
+ .blog-pagination-link { font-weight: 700; color: var(--color-accent-ink); }
170
+ .blog-pagination-status { color: var(--color-muted); font: 600 .78rem/1 var(--font-mono); letter-spacing: .04em; text-transform: uppercase; }
171
+
172
+ .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
173
+ .news-signup { margin: 0 auto clamp(3rem, 7vw, 6rem); max-width: 40rem; padding: clamp(1.75rem, 4vw, 2.5rem); text-align: center; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-surface); }
174
+ .news-signup h2 { font-size: clamp(1.4rem, 3vw, 1.8rem); }
175
+ .news-signup p { margin: .6rem 0 0; color: var(--color-muted); line-height: 1.6; }
176
+ .news-form { display: flex; flex-wrap: wrap; gap: .6rem; justify-content: center; margin-top: 1.35rem; }
177
+ .news-form input { flex: 1 1 16rem; min-width: 0; padding: .7rem .9rem; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-bg); color: inherit; font-size: 1rem; }
178
+ .news-form button { padding: .7rem 1.4rem; border: 0; border-radius: var(--radius-md); background: var(--color-primary); color: var(--color-primary-contrast); font-weight: 700; cursor: pointer; }
179
+ .news-form button:hover { background: color-mix(in srgb, var(--color-primary) 88%, black); }
180
+ .news-status { margin-top: 1rem; font-weight: 600; color: var(--color-accent-ink); }
181
+ </style>
@@ -4,7 +4,7 @@ import {
4
4
  isBlogPostVisibleOnLive,
5
5
  tenantDirSegment,
6
6
  } from "@tot/public-runtime";
7
- import type { AuthorBio, BlogPost, BlogPostSummary, PostStatus } from "./types.js";
7
+ import type { AuthorBio, BlogPost, BlogPostSummary, BlogSettings, PostStatus } from "./types.js";
8
8
  import type { ContentProvider } from "../storyblok/provider.js";
9
9
  import { readEnv } from "../env.js";
10
10
 
@@ -48,6 +48,13 @@ const authorsGlobs = import.meta.glob<Record<string, AuthorBio>>(
48
48
  ],
49
49
  { import: "default" },
50
50
  );
51
+ const settingsGlobs = import.meta.glob<BlogSettings>(
52
+ [
53
+ "../../../../../tenants/*/content/*/settings.json",
54
+ "../../../../../tenants/e2e/*/content/*/settings.json",
55
+ ],
56
+ { import: "default" },
57
+ );
51
58
 
52
59
  const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
53
60
  const SEGMENT_RE = /^[a-z0-9][a-z0-9._-]*$/;
@@ -417,3 +424,38 @@ export async function getAuthors(
417
424
  if (!key) return {};
418
425
  return (await authorsGlobs[key]!()) ?? {};
419
426
  }
427
+
428
+ /**
429
+ * Optional per-tenant × per-collection index copy + page size
430
+ * (`tenants/<id>/content/<collection>/settings.json`), or null when the tenant
431
+ * has declared none, the file is malformed, or `collection` is not a legal
432
+ * segment. Null is "use the neutral default", never an error — every caller
433
+ * (`resolveBlogIndexCopy`, `resolveTheBuildIndexCopy` in ./pagination.ts and
434
+ * ../the-build/pagination.ts) applies its own tenant-derived default, never
435
+ * Token of Trust's own copy (fb-1791301493691-brzl6f + the-build sibling).
436
+ */
437
+ export async function getIndexSettings(
438
+ tenantId: string,
439
+ collection: string,
440
+ content?: ArtifactSource,
441
+ ): Promise<BlogSettings | null> {
442
+ if (!SEGMENT_RE.test(collection)) return null;
443
+ if (canReadArtifacts(content)) {
444
+ const paths = await content.listArtifactPaths(`${collection}/`);
445
+ const raw = await content.readArtifact(`${collection}/settings.json`);
446
+ if (raw) {
447
+ try {
448
+ return JSON.parse(raw) as BlogSettings;
449
+ } catch {
450
+ return null;
451
+ }
452
+ }
453
+ if (paths !== null) return null;
454
+ }
455
+ const key = Object.keys(settingsGlobs).find(
456
+ (candidate) =>
457
+ candidate.includes(`${tenantDirSegment(tenantId)}content/${collection}/`) &&
458
+ candidate.endsWith("/settings.json"),
459
+ );
460
+ return key ? (await settingsGlobs[key]!()) ?? null : null;
461
+ }
@@ -23,21 +23,41 @@ function trimmed(value: string | undefined): string | undefined {
23
23
  return v ? v : undefined;
24
24
  }
25
25
 
26
- export function resolveBlogIndexCopy(
27
- settings: BlogSettings | null | undefined,
28
- displayName: string,
26
+ /**
27
+ * Generic engine behind `resolveBlogIndexCopy` and every other per-tenant
28
+ * collection index (e.g. `resolveTheBuildIndexCopy` in `../the-build/pagination.js`):
29
+ * a declared settings field wins; an absent one falls back to the caller's own
30
+ * NEUTRAL, tenant-derived default — never a Token of Trust-branded one
31
+ * (fb-1791301493691-brzl6f, and its the-build sibling). `defaults.tagline` is
32
+ * also the description fallback, matching every collection's settings schema.
33
+ */
34
+ export function resolveIndexCopy(
35
+ settings: { title?: string; eyebrow?: string; tagline?: string; description?: string; pageSize?: number } | null | undefined,
36
+ defaults: { title: string; eyebrow: string; tagline: string; description?: string },
37
+ defaultPageSize: number,
29
38
  ): BlogIndexCopy {
30
- const tagline = trimmed(settings?.tagline) ?? `The latest from ${displayName}.`;
39
+ const tagline = trimmed(settings?.tagline) ?? defaults.tagline;
31
40
  const pageSize = settings?.pageSize;
32
41
  return {
33
- title: trimmed(settings?.title) ?? `${displayName} Blog`,
34
- eyebrow: trimmed(settings?.eyebrow) ?? "Updates",
42
+ title: trimmed(settings?.title) ?? defaults.title,
43
+ eyebrow: trimmed(settings?.eyebrow) ?? defaults.eyebrow,
35
44
  tagline,
36
- description: trimmed(settings?.description) ?? tagline,
37
- pageSize: Number.isInteger(pageSize) && pageSize! > 0 ? pageSize! : DEFAULT_BLOG_PAGE_SIZE,
45
+ description: trimmed(settings?.description) ?? defaults.description ?? tagline,
46
+ pageSize: Number.isInteger(pageSize) && pageSize! > 0 ? pageSize! : defaultPageSize,
38
47
  };
39
48
  }
40
49
 
50
+ export function resolveBlogIndexCopy(
51
+ settings: BlogSettings | null | undefined,
52
+ displayName: string,
53
+ ): BlogIndexCopy {
54
+ return resolveIndexCopy(
55
+ settings,
56
+ { title: `${displayName} Blog`, eyebrow: "Updates", tagline: `The latest from ${displayName}.` },
57
+ DEFAULT_BLOG_PAGE_SIZE,
58
+ );
59
+ }
60
+
41
61
  export interface BlogPageSlice<T> {
42
62
  posts: T[];
43
63
  page: number;
@@ -61,9 +81,30 @@ export function paginateBlogPosts<T>(
61
81
  return { posts: posts.slice(start, start + pageSize), page, totalPages };
62
82
  }
63
83
 
84
+ /**
85
+ * Generic engine behind `blogPagePath` and every other collection's page path
86
+ * (e.g. `theBuildPagePath`): page 1 is the bare index; page N≥2 is
87
+ * `<basePath>/page/<n>/`. `basePath` carries no trailing slash (e.g. `/blog`).
88
+ */
89
+ export function pagedRoutePath(basePath: string, page: number): string {
90
+ return page <= 1 ? `${basePath}/` : `${basePath}/page/${page}/`;
91
+ }
92
+
64
93
  /** `/blog/` is page 1; `/blog/page/<n>/` is page N for N≥2. */
65
94
  export function blogPagePath(page: number): string {
66
- return page <= 1 ? "/blog/" : `/blog/page/${page}/`;
95
+ return pagedRoutePath("/blog", page);
96
+ }
97
+
98
+ /**
99
+ * Generic engine behind `parseBlogPageParam` and every other collection's
100
+ * `page/[n]` route param: not a bare positive-integer string (rejects "01",
101
+ * "1.5", "-1", "abc", ""), or N<2 (page 1 lives at the bare index, never at
102
+ * `page/1/`), parses to null — the caller's cue to 404.
103
+ */
104
+ export function parsePagedRouteParam(raw: string | undefined): number | null {
105
+ if (!raw || !/^[1-9]\d*$/.test(raw)) return null;
106
+ const n = Number(raw);
107
+ return n >= 2 ? n : null;
67
108
  }
68
109
 
69
110
  /**
@@ -72,7 +113,5 @@ export function blogPagePath(page: number): string {
72
113
  * "1.5", "-1", "abc", ""), or N<2 (page 1 lives at `/blog/`, not `/blog/page/1/`).
73
114
  */
74
115
  export function parseBlogPageParam(raw: string | undefined): number | null {
75
- if (!raw || !/^[1-9]\d*$/.test(raw)) return null;
76
- const n = Number(raw);
77
- return n >= 2 ? n : null;
116
+ return parsePagedRouteParam(raw);
78
117
  }
@@ -10,7 +10,7 @@ import {
10
10
  isBlogPostVisibleOnLive,
11
11
  tenantDirSegment,
12
12
  } from "@tot/public-runtime";
13
- import { listPosts } from "./collection.js";
13
+ import { getIndexSettings, listPosts } from "./collection.js";
14
14
  import type { BlogPost, BlogPostSummary, BlogSettings } from "./types.js";
15
15
  import type { ContentProvider } from "../storyblok/provider.js";
16
16
  import { readEnv } from "../env.js";
@@ -53,14 +53,6 @@ const redirectGlobs = import.meta.glob<Record<string, string>>(
53
53
  ],
54
54
  { import: "default" },
55
55
  );
56
- const settingsGlobs = import.meta.glob<BlogSettings>(
57
- [
58
- "../../../../../tenants/*/content/blog/settings.json",
59
- "../../../../../tenants/e2e/*/content/blog/settings.json",
60
- ],
61
- { import: "default" },
62
- );
63
-
64
56
  function tenantKey(keys: string[], tenantId: string, suffix: string): string | undefined {
65
57
  const segment = `${tenantDirSegment(tenantId)}content/blog/`;
66
58
  return keys.find((key) => key.includes(segment) && key.endsWith(suffix));
@@ -178,24 +170,12 @@ export async function resolveBlogRedirect(
178
170
  * Optional per-tenant blog index copy + page size (`tenants/<id>/content/blog/settings.json`),
179
171
  * or null when the tenant has declared none. Null is "use the neutral default", never an error —
180
172
  * see `resolveBlogIndexCopy` (./pagination.ts), which is the one place that applies the default.
173
+ * The `/blog`-specific instantiation of the generic `getIndexSettings` (./collection.ts), which
174
+ * every other collection (e.g. the-build) shares instead of re-deriving its own glob.
181
175
  */
182
176
  export async function getBlogSettings(
183
177
  tenantId: string,
184
178
  source?: ArtifactSource,
185
179
  ): Promise<BlogSettings | null> {
186
- if (canReadArtifacts(source)) {
187
- const paths = await source.listArtifactPaths("blog/");
188
- const raw = await source.readArtifact("blog/settings.json");
189
- if (raw) {
190
- try {
191
- return JSON.parse(raw) as BlogSettings;
192
- } catch {
193
- return null;
194
- }
195
- }
196
- if (paths !== null) return null;
197
- }
198
- const key = tenantKey(Object.keys(settingsGlobs), tenantId, "/settings.json")
199
- ?? Object.keys(settingsGlobs).find((candidate) => candidate.includes(`${tenantDirSegment(tenantId)}content/blog/settings.json`));
200
- return key ? (await settingsGlobs[key]!()) ?? null : null;
180
+ return getIndexSettings(tenantId, BLOG_COLLECTION, source);
201
181
  }
@@ -0,0 +1,46 @@
1
+ // The `the-build` instantiation of the generic engine in ../blog/pagination.ts —
2
+ // same contract as /blog, so the-build's own index copy and pagination can't
3
+ // drift from it (fb-1791301493691-brzl6f's sibling: /the-build hardcoded
4
+ // Token of Trust's own branding and never paginated, exactly like /blog did).
5
+ import type { BlogSettings } from "../blog/types.js";
6
+ import {
7
+ type BlogIndexCopy,
8
+ pagedRoutePath,
9
+ parsePagedRouteParam,
10
+ resolveIndexCopy,
11
+ } from "../blog/pagination.js";
12
+
13
+ /** Posts per `/the-build/` page when a tenant declares no `pageSize` (or an invalid one). */
14
+ export const DEFAULT_THE_BUILD_PAGE_SIZE = 12;
15
+
16
+ export type TheBuildIndexCopy = BlogIndexCopy;
17
+
18
+ /**
19
+ * Resolved `/the-build/` index copy + page size. A declared `content/the-build/settings.json`
20
+ * field wins; an absent one falls back to a NEUTRAL default derived from the tenant's own
21
+ * `displayName` — never Token of Trust's own "The Build" branding. A tenant that wants ToT's
22
+ * current copy (tokenoftrust.com included) declares it in its own settings file.
23
+ */
24
+ export function resolveTheBuildIndexCopy(
25
+ settings: BlogSettings | null | undefined,
26
+ displayName: string,
27
+ ): TheBuildIndexCopy {
28
+ return resolveIndexCopy(
29
+ settings,
30
+ { title: `${displayName} Build Notes`, eyebrow: "Notes", tagline: `Notes on building out ${displayName}.` },
31
+ DEFAULT_THE_BUILD_PAGE_SIZE,
32
+ );
33
+ }
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
+ /**
41
+ * Parse a `/the-build/page/<n>/` route param into a page number, or null when it
42
+ * is not a valid page-N route (see `parsePagedRouteParam`).
43
+ */
44
+ export function parseTheBuildPageParam(raw: string | undefined): number | null {
45
+ return parsePagedRouteParam(raw);
46
+ }
@@ -2,10 +2,12 @@
2
2
  // The engine (src/lib/blog/collection.ts) is the single implementation; this
3
3
  // module only fixes the collection id so existing/downstream callers can import
4
4
  // collection-bound helpers.
5
- import { getPost, listPosts, resolveRedirect, type Collection } from "../blog/collection.js";
6
- import type { BlogPost, BlogPostSummary } from "../blog/types.js";
5
+ import { getIndexSettings, getPost, listPosts, resolveRedirect, type Collection } from "../blog/collection.js";
6
+ import type { BlogPost, BlogPostSummary, BlogSettings } from "../blog/types.js";
7
7
  import type { ContentProvider } from "../storyblok/provider.js";
8
8
 
9
+ type ArtifactSource = Pick<ContentProvider, "readArtifact" | "listArtifactPaths">;
10
+
9
11
  export const THE_BUILD: Collection = { id: "the-build", base: "/the-build" };
10
12
 
11
13
  export function listBuildPosts(
@@ -30,3 +32,15 @@ export function resolveBuildRedirect(
30
32
  ): Promise<string | null> {
31
33
  return resolveRedirect(tenantId, THE_BUILD.id, slug, content);
32
34
  }
35
+
36
+ /**
37
+ * Optional per-tenant the-build index copy + page size
38
+ * (`tenants/<id>/content/the-build/settings.json`), or null when undeclared. See
39
+ * `resolveTheBuildIndexCopy` (./pagination.ts), the one place that applies the default.
40
+ */
41
+ export function getTheBuildSettings(
42
+ tenantId: string,
43
+ content?: ArtifactSource,
44
+ ): Promise<BlogSettings | null> {
45
+ return getIndexSettings(tenantId, THE_BUILD.id, content);
46
+ }
@@ -13,7 +13,8 @@ import { nonEmptyCollections } from "@/lib/tot/query.js";
13
13
  import { getBlogSettings, listBlogPosts } from "@/lib/blog/provider.js";
14
14
  import { authorSlug, categorySlug } from "@/lib/blog/collection.js";
15
15
  import { resolveBlogIndexCopy } from "@/lib/blog/pagination.js";
16
- import { listBuildPosts } from "@/lib/the-build/provider.js";
16
+ import { getTheBuildSettings, listBuildPosts } from "@/lib/the-build/provider.js";
17
+ import { resolveTheBuildIndexCopy } from "@/lib/the-build/pagination.js";
17
18
  import { isIndexableHtml, rawPageCanonicalUrl } from "@/lib/rawMarketingHtml.js";
18
19
 
19
20
  interface SitemapUrl {
@@ -107,6 +108,12 @@ export async function GET(context: APIContext): Promise<Response> {
107
108
  const buildPosts = await listBuildPosts(tenant.tenant_id, { content, now: publicationTimestamp });
108
109
  if (buildPosts.length > 0) {
109
110
  urls.push({ loc: `${base}/the-build/` });
111
+ // Paginated pages (N≥2), same reasoning as /blog/page/N/ above: the static
112
+ // materializer discovers these new routes straight off this list.
113
+ const buildSettings = await getTheBuildSettings(tenant.tenant_id, content);
114
+ const { pageSize } = resolveTheBuildIndexCopy(buildSettings, tenant.displayName);
115
+ const totalBuildPages = Math.max(1, Math.ceil(buildPosts.length / pageSize));
116
+ for (let n = 2; n <= totalBuildPages; n++) urls.push({ loc: `${base}/the-build/page/${n}/` });
110
117
  for (const post of buildPosts) {
111
118
  urls.push({ loc: `${base}/the-build/${post.slug}/`, lastmod: post.modifiedAt || post.publishedAt });
112
119
  }
@@ -4,143 +4,27 @@
4
4
  // collection.ts) via the the-build/provider.ts thin wrapper, so any tenant
5
5
  // with the-build content renders the same way (per-tenant/multi-collection
6
6
  // invariant — see docs/plans/2026-08-21-blog-platform-workstream.md §1).
7
- import Layout from "@/layouts/Layout.astro";
8
- import PostAccessBadge from "@/components/blog/PostAccessBadge.astro";
9
- import { listBuildPosts } from "@/lib/the-build/provider.js";
7
+ import TheBuildIndexView from "@/components/the-build/TheBuildIndexView.astro";
8
+ import { getTheBuildSettings, listBuildPosts } from "@/lib/the-build/provider.js";
9
+ import { paginateBlogPosts } from "@/lib/blog/pagination.js";
10
+ import { resolveTheBuildIndexCopy } from "@/lib/the-build/pagination.js";
10
11
 
11
12
  const { tenant, basePath, canonicalBase, content, publicationTimestamp } = Astro.locals;
12
13
  const posts = await listBuildPosts(tenant.tenant_id, { content, now: publicationTimestamp });
13
14
  if (posts.length === 0) return new Response(null, { status: 404 });
14
15
 
15
- const href = (path: string) => `${basePath}${path}` || "/";
16
- const itemList = {
17
- "@context": "https://schema.org",
18
- "@type": "ItemList",
19
- numberOfItems: posts.length,
20
- itemListElement: posts.map((post, index) => ({
21
- "@type": "ListItem",
22
- position: index + 1,
23
- name: post.title,
24
- url: new URL(`/the-build/${post.slug}/`, canonicalBase + "/").toString(),
25
- })),
26
- };
16
+ const settings = await getTheBuildSettings(tenant.tenant_id, content);
17
+ const copy = resolveTheBuildIndexCopy(settings, tenant.displayName);
18
+ // Page 1 is always in range once posts.length > 0 — paginateBlogPosts never
19
+ // returns null here.
20
+ const { posts: pagePosts, totalPages } = paginateBlogPosts(posts, copy.pageSize, 1)!;
27
21
  ---
28
22
 
29
- <Layout
30
- title="The Build"
31
- description="Short articles on building a compliant, resilient storefront — from Token of Trust."
32
- path="/the-build/"
33
- fullBleed
34
- jsonLd={itemList}
35
- >
36
- <header class="blog-hero">
37
- <div class="blog-shell">
38
- <p class="eyebrow eyebrow--tick">The Build</p>
39
- <h1>The Build</h1>
40
- <p>Notes from building a compliant, resilient storefront.</p>
41
- </div>
42
- </header>
43
-
44
- <div class="blog-shell">
45
- <section class="post-grid" aria-label="The Build posts" id="the-build-posts">
46
- {posts.map((post, index) => (
47
- <article class="post-card">
48
- <a href={href(`/the-build/${post.slug}/`)} aria-label={`Read ${post.title}`}>
49
- {post.featuredImage ? (
50
- <img
51
- src={post.featuredImage.src}
52
- alt={post.featuredImage.alt}
53
- width={post.featuredImage.width}
54
- height={post.featuredImage.height}
55
- loading={index < 3 ? "eager" : "lazy"}
56
- decoding="async"
57
- />
58
- ) : <div class="image-placeholder" aria-hidden="true"></div>}
59
- <div class="post-card-copy">
60
- <p class="post-meta"><time datetime={post.publishedAt}>{new Date(post.publishedAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" })}</time>{post.tags[0] && <> · {post.tags[0]}</>}</p>
61
- <PostAccessBadge access={post.access} />
62
- <h2>{post.title}</h2>
63
- <p>{post.excerpt}</p>
64
- <span>Read article <span aria-hidden="true">→</span></span>
65
- </div>
66
- </a>
67
- </article>
68
- ))}
69
- </section>
70
-
71
- <section class="news-signup" aria-labelledby="news-signup-title">
72
- <h2 id="news-signup-title">Get The Build in your inbox</h2>
73
- <p>A short note when something worth reading lands. Double opt-in — we'll email a link to confirm.</p>
74
- <form class="news-form" method="post" action={href("/api/newsletter/the-build/subscribe")}>
75
- <label class="sr-only" for="news-email">Email address</label>
76
- <input id="news-email" name="email" type="email" inputmode="email" autocomplete="email"
77
- required placeholder="you@example.com" />
78
- <button type="submit">Subscribe</button>
79
- </form>
80
- <p class="news-status" role="status" aria-live="polite" hidden></p>
81
- </section>
82
- </div>
83
-
84
- <script>
85
- // Progressive enhancement: the form POSTs natively without JS (the endpoint
86
- // redirects back with ?subscribed=1). With JS we intercept for inline feedback.
87
- const form = document.querySelector<HTMLFormElement>(".news-form");
88
- const status = document.querySelector<HTMLParagraphElement>(".news-status");
89
- function show(msg: string) {
90
- if (!status) return;
91
- status.textContent = msg;
92
- status.hidden = false;
93
- }
94
- if (new URLSearchParams(location.search).get("subscribed") === "1") {
95
- show("Almost there — check your inbox to confirm.");
96
- }
97
- form?.addEventListener("submit", async (e) => {
98
- e.preventDefault();
99
- const email = new FormData(form).get("email");
100
- try {
101
- const res = await fetch(form.action, {
102
- method: "POST",
103
- headers: { "content-type": "application/json", accept: "application/json" },
104
- body: JSON.stringify({ email }),
105
- });
106
- if (res.status === 429) show("You've tried a few times — give it a minute and retry.");
107
- else if (res.ok) {
108
- form.reset();
109
- show("Almost there — check your inbox to confirm.");
110
- } else show("That address didn't look right — try again.");
111
- } catch {
112
- show("Something went wrong — try again in a moment.");
113
- }
114
- });
115
- </script>
116
- </Layout>
117
-
118
- <style>
119
- .blog-shell { width: min(1180px, calc(100% - 2rem)); margin-inline: auto; }
120
- .blog-hero { padding: clamp(4.5rem, 9vw, 8rem) 0 clamp(3rem, 6vw, 5rem); background: linear-gradient(145deg, var(--color-primary), color-mix(in srgb, var(--color-primary) 82%, var(--color-accent))); color: var(--color-primary-contrast); }
121
- .blog-hero h1 { max-width: 12ch; margin-top: .75rem; color: inherit; font-size: clamp(2.9rem, 7vw, 5.6rem); }
122
- .blog-hero p:last-child { max-width: 58ch; margin-top: 1.25rem; color: color-mix(in srgb, var(--color-primary-contrast) 82%, transparent); font-size: 1.12rem; }
123
- .blog-hero .eyebrow { color: color-mix(in srgb, var(--color-primary-contrast) 75%, transparent); }
124
- .post-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1.35rem; align-items: start; padding-block: clamp(3rem, 7vw, 6rem); }
125
- .post-card { height: 100%; overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-surface); box-shadow: var(--shadow-sm); transition: transform .2s ease, box-shadow .2s ease; }
126
- .post-card:hover { transform: translateY(-4px); box-shadow: var(--shadow-md); }
127
- .post-card a { display: flex; height: 100%; flex-direction: column; }
128
- .post-card img, .image-placeholder { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; background: linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 12%, white), color-mix(in srgb, var(--color-accent) 24%, white)); }
129
- .post-card-copy { display: flex; flex: 1; flex-direction: column; padding: 1.25rem; }
130
- .post-meta { margin: 0 0 .7rem; color: var(--color-muted); font: 600 .72rem/1.45 var(--font-mono); text-transform: uppercase; letter-spacing: .05em; }
131
- .post-card h2 { font-size: 1.18rem; line-height: 1.25; }
132
- .post-card-copy > p:not(.post-meta) { display: -webkit-box; margin: .75rem 0 1rem; overflow: hidden; color: var(--color-muted); font-size: .92rem; -webkit-line-clamp: 3; -webkit-box-orient: vertical; }
133
- .post-card-copy > span { margin-top: auto; color: var(--color-accent-ink); font-weight: 700; font-size: .88rem; }
134
- @media (max-width: 980px) { .post-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
135
- @media (max-width: 520px) { .post-grid { grid-template-columns: 1fr; } }
136
-
137
- .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
138
- .news-signup { margin: 0 auto clamp(3rem, 7vw, 6rem); max-width: 40rem; padding: clamp(1.75rem, 4vw, 2.5rem); text-align: center; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-surface); }
139
- .news-signup h2 { font-size: clamp(1.4rem, 3vw, 1.8rem); }
140
- .news-signup p { margin: .6rem 0 0; color: var(--color-muted); line-height: 1.6; }
141
- .news-form { display: flex; flex-wrap: wrap; gap: .6rem; justify-content: center; margin-top: 1.35rem; }
142
- .news-form input { flex: 1 1 16rem; min-width: 0; padding: .7rem .9rem; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-bg); color: inherit; font-size: 1rem; }
143
- .news-form button { padding: .7rem 1.4rem; border: 0; border-radius: var(--radius-md); background: var(--color-primary); color: var(--color-primary-contrast); font-weight: 700; cursor: pointer; }
144
- .news-form button:hover { background: color-mix(in srgb, var(--color-primary) 88%, black); }
145
- .news-status { margin-top: 1rem; font-weight: 600; color: var(--color-accent-ink); }
146
- </style>
23
+ <TheBuildIndexView
24
+ basePath={basePath}
25
+ canonicalBase={canonicalBase}
26
+ copy={copy}
27
+ pagePosts={pagePosts}
28
+ page={1}
29
+ totalPages={totalPages}
30
+ />
@@ -0,0 +1,34 @@
1
+ ---
2
+ // /the-build/page/<n>/ — page N≥2 of the the-build index. Page 1 lives at
3
+ // /the-build/ (src/pages/the-build/index.astro); both render through the same
4
+ // TheBuildIndexView so the tenant-copy + pagination contract can't drift
5
+ // between the two routes (mirrors src/pages/blog/page/[n].astro).
6
+ import TheBuildIndexView from "@/components/the-build/TheBuildIndexView.astro";
7
+ import { getTheBuildSettings, listBuildPosts } from "@/lib/the-build/provider.js";
8
+ import { paginateBlogPosts } from "@/lib/blog/pagination.js";
9
+ import { parseTheBuildPageParam, resolveTheBuildIndexCopy } from "@/lib/the-build/pagination.js";
10
+
11
+ const page = parseTheBuildPageParam(Astro.params.n);
12
+ if (page === null) return new Response(null, { status: 404 });
13
+
14
+ const { tenant, basePath, canonicalBase, content, publicationTimestamp } = Astro.locals;
15
+ const posts = await listBuildPosts(tenant.tenant_id, { content, now: publicationTimestamp });
16
+ if (posts.length === 0) return new Response(null, { status: 404 });
17
+
18
+ const settings = await getTheBuildSettings(tenant.tenant_id, content);
19
+ const copy = resolveTheBuildIndexCopy(settings, tenant.displayName);
20
+ const sliced = paginateBlogPosts(posts, copy.pageSize, page);
21
+ // Out of [1, totalPages] (e.g. /the-build/page/99/ on a 3-page collection) — a
22
+ // real 404, not an empty-collection one, since the collection itself resolves
23
+ // fine at /the-build/.
24
+ if (!sliced) return new Response(null, { status: 404 });
25
+ ---
26
+
27
+ <TheBuildIndexView
28
+ basePath={basePath}
29
+ canonicalBase={canonicalBase}
30
+ copy={copy}
31
+ pagePosts={sliced.posts}
32
+ page={sliced.page}
33
+ totalPages={sliced.totalPages}
34
+ />
@@ -7,7 +7,8 @@
7
7
  * which is tenant/collection-generic so `/blog/rss.xml` can reuse it later.
8
8
  */
9
9
  import type { APIContext } from "astro";
10
- import { listBuildPosts, THE_BUILD } from "@/lib/the-build/provider.js";
10
+ import { getTheBuildSettings, listBuildPosts, THE_BUILD } from "@/lib/the-build/provider.js";
11
+ import { resolveTheBuildIndexCopy } from "@/lib/the-build/pagination.js";
11
12
  import { renderRssFeed } from "@/lib/blog/rss.js";
12
13
 
13
14
  export async function GET(context: APIContext): Promise<Response> {
@@ -15,10 +16,15 @@ export async function GET(context: APIContext): Promise<Response> {
15
16
  const posts = await listBuildPosts(tenant.tenant_id, { content, now: publicationTimestamp });
16
17
  if (posts.length === 0) return new Response(null, { status: 404 });
17
18
 
19
+ // Same tenant-derived copy the index page resolves — never Token of Trust's
20
+ // own description for a store that hasn't declared its own (the-build
21
+ // sibling of fb-1791301493691-brzl6f).
22
+ const settings = await getTheBuildSettings(tenant.tenant_id, content);
23
+ const copy = resolveTheBuildIndexCopy(settings, tenant.displayName);
24
+
18
25
  const body = renderRssFeed({
19
- channelTitle: `The Build — ${tenant.displayName}`,
20
- channelDescription:
21
- "Short articles on building a compliant, resilient storefront — from Token of Trust.",
26
+ channelTitle: copy.title,
27
+ channelDescription: copy.description,
22
28
  canonicalBase,
23
29
  collectionBase: THE_BUILD.base,
24
30
  posts,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.2.107",
3
+ "version": "2.2.108",
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",