@tokenoftrust/storefront-runner 2.4.73 → 2.4.75

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.
Files changed (36) hide show
  1. package/apps/storefront/src/components/Breadcrumbs.astro +3 -2
  2. package/apps/storefront/src/components/CollectionCard.astro +3 -2
  3. package/apps/storefront/src/components/ProductCard.astro +2 -1
  4. package/apps/storefront/src/components/Seo.astro +6 -4
  5. package/apps/storefront/src/components/blog/BlogIndexView.astro +7 -2
  6. package/apps/storefront/src/components/blog/BlogPostArticle.astro +156 -0
  7. package/apps/storefront/src/components/home/Hero.astro +2 -1
  8. package/apps/storefront/src/components/marketing/Faq.astro +7 -2
  9. package/apps/storefront/src/layouts/Layout.astro +25 -5
  10. package/apps/storefront/src/lib/blog/collectionPaths.ts +23 -2
  11. package/apps/storefront/src/lib/blog/postView.ts +141 -0
  12. package/apps/storefront/src/lib/blog/types.ts +10 -0
  13. package/apps/storefront/src/lib/publication/rootDiscovery.ts +5 -4
  14. package/apps/storefront/src/lib/seo/faqItems.ts +63 -0
  15. package/apps/storefront/src/lib/seo/structuredData.ts +267 -0
  16. package/apps/storefront/src/lib/seo/urlForm.ts +40 -0
  17. package/apps/storefront/src/lib/storyblok/content-model.ts +7 -0
  18. package/apps/storefront/src/lib/storyblok/provider.ts +4 -0
  19. package/apps/storefront/src/pages/[...slug].astro +122 -78
  20. package/apps/storefront/src/pages/blog/[slug].astro +8 -218
  21. package/apps/storefront/src/pages/blog/author/[author].astro +5 -3
  22. package/apps/storefront/src/pages/blog/category/[category].astro +5 -3
  23. package/apps/storefront/src/pages/blog/index.astro +1 -0
  24. package/apps/storefront/src/pages/blog/page/[n].astro +1 -0
  25. package/apps/storefront/src/pages/blog/tag/[tag].astro +5 -3
  26. package/apps/storefront/src/pages/collections/[handle].astro +8 -1
  27. package/apps/storefront/src/pages/collections/index.astro +1 -0
  28. package/apps/storefront/src/pages/index.astro +8 -0
  29. package/apps/storefront/src/pages/llms.txt.ts +5 -4
  30. package/apps/storefront/src/pages/products/[handle].astro +11 -1
  31. package/apps/storefront/src/pages/sitemap.xml.ts +5 -4
  32. package/package.json +1 -1
  33. package/packages/cli/src/declared-config.mjs +115 -1
  34. package/packages/public-runtime/src/declared-tenant-config.d.mts +8 -1
  35. package/packages/public-runtime/src/declared-tenant-config.mjs +109 -1
  36. package/packages/public-runtime/src/tenant.ts +40 -0
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The question/answer pairs a page visibly carries, for a declared FAQPage node
3
+ * (lib/seo/structuredData.ts). Derived from the rendered content itself, so the markup can never
4
+ * claim an answer the page does not show: a heading phrased as a question, answered by the text
5
+ * up to the next heading. Fewer than two pairs is not an FAQ.
6
+ */
7
+ import type { ProseSection } from "@/lib/content/prose";
8
+ import { plainText } from "@/lib/content/richtext";
9
+
10
+ export interface FaqItem {
11
+ q: string;
12
+ a: string;
13
+ }
14
+
15
+ const MIN_PAIRS = 2;
16
+
17
+ const isQuestion = (text: string) => text.trim().endsWith("?");
18
+
19
+ function decode(text: string): string {
20
+ return text
21
+ .replace(/ /g, " ").replace(/"/g, '"').replace(/&#0?39;|’|’/g, "'")
22
+ .replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
23
+ }
24
+
25
+ const textOf = (html: string) => decode(html.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
26
+
27
+ function atLeastTwo(items: FaqItem[]): FaqItem[] {
28
+ const answered = items.filter((it) => it.q && it.a);
29
+ return answered.length >= MIN_PAIRS ? answered : [];
30
+ }
31
+
32
+ /** FAQ pairs from sanitized body HTML: each question-phrased h2–h4 and the text after it. */
33
+ export function faqFromHtml(html: string | undefined): FaqItem[] {
34
+ if (!html) return [];
35
+ const parts = html.split(/(<h[2-4][^>]*>[\s\S]*?<\/h[2-4]>)/i);
36
+ const items: FaqItem[] = [];
37
+ for (let i = 1; i < parts.length; i += 2) {
38
+ const q = textOf(parts[i]!);
39
+ if (isQuestion(q)) items.push({ q, a: textOf(parts[i + 1] ?? "") });
40
+ }
41
+ return atLeastTwo(items);
42
+ }
43
+
44
+ /** FAQ pairs from structured prose: each question-phrased heading and the paragraphs/lists after it. */
45
+ export function faqFromSections(sections: ProseSection[] | undefined): FaqItem[] {
46
+ const items: FaqItem[] = [];
47
+ let current: FaqItem | null = null;
48
+ for (const section of sections ?? []) {
49
+ if (section.kind === "heading") {
50
+ current = isQuestion(section.text) ? { q: section.text.trim(), a: "" } : null;
51
+ if (current) items.push(current);
52
+ continue;
53
+ }
54
+ if (!current) continue;
55
+ const text = section.kind === "paragraph"
56
+ ? plainText(section.text)
57
+ : section.kind === "list"
58
+ ? section.items.map(plainText).join(" ")
59
+ : "";
60
+ if (text) current.a = current.a ? `${current.a} ${text}` : text;
61
+ }
62
+ return atLeastTwo(items);
63
+ }
@@ -0,0 +1,267 @@
1
+ /**
2
+ * The declared structured-data profile (`seo.structuredData` in `.tot/config.json`): per page
3
+ * type, exactly which JSON-LD nodes the page emits, in one `@graph`. A migrated store keeps the
4
+ * graph its rankings were earned under — the same node types, ids linking them, and the key
5
+ * fields (name, url, sku, price) a parity check compares — instead of whatever the platform
6
+ * default happens to emit.
7
+ *
8
+ * When a tenant declares a graph for a page type, `composeStructuredData` is the ONLY JSON-LD
9
+ * that page emits: Layout drops the platform defaults and the page's own graphs, and widget
10
+ * emitters (Faq.astro) defer to it. A node whose data the page lacks is omitted, never invented.
11
+ * Pure: pages hand it the data, Layout hands it the tenant and the canonical.
12
+ */
13
+ import type {
14
+ CatalogProduct,
15
+ StructuredDataNodeToken,
16
+ StructuredDataPageType,
17
+ TenantConfig,
18
+ } from "@tot/public-runtime";
19
+ import { productLd } from "../jsonld.js";
20
+ import { pageUrlForm } from "./urlForm.js";
21
+
22
+ type Node = Record<string, unknown>;
23
+ type Profile = NonNullable<NonNullable<TenantConfig["seo"]>["structuredData"]>;
24
+
25
+ /** What a page knows about itself, for the nodes that describe it. Every field is optional. */
26
+ export interface StructuredDataPage {
27
+ pageType: StructuredDataPageType;
28
+ /** The page's primary image, site-relative or absolute. */
29
+ image?: string | null;
30
+ author?: { name: string; url?: string } | null;
31
+ /** The entity's own title (a post or page heading). */
32
+ headline?: string;
33
+ description?: string;
34
+ datePublished?: string;
35
+ dateModified?: string;
36
+ product?: CatalogProduct;
37
+ /** Real sourced `aggregateRating` / `review` (reviewLd output); emitted only when declared. */
38
+ reviews?: Record<string, unknown>;
39
+ faq?: { q: string; a: string }[];
40
+ crumbs?: { label: string; href: string }[];
41
+ items?: { title: string; url: string }[];
42
+ }
43
+
44
+ /** The tenant's declared profile, or null when it declares none. */
45
+ export function structuredDataProfile(tenant: Pick<TenantConfig, "seo"> | undefined): Profile | null {
46
+ return tenant?.seo?.structuredData ?? null;
47
+ }
48
+
49
+ /**
50
+ * A product with more than one variant is a `productGroup` when the profile declares that graph;
51
+ * every other page type maps to itself.
52
+ */
53
+ export function productPageType(profile: Profile | null, product: CatalogProduct): StructuredDataPageType {
54
+ return product.variants.length > 1 && profile?.graphs.productGroup ? "productGroup" : "product";
55
+ }
56
+
57
+ /** The declared node list for a page type; `productGroup` falls back to `product`. */
58
+ export function graphFor(profile: Profile | null, pageType: StructuredDataPageType): StructuredDataNodeToken[] | null {
59
+ if (!profile) return null;
60
+ return profile.graphs[pageType] ?? (pageType === "productGroup" ? profile.graphs.product ?? null : null);
61
+ }
62
+
63
+ const SITE_TOKENS = new Set<StructuredDataNodeToken>(["Organization", "Person", "Organization+Person"]);
64
+ const PAGE_ENTITY_TOKENS = new Set<StructuredDataNodeToken>(["WebPage", "ItemPage", "CollectionPage", "AboutPage", "ContactPage"]);
65
+
66
+ function absolute(value: string, canonicalBase: string): string {
67
+ return new URL(value, canonicalBase + "/").toString();
68
+ }
69
+
70
+ export interface ComposeInput {
71
+ profile: Profile;
72
+ page: StructuredDataPage;
73
+ tenant: TenantConfig;
74
+ canonicalBase: string;
75
+ /** The page's absolute canonical URL, already in the declared URL form. */
76
+ canonical: string;
77
+ /** The page's full `<title>`. */
78
+ documentTitle: string;
79
+ }
80
+
81
+ /** One `@graph` holding exactly the declared nodes the page has data for, or null when the page type declares none. */
82
+ export function composeStructuredData(input: ComposeInput): Node | null {
83
+ const { profile, page, tenant, canonicalBase, canonical, documentTitle } = input;
84
+ const tokens = graphFor(profile, page.pageType);
85
+ if (!tokens) return null;
86
+ const base = canonicalBase.replace(/\/$/, "");
87
+ const lang = tenant.locale || "en";
88
+ const nameOf = (type: "Product" | "ProductGroup" | "Article" | "BlogPosting", own: string | undefined, fallback: "documentTitle" | "title") =>
89
+ (profile.names?.[type] ?? fallback) === "documentTitle" ? documentTitle : own || documentTitle;
90
+
91
+ const siteToken = tokens.find((t) => SITE_TOKENS.has(t));
92
+ const siteTypes = siteToken ? siteToken.split("+") : [];
93
+ const siteId = `${base}/#${siteTypes.includes("Person") ? "person" : "organization"}`;
94
+ const imageUrl = page.image ? absolute(page.image, canonicalBase) : null;
95
+ const imageId = imageUrl ?? undefined;
96
+ const pageToken = tokens.find((t) => PAGE_ENTITY_TOKENS.has(t));
97
+ const pageId = `${canonical}#webpage`;
98
+ const authorId = page.author ? `${page.author.url ? absolute(page.author.url, canonicalBase) : canonical}#author` : undefined;
99
+ const ref = (id: string | undefined) => (id ? { "@id": id } : undefined);
100
+ const pageUrl = (href: string) => pageUrlForm(absolute(href, canonicalBase), tenant.seo);
101
+
102
+ const nodes: Node[] = [];
103
+ for (const token of tokens) {
104
+ const node = buildNode(token);
105
+ if (node) nodes.push(node);
106
+ }
107
+ return { "@context": "https://schema.org", "@graph": nodes };
108
+
109
+ function buildNode(token: StructuredDataNodeToken): Node | null {
110
+ if (SITE_TOKENS.has(token)) {
111
+ const logo = tenant.theme_tokens?.brand?.logoLight ?? tenant.theme_tokens?.brand?.ogImage;
112
+ return {
113
+ "@type": siteTypes.length > 1 ? siteTypes : siteTypes[0],
114
+ "@id": siteId,
115
+ name: tenant.displayName,
116
+ url: base,
117
+ ...(logo && siteTypes.includes("Organization") ? { logo: { "@type": "ImageObject", url: absolute(logo, canonicalBase) } } : {}),
118
+ };
119
+ }
120
+ if (PAGE_ENTITY_TOKENS.has(token)) {
121
+ return {
122
+ "@type": token,
123
+ "@id": pageId,
124
+ url: canonical,
125
+ name: documentTitle,
126
+ ...(page.description ? { description: page.description } : {}),
127
+ isPartOf: { "@id": `${base}/#website` },
128
+ ...(imageId ? { primaryImageOfPage: { "@id": imageId } } : {}),
129
+ ...(page.datePublished ? { datePublished: page.datePublished } : {}),
130
+ ...(page.dateModified ? { dateModified: page.dateModified } : {}),
131
+ inLanguage: lang,
132
+ };
133
+ }
134
+ switch (token) {
135
+ case "WebSite":
136
+ return {
137
+ "@type": "WebSite",
138
+ "@id": `${base}/#website`,
139
+ url: base,
140
+ name: tenant.displayName,
141
+ ...(siteToken ? { publisher: { "@id": siteId } } : {}),
142
+ inLanguage: lang,
143
+ };
144
+ case "ImageObject":
145
+ return imageUrl ? { "@type": "ImageObject", "@id": imageUrl, url: imageUrl, inLanguage: lang } : null;
146
+ case "Person:author":
147
+ return page.author
148
+ ? {
149
+ "@type": "Person",
150
+ "@id": authorId,
151
+ name: page.author.name,
152
+ ...(page.author.url ? { url: pageUrl(page.author.url) } : {}),
153
+ }
154
+ : null;
155
+ case "Article":
156
+ case "BlogPosting": {
157
+ const name = nameOf(token, page.headline, token === "Article" ? "documentTitle" : "title");
158
+ return {
159
+ "@type": token,
160
+ "@id": `${canonical}#richSnippet`,
161
+ headline: page.headline || name,
162
+ name,
163
+ ...(page.description ? { description: page.description } : {}),
164
+ ...(page.datePublished ? { datePublished: page.datePublished } : {}),
165
+ ...(page.dateModified ? { dateModified: page.dateModified } : {}),
166
+ ...(authorId ? { author: { "@id": authorId, name: page.author!.name } } : {}),
167
+ ...(siteToken ? { publisher: { "@id": siteId } } : {}),
168
+ ...(imageId ? { image: { "@id": imageId } } : {}),
169
+ ...(pageToken ? { isPartOf: { "@id": pageId }, mainEntityOfPage: { "@id": pageId } } : {}),
170
+ inLanguage: lang,
171
+ };
172
+ }
173
+ case "Product":
174
+ return page.product ? productNode(page.product) : null;
175
+ case "ProductGroup":
176
+ return page.product ? productGroupNode(page.product) : null;
177
+ case "FAQPage":
178
+ return page.faq?.length
179
+ ? {
180
+ "@type": "FAQPage",
181
+ "@id": `${canonical}#faq`,
182
+ mainEntity: page.faq.map((it) => ({
183
+ "@type": "Question",
184
+ name: it.q,
185
+ acceptedAnswer: { "@type": "Answer", text: it.a },
186
+ })),
187
+ }
188
+ : null;
189
+ case "BreadcrumbList":
190
+ return page.crumbs?.length
191
+ ? {
192
+ "@type": "BreadcrumbList",
193
+ "@id": `${canonical}#breadcrumb`,
194
+ itemListElement: page.crumbs.map((c, i) => ({
195
+ "@type": "ListItem",
196
+ position: i + 1,
197
+ name: c.label,
198
+ item: pageUrl(c.href),
199
+ })),
200
+ }
201
+ : null;
202
+ case "ItemList":
203
+ return page.items?.length
204
+ ? {
205
+ "@type": "ItemList",
206
+ numberOfItems: page.items.length,
207
+ itemListElement: page.items.map((it, i) => ({
208
+ "@type": "ListItem",
209
+ position: i + 1,
210
+ name: it.title,
211
+ url: pageUrlForm(it.url, tenant.seo),
212
+ })),
213
+ }
214
+ : null;
215
+ default:
216
+ return null;
217
+ }
218
+ }
219
+
220
+ function offer(v: CatalogProduct["variants"][number]): Node {
221
+ return {
222
+ "@type": "Offer",
223
+ ...(v.sku ? { sku: v.sku } : {}),
224
+ price: v.price.toFixed(2),
225
+ priceCurrency: v.currency,
226
+ availability: v.inventory.available ? "https://schema.org/InStock" : "https://schema.org/OutOfStock",
227
+ url: canonical,
228
+ ...(siteToken ? { seller: { "@id": siteId } } : {}),
229
+ };
230
+ }
231
+
232
+ function productNode(p: CatalogProduct): Node {
233
+ const { "@context": _context, ...ld } = productLd(p, tenant, canonicalBase) as Node;
234
+ const single = p.variants.length === 1 ? p.variants[0] : undefined;
235
+ return {
236
+ ...ld,
237
+ "@id": `${canonical}#product`,
238
+ name: nameOf("Product", p.title, "title"),
239
+ url: canonical,
240
+ ...(pageToken ? { mainEntityOfPage: { "@id": pageId } } : {}),
241
+ ...(single ? { offers: offer(single) } : {}),
242
+ ...(profile.reviews ? page.reviews ?? {} : {}),
243
+ };
244
+ }
245
+
246
+ function productGroupNode(p: CatalogProduct): Node {
247
+ const name = nameOf("ProductGroup", p.title, "title");
248
+ return {
249
+ "@type": "ProductGroup",
250
+ "@id": `${canonical}#product`,
251
+ name,
252
+ url: canonical,
253
+ productGroupID: p.handle,
254
+ ...(p.seo.description || p.description_text ? { description: p.seo.description || p.description_text.slice(0, 300) } : {}),
255
+ ...(p.vendor ? { brand: { "@type": "Brand", name: p.vendor } } : {}),
256
+ ...(p.options.length ? { variesBy: p.options.map((o) => o.name) } : {}),
257
+ ...(pageToken ? { mainEntityOfPage: { "@id": pageId } } : {}),
258
+ hasVariant: p.variants.map((v) => ({
259
+ "@type": "Product",
260
+ name: `${p.title} - ${v.title}`,
261
+ ...(v.sku ? { sku: v.sku } : {}),
262
+ offers: offer(v),
263
+ })),
264
+ ...(profile.reviews ? page.reviews ?? {} : {}),
265
+ };
266
+ }
267
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The tenant's declared URL form (`seo.trailingSlash` in `.tot/config.json`). A migrated store
3
+ * keeps the URL shape its rankings were earned under: canonicals, og:url, JSON-LD urls, sitemap
4
+ * locs and platform-generated page links all take the same form. Both forms serve the same
5
+ * directory-index file on every substrate, so this changes what we ADVERTISE, never what resolves.
6
+ */
7
+ import type { SeoDeclaration } from "@tot/public-runtime";
8
+
9
+ /** A last segment with a file extension names a file (`/sitemap.xml`), never a page. */
10
+ const FILE_SEGMENT = /\.[a-z0-9]{1,8}$/i;
11
+
12
+ function slashPathname(pathname: string): string {
13
+ if (pathname === "" || pathname.endsWith("/")) return pathname || "/";
14
+ const last = pathname.slice(pathname.lastIndexOf("/") + 1);
15
+ return FILE_SEGMENT.test(last) ? pathname : `${pathname}/`;
16
+ }
17
+
18
+ /**
19
+ * A page path or absolute page URL in the tenant's declared form. Without `trailingSlash` the
20
+ * value is returned unchanged. Query and fragment are kept; non-page values (files, `#x`, `?x`,
21
+ * `mailto:`, protocol-relative) pass through.
22
+ */
23
+ export function pageUrlForm(value: string, seo: SeoDeclaration | undefined): string {
24
+ if (!seo?.trailingSlash || !value) return value;
25
+ if (/^https?:\/\//i.test(value)) {
26
+ const url = new URL(value);
27
+ url.pathname = slashPathname(url.pathname);
28
+ return url.toString();
29
+ }
30
+ if (!value.startsWith("/") || value.startsWith("//")) return value;
31
+ const cut = value.search(/[?#]/);
32
+ const path = cut < 0 ? value : value.slice(0, cut);
33
+ const rest = cut < 0 ? "" : value.slice(cut);
34
+ return slashPathname(path) + rest;
35
+ }
36
+
37
+ /** The absolute canonical URL for a path-only page path, in the declared form. */
38
+ export function canonicalUrl(path: string, canonicalBase: string, seo: SeoDeclaration | undefined): string {
39
+ return pageUrlForm(new URL(path, canonicalBase + "/").toString(), seo);
40
+ }
@@ -304,6 +304,9 @@ export interface HomeContent {
304
304
  /** SEO + social. */
305
305
  seoTitle?: string;
306
306
  seoDescription?: string;
307
+ /** The home page's byline author and primary image, for its structured data. */
308
+ author?: { name: string; url?: string };
309
+ image?: string;
307
310
  /** Commerce home blocks OR marketing blocks — the page branches on tenant
308
311
  * siteType/features and renders the matching set. */
309
312
  blocks: (HomeBlock | MarketingBlock)[];
@@ -344,6 +347,10 @@ export interface EditorialPage {
344
347
  updated?: string;
345
348
  /** When true, listed in the footer policy column. */
346
349
  isPolicy?: boolean;
350
+ /** The page's byline author, named by its structured data (a profile's `Person:author`). */
351
+ author?: { name: string; url?: string };
352
+ /** The page's primary image, site-relative or absolute (a profile's `ImageObject`). */
353
+ image?: string;
347
354
  }
348
355
 
349
356
  // Site chrome (header/footer) is the ONE consolidated schema in
@@ -472,6 +472,8 @@ function mapHome(content: Record<string, any>): HomeContent {
472
472
  return {
473
473
  seoTitle: content.seoTitle ?? content.seo_title,
474
474
  seoDescription: content.seoDescription ?? content.seo_description,
475
+ author: content.author,
476
+ image: content.image,
475
477
  blocks: blocks as HomeContent["blocks"],
476
478
  };
477
479
  }
@@ -497,6 +499,8 @@ function mapPage(content: Record<string, any>, slug: string): EditorialPage {
497
499
  intro: content.intro,
498
500
  updated: content.updated,
499
501
  isPolicy: !!content.isPolicy,
502
+ author: content.author,
503
+ image: content.image,
500
504
  };
501
505
  }
502
506
 
@@ -12,9 +12,13 @@ import ProseSections from "@/components/content/ProseSections.astro";
12
12
  import ProseToc from "@/components/content/ProseToc.astro";
13
13
  import { breadcrumbLd } from "@/lib/jsonld";
14
14
  import { truncate } from "@/lib/format";
15
+ import { faqFromHtml, faqFromSections } from "@/lib/seo/faqItems";
15
16
  import { rawHtmlPolicy, rawHtmlResponse, rawPageCanonicalUrl } from "@/lib/rawMarketingHtml";
16
17
  import { renderRawComplianceTop, renderRawComplianceBottom } from "@/lib/compliance/rawComplianceNotices";
17
18
  import { styleGuideAliasRewrite } from "@/lib/styleGuidePaths";
19
+ import { getBlogSettings } from "@/lib/blog/provider.js";
20
+ import { loadBlogPostViewV1, type BlogPostViewPropsV1 } from "@/lib/blog/postView.js";
21
+ import BlogPostArticle from "@/components/blog/BlogPostArticle.astro";
18
22
 
19
23
  const { tenant, canonicalBase, content, basePath, apiBasePath, capabilities, features } =
20
24
  Astro.locals;
@@ -41,51 +45,77 @@ if (styleGuideTarget !== undefined) {
41
45
  return Astro.rewrite(styleGuideTarget);
42
46
  }
43
47
 
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
- });
48
+ // A tenant that declares `BlogSettings.postsAtRoot` (see lib/blog/types.ts) serves its
49
+ // individual posts at the site root — a single-segment slug tries the blog collection
50
+ // before falling through to a raw marketing page or a CMS page. Multi-segment slugs are
51
+ // never posts, so this never shadows a nested marketing/CMS path.
52
+ // A renamed root post's 308 is the same declared blog redirect the publication composition
53
+ // materializes for the blog collection (blog/[slug].astro declares the same source).
54
+ export const MATERIALIZED_REDIRECT_SOURCE = "content/blog/redirects.json";
55
+
56
+ let blogPostProps: BlogPostViewPropsV1 | null = null;
57
+ if (!slug.includes("/")) {
58
+ const blogSettings = await getBlogSettings(tenant.tenant_id, content);
59
+ if (blogSettings?.postsAtRoot) {
60
+ const result = await loadBlogPostViewV1(Astro, slug);
61
+ if (result.status === "redirect") return Astro.redirect(result.to, 308);
62
+ if (result.status === "ok") blogPostProps = result.props;
63
+ }
72
64
  }
73
65
 
74
- const page = await content.getPage(slug);
75
- if (!page) return new Response(null, { status: 404 });
66
+ let page: Awaited<ReturnType<typeof content.getPage>> | null = null;
67
+ if (!blogPostProps) {
68
+ // Raw tenant-authored marketing subpage (vendor-injected HTML) — serve the
69
+ // self-contained document VERBATIM only when rawHtmlPolicy says this tenant may
70
+ // bypass Layout. Regulated commerce tenants fall through to platform-composed
71
+ // pages so compliance UI cannot be removed by raw HTML.
72
+ const rawHtml = await content.getPageHtml(slug);
73
+ const rawPolicy = rawHtmlPolicy(tenant);
74
+ if (rawHtml && rawPolicy.allowed) {
75
+ // Shared chrome: a body-only fragment gets wrapped in the tenant's chrome.html;
76
+ // a full document serves verbatim (backward-compatible). See rawMarketingHtml.ts.
77
+ const chromeWrapper = await content.getChromeHtml(`/${slug}`);
78
+ const notices = {
79
+ nicotineWarning: tenant.compliance?.nicotineWarning,
80
+ pactAct: !!tenant.compliance?.pactAct,
81
+ minAge: tenant.compliance?.minAge,
82
+ shippingRestriction: tenant.compliance?.shippingRestriction,
83
+ exciseTaxEnabled: capabilities.exciseTax.enabled && features.catalog,
84
+ };
85
+ return rawHtmlResponse(rawHtml, {
86
+ nonce: Astro.locals.cspNonce,
87
+ basePath,
88
+ apiBasePath,
89
+ chromeWrapper,
90
+ plan: Astro.locals.integrationPlan,
91
+ consentSnapshot: Astro.locals.consentSnapshot,
92
+ canonicalUrl: rawPageCanonicalUrl(canonicalBase, slug),
93
+ complianceTop: rawPolicy.regulated ? renderRawComplianceTop(notices) : undefined,
94
+ complianceBottom: rawPolicy.regulated ? renderRawComplianceBottom(notices) : undefined,
95
+ });
96
+ }
97
+
98
+ page = await content.getPage(slug);
99
+ if (!page) return new Response(null, { status: 404 });
100
+ }
76
101
 
77
- const crumbs = siteTrail(
78
- homeCrumbLabel(await Astro.locals.content.getChrome()),
79
- { label: page.title, href: `/${page.slug}` },
80
- );
102
+ // Only reached when no root-level blog post claimed this slug. `page` is always truthy by
103
+ // the time this renders (the block above already 404s otherwise) — `[]` here is only to
104
+ // keep `crumbs` non-nullable for the breadcrumbLd/<Breadcrumbs> calls below.
105
+ const crumbs = page
106
+ ? siteTrail(
107
+ homeCrumbLabel(await Astro.locals.content.getChrome()),
108
+ { label: page.title, href: `/${page.slug}` },
109
+ )
110
+ : [];
81
111
 
82
- const description =
83
- page.seoDescription ||
84
- (page.intro ? truncate(page.intro, 160) : undefined);
112
+ const description = page
113
+ ? page.seoDescription || (page.intro ? truncate(page.intro, 160) : undefined)
114
+ : undefined;
85
115
 
86
116
  // Structured pages drive the body from `sections`; legacy pages keep the
87
117
  // sanitized `body_html`. Prefer structured whenever a page ships sections.
88
- const structured = (page.sections?.length ?? 0) > 0;
118
+ const structured = (page?.sections?.length ?? 0) > 0;
89
119
 
90
120
  // Reviewer-only needs-review flags render in preview/dev only — mirrors the
91
121
  // middleware's "production read" definition (no preview host / token / cookie),
@@ -96,7 +126,7 @@ const isPreview =
96
126
  Astro.url.searchParams.has("__preview") ||
97
127
  Astro.cookies.has("tot_preview");
98
128
 
99
- const updatedLabel = page.updated
129
+ const updatedLabel = page?.updated
100
130
  ? new Date(page.updated).toLocaleDateString("en-US", {
101
131
  year: "numeric",
102
132
  month: "long",
@@ -105,44 +135,58 @@ const updatedLabel = page.updated
105
135
  : null;
106
136
  ---
107
137
 
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>
138
+ {blogPostProps ? (
139
+ <BlogPostArticle {...blogPostProps} />
140
+ ) : (
141
+ <Layout
142
+ title={page!.title}
143
+ seoTitle={page!.seoTitle}
144
+ description={description}
145
+ path={`/${page!.slug}`}
146
+ ogType="article"
147
+ jsonLd={breadcrumbLd(crumbs, canonicalBase)}
148
+ structuredData={{
149
+ pageType: "page",
150
+ headline: page!.title,
151
+ image: page!.image,
152
+ author: page!.author,
153
+ description,
154
+ dateModified: page!.updated,
155
+ faq: structured ? faqFromSections(page!.sections) : faqFromHtml(page!.body_html),
156
+ crumbs,
157
+ }}
158
+ >
159
+ <div class="container-prose pt-6">
160
+ <Breadcrumbs crumbs={crumbs} />
161
+ </div>
119
162
 
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>
163
+ {/* Page hero */}
164
+ <header class="container-prose pb-2 pt-8">
165
+ {page!.eyebrow && <p class="eyebrow eyebrow--tick mb-3">{page!.eyebrow}</p>}
166
+ <h1 class="display-xl max-w-[20ch]">
167
+ {page!.title}
168
+ </h1>
169
+ {page!.intro && (
170
+ <p class="mt-5 max-w-[60ch] text-[1.1rem] text-[var(--color-muted)]">
171
+ {page!.intro}
172
+ </p>
173
+ )}
174
+ {updatedLabel && (
175
+ <p class="mt-3 text-sm text-[var(--color-muted)]">Last updated {updatedLabel}</p>
176
+ )}
177
+ {structured && <ProseToc sections={page!.sections!} />}
178
+ </header>
136
179
 
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>
180
+ {/* Body — constrained to a readable measure. Structured pages render
181
+ framework widgets from `sections`; legacy pages keep the sanitized HTML. */}
182
+ <div class="container-prose pb-[clamp(3rem,7vw,6rem)] pt-8">
183
+ {structured ? (
184
+ <div class="mx-auto max-w-[68ch]">
185
+ <ProseSections sections={page!.sections!} includeNeedsReview={isPreview} />
186
+ </div>
187
+ ) : (
188
+ <div class="rte mx-auto max-w-[68ch] text-[var(--color-text)]" set:html={page!.body_html} />
189
+ )}
190
+ </div>
191
+ </Layout>
192
+ )}