@tokenoftrust/storefront-runner 2.4.73 → 2.4.74

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.
@@ -20,10 +20,14 @@ interface Props {
20
20
  pagePosts: BlogPostSummary[];
21
21
  page: number;
22
22
  totalPages: number;
23
+ /** `BlogSettings.postsAtRoot` — see `collectionPaths.js`'s `blogPathOptsV1`. */
24
+ postsAtRoot?: boolean;
23
25
  }
24
26
 
25
- const { basePath, canonicalBase, copy, presentation, tags, pagePosts, page, totalPages } = Astro.props;
26
- const blogPaths = collectionPathsV1(Astro.locals, "blog");
27
+ const {
28
+ basePath, canonicalBase, copy, presentation, tags, pagePosts, page, totalPages, postsAtRoot,
29
+ } = Astro.props;
30
+ const blogPaths = collectionPathsV1(Astro.locals, "blog", postsAtRoot ? { postBase: "" } : {});
27
31
  const showTopics = presentation.topics && tags.length > 0;
28
32
 
29
33
  const href = (path: string) => `${basePath}${path}` || "/";
@@ -0,0 +1,146 @@
1
+ ---
2
+ /**
3
+ * The post article view — rendered by `pages/blog/[slug].astro` at its usual
4
+ * `/blog/<slug>` URL, and by `pages/[...slug].astro` at the site root when the
5
+ * tenant declares `BlogSettings.postsAtRoot`. Both callers resolve their props
6
+ * through `@/lib/blog/postView.js`'s `loadBlogPostViewV1`, so the markup, byline,
7
+ * paywall gate and structured data are identical regardless of which URL served
8
+ * the request.
9
+ */
10
+ import Layout from "@/layouts/Layout.astro";
11
+ import Breadcrumbs from "@/components/Breadcrumbs.astro";
12
+ import BlogPaywall from "@/components/blog/BlogPaywall.astro";
13
+ import ReactionBar from "@/components/blog/ReactionBar.astro";
14
+ import ShareRow from "@/components/blog/ShareRow.astro";
15
+ import { languageLabel } from "@/lib/i18n.js";
16
+ import type { BlogPostViewPropsV1 } from "@/lib/blog/postView.js";
17
+
18
+ type Props = BlogPostViewPropsV1;
19
+
20
+ const {
21
+ post, path, bodyHtml, gated, unlock, next, loginHref, viewer, presentation, author,
22
+ authorHref, authorUrl, tagLinks, articleLd, trail, crumbs, blogHref, share, translationLinks,
23
+ alternates, lang,
24
+ } = Astro.props;
25
+ ---
26
+
27
+ <Layout
28
+ title={post.seoTitle || post.title}
29
+ description={post.description}
30
+ path={path}
31
+ ogType="article"
32
+ ogImage={post.featuredImage?.src}
33
+ noindex={post.status === "unlisted"}
34
+ jsonLd={[crumbs, articleLd]}
35
+ article={{
36
+ publishedTime: post.publishedAt,
37
+ modifiedTime: post.modifiedAt,
38
+ author: authorUrl,
39
+ section: post.categories[0],
40
+ tags: post.tags,
41
+ }}
42
+ imageAlt={post.featuredImage?.alt}
43
+ imageWidth={post.featuredImage?.width}
44
+ imageHeight={post.featuredImage?.height}
45
+ canonicalOverride={post.canonicalUrl}
46
+ alternates={alternates}
47
+ lang={lang}
48
+ >
49
+ <article class="article-shell">
50
+ <Breadcrumbs crumbs={trail} class="breadcrumbs" />
51
+ <header>
52
+ {presentation.postEyebrow && post.tags[0] && <p class="eyebrow eyebrow--tick">{post.tags[0]}</p>}
53
+ <h1>{post.title}</h1>
54
+ {presentation.dek && post.excerpt && <p class="dek">{post.excerpt}</p>}
55
+ <div class="byline">
56
+ {author && <span>By {authorHref
57
+ ? <a href={authorHref} class="byline-author" rel="author">{author}</a>
58
+ : <span class="byline-author">{author}</span>}</span>}
59
+ {author && <span aria-hidden="true">·</span>}
60
+ <time datetime={post.publishedAt}>{new Date(post.publishedAt).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })}</time>
61
+ </div>
62
+ {translationLinks.length > 1 && (
63
+ <nav class="lang-switcher" aria-label="Available languages">
64
+ {translationLinks.map(({ code, href, current }) => (
65
+ <a hreflang={code} href={href} aria-current={current ? "true" : undefined}>
66
+ {languageLabel(code)}
67
+ </a>
68
+ ))}
69
+ </nav>
70
+ )}
71
+ </header>
72
+ {post.featuredImage && (
73
+ <img
74
+ class="featured"
75
+ src={post.featuredImage.src}
76
+ alt={post.featuredImage.alt}
77
+ width={post.featuredImage.width}
78
+ height={post.featuredImage.height}
79
+ fetchpriority="high"
80
+ decoding="async"
81
+ />
82
+ )}
83
+ <div class:list={["article-grid", { "article-grid--single": !presentation.aside }]}>
84
+ {presentation.aside && (
85
+ <aside>
86
+ <a href={blogHref}>← All articles</a>
87
+ {tagLinks.length > 0 && <div class="article-tags">{tagLinks.map((tag) => tag.href ? <a href={tag.href}>{tag.label}</a> : <span>{tag.label}</span>)}</div>}
88
+ </aside>
89
+ )}
90
+ <div class="blog-main">
91
+ <div class="blog-body" set:html={bodyHtml}></div>
92
+ {gated && !unlock && (
93
+ <BlogPaywall access={post.access ?? "authenticated"} next={next} signedIn={!!viewer} loginHref={loginHref} />
94
+ )}
95
+ {!presentation.aside && tagLinks.length > 0 && (
96
+ <ul class="post-tags" aria-label="Tags">
97
+ {tagLinks.map((tag) => <li>{tag.href ? <a href={tag.href}>{tag.label}</a> : <span>{tag.label}</span>}</li>)}
98
+ </ul>
99
+ )}
100
+ <ShareRow links={share} />
101
+ </div>
102
+ </div>
103
+ {presentation.reactions && <ReactionBar collection="blog" slug={post.slug} />}
104
+ </article>
105
+ </Layout>
106
+
107
+ <style>
108
+ .article-shell { width: min(1120px, calc(100% - 2rem)); margin-inline: auto; padding-block: clamp(2rem, 5vw, 5rem); }
109
+ .article-shell :global(.breadcrumbs) { font-size: .84rem; }
110
+ .article-shell :global(.breadcrumbs a) { color: var(--color-accent-ink); font-weight: 700; }
111
+ header { max-width: 880px; margin: clamp(2.5rem, 6vw, 5rem) auto clamp(2rem, 5vw, 3.5rem); text-align: center; }
112
+ header .eyebrow { justify-content: center; }
113
+ h1 { margin-top: .9rem; font-size: clamp(2.5rem, 6vw, 5rem); line-height: 1.02; }
114
+ .dek { max-width: 68ch; margin: 1.5rem auto 0; color: var(--color-muted); font-size: clamp(1rem, 2vw, 1.2rem); }
115
+ .byline { display: flex; justify-content: center; gap: .55rem; margin-top: 1.35rem; color: var(--color-muted); font-size: .85rem; }
116
+ .byline-author { color: var(--color-accent-ink); font-weight: 700; text-decoration: none; }
117
+ .byline-author:hover { text-decoration: underline; text-underline-offset: .18em; }
118
+ .lang-switcher { display: flex; flex-wrap: wrap; justify-content: center; gap: .4rem; margin-top: 1.1rem; }
119
+ .lang-switcher a { padding: .28rem .6rem; border: 1px solid var(--color-border); border-radius: var(--radius-full); color: var(--color-muted); font-size: .78rem; font-weight: 600; text-decoration: none; }
120
+ .lang-switcher a:hover { color: var(--color-accent-ink); border-color: var(--color-accent); }
121
+ .lang-switcher a[aria-current="true"] { color: var(--color-accent-ink); border-color: var(--color-accent); background: var(--color-surface); }
122
+ .featured { display: block; width: 100%; max-height: 620px; object-fit: cover; border-radius: var(--radius-lg); box-shadow: var(--shadow-md); }
123
+ .article-grid { display: grid; grid-template-columns: 190px minmax(0, 720px); justify-content: center; gap: clamp(2rem, 6vw, 5rem); margin-top: clamp(3rem, 7vw, 5.5rem); }
124
+ aside { align-self: start; position: sticky; top: 7rem; }
125
+ aside > a { color: var(--color-accent-ink); font-size: .88rem; font-weight: 700; }
126
+ .article-grid--single { grid-template-columns: minmax(0, 720px); }
127
+ .article-tags { display: flex; flex-wrap: wrap; gap: .45rem; margin-top: 1.2rem; }
128
+ .article-tags > *, .post-tags :is(a, span) { padding: .3rem .55rem; border: 1px solid var(--color-border); border-radius: var(--radius-full); color: var(--color-muted); font-size: .7rem; text-decoration: none; }
129
+ .article-tags a:hover, .post-tags a:hover { color: var(--color-accent-ink); border-color: var(--color-accent); }
130
+ .post-tags { display: flex; flex-wrap: wrap; gap: .45rem; margin: 2rem 0 0; padding: 0; list-style: none; }
131
+ .blog-main { min-width: 0; }
132
+ .blog-body { min-width: 0; font-size: 1.05rem; line-height: 1.78; }
133
+ .blog-body :global(p), .blog-body :global(ul), .blog-body :global(ol), .blog-body :global(blockquote), .blog-body :global(figure), .blog-body :global(table), .blog-body :global(pre) { margin-block: 1.25rem; }
134
+ .blog-body :global(h2), .blog-body :global(h3), .blog-body :global(h4) { scroll-margin-top: 7rem; margin: 2.8rem 0 1rem; line-height: 1.18; }
135
+ .blog-body :global(h2) { font-size: clamp(1.75rem, 3vw, 2.35rem); }
136
+ .blog-body :global(h3) { font-size: clamp(1.35rem, 2.4vw, 1.75rem); }
137
+ .blog-body :global(a) { color: var(--color-accent-ink); text-decoration: underline; text-underline-offset: .18em; }
138
+ .blog-body :global(ul), .blog-body :global(ol) { padding-left: 1.4rem; }
139
+ .blog-body :global(li) { margin-block: .45rem; }
140
+ .blog-body :global(blockquote) { padding: .8rem 1.25rem; border-left: 4px solid var(--color-accent); background: var(--color-surface); color: var(--color-muted); }
141
+ .blog-body :global(img) { display: block; width: auto; max-width: 100%; margin-inline: auto; border-radius: var(--radius-md); }
142
+ .blog-body :global(figcaption) { margin-top: .5rem; color: var(--color-muted); text-align: center; font-size: .78rem; }
143
+ .blog-body :global(table) { display: block; width: 100%; overflow-x: auto; border-collapse: collapse; }
144
+ .blog-body :global(th), .blog-body :global(td) { padding: .7rem; border: 1px solid var(--color-border); text-align: left; }
145
+ @media (max-width: 760px) { .article-grid { grid-template-columns: 1fr; } aside { position: static; } .article-tags { display: none; } }
146
+ </style>
@@ -11,6 +11,7 @@ import {
11
11
  type PublicationCollectionV1,
12
12
  type PublicationRoutePlanV1,
13
13
  } from "@tot/public-runtime";
