@tokenoftrust/storefront-runner 2.2.84 → 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.
- package/apps/storefront/src/components/Seo.astro +12 -0
- package/apps/storefront/src/components/blog/BlogIndexView.astro +140 -0
- package/apps/storefront/src/layouts/Layout.astro +4 -0
- package/apps/storefront/src/lib/blog/pagination.ts +78 -0
- package/apps/storefront/src/lib/blog/provider.ts +34 -1
- package/apps/storefront/src/lib/blog/types.ts +18 -0
- package/apps/storefront/src/pages/blog/index.astro +17 -91
- package/apps/storefront/src/pages/blog/page/[n].astro +39 -0
- package/apps/storefront/src/pages/sitemap.xml.ts +10 -1
- package/package.json +1 -1
|
@@ -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
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
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
|
|
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
|
-
<
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
<p class="eyebrow eyebrow--tick">Insights & 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.
|
|
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",
|