@tokenoftrust/storefront-runner 2.2.83 → 2.2.85

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.
@@ -23,6 +23,9 @@ interface Props {
23
23
  jsonLd?: JsonLd | JsonLd[];
24
24
  /** Alternate RSS/Atom feeds this page advertises (e.g. a collection's feed). */
25
25
  feeds?: { title: string; href: string }[];
26
+ /** A paginated collection's siblings — path-only (like `path`), joined to the
27
+ * same canonical base. Absent fields emit no link (first/last page). */
28
+ pagination?: { prev?: string; next?: string };
26
29
  // --- B1.6 article SEO hardening (all additive/optional) ---
27
30
  /** Open Graph `article:*` meta; emitted only when `ogType === "article"`. */
28
31
  article?: ArticleMeta;
@@ -51,6 +54,7 @@ const {
51
54
  noindex = false,
52
55
  jsonLd,
53
56
  feeds = [],
57
+ pagination,
54
58
  article,
55
59
  imageAlt,
56
60
  imageWidth,
@@ -84,6 +88,10 @@ const articleMeta = articleSeoMetaTags({
84
88
 
85
89
  const graphs = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
86
90
  const alternateLinks = withXDefault(alternates);
91
+ const paginationLinks = [
92
+ ...(pagination?.prev ? [{ rel: "prev", href: new URL(pagination.prev, canonicalBase + "/").toString() }] : []),
93
+ ...(pagination?.next ? [{ rel: "next", href: new URL(pagination.next, canonicalBase + "/").toString() }] : []),
94
+ ];
87
95
  ---
88
96
 
89
97
  <title>{fullTitle}</title>
@@ -91,6 +99,10 @@ const alternateLinks = withXDefault(alternates);
91
99
  <link rel="canonical" href={canonical} />
92
100
  {noindex && <meta name="robots" content="noindex,nofollow" />}
93
101
 
102
+ {
103
+ paginationLinks.map((l) => <link rel={l.rel} href={l.href} />)
104
+ }
105
+
94
106
  {
95
107
  feeds.map((f) => (
96
108
  <link rel="alternate" type="application/rss+xml" title={f.title} href={f.href} />
@@ -0,0 +1,140 @@
1
+ ---
2
+ // Shared render for every `/blog/` index page — page 1 (src/pages/blog/index.astro)
3
+ // and every `/blog/page/<n>/` (src/pages/blog/page/[n].astro) render through this ONE
4
+ // component so the hero/grid/pagination markup, and the tenant-copy + pagination
5
+ // contract it implements, can't drift between the two routes (fb-1791301493691-brzl6f).
6
+ import Layout from "@/layouts/Layout.astro";
7
+ import PostAccessBadge from "@/components/blog/PostAccessBadge.astro";
8
+ import type { BlogPostSummary } from "@/lib/blog/types.js";
9
+ import type { BlogIndexCopy } from "@/lib/blog/pagination.js";
10
+ import { blogPagePath } from "@/lib/blog/pagination.js";
11
+
12
+ interface Props {
13
+ basePath: string;
14
+ canonicalBase: string;
15
+ copy: BlogIndexCopy;
16
+ tags: string[];
17
+ pagePosts: BlogPostSummary[];
18
+ page: number;
19
+ totalPages: number;
20
+ }
21
+
22
+ const { basePath, canonicalBase, copy, tags, pagePosts, page, totalPages } = Astro.props;
23
+
24
+ const href = (path: string) => `${basePath}${path}` || "/";
25
+ const path = blogPagePath(page);
26
+ // Page 1 keeps the tenant's declared title verbatim (unchanged default copy); page
27
+ // N>1 is distinguished in the <title> tag only — the on-page <h1> stays identical
28
+ // across pages, matching how a reader experiences "the same blog, page 2".
29
+ const documentTitle = page > 1 ? `${copy.title} — Page ${page}` : copy.title;
30
+ const pagination = {
31
+ ...(page > 1 ? { prev: blogPagePath(page - 1) } : {}),
32
+ ...(page < totalPages ? { next: blogPagePath(page + 1) } : {}),
33
+ };
34
+
35
+ const itemList = {
36
+ "@context": "https://schema.org",
37
+ "@type": "ItemList",
38
+ numberOfItems: pagePosts.length,
39
+ itemListElement: pagePosts.map((post, index) => ({
40
+ "@type": "ListItem",
41
+ position: index + 1,
42
+ name: post.title,
43
+ url: new URL(`/blog/${post.slug}/`, canonicalBase + "/").toString(),
44
+ })),
45
+ };
46
+ ---
47
+
48
+ <Layout
49
+ title={documentTitle}
50
+ description={copy.description}
51
+ path={path}
52
+ fullBleed
53
+ jsonLd={itemList}
54
+ pagination={pagination}
55
+ >
56
+ <header class="blog-hero">
57
+ <div class="blog-shell">
58
+ <p class="eyebrow eyebrow--tick">{copy.eyebrow}</p>
59
+ <h1>{copy.title}</h1>
60
+ <p>{copy.tagline}</p>
61
+ </div>
62
+ </header>
63
+
64
+ <div class="blog-shell blog-layout">
65
+ <aside aria-label="Blog topics">
66
+ <p class="filter-label">Explore topics</p>
67
+ <div class="tag-list">
68
+ {tags.map((tag) => <span>{tag}</span>)}
69
+ </div>
70
+ </aside>
71
+
72
+ <section class="post-grid" aria-label="Blog posts" id="blog-posts">
73
+ {pagePosts.map((post, index) => (
74
+ <article class="post-card">
75
+ <a href={href(`/blog/${post.slug}/`)} aria-label={`Read ${post.title}`}>
76
+ {post.featuredImage ? (
77
+ <img
78
+ src={post.featuredImage.src}
79
+ alt={post.featuredImage.alt}
80
+ width={post.featuredImage.width}
81
+ height={post.featuredImage.height}
82
+ loading={index < 3 ? "eager" : "lazy"}
83
+ decoding="async"
84
+ />
85
+ ) : <div class="image-placeholder" aria-hidden="true"></div>}
86
+ <div class="post-card-copy">
87
+ <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>
88
+ <PostAccessBadge access={post.access} />
89
+ <h2>{post.title}</h2>
90
+ <p>{post.excerpt}</p>
91
+ <span>Read article <span aria-hidden="true">→</span></span>
92
+ </div>
93
+ </a>
94
+ </article>
95
+ ))}
96
+ </section>
97
+ </div>
98
+
99
+ {totalPages > 1 && (
100
+ <nav class="blog-shell blog-pagination" aria-label="Blog pagination">
101
+ {page > 1
102
+ ? <a class="blog-pagination-link" rel="prev" href={href(blogPagePath(page - 1))}>← Newer posts</a>
103
+ : <span />}
104
+ <span class="blog-pagination-status">Page {page} of {totalPages}</span>
105
+ {page < totalPages
106
+ ? <a class="blog-pagination-link" rel="next" href={href(blogPagePath(page + 1))}>Older posts →</a>
107
+ : <span />}
108
+ </nav>
109
+ )}
110
+ </Layout>
111
+
112
+ <style>
113
+ .blog-shell { width: min(1180px, calc(100% - 2rem)); margin-inline: auto; }
114
+ .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); }
115
+ .blog-hero h1 { max-width: 12ch; margin-top: .75rem; color: inherit; font-size: clamp(2.9rem, 7vw, 5.6rem); }
116
+ .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; }
117
+ .blog-hero .eyebrow { color: color-mix(in srgb, var(--color-primary-contrast) 75%, transparent); }
118
+ .blog-layout { display: grid; grid-template-columns: 210px minmax(0, 1fr); gap: clamp(2rem, 5vw, 4rem); padding-block: clamp(3rem, 7vw, 6rem); }
119
+ aside { align-self: start; position: sticky; top: 7rem; }
120
+ .filter-label { margin-bottom: .85rem; font: 700 .76rem/1 var(--font-mono); letter-spacing: .12em; text-transform: uppercase; color: var(--color-muted); }
121
+ .tag-list { display: flex; flex-direction: column; align-items: stretch; gap: .3rem; }
122
+ .tag-list span { padding: .45rem .7rem; border: 1px solid var(--color-border); border-radius: var(--radius-full); background: var(--color-surface); color: var(--color-muted); font-size: .76rem; }
123
+ .post-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1.35rem; align-items: start; }
124
+ .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; }
125
+ .post-card:hover { transform: translateY(-4px); box-shadow: var(--shadow-md); }
126
+ .post-card a { display: flex; height: 100%; flex-direction: column; }
127
+ .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)); }
128
+ .post-card-copy { display: flex; flex: 1; flex-direction: column; padding: 1.25rem; }
129
+ .post-meta { margin: 0 0 .7rem; color: var(--color-muted); font: 600 .72rem/1.45 var(--font-mono); text-transform: uppercase; letter-spacing: .05em; }
130
+ .post-card h2 { font-size: 1.18rem; line-height: 1.25; }
131
+ .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; }
132
+ .post-card-copy > span { margin-top: auto; color: var(--color-accent-ink); font-weight: 700; font-size: .88rem; }
133
+ @media (max-width: 980px) { .post-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
134
+ @media (max-width: 720px) { .blog-layout { grid-template-columns: 1fr; } aside { position: static; } .tag-list { flex-direction: row; overflow-x: auto; padding-bottom: .4rem; } .tag-list button { flex: 0 0 auto; } }
135
+ @media (max-width: 520px) { .post-grid { grid-template-columns: 1fr; } }
136
+
137
+ .blog-pagination { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding-bottom: clamp(3rem, 7vw, 6rem); }
138
+ .blog-pagination-link { font-weight: 700; color: var(--color-accent-ink); }
139
+ .blog-pagination-status { color: var(--color-muted); font: 600 .78rem/1 var(--font-mono); letter-spacing: .04em; text-transform: uppercase; }
140
+ </style>
@@ -46,6 +46,8 @@ interface Props {
46
46
  jsonLd?: Record<string, unknown> | Record<string, unknown>[];
47
47
  /** Alternate RSS/Atom feeds this page advertises (threaded to <Seo>). */
48
48
  feeds?: { title: string; href: string }[];
49
+ /** A paginated collection's prev/next siblings (threaded to <Seo>). */
50
+ pagination?: { prev?: string; next?: string };
49
51
  /** Wider pages (PLP/home) drop the prose max-width. */
50
52
  fullBleed?: boolean;
51
53
  /**
@@ -85,6 +87,7 @@ const {
85
87
  noindex,
86
88
  jsonLd,
87
89
  feeds,
90
+ pagination,
88
91
  article,
89
92
  imageAlt,
90
93
  imageWidth,
@@ -302,6 +305,7 @@ const footerWidgets = await renderWidgetSlot(
302
305
  noindex={noindex}
303
306
  jsonLd={allJsonLd}
304
307
  feeds={feeds}
308
+ pagination={pagination}
305
309
  article={article}
306
310
  imageAlt={imageAlt}
307
311
  imageWidth={imageWidth}
@@ -0,0 +1,78 @@
1
+ import type { BlogSettings } from "./types.js";
2
+
3
+ /** Posts per `/blog/` page when a tenant declares no `pageSize` (or an invalid one). */
4
+ export const DEFAULT_BLOG_PAGE_SIZE = 12;
5
+
6
+ /**
7
+ * Resolved `/blog/` index copy + page size. A declared `BlogSettings` field wins;
8
+ * an absent one falls back to a NEUTRAL default derived from the tenant's own
9
+ * `displayName` — never Token of Trust's own copy (fb-1791301493691-brzl6f). A
10
+ * tenant that wants ToT's current copy (tokenoftrust.com included) declares it
11
+ * in its own `content/blog/settings.json`.
12
+ */
13
+ export interface BlogIndexCopy {
14
+ title: string;
15
+ eyebrow: string;
16
+ tagline: string;
17
+ description: string;
18
+ pageSize: number;
19
+ }
20
+
21
+ function trimmed(value: string | undefined): string | undefined {
22
+ const v = value?.trim();
23
+ return v ? v : undefined;
24
+ }
25
+
26
+ export function resolveBlogIndexCopy(
27
+ settings: BlogSettings | null | undefined,
28
+ displayName: string,
29
+ ): BlogIndexCopy {
30
+ const tagline = trimmed(settings?.tagline) ?? `The latest from ${displayName}.`;
31
+ const pageSize = settings?.pageSize;
32
+ return {
33
+ title: trimmed(settings?.title) ?? `${displayName} Blog`,
34
+ eyebrow: trimmed(settings?.eyebrow) ?? "Updates",
35
+ tagline,
36
+ description: trimmed(settings?.description) ?? tagline,
37
+ pageSize: Number.isInteger(pageSize) && pageSize! > 0 ? pageSize! : DEFAULT_BLOG_PAGE_SIZE,
38
+ };
39
+ }
40
+
41
+ export interface BlogPageSlice<T> {
42
+ posts: T[];
43
+ page: number;
44
+ totalPages: number;
45
+ }
46
+
47
+ /**
48
+ * One page of `posts` (newest/curated-order preserved), or null when `page` is
49
+ * out of `[1, totalPages]` — the caller's cue to 404. `posts.length === 0` still
50
+ * resolves `totalPages` to 1 so a caller checks emptiness itself (the existing
51
+ * "no blog at all" 404 is a different case, with its own marker header).
52
+ */
53
+ export function paginateBlogPosts<T>(
54
+ posts: readonly T[],
55
+ pageSize: number,
56
+ page: number,
57
+ ): BlogPageSlice<T> | null {
58
+ const totalPages = Math.max(1, Math.ceil(posts.length / pageSize));
59
+ if (!Number.isInteger(page) || page < 1 || page > totalPages) return null;
60
+ const start = (page - 1) * pageSize;
61
+ return { posts: posts.slice(start, start + pageSize), page, totalPages };
62
+ }
63
+
64
+ /** `/blog/` is page 1; `/blog/page/<n>/` is page N for N≥2. */
65
+ export function blogPagePath(page: number): string {
66
+ return page <= 1 ? "/blog/" : `/blog/page/${page}/`;
67
+ }
68
+
69
+ /**
70
+ * Parse a `/blog/page/<n>/` route param into a page number, or null when it is
71
+ * not a valid page-N route: not a bare positive-integer string (rejects "01",
72
+ * "1.5", "-1", "abc", ""), or N<2 (page 1 lives at `/blog/`, not `/blog/page/1/`).
73
+ */
74
+ 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;
78
+ }
@@ -11,7 +11,7 @@ import {
11
11
  tenantDirSegment,
12
12
  } from "@tot/public-runtime";
13
13
  import { listPosts } from "./collection.js";
14
- import type { BlogPost, BlogPostSummary } from "./types.js";
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";
17
17
 
@@ -53,6 +53,13 @@ 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
+ );
56
63
 