14
+ import type { BlogSettings } from "./types.js";
14
15
 
15
16
  export interface CollectionPathsV1 {
16
17
  /** The collection index, page 1. */
@@ -23,10 +24,21 @@ export interface CollectionPathsV1 {
23
24
  rss: string;
24
25
  }
25
26
 
26
- /** The collection paths for the space the request resolved to (the root space otherwise). */
27
+ /**
28
+ * The collection paths for the space the request resolved to (the root space otherwise).
29
+ *
30
+ * `opts.postBase`, when given, replaces the collection's declared base for the `post` route
31
+ * only — every other route (index, page, author, category, tag, rss) keeps resolving from the
32
+ * manifest as before. This is the one constructor a tenant's declared
33
+ * `BlogSettings.postsAtRoot` goes through (see `./types.ts`): callers pass `postBase: ""` to
34
+ * serve posts at the mount root while the archive stays at its usual base. Still a single
35
+ * spelling of the path shape — never a second one — because every call still ends in
36
+ * `publicationCollectionPathV1`.
37
+ */
27
38
  export function collectionPathsV1(
28
39
  locals: { publicationRoute?: PublicationRoutePlanV1 },
29
40
  collection: PublicationCollectionV1,
41
+ opts?: { postBase?: string },
30
42
  ): CollectionPathsV1 {
31
43
  const plan = locals.publicationRoute;
32
44
  const manifest = plan && plan.action === "render" ? plan.manifest : null;
@@ -34,13 +46,22 @@ export function collectionPathsV1(
34
46
  const base = publicationCollectionBaseV1(manifest, collection);
35
47
  const path = (route: Parameters<typeof publicationCollectionPathV1>[2]) =>
36
48
  publicationCollectionPathV1(mount, base, route);
49
+ const postBase = opts?.postBase;
37
50
  return {
38
51
  index: path({ kind: "index" }),
39
52
  page: (page) => path({ kind: "page", page }),
40
- post: (slug) => path({ kind: "post", slug }),
53
+ post: (slug) =>
54
+ postBase !== undefined
55
+ ? publicationCollectionPathV1(mount, postBase, { kind: "post", slug })
56
+ : path({ kind: "post", slug }),
41
57
  author: (slug) => path({ kind: "author", slug }),
42
58
  category: (slug) => path({ kind: "category", slug }),
43
59
  tag: (slug) => path({ kind: "tag", slug }),
44
60
  rss: path({ kind: "rss" }),
45
61
  };
46
62
  }
63
+
64
+ /** `collectionPathsV1`'s `postBase` override for `/blog`, from the tenant's own declared settings. */
65
+ export function blogPathOptsV1(settings: BlogSettings | null | undefined): { postBase?: string } {
66
+ return settings?.postsAtRoot ? { postBase: "" } : {};
67
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * The resolved view for one blog post — the frontmatter `pages/blog/[slug].astro` used to compute
3
+ * inline, factored out so a SECOND route can serve the same post at a different URL shape.
4
+ * `pages/[...slug].astro` calls this when the tenant declares `BlogSettings.postsAtRoot` (see
5
+ * `./types.js`), so a root-level request renders the identical article the `/blog/<slug>` route
6
+ * would, through the one resolver rather than a second re-implementation.
7
+ */
8
+ import type { AstroGlobal } from "astro";
9
+ import { getBlogPost, getBlogSettings, listBlogPosts, resolveBlogRedirect } from "./provider.js";
10
+ import { authorSlug, categorySlug, getTranslations } from "./collection.js";
11
+ import {
12
+ bylineAuthor,
13
+ resolveBlogPresentation,
14
+ shareLinks,
15
+ type BlogPresentation,
16
+ type ShareLink,
17
+ } from "./presentation.js";
18
+ import { homeCrumbLabel, siteTrail, type Crumb } from "@/lib/breadcrumbs.js";
19
+ import { viewerHasBlogAccess } from "./access.js";
20
+ import { articleBodyHtml } from "./teaser.js";
21
+ import { readViewerSession, safeNext } from "@/lib/auth/route.js";
22
+ import { buildAlternates, type Alternate } from "@/lib/seo/alternates.js";
23
+ import { breadcrumbLd, blogPostingLd } from "@/lib/jsonld.js";
24
+ import { collectionPathsV1, blogPathOptsV1 } from "./collectionPaths.js";
25
+ import type { BlogPost } from "./types.js";
26
+ import type { SessionRecord } from "@/lib/auth/session.js";
27
+
28
+ export interface BlogPostViewPropsV1 {
29
+ post: BlogPost;
30
+ /** Tenant-relative path this post actually resolved at (root or `/blog/<slug>`). */
31
+ path: string;
32
+ bodyHtml: string;
33
+ gated: boolean;
34
+ unlock: boolean;
35
+ next: string;
36
+ loginHref: string;
37
+ viewer: SessionRecord | null;
38
+ canonical: string;
39
+ presentation: BlogPresentation;
40
+ author: string | null;
41
+ authorHref: string | null;
42
+ authorUrl: string | undefined;
43
+ tagLinks: { label: string; href: string | null }[];
44
+ articleLd: ReturnType<typeof blogPostingLd>;
45
+ trail: Crumb[];
46
+ crumbs: ReturnType<typeof breadcrumbLd>;
47
+ blogHref: string;
48
+ share: ShareLink[];
49
+ translationLinks: { code: string; href: string; current: boolean }[];
50
+ alternates: Alternate[];
51
+ lang: string | undefined;
52
+ }
53
+
54
+ export type BlogPostViewResultV1 =
55
+ | { status: "not_found" }
56
+ | { status: "redirect"; to: string }
57
+ | { status: "ok"; props: BlogPostViewPropsV1 };
58
+
59
+ /** Resolve one post, by slug, into everything `BlogPostArticle.astro` renders. */
60
+ export async function loadBlogPostViewV1(Astro: AstroGlobal, slug: string): Promise<BlogPostViewResultV1> {
61
+ const { tenant, canonicalBase, basePath, content, publicationTimestamp } = Astro.locals;
62
+ const blogSettings = await getBlogSettings(tenant.tenant_id, content);
63
+ const blogPaths = collectionPathsV1(Astro.locals, "blog", blogPathOptsV1(blogSettings));
64
+ const post = await getBlogPost(tenant.tenant_id, slug, content, publicationTimestamp);
65
+ if (!post) {
66
+ const redirect = await resolveBlogRedirect(tenant.tenant_id, slug, content);
67
+ if (redirect) return { status: "redirect", to: `${basePath}${blogPaths.post(redirect)}` };
68
+ return { status: "not_found" };
69
+ }
70
+
71
+ const path = blogPaths.post(post.slug);
72
+ const viewer = await readViewerSession(Astro);
73
+ const gated = (post.access ?? "public") !== "public";
74
+ const unlock = viewerHasBlogAccess(viewer, post);
75
+ const bodyHtml = articleBodyHtml(post.contentHtml, { gated, unlock });
76
+ const next = safeNext(`${basePath}${blogPaths.post(post.slug)}`);
77
+ const loginHref = `/auth/login?next=${encodeURIComponent(next)}`;
78
+
79
+ const canonical = new URL(blogPaths.post(post.slug), canonicalBase + "/").toString();
80
+ const presentation = resolveBlogPresentation(blogSettings);
81
+ const author = bylineAuthor(post.author);
82
+
83
+ const listed = await listBlogPosts(tenant.tenant_id, content, publicationTimestamp);
84
+ const authorArchiveSlug = author ? authorSlug(author) : "";
85
+ const authorHasArchive = authorArchiveSlug !== "" && listed.some(
86
+ (p) => authorSlug(p.author) === authorArchiveSlug,
87
+ );
88
+ const archivedTags = new Set(listed.flatMap((p) => p.tags.map(categorySlug)));
89
+ const tagLinks = post.tags.map((tag) => {
90
+ const tagSlug = categorySlug(tag);
91
+ return { label: tag, href: tagSlug && archivedTags.has(tagSlug) ? `${basePath}${blogPaths.tag(tagSlug)}` : null };
92
+ });
93
+ const authorUrl = authorHasArchive
94
+ ? new URL(blogPaths.author(authorArchiveSlug), canonicalBase + "/").toString()
95
+ : undefined;
96
+ const articleLd = blogPostingLd(post, {
97
+ url: canonical,
98
+ canonicalBase,
99
+ publisherName: tenant.displayName,
100
+ authorUrl,
101
+ });
102
+ const trail = siteTrail(
103
+ homeCrumbLabel(await content.getChrome()),
104
+ { label: "Blog", href: blogPaths.index },
105
+ { label: post.title, href: blogPaths.post(post.slug) },
106
+ );
107
+ const crumbs = breadcrumbLd(trail, canonicalBase);
108
+ const blogHref = `${basePath}${blogPaths.index}`;
109
+ const authorHref = authorHasArchive ? `${basePath}${blogPaths.author(authorArchiveSlug)}` : null;
110
+ const share = shareLinks(presentation.share, {
111
+ url: canonical,
112
+ title: post.title,
113
+ image: post.featuredImage ? new URL(post.featuredImage.src, canonicalBase + "/").toString() : undefined,
114
+ });
115
+
116
+ const translations = post.translationGroupId
117
+ ? await getTranslations(tenant.tenant_id, "blog", post.slug, { content, now: publicationTimestamp })
118
+ : [];
119
+ const alternates = buildAlternates(translations, (s) =>
120
+ new URL(blogPaths.post(s), canonicalBase + "/").toString());
121
+ const lang = alternates.length ? (post.inLanguage ?? "en") : undefined;
122
+ // The on-page language switcher stays same-origin (preview-host-relative), unlike `alternates`
123
+ // (absolute canonical URLs for the <link rel="alternate"> head tags) — a path-prefixed preview
124
+ // host must never send a reader off to the production domain.
125
+ const translationLinks = translations.length > 1
126
+ ? translations.map((variant) => ({
127
+ code: variant.inLanguage ?? "en",
128
+ href: `${basePath}${blogPaths.post(variant.slug)}`,
129
+ current: variant.slug === post.slug,
130
+ }))
131
+ : [];
132
+
133
+ return {
134
+ status: "ok",
135
+ props: {
136
+ post, path, bodyHtml, gated, unlock, next, loginHref, viewer, canonical, presentation, author,
137
+ authorHref, authorUrl, tagLinks, articleLd, trail, crumbs, blogHref, share, translationLinks,
138
+ alternates, lang,
139
+ },
140
+ };
141
+ }
@@ -77,6 +77,16 @@ export interface BlogSettings {
77
77
  reactions?: boolean;
78
78
  /** Share row after the post's tags, in this order. `[]` renders none; absent uses `DEFAULT_SHARE_TARGETS`. */
79
79
  share?: string[];
80
+ /**
81
+ * Declared permalink shape: `true` serves individual posts at the site root
82
+ * (`/<slug>/`, no `/blog` prefix), matching a migrated source whose archive stays
83
+ * at `/blog/` but whose singular posts never carried that prefix (a common
84
+ * WordPress permalink split: a static "posts page" plus `/%postname%/`).
85
+ * Default false/absent keeps every post under `/blog/<slug>/`. The index, author,
86
+ * category and tag archives are unaffected either way — only the `post` route
87
+ * moves. Declared per tenant; never branched on tenant id.
88
+ */
89
+ postsAtRoot?: boolean;
80
90
  }
81
91
 
82
92
  /**
@@ -5,10 +5,10 @@
5
5
  * Paths are spelled exactly as the root sitemap lists them.
6
6
  */
7
7
  import type { APIContext } from "astro";
8
- import { listBlogPosts } from "@/lib/blog/provider.js";
8
+ import { getBlogSettings, listBlogPosts } from "@/lib/blog/provider.js";
9
9
  import { listBuildPosts } from "@/lib/the-build/provider.js";
10
10
  import { isIndexableHtml, rawPageCanonicalUrl } from "@/lib/rawMarketingHtml.js";
11
- import { collectionPathsV1 } from "@/lib/blog/collectionPaths.js";
11
+ import { collectionPathsV1, blogPathOptsV1 } from "@/lib/blog/collectionPaths.js";
12
12
 
13
13
  export interface RootDiscoveryDocumentV1 {
14
14
  kind: "home" | "page" | "post";
@@ -33,11 +33,12 @@ function htmlTitle(html: string): string {
33
33
  /** Root listable posts (blog and the-build), newest first as the collections list them. */
34
34
  export async function rootPostDocumentsV1(locals: APIContext["locals"]): Promise<RootDiscoveryDocumentV1[]> {
35
35
  const { tenant, content, publicationTimestamp } = locals;
36
- const [blogPosts, buildPosts] = await Promise.all([
36
+ const [blogPosts, buildPosts, blogSettings] = await Promise.all([
37
37
  listBlogPosts(tenant.tenant_id, content, publicationTimestamp),
38
38
  listBuildPosts(tenant.tenant_id, { content, now: publicationTimestamp }),
39
+ getBlogSettings(tenant.tenant_id, content),
39
40
  ]);
40
- const blogPaths = collectionPathsV1(locals, "blog");
41
+ const blogPaths = collectionPathsV1(locals, "blog", blogPathOptsV1(blogSettings));
41
42
  const buildPaths = collectionPathsV1(locals, "the-build");
42
43
  return [
43
44
  ...blogPosts.map((post) => ({ post, paths: blogPaths })),
@@ -15,6 +15,9 @@ import { truncate } from "@/lib/format";
15
15
  import { rawHtmlPolicy, rawHtmlResponse, rawPageCanonicalUrl } from "@/lib/rawMarketingHtml";
16
16
  import { renderRawComplianceTop, renderRawComplianceBottom } from "@/lib/compliance/rawComplianceNotices";
17
17
  import { styleGuideAliasRewrite } from "@/lib/styleGuidePaths";
18
+ import { getBlogSettings } from "@/lib/blog/provider.js";
19
+ import { loadBlogPostViewV1, type BlogPostViewPropsV1 } from "@/lib/blog/postView.js";
20
+ import BlogPostArticle from "@/components/blog/BlogPostArticle.astro";
18
21
 
19
22
  const { tenant, canonicalBase, content, basePath, apiBasePath, capabilities, features } =
20
23
  Astro.locals;
@@ -41,51 +44,73 @@ if (styleGuideTarget !== undefined) {
41
44
  return Astro.rewrite(styleGuideTarget);
42
45
  }
43
46
 
44
- // Raw tenant-authored marketing subpage (vendor-injected HTML) — serve the
45
- // self-contained document VERBATIM only when rawHtmlPolicy says this tenant may
46
- // bypass Layout. Regulated commerce tenants fall through to platform-composed
47
- // pages so compliance UI cannot be removed by raw HTML.
48
- const rawHtml = await content.getPageHtml(slug);
49
- const rawPolicy = rawHtmlPolicy(tenant);
50
- if (rawHtml && rawPolicy.allowed) {
51
- // Shared chrome: a body-only fragment gets wrapped in the tenant's chrome.html;
52
- // a full document serves verbatim (backward-compatible). See rawMarketingHtml.ts.
53
- const chromeWrapper = await content.getChromeHtml(`/${slug}`);
54
- const notices = {
55
- nicotineWarning: tenant.compliance?.nicotineWarning,
56
- pactAct: !!tenant.compliance?.pactAct,
57
- minAge: tenant.compliance?.minAge,
58
- shippingRestriction: tenant.compliance?.shippingRestriction,
59
- exciseTaxEnabled: capabilities.exciseTax.enabled && features.catalog,
60
- };
61
- return rawHtmlResponse(rawHtml, {
62
- nonce: Astro.locals.cspNonce,
63
- basePath,
64
- apiBasePath,
65
- chromeWrapper,
66
- plan: Astro.locals.integrationPlan,
67
- consentSnapshot: Astro.locals.consentSnapshot,
68
- canonicalUrl: rawPageCanonicalUrl(canonicalBase, slug),
69
- complianceTop: rawPolicy.regulated ? renderRawComplianceTop(notices) : undefined,
70
- complianceBottom: rawPolicy.regulated ? renderRawComplianceBottom(notices) : undefined,
71
- });
47
+ // A tenant that declares `BlogSettings.postsAtRoot` (see lib/blog/types.ts) serves its
48
+ // individual posts at the site root — a single-segment slug tries the blog collection
49
+ // before falling through to a raw marketing page or a CMS page. Multi-segment slugs are
50
+ // never posts, so this never shadows a nested marketing/CMS path.
51
+ let blogPostProps: BlogPostViewPropsV1 | null = null;
52
+ if (!slug.includes("/")) {
53
+ const blogSettings = await getBlogSettings(tenant.tenant_id, content);
54
+ if (blogSettings?.postsAtRoot) {
55
+ const result = await loadBlogPostViewV1(Astro, slug);
56
+ if (result.status === "redirect") return Astro.redirect(result.to, 308);
57
+ if (result.status === "ok") blogPostProps = result.props;
58
+ }
72
59
  }
73
60
 
74
- const page = await content.getPage(slug);
75
- if (!page) return new Response(null, { status: 404 });
61
+ let page: Awaited<ReturnType<typeof content.getPage>> | null = null;
62
+ if (!blogPostProps) {
63
+ // Raw tenant-authored marketing subpage (vendor-injected HTML) — serve the
64
+ // self-contained document VERBATIM only when rawHtmlPolicy says this tenant may
65
+ // bypass Layout. Regulated commerce tenants fall through to platform-composed
66
+ // pages so compliance UI cannot be removed by raw HTML.
67
+ const rawHtml = await content.getPageHtml(slug);
68
+ const rawPolicy = rawHtmlPolicy(tenant);
69
+ if (rawHtml && rawPolicy.allowed) {
70
+ // Shared chrome: a body-only fragment gets wrapped in the tenant's chrome.html;
71
+ // a full document serves verbatim (backward-compatible). See rawMarketingHtml.ts.
72
+ const chromeWrapper = await content.getChromeHtml(`/${slug}`);
73
+ const notices = {
74
+ nicotineWarning: tenant.compliance?.nicotineWarning,
75
+ pactAct: !!tenant.compliance?.pactAct,
76
+ minAge: tenant.compliance?.minAge,
77
+ shippingRestriction: tenant.compliance?.shippingRestriction,
78
+ exciseTaxEnabled: capabilities.exciseTax.enabled && features.catalog,
79
+ };
80
+ return rawHtmlResponse(rawHtml, {
81
+ nonce: Astro.locals.cspNonce,
82
+ basePath,
83
+ apiBasePath,
84
+ chromeWrapper,
85
+ plan: Astro.locals.integrationPlan,
86
+ consentSnapshot: Astro.locals.consentSnapshot,
87
+ canonicalUrl: rawPageCanonicalUrl(canonicalBase, slug),
88
+ complianceTop: rawPolicy.regulated ? renderRawComplianceTop(notices) : undefined,
89
+ complianceBottom: rawPolicy.regulated ? renderRawComplianceBottom(notices) : undefined,
90
+ });
91
+ }
76
92
 
77
- const crumbs = siteTrail(
78
- homeCrumbLabel(await Astro.locals.content.getChrome()),
79
- { label: page.title, href: `/${page.slug}` },
80
- );
93
+ page = await content.getPage(slug);
94
+ if (!page) return new Response(null, { status: 404 });
95
+ }
96
+
97
+ // Only reached when no root-level blog post claimed this slug. `page` is always truthy by
98
+ // the time this renders (the block above already 404s otherwise) — `[]` here is only to
99
+ // keep `crumbs` non-nullable for the breadcrumbLd/<Breadcrumbs> calls below.
100
+ const crumbs = page
101
+ ? siteTrail(
102
+ homeCrumbLabel(await Astro.locals.content.getChrome()),
103
+ { label: page.title, href: `/${page.slug}` },
104
+ )
105
+ : [];
81
106
 
82
- const description =
83
- page.seoDescription ||
84
- (page.intro ? truncate(page.intro, 160) : undefined);
107
+ const description = page
108
+ ? page.seoDescription || (page.intro ? truncate(page.intro, 160) : undefined)
109
+ : undefined;
85
110
 
86
111
  // Structured pages drive the body from `sections`; legacy pages keep the
87
112
  // sanitized `body_html`. Prefer structured whenever a page ships sections.
88
- const structured = (page.sections?.length ?? 0) > 0;
113
+ const structured = (page?.sections?.length ?? 0) > 0;
89
114
 
90
115
  // Reviewer-only needs-review flags render in preview/dev only — mirrors the
91
116
  // middleware's "production read" definition (no preview host / token / cookie),
@@ -96,7 +121,7 @@ const isPreview =
96
121
  Astro.url.searchParams.has("__preview") ||
97
122
  Astro.cookies.has("tot_preview");
98
123
 
99
- const updatedLabel = page.updated
124
+ const updatedLabel = page?.updated
100
125
  ? new Date(page.updated).toLocaleDateString("en-US", {
101
126
  year: "numeric",
102
127
  month: "long",
@@ -105,44 +130,48 @@ const updatedLabel = page.updated
105
130
  : null;
106
131
  ---
107
132
 
108
- <Layout
109
- title={page.title}
110
- seoTitle={page.seoTitle}
111
- description={description}
112
- path={`/${page.slug}`}
113
- ogType="article"
114
- jsonLd={breadcrumbLd(crumbs, canonicalBase)}
115
- >
116
- <div class="container-prose pt-6">
117
- <Breadcrumbs crumbs={crumbs} />
118
- </div>
133
+ {blogPostProps ? (
134
+ <BlogPostArticle {...blogPostProps} />
135
+ ) : (
136
+ <Layout
137
+ title={page!.title}
138
+ seoTitle={page!.seoTitle}
139
+ description={description}
140
+ path={`/${page!.slug}`}
141
+ ogType="article"
142
+ jsonLd={breadcrumbLd(crumbs, canonicalBase)}
143
+ >
144
+ <div class="container-prose pt-6">
145
+ <Breadcrumbs crumbs={crumbs} />
146
+ </div>
119
147
 
120
- {/* Page hero */}
121
- <header class="container-prose pb-2 pt-8">
122
- {page.eyebrow && <p class="eyebrow eyebrow--tick mb-3">{page.eyebrow}</p>}
123
- <h1 class="display-xl max-w-[20ch]">
124
- {page.title}
125
- </h1>
126
- {page.intro && (
127
- <p class="mt-5 max-w-[60ch] text-[1.1rem] text-[var(--color-muted)]">
128
- {page.intro}
129
- </p>
130
- )}
131
- {updatedLabel && (
132
- <p class="mt-3 text-sm text-[var(--color-muted)]">Last updated {updatedLabel}</p>
133
- )}
134
- {structured && <ProseToc sections={page.sections!} />}
135
- </header>
148
+ {/* Page hero */}
149
+ <header class="container-prose pb-2 pt-8">
150
+ {page!.eyebrow && <p class="eyebrow eyebrow--tick mb-3">{page!.eyebrow}</p>}
151
+ <h1 class="display-xl max-w-[20ch]">
152
+ {page!.title}
153
+ </h1>
154
+ {page!.intro && (
155
+ <p class="mt-5 max-w-[60ch] text-[1.1rem] text-[var(--color-muted)]">
156
+ {page!.intro}
157
+ </p>
158
+ )}
159
+ {updatedLabel && (
160
+ <p class="mt-3 text-sm text-[var(--color-muted)]">Last updated {updatedLabel}</p>
161
+ )}
162
+ {structured && <ProseToc sections={page!.sections!} />}
163
+ </header>
136
164
 
137
- {/* Body — constrained to a readable measure. Structured pages render
138
- framework widgets from `sections`; legacy pages keep the sanitized HTML. */}
139
- <div class="container-prose pb-[clamp(3rem,7vw,6rem)] pt-8">
140
- {structured ? (
141
- <div class="mx-auto max-w-[68ch]">
142
- <ProseSections sections={page.sections!} includeNeedsReview={isPreview} />
143
- </div>
144
- ) : (
145
- <div class="rte mx-auto max-w-[68ch] text-[var(--color-text)]" set:html={page.body_html} />
146
- )}
147
- </div>
148
- </Layout>
165
+ {/* Body — constrained to a readable measure. Structured pages render
166
+ framework widgets from `sections`; legacy pages keep the sanitized HTML. */}
167
+ <div class="container-prose pb-[clamp(3rem,7vw,6rem)] pt-8">
168
+ {structured ? (
169
+ <div class="mx-auto max-w-[68ch]">
170
+ <ProseSections sections={page!.sections!} includeNeedsReview={isPreview} />
171
+ </div>
172
+ ) : (
173
+ <div class="rte mx-auto max-w-[68ch] text-[var(--color-text)]" set:html={page!.body_html} />
174
+ )}
175
+ </div>
176
+ </Layout>
177
+ )}
@@ -1,20 +1,6 @@
1
1
  ---
2
- import Layout from "@/layouts/Layout.astro";
3
- import Breadcrumbs from "@/components/Breadcrumbs.astro";
4
- import BlogPaywall from "@/components/blog/BlogPaywall.astro";
5
- import ReactionBar from "@/components/blog/ReactionBar.astro";
6
- import ShareRow from "@/components/blog/ShareRow.astro";
7
- import { getBlogPost, getBlogSettings, listBlogPosts, resolveBlogRedirect } from "@/lib/blog/provider.js";
8
- import { authorSlug, categorySlug, getTranslations } from "@/lib/blog/collection.js";
9
- import { bylineAuthor, resolveBlogPresentation, shareLinks } from "@/lib/blog/presentation.js";
10
- import { homeCrumbLabel, siteTrail } from "@/lib/breadcrumbs.js";
11
- import { viewerHasBlogAccess } from "@/lib/blog/access.js";
12
- import { articleBodyHtml } from "@/lib/blog/teaser.js";
13
- import { readViewerSession, safeNext } from "@/lib/auth/route.js";
14
- import { buildAlternates } from "@/lib/seo/alternates.js";
15
- import { languageLabel } from "@/lib/i18n.js";
16
- import { breadcrumbLd, blogPostingLd } from "@/lib/jsonld.js";
17
- import { collectionPathsV1 } from "@/lib/blog/collectionPaths.js";
2
+ import BlogPostArticle from "@/components/blog/BlogPostArticle.astro";
3
+ import { loadBlogPostViewV1 } from "@/lib/blog/postView.js";
18
4
 
19
5
  // These redirects also exist in the published artifact: the publication composition lifts this
20
6
  // file into the space's `routes.aliases`, which reach the CDN plane through the live pointer. The
@@ -24,209 +10,13 @@ import { collectionPathsV1 } from "@/lib/blog/collectionPaths.js";
24
10
  // a source the composition actually materializes.
25
11
  export const MATERIALIZED_REDIRECT_SOURCE = "content/blog/redirects.json";
26
12
 
27
- const { tenant, canonicalBase, basePath, content, publicationTimestamp } = Astro.locals;
28
- const blogPaths = collectionPathsV1(Astro.locals, "blog");
29
13
  const slug = Astro.params.slug ?? "";
30
- const post = await getBlogPost(tenant.tenant_id, slug, content, publicationTimestamp);
31
- if (!post) {
32
- const redirect = await resolveBlogRedirect(tenant.tenant_id, slug, content);
33
- // 308, matching the `redirect-308` alias contract the published plane serves, so a renamed slug
34
- // behaves identically on both substrates.
35
- if (redirect) return Astro.redirect(`${basePath}${blogPaths.post(redirect)}`, 308);
36
- return new Response(null, { status: 404 });
37
- }
14
+ const result = await loadBlogPostViewV1(Astro, slug);
38
15
 
39
- // Entitlement (⟂ visibility): a gated post is still published/listed, but an
40
- // unentitled reader sees a teaser + paywall rather than the full body. A teaser
41
- // is a legitimate partial-content 200 — indexable, NOT noindexed (B4.0 spike §4).
42
- const viewer = await readViewerSession(Astro);
43
- const gated = (post.access ?? "public") !== "public";
44
- const unlock = viewerHasBlogAccess(viewer, post);
45
- const bodyHtml = articleBodyHtml(post.contentHtml, { gated, unlock });
46
- const next = safeNext(`${basePath}${blogPaths.post(post.slug)}`);
47
- const loginHref = `/auth/login?next=${encodeURIComponent(next)}`;
48
-
49
- const canonical = new URL(blogPaths.post(post.slug), canonicalBase + "/").toString();
50
- const presentation = resolveBlogPresentation(await getBlogSettings(tenant.tenant_id, content));
51
- // A post that names no author has no byline: never attribute it to anyone (the blog's title, the
52
- // store) its content does not name.
53
- const author = bylineAuthor(post.author);
54
- // The author archive (`/blog/author/<slug>`) lists posts from the curated blog
55
- // index, and 404s for an author with no LISTED post (an unlisted-only or
56
- // index-omitted byline). Resolve that up front so the byline links ONLY when the
57
- // archive will actually render — a byline must never point at a missing page
58
- // (the invariant `blog/author/[author].astro` documents). Same slug + same source
59
- // the archive itself filters on, so the two can't disagree. Tag links follow the
60
- // same rule against `blog/tag/[tag].astro`.
61
- const listed = await listBlogPosts(tenant.tenant_id, content, publicationTimestamp);
62
- const authorArchiveSlug = author ? authorSlug(author) : "";
63
- const authorHasArchive = authorArchiveSlug !== "" && listed.some(
64
- (p) => authorSlug(p.author) === authorArchiveSlug,
65
- );
66
- const archivedTags = new Set(listed.flatMap((p) => p.tags.map(categorySlug)));
67
- const tagLinks = post.tags.map((tag) => {
68
- const slug = categorySlug(tag);
69
- return { label: tag, href: slug && archivedTags.has(slug) ? `${basePath}${blogPaths.tag(slug)}` : null };
70
- });
71
- const authorUrl = authorHasArchive
72
- ? new URL(blogPaths.author(authorArchiveSlug), canonicalBase + "/").toString()
73
- : undefined;
74
- const articleLd = blogPostingLd(post, {
75
- url: canonical,
76
- canonicalBase,
77
- publisherName: tenant.displayName,
78
- authorUrl,
79
- });
80
- const trail = siteTrail(
81
- homeCrumbLabel(await content.getChrome()),
82
- { label: "Blog", href: blogPaths.index },
83
- { label: post.title, href: blogPaths.post(post.slug) },
84
- );
85
- const crumbs = breadcrumbLd(trail, canonicalBase);
86
- const blogHref = `${basePath}${blogPaths.index}`;
87
- const authorHref = authorHasArchive
88
- ? `${basePath}${blogPaths.author(authorArchiveSlug)}`
89
- : null;
90
- const share = shareLinks(presentation.share, {
91
- url: canonical,
92
- title: post.title,
93
- image: post.featuredImage ? new URL(post.featuredImage.src, canonicalBase + "/").toString() : undefined,
94
- });
95
-
96
- // I2 — hreflang alternates + per-variant <html lang>. Singleton ⇒ [] alternates
97
- // and no `lang` override, so a non-translated post is byte-identical to today.
98
- const translations = post.translationGroupId
99
- ? await getTranslations(tenant.tenant_id, "blog", post.slug, { content, now: publicationTimestamp })
100
- : [];
101
- const alternates = buildAlternates(translations, (s) =>
102
- new URL(blogPaths.post(s), canonicalBase + "/").toString(),
103
- );
104
- const lang = alternates.length ? (post.inLanguage ?? "en") : undefined;
16
+ if (result.status === "not_found") return new Response(null, { status: 404 });
17
+ // 308, matching the `redirect-308` alias contract the published plane serves, so a renamed slug
18
+ // behaves identically on both substrates.
19
+ if (result.status === "redirect") return Astro.redirect(result.to, 308);
105
20
  ---
106
21
 
107
- <Layout
108
- title={post.seoTitle || post.title}
109
- description={post.description}
110
- path={blogPaths.post(post.slug)}
111
- ogType="article"
112
- ogImage={post.featuredImage?.src}
113
- noindex={post.status === "unlisted"}
114
- jsonLd={[crumbs, articleLd]}
115
- article={{
116
- publishedTime: post.publishedAt,
117
- modifiedTime: post.modifiedAt,
118
- author: authorUrl,
119
- section: post.categories[0],
120
- tags: post.tags,
121
- }}
122
- imageAlt={post.featuredImage?.alt}
123
- imageWidth={post.featuredImage?.width}
124
- imageHeight={post.featuredImage?.height}
125
- canonicalOverride={post.canonicalUrl}
126
- alternates={alternates}
127
- lang={lang}
128
- >
129
- <article class="article-shell">
130
- <Breadcrumbs crumbs={trail} class="breadcrumbs" />
131
- <header>
132
- {presentation.postEyebrow && post.tags[0] && <p class="eyebrow eyebrow--tick">{post.tags[0]}</p>}
133
- <h1>{post.title}</h1>
134
- {presentation.dek && post.excerpt && <p class="dek">{post.excerpt}</p>}
135
- <div class="byline">
136
- {author && <span>By {authorHref
137
- ? <a href={authorHref} class="byline-author" rel="author">{author}</a>
138
- : <span class="byline-author">{author}</span>}</span>}
139
- {author && <span aria-hidden="true">·</span>}
140
- <time datetime={post.publishedAt}>{new Date(post.publishedAt).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })}</time>
141
- </div>
142
- {translations.length > 1 && (
143
- <nav class="lang-switcher" aria-label="Available languages">
144
- {translations.map((variant) => {
145
- const code = variant.inLanguage ?? "en";
146
- const current = variant.slug === post.slug;
147
- return (
148
- <a
149
- hreflang={code}
150
- href={`${basePath}${blogPaths.post(variant.slug)}`}
151
- aria-current={current ? "true" : undefined}
152
- >{languageLabel(code)}</a>
153
- );
154
- })}
155
- </nav>
156
- )}
157
- </header>
158
- {post.featuredImage && (
159
- <img
160
- class="featured"
161
- src={post.featuredImage.src}
162
- alt={post.featuredImage.alt}
163
- width={post.featuredImage.width}
164
- height={post.featuredImage.height}
165
- fetchpriority="high"
166
- decoding="async"
167
- />
168
- )}
169
- <div class:list={["article-grid", { "article-grid--single": !presentation.aside }]}>
170
- {presentation.aside && (
171
- <aside>
172
- <a href={blogHref}>← All articles</a>
173
- {tagLinks.length > 0 && <div class="article-tags">{tagLinks.map((tag) => tag.href ? <a href={tag.href}>{tag.label}</a> : <span>{tag.label}</span>)}</div>}
174
- </aside>
175
- )}
176
- <div class="blog-main">
177
- <div class="blog-body" set:html={bodyHtml}></div>
178
- {gated && !unlock && (
179
- <BlogPaywall access={post.access ?? "authenticated"} next={next} signedIn={!!viewer} loginHref={loginHref} />
180
- )}
181
- {!presentation.aside && tagLinks.length > 0 && (
182
- <ul class="post-tags" aria-label="Tags">
183
- {tagLinks.map((tag) => <li>{tag.href ? <a href={tag.href}>{tag.label}</a> : <span>{tag.label}</span>}</li>)}
184
- </ul>
185
- )}
186
- <ShareRow links={share} />
187
- </div>
188
- </div>
189
- {presentation.reactions && <ReactionBar collection="blog" slug={post.slug} />}
190
- </article>
191
- </Layout>
192
-
193
- <style>
194
- .article-shell { width: min(1120px, calc(100% - 2rem)); margin-inline: auto; padding-block: clamp(2rem, 5vw, 5rem); }
195
- .article-shell :global(.breadcrumbs) { font-size: .84rem; }
196
- .article-shell :global(.breadcrumbs a) { color: var(--color-accent-ink); font-weight: 700; }
197
- header { max-width: 880px; margin: clamp(2.5rem, 6vw, 5rem) auto clamp(2rem, 5vw, 3.5rem); text-align: center; }
198
- header .eyebrow { justify-content: center; }
199
- h1 { margin-top: .9rem; font-size: clamp(2.5rem, 6vw, 5rem); line-height: 1.02; }
200
- .dek { max-width: 68ch; margin: 1.5rem auto 0; color: var(--color-muted); font-size: clamp(1rem, 2vw, 1.2rem); }
201
- .byline { display: flex; justify-content: center; gap: .55rem; margin-top: 1.35rem; color: var(--color-muted); font-size: .85rem; }
202
- .byline-author { color: var(--color-accent-ink); font-weight: 700; text-decoration: none; }
203
- .byline-author:hover { text-decoration: underline; text-underline-offset: .18em; }
204
- .lang-switcher { display: flex; flex-wrap: wrap; justify-content: center; gap: .4rem; margin-top: 1.1rem; }
205
- .lang-switcher a { padding: .28rem .6rem; border: 1px solid var(--color-border); border-radius: var(--radius-full); color: var(--color-muted); font-size: .78rem; font-weight: 600; text-decoration: none; }
206
- .lang-switcher a:hover { color: var(--color-accent-ink); border-color: var(--color-accent); }
207
- .lang-switcher a[aria-current="true"] { color: var(--color-accent-ink); border-color: var(--color-accent); background: var(--color-surface); }
208
- .featured { display: block; width: 100%; max-height: 620px; object-fit: cover; border-radius: var(--radius-lg); box-shadow: var(--shadow-md); }
209
- .article-grid { display: grid; grid-template-columns: 190px minmax(0, 720px); justify-content: center; gap: clamp(2rem, 6vw, 5rem); margin-top: clamp(3rem, 7vw, 5.5rem); }
210
- aside { align-self: start; position: sticky; top: 7rem; }
211
- aside > a { color: var(--color-accent-ink); font-size: .88rem; font-weight: 700; }
212
- .article-grid--single { grid-template-columns: minmax(0, 720px); }
213
- .article-tags { display: flex; flex-wrap: wrap; gap: .45rem; margin-top: 1.2rem; }
214
- .article-tags > *, .post-tags :is(a, span) { padding: .3rem .55rem; border: 1px solid var(--color-border); border-radius: var(--radius-full); color: var(--color-muted); font-size: .7rem; text-decoration: none; }
215
- .article-tags a:hover, .post-tags a:hover { color: var(--color-accent-ink); border-color: var(--color-accent); }
216
- .post-tags { display: flex; flex-wrap: wrap; gap: .45rem; margin: 2rem 0 0; padding: 0; list-style: none; }
217
- .blog-main { min-width: 0; }
218
- .blog-body { min-width: 0; font-size: 1.05rem; line-height: 1.78; }
219
- .blog-body :global(p), .blog-body :global(ul), .blog-body :global(ol), .blog-body :global(blockquote), .blog-body :global(figure), .blog-body :global(table), .blog-body :global(pre) { margin-block: 1.25rem; }
220
- .blog-body :global(h2), .blog-body :global(h3), .blog-body :global(h4) { scroll-margin-top: 7rem; margin: 2.8rem 0 1rem; line-height: 1.18; }
221
- .blog-body :global(h2) { font-size: clamp(1.75rem, 3vw, 2.35rem); }
222
- .blog-body :global(h3) { font-size: clamp(1.35rem, 2.4vw, 1.75rem); }
223
- .blog-body :global(a) { color: var(--color-accent-ink); text-decoration: underline; text-underline-offset: .18em; }
224
- .blog-body :global(ul), .blog-body :global(ol) { padding-left: 1.4rem; }
225
- .blog-body :global(li) { margin-block: .45rem; }
226
- .blog-body :global(blockquote) { padding: .8rem 1.25rem; border-left: 4px solid var(--color-accent); background: var(--color-surface); color: var(--color-muted); }
227
- .blog-body :global(img) { display: block; width: auto; max-width: 100%; margin-inline: auto; border-radius: var(--radius-md); }
228
- .blog-body :global(figcaption) { margin-top: .5rem; color: var(--color-muted); text-align: center; font-size: .78rem; }
229
- .blog-body :global(table) { display: block; width: 100%; overflow-x: auto; border-collapse: collapse; }
230
- .blog-body :global(th), .blog-body :global(td) { padding: .7rem; border: 1px solid var(--color-border); text-align: left; }
231
- @media (max-width: 760px) { .article-grid { grid-template-columns: 1fr; } aside { position: static; } .article-tags { display: none; } }
232
- </style>
22
+ <BlogPostArticle {...result.props} />
@@ -7,18 +7,19 @@ import { resolveBlogPresentation } from "@/lib/blog/presentation.js";
7
7
  import { homeCrumbLabel, siteTrail } from "@/lib/breadcrumbs.js";
8
8
  import { authorSlug, getAuthors } from "@/lib/blog/collection.js";
9
9
  import { breadcrumbLd } from "@/lib/jsonld.js";
10
- import { collectionPathsV1 } from "@/lib/blog/collectionPaths.js";
10
+ import { collectionPathsV1, blogPathOptsV1 } from "@/lib/blog/collectionPaths.js";
11
11
 
12
12
  // Author archive for the `/blog` collection. Lists exactly the posts whose
13
13
  // bylines link here (same source as /blog/index.astro + /blog/[slug].astro),
14
14
  // so a byline never points at a page missing its post.
15
15
  const { tenant, basePath, canonicalBase, content, publicationTimestamp } = Astro.locals;
16
- const blogPaths = collectionPathsV1(Astro.locals, "blog");
17
16
  const slug = Astro.params.author ?? "";
18
17
 
19
18
  const allPosts = await listBlogPosts(tenant.tenant_id, content, publicationTimestamp);
20
19
  const posts = slug ? allPosts.filter((post) => authorSlug(post.author) === slug) : [];
21
20
  if (posts.length === 0) return new Response(null, { status: 404 });
21
+ const blogSettings = await getBlogSettings(tenant.tenant_id, content);
22
+ const blogPaths = collectionPathsV1(Astro.locals, "blog", blogPathOptsV1(blogSettings));
22
23
 
23
24
  const authorName = posts[0]!.author;
24
25
  const bio = (await getAuthors(tenant.tenant_id, "blog", content))[slug];
@@ -54,7 +55,7 @@ const itemListLd = {
54
55
  url: new URL(blogPaths.post(post.slug), canonicalBase + "/").toString(),
55
56
  })),
56
57
  };
57
- const presentation = resolveBlogPresentation(await getBlogSettings(tenant.tenant_id, content));
58
+ const presentation = resolveBlogPresentation(blogSettings);
58
59
  const trail = siteTrail(
59
60
  homeCrumbLabel(await content.getChrome()),
60
61
  { label: "Blog", href: blogPaths.index },
@@ -7,10 +7,9 @@ import { collectPostsByTerm } from "@/lib/blog/collection.js";
7
7
  import { resolveBlogPresentation } from "@/lib/blog/presentation.js";
8
8
  import { homeCrumbLabel, siteTrail } from "@/lib/breadcrumbs.js";
9
9
  import { breadcrumbLd } from "@/lib/jsonld.js";
10
- import { collectionPathsV1 } from "@/lib/blog/collectionPaths.js";
10
+ import { collectionPathsV1, blogPathOptsV1 } from "@/lib/blog/collectionPaths.js";
11
11
 
12
12
  const { tenant, basePath, canonicalBase, content, publicationTimestamp } = Astro.locals;
13
- const blogPaths = collectionPathsV1(Astro.locals, "blog");
14
13
  const param = Astro.params.category ?? "";
15
14
 
16
15
  // Same source as the /blog index (the collection's curated list), filtered to
@@ -19,7 +18,9 @@ const all = await listBlogPosts(tenant.tenant_id, content, publicationTimestamp)
19
18
  const match = collectPostsByTerm(all, "categories", param);
20
19
  if (!match) return new Response(null, { status: 404 });
21
20
  const { label, posts } = match;
22
- const presentation = resolveBlogPresentation(await getBlogSettings(tenant.tenant_id, content));
21
+ const blogSettings = await getBlogSettings(tenant.tenant_id, content);
22
+ const blogPaths = collectionPathsV1(Astro.locals, "blog", blogPathOptsV1(blogSettings));
23
+ const presentation = resolveBlogPresentation(blogSettings);
23
24
 
24
25
  const path = blogPaths.category(param);
25
26
  const itemList = {
@@ -34,4 +34,5 @@ const tags = [...new Set(posts.flatMap((post) => post.tags))].sort((a, b) => a.l
34
34
  pagePosts={pagePosts}
35
35
  page={1}
36
36
  totalPages={totalPages}
37
+ postsAtRoot={settings?.postsAtRoot}
37
38
  />
@@ -38,4 +38,5 @@ const tags = [...new Set(posts.flatMap((post) => post.tags))].sort((a, b) => a.l
38
38
  pagePosts={sliced.posts}
39
39
  page={sliced.page}
40
40
  totalPages={sliced.totalPages}
41
+ postsAtRoot={settings?.postsAtRoot}
41
42
  />
@@ -7,17 +7,18 @@ import { collectPostsByTerm } from "@/lib/blog/collection.js";
7
7
  import { resolveBlogPresentation } from "@/lib/blog/presentation.js";
8
8
  import { homeCrumbLabel, siteTrail } from "@/lib/breadcrumbs.js";
9
9
  import { breadcrumbLd } from "@/lib/jsonld.js";
10
- import { collectionPathsV1 } from "@/lib/blog/collectionPaths.js";
10
+ import { collectionPathsV1, blogPathOptsV1 } from "@/lib/blog/collectionPaths.js";
11
11
 
12
12
  const { tenant, basePath, canonicalBase, content, publicationTimestamp } = Astro.locals;
13
- const blogPaths = collectionPathsV1(Astro.locals, "blog");
14
13
  const param = Astro.params.tag ?? "";
15
14
 
16
15
  const all = await listBlogPosts(tenant.tenant_id, content, publicationTimestamp);
17
16
  const match = collectPostsByTerm(all, "tags", param);
18
17
  if (!match) return new Response(null, { status: 404 });
19
18
  const { label, posts } = match;
20
- const presentation = resolveBlogPresentation(await getBlogSettings(tenant.tenant_id, content));
19
+ const blogSettings = await getBlogSettings(tenant.tenant_id, content);
20
+ const blogPaths = collectionPathsV1(Astro.locals, "blog", blogPathOptsV1(blogSettings));
21
+ const presentation = resolveBlogPresentation(blogSettings);
21
22
 
22
23
  const path = blogPaths.tag(param);
23
24
  const itemList = {
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import type { APIContext } from "astro";
13
13
  import { nonEmptyCollections } from "@/lib/tot/query.js";
14
- import { listBlogPosts } from "@/lib/blog/provider.js";
14
+ import { getBlogSettings, listBlogPosts } from "@/lib/blog/provider.js";
15
15
  import { compileRouteTierIndexV1 } from "@tot/public-runtime";
16
16
  import { readKv } from "@/lib/runtimeEnv.js";
17
17
  import {
@@ -20,7 +20,7 @@ import {
20
20
  readMountedPublicationDiscoveryV1,
21
21
  rootOwnsDiscoveryPathV1,
22
22
  } from "@/lib/publication/mountedDiscovery.js";
23
- import { collectionPathsV1 } from "@/lib/blog/collectionPaths.js";
23
+ import { collectionPathsV1, blogPathOptsV1 } from "@/lib/blog/collectionPaths.js";
24
24
 
25
25
  /** Collapse HTML/whitespace to a single trimmed line for a link description. */
26
26
  function oneLine(s: string | undefined, max = 140): string | undefined {
@@ -32,12 +32,11 @@ function oneLine(s: string | undefined, max = 140): string | undefined {
32
32
 
33
33
  export async function GET(context: APIContext): Promise<Response> {
34
34
  const { tot, tenant, canonicalBase, content, publicationTimestamp } = context.locals;
35
- const blogPaths = collectionPathsV1(context.locals, "blog");
36
35
  const base = canonicalBase.replace(/\/$/, "");
37
36
 
38
37
  // Use the request-resolved ContentProvider (locals.content) so any published /
39
38
  // dev-draft overlay is reflected, matching sitemap.xml.ts (not a fresh provider).
40
- const [allCollections, activeProducts, rootPages, featured, home, rootBlogPosts] =
39
+ const [allCollections, activeProducts, rootPages, featured, home, rootBlogPosts, blogSettings] =
41
40
  await Promise.all([
42
41
  tot.listCollections(),
43
42
  tot.getAllActiveProducts(),
@@ -45,7 +44,9 @@ export async function GET(context: APIContext): Promise<Response> {
45
44
  tot.getFeatured(12),
46
45
  content.getHome().catch(() => null),
47
46
  listBlogPosts(tenant.tenant_id, content, publicationTimestamp),
47
+ getBlogSettings(tenant.tenant_id, content),
48
48
  ]);
49
+ const blogPaths = collectionPathsV1(context.locals, "blog", blogPathOptsV1(blogSettings));
49
50
  // Empty synthesized collections (no active members) never make the guide —
50
51
  // an LLM shouldn't be pointed at a URL that resolves to zero products.
51
52
  const collections = nonEmptyCollections(allCollections, activeProducts);
@@ -24,7 +24,7 @@ import {
24
24
  readMountedPublicationDiscoveryV1,
25
25
  rootOwnsDiscoveryPathV1,
26
26
  } from "@/lib/publication/mountedDiscovery.js";
27
- import { collectionPathsV1 } from "@/lib/blog/collectionPaths.js";
27
+ import { collectionPathsV1, blogPathOptsV1 } from "@/lib/blog/collectionPaths.js";
28
28
 
29
29
  interface SitemapUrl {
30
30
  loc: string;
@@ -44,7 +44,8 @@ function escapeXml(value: string): string {
44
44
  export async function GET(context: APIContext): Promise<Response> {
45
45
  const { tot, tenant, canonicalBase, content, features, publicationTimestamp } = context.locals;
46
46
  const base = canonicalBase.replace(/\/$/, "");
47
- const blogPaths = collectionPathsV1(context.locals, "blog");
47
+ const blogSettings = await getBlogSettings(tenant.tenant_id, content);
48
+ const blogPaths = collectionPathsV1(context.locals, "blog", blogPathOptsV1(blogSettings));
48
49
  const buildPaths = collectionPathsV1(context.locals, "the-build");
49
50
 
50
51
  const urls: SitemapUrl[] = [{ loc: `${base}/` }];
@@ -93,7 +94,6 @@ export async function GET(context: APIContext): Promise<Response> {
93
94
  // straight off this list (withSitemapRoutes in scripts/publish/lib/routes.mjs),
94
95
  // so listing them here is what makes /blog/page/N/ exist in BOTH serving
95
96
  // planes, not just the SSR Worker.
96
- const blogSettings = await getBlogSettings(tenant.tenant_id, content);
97
97
  const { pageSize } = resolveBlogIndexCopy(blogSettings, tenant.displayName);
98
98
  const totalBlogPages = Math.max(1, Math.ceil(blogPosts.length / pageSize));
99
99
  for (let n = 2; n <= totalBlogPages; n++) urls.push({ loc: `${base}${blogPaths.page(n)}` });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.4.73",
3
+ "version": "2.4.74",
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",