@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.
- package/apps/storefront/src/components/Breadcrumbs.astro +3 -2
- package/apps/storefront/src/components/CollectionCard.astro +3 -2
- package/apps/storefront/src/components/ProductCard.astro +2 -1
- package/apps/storefront/src/components/Seo.astro +6 -4
- package/apps/storefront/src/components/blog/BlogIndexView.astro +7 -2
- package/apps/storefront/src/components/blog/BlogPostArticle.astro +156 -0
- package/apps/storefront/src/components/home/Hero.astro +2 -1
- package/apps/storefront/src/components/marketing/Faq.astro +7 -2
- package/apps/storefront/src/layouts/Layout.astro +25 -5
- package/apps/storefront/src/lib/blog/collectionPaths.ts +23 -2
- package/apps/storefront/src/lib/blog/postView.ts +141 -0
- package/apps/storefront/src/lib/blog/types.ts +10 -0
- package/apps/storefront/src/lib/publication/rootDiscovery.ts +5 -4
- package/apps/storefront/src/lib/seo/faqItems.ts +63 -0
- package/apps/storefront/src/lib/seo/structuredData.ts +267 -0
- package/apps/storefront/src/lib/seo/urlForm.ts +40 -0
- package/apps/storefront/src/lib/storyblok/content-model.ts +7 -0
- package/apps/storefront/src/lib/storyblok/provider.ts +4 -0
- package/apps/storefront/src/pages/[...slug].astro +122 -78
- package/apps/storefront/src/pages/blog/[slug].astro +8 -218
- package/apps/storefront/src/pages/blog/author/[author].astro +5 -3
- package/apps/storefront/src/pages/blog/category/[category].astro +5 -3
- package/apps/storefront/src/pages/blog/index.astro +1 -0
- package/apps/storefront/src/pages/blog/page/[n].astro +1 -0
- package/apps/storefront/src/pages/blog/tag/[tag].astro +5 -3
- package/apps/storefront/src/pages/collections/[handle].astro +8 -1
- package/apps/storefront/src/pages/collections/index.astro +1 -0
- package/apps/storefront/src/pages/index.astro +8 -0
- package/apps/storefront/src/pages/llms.txt.ts +5 -4
- package/apps/storefront/src/pages/products/[handle].astro +11 -1
- package/apps/storefront/src/pages/sitemap.xml.ts +5 -4
- package/package.json +1 -1
- package/packages/cli/src/declared-config.mjs +115 -1
- package/packages/public-runtime/src/declared-tenant-config.d.mts +8 -1
- package/packages/public-runtime/src/declared-tenant-config.mjs +109 -1
- 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(/�?39;|’|’/g, "'")
|
|
22
|
+
.replace(/</g, "<").replace(/>/g, ">").replace(/&/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
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
75
|
-
if (!
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
+
)}
|