57
64
  function tenantKey(keys: string[], tenantId: string, suffix: string): string | undefined {
58
65
  const segment = `${tenantDirSegment(tenantId)}content/blog/`;
@@ -166,3 +173,29 @@ export async function resolveBlogRedirect(
166
173
  const redirects = await redirectGlobs[key]!();
167
174
  return redirects[slug] ?? null;
168
175
  }
176
+
177
+ /**
178
+ * Optional per-tenant blog index copy + page size (`tenants/<id>/content/blog/settings.json`),
179
+ * or null when the tenant has declared none. Null is "use the neutral default", never an error —
180
+ * see `resolveBlogIndexCopy` (./pagination.ts), which is the one place that applies the default.
181
+ */
182
+ export async function getBlogSettings(
183
+ tenantId: string,
184
+ source?: ArtifactSource,
185
+ ): 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;
201
+ }
@@ -44,6 +44,24 @@ export interface BlogPost extends BlogPostSummary {
44
44
  bodyMarkdown?: string;
45
45
  }
46
46
 
47
+ /**
48
+ * Optional per-tenant blog index copy + page size, sourced from
49
+ * `tenants/<id>/content/blog/settings.json`. Every field is optional — an absent
50
+ * field, or an absent file, resolves to a NEUTRAL default derived from the
51
+ * tenant's own `displayName` (see `resolveBlogIndexCopy` in `./pagination.ts`),
52
+ * never Token of Trust's own copy. A tenant that wants its own branding
53
+ * (tokenoftrust.com included) declares it here.
54
+ */
55
+ export interface BlogSettings {
56
+ title?: string;
57
+ eyebrow?: string;
58
+ tagline?: string;
59
+ description?: string;
60
+ /** Posts per `/blog/` page. Must be a positive integer; anything else falls
61
+ * back to the platform default (`DEFAULT_BLOG_PAGE_SIZE`). */
62
+ pageSize?: number;
63
+ }
64
+
47
65
  /**
48
66
  * Optional per-tenant × per-collection author bios, keyed by the author's slug
49
67
  * (see `authorSlug`). Sourced from `tenants/<id>/content/<collection>/authors.json`;
@@ -1,7 +1,7 @@
1
1
  ---
2
- import Layout from "@/layouts/Layout.astro";
3
- import PostAccessBadge from "@/components/blog/PostAccessBadge.astro";
4
- import { listBlogPosts } from "@/lib/blog/provider.js";
2
+ import BlogIndexView from "@/components/blog/BlogIndexView.astro";
3
+ import { getBlogSettings, listBlogPosts } from "@/lib/blog/provider.js";
4
+ import { paginateBlogPosts, resolveBlogIndexCopy } from "@/lib/blog/pagination.js";
5
5
 
6
6
  const { tenant, basePath, canonicalBase, content, publicationTimestamp } = Astro.locals;
7
7
  const posts = await listBlogPosts(tenant.tenant_id, content, publicationTimestamp);
@@ -16,94 +16,20 @@ if (posts.length === 0) {
16
16
  });
17
17
  }
18
18
 
19
- const href = (path: string) => `${basePath}${path}` || "/";
19
+ const settings = await getBlogSettings(tenant.tenant_id, content);
20
+ const copy = resolveBlogIndexCopy(settings, tenant.displayName);
21
+ // Page 1 is always in range once posts.length > 0 — paginateBlogPosts never
22
+ // returns null here.
23
+ const { posts: pagePosts, totalPages } = paginateBlogPosts(posts, copy.pageSize, 1)!;
20
24
  const tags = [...new Set(posts.flatMap((post) => post.tags))].sort((a, b) => a.localeCompare(b));
21
- const itemList = {
22
- "@context": "https://schema.org",
23
- "@type": "ItemList",
24
- numberOfItems: posts.length,
25
- itemListElement: posts.map((post, index) => ({
26
- "@type": "ListItem",
27
- position: index + 1,
28
- name: post.title,
29
- url: new URL(`/blog/${post.slug}/`, canonicalBase + "/").toString(),
30
- })),
31
- };
32
25
  ---
33
26
 
34
- <Layout
35
- title="Blog"
36
- description="Product updates, compliance news, and practical guidance from Token of Trust."
37
- path="/blog/"
38
- fullBleed
39
- jsonLd={itemList}
40
- >
41
- <header class="blog-hero">
42
- <div class="blog-shell">
43
- <p class="eyebrow eyebrow--tick">Insights &amp; updates</p>
44
- <h1>Token of Trust blog</h1>
45
- <p>Practical guidance for identity, age assurance, fraud prevention, and regulated commerce.</p>
46
- </div>
47
- </header>
48
-
49
- <div class="blog-shell blog-layout">
50
- <aside aria-label="Blog topics">
51
- <p class="filter-label">Explore topics</p>
52
- <div class="tag-list">
53
- {tags.map((tag) => <span>{tag}</span>)}
54
- </div>
55
- </aside>
56
-
57
- <section class="post-grid" aria-label="Blog posts" id="blog-posts">
58
- {posts.map((post, index) => (
59
- <article class="post-card">
60
- <a href={href(`/blog/${post.slug}/`)} aria-label={`Read ${post.title}`}>
61
- {post.featuredImage ? (
62
- <img
63
- src={post.featuredImage.src}
64
- alt={post.featuredImage.alt}
65
- width={post.featuredImage.width}
66
- height={post.featuredImage.height}
67
- loading={index < 3 ? "eager" : "lazy"}
68
- decoding="async"
69
- />
70
- ) : <div class="image-placeholder" aria-hidden="true"></div>}
71
- <div class="post-card-copy">
72
- <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>
73
- <PostAccessBadge access={post.access} />
74
- <h2>{post.title}</h2>
75
- <p>{post.excerpt}</p>
76
- <span>Read article <span aria-hidden="true">→</span></span>
77
- </div>
78
- </a>
79
- </article>
80
- ))}
81
- </section>
82
- </div>
83
- </Layout>
84
-
85
- <style>
86
- .blog-shell { width: min(1180px, calc(100% - 2rem)); margin-inline: auto; }
87
- .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); }
88
- .blog-hero h1 { max-width: 12ch; margin-top: .75rem; color: inherit; font-size: clamp(2.9rem, 7vw, 5.6rem); }
89
- .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; }
90
- .blog-hero .eyebrow { color: color-mix(in srgb, var(--color-primary-contrast) 75%, transparent); }
91
- .blog-layout { display: grid; grid-template-columns: 210px minmax(0, 1fr); gap: clamp(2rem, 5vw, 4rem); padding-block: clamp(3rem, 7vw, 6rem); }
92
- aside { align-self: start; position: sticky; top: 7rem; }
93
- .filter-label { margin-bottom: .85rem; font: 700 .76rem/1 var(--font-mono); letter-spacing: .12em; text-transform: uppercase; color: var(--color-muted); }
94
- .tag-list { display: flex; flex-direction: column; align-items: stretch; gap: .3rem; }
95
- .tag-list span { padding: .45rem .7rem; border: 1px solid var(--color-border); border-radius: var(--radius-full); background: var(--color-surface); color: var(--color-muted); font-size: .76rem; }
96
- .post-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1.35rem; align-items: start; }
97
- .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; }
98
- .post-card:hover { transform: translateY(-4px); box-shadow: var(--shadow-md); }
99
- .post-card a { display: flex; height: 100%; flex-direction: column; }
100
- .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)); }
101
- .post-card-copy { display: flex; flex: 1; flex-direction: column; padding: 1.25rem; }
102
- .post-meta { margin: 0 0 .7rem; color: var(--color-muted); font: 600 .72rem/1.45 var(--font-mono); text-transform: uppercase; letter-spacing: .05em; }
103
- .post-card h2 { font-size: 1.18rem; line-height: 1.25; }
104
- .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; }
105
- .post-card-copy > span { margin-top: auto; color: var(--color-accent-ink); font-weight: 700; font-size: .88rem; }
106
- @media (max-width: 980px) { .post-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
107
- @media (max-width: 720px) { .blog-layout { grid-template-columns: 1fr; } aside { position: static; } .tag-list { flex-direction: row; overflow-x: auto; padding-bottom: .4rem; } .tag-list button { flex: 0 0 auto; } }
108
- @media (max-width: 520px) { .post-grid { grid-template-columns: 1fr; } }
109
- </style>
27
+ <BlogIndexView
28
+ basePath={basePath}
29
+ canonicalBase={canonicalBase}
30
+ copy={copy}
31
+ tags={tags}
32
+ pagePosts={pagePosts}
33
+ page={1}
34
+ totalPages={totalPages}
35
+ />
@@ -0,0 +1,39 @@
1
+ ---
2
+ // /blog/page/<n>/ — page N≥2 of the blog index. Page 1 lives at /blog/
3
+ // (src/pages/blog/index.astro); both render through the same BlogIndexView so the
4
+ // tenant-copy + pagination contract can't drift between the two routes.
5
+ import BlogIndexView from "@/components/blog/BlogIndexView.astro";
6
+ import { getBlogSettings, listBlogPosts } from "@/lib/blog/provider.js";
7
+ import { paginateBlogPosts, parseBlogPageParam, resolveBlogIndexCopy } from "@/lib/blog/pagination.js";
8
+
9
+ const page = parseBlogPageParam(Astro.params.n);
10
+ if (page === null) return new Response(null, { status: 404 });
11
+
12
+ const { tenant, basePath, canonicalBase, content, publicationTimestamp } = Astro.locals;
13
+ const posts = await listBlogPosts(tenant.tenant_id, content, publicationTimestamp);
14
+ if (posts.length === 0) {
15
+ return new Response(null, {
16
+ status: 404,
17
+ headers: { "x-tot-blog-collection": "empty" },
18
+ });
19
+ }
20
+
21
+ const settings = await getBlogSettings(tenant.tenant_id, content);
22
+ const copy = resolveBlogIndexCopy(settings, tenant.displayName);
23
+ const sliced = paginateBlogPosts(posts, copy.pageSize, page);
24
+ // Out of [1, totalPages] (e.g. /blog/page/99/ on a 3-page blog) — a real 404, not
25
+ // an empty-collection one, since the blog itself exists and resolves fine at /blog/.
26
+ if (!sliced) return new Response(null, { status: 404 });
27
+
28
+ const tags = [...new Set(posts.flatMap((post) => post.tags))].sort((a, b) => a.localeCompare(b));
29
+ ---
30
+
31
+ <BlogIndexView
32
+ basePath={basePath}
33
+ canonicalBase={canonicalBase}
34
+ copy={copy}
35
+ tags={tags}
36
+ pagePosts={sliced.posts}
37
+ page={sliced.page}
38
+ totalPages={sliced.totalPages}
39
+ />
@@ -10,8 +10,9 @@
10
10
  */
11
11
  import type { APIContext } from "astro";
12
12
  import { nonEmptyCollections } from "@/lib/tot/query.js";
13
- import { listBlogPosts } from "@/lib/blog/provider.js";
13
+ import { getBlogSettings, listBlogPosts } from "@/lib/blog/provider.js";
14
14
  import { authorSlug, categorySlug } from "@/lib/blog/collection.js";
15
+ import { resolveBlogIndexCopy } from "@/lib/blog/pagination.js";
15
16
  import { listBuildPosts } from "@/lib/the-build/provider.js";
16
17
  import { isIndexableHtml, rawPageCanonicalUrl } from "@/lib/rawMarketingHtml.js";
17
18
 
@@ -76,6 +77,14 @@ export async function GET(context: APIContext): Promise<Response> {
76
77
  const blogPosts = await listBlogPosts(tenant.tenant_id, content, publicationTimestamp);
77
78
  if (blogPosts.length > 0) {
78
79
  urls.push({ loc: `${base}/blog/` });
80
+ // Paginated pages (N≥2) — the static materializer discovers these new routes
81
+ // straight off this list (withSitemapRoutes in scripts/publish/lib/routes.mjs),
82
+ // so listing them here is what makes /blog/page/N/ exist in BOTH serving
83
+ // planes, not just the SSR Worker.
84
+ const blogSettings = await getBlogSettings(tenant.tenant_id, content);
85
+ const { pageSize } = resolveBlogIndexCopy(blogSettings, tenant.displayName);
86
+ const totalBlogPages = Math.max(1, Math.ceil(blogPosts.length / pageSize));
87
+ for (let n = 2; n <= totalBlogPages; n++) urls.push({ loc: `${base}/blog/page/${n}/` });
79
88
  for (const post of blogPosts) {
80
89
  urls.push({ loc: `${base}/blog/${post.slug}/`, lastmod: post.modifiedAt || post.publishedAt });
81
90
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.2.83",
3
+ "version": "2.2.85",
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",