@tokenoftrust/storefront-runner 2.4.74 → 2.4.76
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 +1 -0
- package/apps/storefront/src/components/blog/BlogPostArticle.astro +10 -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/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 +15 -0
- package/apps/storefront/src/pages/blog/author/[author].astro +1 -0
- package/apps/storefront/src/pages/blog/category/[category].astro +1 -0
- package/apps/storefront/src/pages/blog/tag/[tag].astro +1 -0
- 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/products/[handle].astro +12 -1
- package/apps/storefront/src/pages/sitemap.xml.ts +2 -1
- 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
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* `class` adds a hook for the page's own styles (`breadcrumbs` on the blog templates).
|
|
15
15
|
*/
|
|
16
16
|
import { withBase } from "@/lib/basePath";
|
|
17
|
+
import { pageUrlForm } from "@/lib/seo/urlForm";
|
|
17
18
|
import type { Crumb } from "@/lib/breadcrumbs";
|
|
18
19
|
|
|
19
20
|
interface Props {
|
|
@@ -22,7 +23,7 @@ interface Props {
|
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
const { crumbs, class: className } = Astro.props;
|
|
25
|
-
const { basePath } = Astro.locals;
|
|
26
|
+
const { basePath, tenant } = Astro.locals;
|
|
26
27
|
---
|
|
27
28
|
|
|
28
29
|
<nav aria-label="Breadcrumb" class:list={["text-sm text-muted", className]}>
|
|
@@ -43,7 +44,7 @@ const { basePath } = Astro.locals;
|
|
|
43
44
|
</span>
|
|
44
45
|
) : (
|
|
45
46
|
<a
|
|
46
|
-
href={withBase(basePath, crumb.href)}
|
|
47
|
+
href={withBase(basePath, pageUrlForm(crumb.href, tenant.seo))}
|
|
47
48
|
class="inline-flex min-h-11 items-center transition-colors hover:text-primary"
|
|
48
49
|
>
|
|
49
50
|
{crumb.label}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import type { CatalogCollection } from "@tot/public-runtime";
|
|
8
8
|
import Img from "@/components/Img.astro";
|
|
9
9
|
import { withBase } from "@/lib/basePath";
|
|
10
|
+
import { pageUrlForm } from "@/lib/seo/urlForm";
|
|
10
11
|
|
|
11
12
|
interface Props {
|
|
12
13
|
collection: CatalogCollection;
|
|
@@ -14,12 +15,12 @@ interface Props {
|
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
const { collection, productCount } = Astro.props;
|
|
17
|
-
const { basePath } = Astro.locals;
|
|
18
|
+
const { basePath, tenant } = Astro.locals;
|
|
18
19
|
const image = collection.image;
|
|
19
20
|
---
|
|
20
21
|
|
|
21
22
|
<a
|
|
22
|
-
href={withBase(basePath, `/collections/${collection.handle}
|
|
23
|
+
href={withBase(basePath, pageUrlForm(`/collections/${collection.handle}`, tenant.seo))}
|
|
23
24
|
data-astro-prefetch
|
|
24
25
|
class="group relative block aspect-[3/2] overflow-hidden rounded-md bg-border"
|
|
25
26
|
data-reveal
|
|
@@ -15,6 +15,7 @@ import Badge from "@/components/Badge.astro";
|
|
|
15
15
|
import PriceDisplay from "@/components/commerce/PriceDisplay.astro";
|
|
16
16
|
import RatingStars from "@/components/commerce/RatingStars.astro";
|
|
17
17
|
import { withBase } from "@/lib/basePath";
|
|
18
|
+
import { pageUrlForm } from "@/lib/seo/urlForm";
|
|
18
19
|
|
|
19
20
|
interface Props {
|
|
20
21
|
product: CatalogProduct;
|
|
@@ -189,7 +190,7 @@ const quickview = {
|
|
|
189
190
|
{/* `py-3` on an inline box raises the tap target to the 44px minimum
|
|
190
191
|
without changing the line box, so the card layout is untouched. */}
|
|
191
192
|
<a
|
|
192
|
-
href={withBase(basePath, `/products/${product.handle}
|
|
193
|
+
href={withBase(basePath, pageUrlForm(`/products/${product.handle}`, tenant.seo))}
|
|
193
194
|
data-astro-prefetch
|
|
194
195
|
class="py-3 after:absolute after:inset-0 after:z-0 transition-colors motion-safe:group-hover:text-primary"
|
|
195
196
|
>
|
|
@@ -7,6 +7,7 @@ import { articleSeoMetaTags, resolveOgImage, type ArticleMeta } from "@/lib/seo/
|
|
|
7
7
|
import { withXDefault, type Alternate } from "@/lib/seo/alternates.js";
|
|
8
8
|
import { safeJsonScriptContent } from "@/lib/jsonScript";
|
|
9
9
|
import { documentTitle } from "@/lib/seo/documentTitle";
|
|
10
|
+
import { canonicalUrl } from "@/lib/seo/urlForm";
|
|
10
11
|
|
|
11
12
|
interface JsonLd {
|
|
12
13
|
[key: string]: unknown;
|
|
@@ -19,7 +20,8 @@ interface Props {
|
|
|
19
20
|
* Absent ⇒ `title`, suffixed with the store's display name. */
|
|
20
21
|
seoTitle?: string;
|
|
21
22
|
description?: string;
|
|
22
|
-
/** Path-only canonical, e.g. /products/foo. Joined to tenant canonical base
|
|
23
|
+
/** Path-only canonical, e.g. /products/foo. Joined to tenant canonical base, in the
|
|
24
|
+
* tenant's declared URL form (`seo.trailingSlash`). */
|
|
23
25
|
path: string;
|
|
24
26
|
ogType?: "website" | "product" | "article";
|
|
25
27
|
ogImage?: string;
|
|
@@ -73,7 +75,7 @@ const {
|
|
|
73
75
|
|
|
74
76
|
const { tenant, canonicalBase } = Astro.locals;
|
|
75
77
|
const canonical =
|
|
76
|
-
canonicalOverride ??
|
|
78
|
+
canonicalOverride ?? canonicalUrl(path, canonicalBase, tenant.seo);
|
|
77
79
|
const fullTitle = documentTitle(title, seoTitle, tenant.displayName);
|
|
78
80
|
const image = resolveOgImage({
|
|
79
81
|
ogImage, themeOgImage: tenant.theme_tokens?.brand?.ogImage ?? null, canonicalBase,
|
|
@@ -91,8 +93,8 @@ const articleMeta = articleSeoMetaTags({
|
|
|
91
93
|
const graphs = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
|
|
92
94
|
const alternateLinks = withXDefault(alternates);
|
|
93
95
|
const paginationLinks = [
|
|
94
|
-
...(pagination?.prev ? [{ rel: "prev", href:
|
|
95
|
-
...(pagination?.next ? [{ rel: "next", href:
|
|
96
|
+
...(pagination?.prev ? [{ rel: "prev", href: canonicalUrl(pagination.prev, canonicalBase, tenant.seo) }] : []),
|
|
97
|
+
...(pagination?.next ? [{ rel: "next", href: canonicalUrl(pagination.next, canonicalBase, tenant.seo) }] : []),
|
|
96
98
|
];
|
|
97
99
|
---
|
|
98
100
|
|
|
@@ -32,6 +32,16 @@ const {
|
|
|
32
32
|
ogImage={post.featuredImage?.src}
|
|
33
33
|
noindex={post.status === "unlisted"}
|
|
34
34
|
jsonLd={[crumbs, articleLd]}
|
|
35
|
+
structuredData={{
|
|
36
|
+
pageType: "post",
|
|
37
|
+
headline: post.title,
|
|
38
|
+
description: post.description,
|
|
39
|
+
datePublished: post.publishedAt,
|
|
40
|
+
dateModified: post.modifiedAt,
|
|
41
|
+
image: post.featuredImage?.src,
|
|
42
|
+
author: author ? { name: author, url: authorUrl } : null,
|
|
43
|
+
crumbs: trail,
|
|
44
|
+
}}
|
|
35
45
|
article={{
|
|
36
46
|
publishedTime: post.publishedAt,
|
|
37
47
|
modifiedTime: post.modifiedAt,
|
|
@@ -23,6 +23,7 @@ import type { HeroBlock } from "@/lib/storyblok/content-model";
|
|
|
23
23
|
import type { CatalogProduct } from "@tot/public-runtime";
|
|
24
24
|
import Img from "@/components/Img.astro";
|
|
25
25
|
import { withBase } from "@/lib/basePath";
|
|
26
|
+
import { pageUrlForm } from "@/lib/seo/urlForm";
|
|
26
27
|
import { heroSlideshowView } from "@/lib/heroSlideshow";
|
|
27
28
|
|
|
28
29
|
interface Props {
|
|
@@ -261,7 +262,7 @@ const notice = block.notice?.trim();
|
|
|
261
262
|
/>
|
|
262
263
|
</div>
|
|
263
264
|
<a
|
|
264
|
-
href={withBase(basePath, `/products/${featureProduct.handle}
|
|
265
|
+
href={withBase(basePath, pageUrlForm(`/products/${featureProduct.handle}`, Astro.locals.tenant?.seo))}
|
|
265
266
|
class="absolute bottom-4 left-4 right-4 flex items-center justify-between rounded-[var(--radius-md)] bg-[var(--color-surface)]/95 px-4 py-3 text-[var(--color-text)] backdrop-blur"
|
|
266
267
|
>
|
|
267
268
|
<span class="font-display text-sm font-medium">{featureProduct.title}</span>
|
|
@@ -2,11 +2,14 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* <Faq> — a no-JS accordion of question/answer pairs using native
|
|
4
4
|
* <details>/<summary>. Emits FAQPage JSON-LD so answers are eligible for rich
|
|
5
|
-
* results
|
|
5
|
+
* results — unless the tenant declares a structured-data profile, which owns every
|
|
6
|
+
* JSON-LD node on the page (lib/seo/structuredData.ts). Answers are plain text
|
|
7
|
+
* (auto-escaped).
|
|
6
8
|
*/
|
|
7
9
|
import Section from "@/components/Section.astro";
|
|
8
10
|
import type { FaqBlock } from "@/lib/storyblok/content-model";
|
|
9
11
|
import { safeJsonScriptContent } from "@/lib/jsonScript";
|
|
12
|
+
import { structuredDataProfile } from "@/lib/seo/structuredData";
|
|
10
13
|
interface Props {
|
|
11
14
|
block: FaqBlock;
|
|
12
15
|
}
|
|
@@ -22,6 +25,8 @@ const faqLd = {
|
|
|
22
25
|
})),
|
|
23
26
|
};
|
|
24
27
|
const faqJsonLd = safeJsonScriptContent(faqLd);
|
|
28
|
+
// A declared structured-data profile owns every JSON-LD node on the page.
|
|
29
|
+
const emitsJsonLd = !structuredDataProfile(Astro.locals.tenant);
|
|
25
30
|
---
|
|
26
31
|
<Section eyebrow={block.eyebrow} title={block.title} tone="surface" widget="marketing-faq">
|
|
27
32
|
{block.lede && (
|
|
@@ -41,4 +46,4 @@ const faqJsonLd = safeJsonScriptContent(faqLd);
|
|
|
41
46
|
))}
|
|
42
47
|
</div>
|
|
43
48
|
</Section>
|
|
44
|
-
<script type="application/ld+json" set:html={faqJsonLd} is:inline />
|
|
49
|
+
{emitsJsonLd && <script type="application/ld+json" set:html={faqJsonLd} is:inline />}
|
|
@@ -25,6 +25,9 @@ import AuthBadge from "@/components/auth/AuthBadge.astro";
|
|
|
25
25
|
import { referenceTheme } from "@/themes/reference";
|
|
26
26
|
import { mergeTheme } from "@/themes/schema";
|
|
27
27
|
import { organizationLd, websiteLd } from "@/lib/jsonld";
|
|
28
|
+
import { composeStructuredData, structuredDataProfile, type StructuredDataPage } from "@/lib/seo/structuredData";
|
|
29
|
+
import { canonicalUrl } from "@/lib/seo/urlForm";
|
|
30
|
+
import { documentTitle } from "@/lib/seo/documentTitle";
|
|
28
31
|
import { nonceInlineTags, prefixApiFormActions, CHROME_BODY_MARKER } from "@/lib/rawMarketingHtml";
|
|
29
32
|
import { CHROME_BEHAVIOR_JS, CHROME_HEAD_JS, sanitizeSharedChromeFragment } from "@/lib/rawChrome";
|
|
30
33
|
import { ASYNC_CSS_SWAP_JS } from "@/lib/asyncCss";
|
|
@@ -49,6 +52,12 @@ interface Props {
|
|
|
49
52
|
ogImage?: string;
|
|
50
53
|
noindex?: boolean;
|
|
51
54
|
jsonLd?: Record<string, unknown> | Record<string, unknown>[];
|
|
55
|
+
/**
|
|
56
|
+
* What the page knows about itself for the tenant's declared structured-data profile
|
|
57
|
+
* (lib/seo/structuredData.ts). When the profile declares a graph for `pageType`, that graph
|
|
58
|
+
* is the page's ONLY JSON-LD and `jsonLd` is not emitted.
|
|
59
|
+
*/
|
|
60
|
+
structuredData?: StructuredDataPage;
|
|
52
61
|
/** Alternate RSS/Atom feeds this page advertises (threaded to <Seo>). */
|
|
53
62
|
feeds?: { title: string; href: string }[];
|
|
54
63
|
/** A paginated collection's prev/next siblings (threaded to <Seo>). */
|
|
@@ -92,6 +101,7 @@ const {
|
|
|
92
101
|
ogImage,
|
|
93
102
|
noindex,
|
|
94
103
|
jsonLd,
|
|
104
|
+
structuredData,
|
|
95
105
|
feeds,
|
|
96
106
|
pagination,
|
|
97
107
|
article,
|
|
@@ -123,12 +133,22 @@ const integrationPhases = renderIntegrationPhases(
|
|
|
123
133
|
|
|
124
134
|
// Organization JSON-LD is emitted site-wide (brief §8); pages add their own
|
|
125
135
|
// graphs (Product, BreadcrumbList…) via the jsonLd prop.
|
|
136
|
+
// A tenant that declares a graph for this page type gets exactly that graph instead.
|
|
126
137
|
const pageGraphs = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
138
|
+
const profile = structuredDataProfile(tenant);
|
|
139
|
+
const declaredGraph = profile && structuredData
|
|
140
|
+
? composeStructuredData({
|
|
141
|
+
profile,
|
|
142
|
+
page: structuredData,
|
|
143
|
+
tenant,
|
|
144
|
+
canonicalBase,
|
|
145
|
+
canonical: canonicalOverride ?? canonicalUrl(path, canonicalBase, tenant.seo),
|
|
146
|
+
documentTitle: documentTitle(title, seoTitle, tenant.displayName),
|
|
147
|
+
})
|
|
148
|
+
: null;
|
|
149
|
+
const allJsonLd = declaredGraph
|
|
150
|
+
? [declaredGraph]
|
|
151
|
+
: [organizationLd(tenant, canonicalBase), websiteLd(tenant, canonicalBase), ...pageGraphs];
|
|
132
152
|
|
|
133
153
|
const theme = mergeTheme(referenceTheme, tenant.theme_tokens);
|
|
134
154
|
|
|
@@ -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,6 +12,7 @@ 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";
|
|
@@ -48,6 +49,10 @@ if (styleGuideTarget !== undefined) {
|
|
|
48
49
|
// individual posts at the site root — a single-segment slug tries the blog collection
|
|
49
50
|
// before falling through to a raw marketing page or a CMS page. Multi-segment slugs are
|
|
50
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
|
+
|
|
51
56
|
let blogPostProps: BlogPostViewPropsV1 | null = null;
|
|
52
57
|
if (!slug.includes("/")) {
|
|
53
58
|
const blogSettings = await getBlogSettings(tenant.tenant_id, content);
|
|
@@ -140,6 +145,16 @@ const updatedLabel = page?.updated
|
|
|
140
145
|
path={`/${page!.slug}`}
|
|
141
146
|
ogType="article"
|
|
142
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
|
+
}}
|
|
143
158
|
>
|
|
144
159
|
<div class="container-prose pt-6">
|
|
145
160
|
<Breadcrumbs crumbs={crumbs} />
|
|
@@ -70,6 +70,7 @@ const crumbs = breadcrumbLd(trail, canonicalBase);
|
|
|
70
70
|
path={blogPaths.author(slug)}
|
|
71
71
|
fullBleed
|
|
72
72
|
jsonLd={[crumbs, profileLd, itemListLd]}
|
|
73
|
+
structuredData={{ pageType: "collection" }}
|
|
73
74
|
>
|
|
74
75
|
<header class:list={["author-hero", { "author-hero--plain": !presentation.hero }]}>
|
|
75
76
|
<div class="blog-shell">
|
|
@@ -48,6 +48,7 @@ const crumbs = breadcrumbLd(trail, canonicalBase);
|
|
|
48
48
|
path={path}
|
|
49
49
|
fullBleed
|
|
50
50
|
jsonLd={[crumbs, itemList]}
|
|
51
|
+
structuredData={{ pageType: "collection" }}
|
|
51
52
|
>
|
|
52
53
|
<header class:list={["blog-hero", { "blog-hero--plain": !presentation.hero }]}>
|
|
53
54
|
<div class="blog-shell">
|
|
@@ -46,6 +46,7 @@ const crumbs = breadcrumbLd(trail, canonicalBase);
|
|
|
46
46
|
path={path}
|
|
47
47
|
fullBleed
|
|
48
48
|
jsonLd={[crumbs, itemList]}
|
|
49
|
+
structuredData={{ pageType: "collection" }}
|
|
49
50
|
>
|
|
50
51
|
<header class:list={["blog-hero", { "blog-hero--plain": !presentation.hero }]}>
|
|
51
52
|
<div class="blog-shell">
|
|
@@ -18,6 +18,7 @@ import Img from "@/components/Img.astro";
|
|
|
18
18
|
import { parseQuery, buildQueryString } from "@/lib/url";
|
|
19
19
|
import { breadcrumbLd, itemListLd } from "@/lib/jsonld";
|
|
20
20
|
import { truncate } from "@/lib/format";
|
|
21
|
+
import { pageUrlForm } from "@/lib/seo/urlForm";
|
|
21
22
|
import { withBase } from "@/lib/basePath";
|
|
22
23
|
import { isBlankDescriptionHtml, sanitizeDescriptionHtml } from "@/lib/content/sanitizeDescriptionHtml";
|
|
23
24
|
|
|
@@ -43,7 +44,7 @@ const { products, page, pageSize, total, facets } = await tot.listProducts(query
|
|
|
43
44
|
// sitemap + directory too. Detect it via the UNFILTERED membership total.
|
|
44
45
|
const { total: memberTotal } = await tot.listProducts({ collection: handle, pageSize: 1 });
|
|
45
46
|
const isEmptyCollection = memberTotal === 0;
|
|
46
|
-
const basePath = `/collections/${handle}
|
|
47
|
+
const basePath = pageUrlForm(`/collections/${handle}`, tenant.seo);
|
|
47
48
|
// Tenant-prefixed page path for facet/sort/pagination, which build links by
|
|
48
49
|
// string-concatenating onto this base (so they stay inside a path-routed
|
|
49
50
|
// tenant). `basePath` itself stays unprefixed for the SEO canonical `path`,
|
|
@@ -97,6 +98,12 @@ if (query.priceMin != null || query.priceMax != null) {
|
|
|
97
98
|
breadcrumbLd(crumbs, canonicalBase),
|
|
98
99
|
itemListLd(products, canonicalBase, (page - 1) * pageSize),
|
|
99
100
|
]}
|
|
101
|
+
structuredData={{
|
|
102
|
+
pageType: "collection",
|
|
103
|
+
description: collection.seo.description || undefined,
|
|
104
|
+
crumbs,
|
|
105
|
+
items: products.map((p) => ({ title: p.title, url: new URL(`/products/${p.handle}`, canonicalBase + "/").toString() })),
|
|
106
|
+
}}
|
|
100
107
|
>
|
|
101
108
|
{/* Collection hero */}
|
|
102
109
|
<header class="bg-[var(--color-primary)] text-[var(--color-primary-contrast)]">
|
|
@@ -34,6 +34,7 @@ const crumbs = siteTrail(
|
|
|
34
34
|
description={`Browse every collection at ${tenant.displayName}.`}
|
|
35
35
|
path="/collections"
|
|
36
36
|
jsonLd={breadcrumbLd(crumbs, canonicalBase)}
|
|
37
|
+
structuredData={{ pageType: "collection" }}
|
|
37
38
|
>
|
|
38
39
|
<div class="container-prose pt-6">
|
|
39
40
|
<Breadcrumbs crumbs={crumbs} />
|
|
@@ -182,6 +182,14 @@ const homeSectionWidgets = marketing
|
|
|
182
182
|
path="/"
|
|
183
183
|
ogType="website"
|
|
184
184
|
fullBleed={marketing}
|
|
185
|
+
structuredData={{
|
|
186
|
+
pageType: "home",
|
|
187
|
+
headline: home.seoTitle ?? tenant.displayName,
|
|
188
|
+
description: home.seoDescription,
|
|
189
|
+
image: home.image,
|
|
190
|
+
author: home.author,
|
|
191
|
+
faq: home.blocks.flatMap((block) => (block.component === "faq" ? block.items : [])),
|
|
192
|
+
}}
|
|
185
193
|
>
|
|
186
194
|
{marketing && <MarketingBlocks blocks={marketingBlocks} basePath={basePath} />}
|
|
187
195
|
{
|
|
@@ -45,6 +45,8 @@ import { truncate } from "@/lib/format";
|
|
|
45
45
|
import { productLd, breadcrumbLd, reviewLd } from "@/lib/jsonld";
|
|
46
46
|
import { isUsableImageUrl } from "@/lib/placeholder";
|
|
47
47
|
import { withBase } from "@/lib/basePath";
|
|
48
|
+
import { productPageType, structuredDataProfile } from "@/lib/seo/structuredData";
|
|
49
|
+
import { pageUrlForm } from "@/lib/seo/urlForm";
|
|
48
50
|
import { shippingPolicyPath } from "@/lib/commerce/shippingPolicy";
|
|
49
51
|
import AppWidgetFrame from "@/components/apps/AppWidgetFrame.astro";
|
|
50
52
|
import { getRegistryService } from "@/lib/apps/registryAccess.js";
|
|
@@ -155,7 +157,7 @@ const flavorTags = (product.tags ?? [])
|
|
|
155
157
|
.map((t) => ({
|
|
156
158
|
label: t.slice(3).trim(),
|
|
157
159
|
href: flavorCollection
|
|
158
|
-
? `/collections/${flavorCollection}?tag=${encodeURIComponent(t)}
|
|
160
|
+
? pageUrlForm(`/collections/${flavorCollection}?tag=${encodeURIComponent(t)}`, tenant.seo)
|
|
159
161
|
: undefined,
|
|
160
162
|
}))
|
|
161
163
|
.filter((f) => f.label);
|
|
@@ -299,6 +301,14 @@ const productJsonLd: Record<string, unknown> = {
|
|
|
299
301
|
...reviewLd(reviewAvg, reviewCount, reviews),
|
|
300
302
|
};
|
|
301
303
|
const jsonLd = [productJsonLd, breadcrumbLd(crumbs, canonicalBase)];
|
|
304
|
+
const structuredData = {
|
|
305
|
+
pageType: productPageType(structuredDataProfile(tenant), product),
|
|
306
|
+
image: ogPrimary && isUsableImageUrl(ogPrimary.url, ogPrimary.rehosted) ? ogPrimary.url : null,
|
|
307
|
+
product,
|
|
308
|
+
reviews: reviewLd(reviewAvg, reviewCount, reviews),
|
|
309
|
+
faq: details.faq?.items.map((it) => ({ q: it.question, a: it.answer })),
|
|
310
|
+
crumbs,
|
|
311
|
+
};
|
|
302
312
|
---
|
|
303
313
|
|
|
304
314
|
<Layout
|
|
@@ -309,6 +319,7 @@ const jsonLd = [productJsonLd, breadcrumbLd(crumbs, canonicalBase)];
|
|
|
309
319
|
ogImage={ogImage}
|
|
310
320
|
noindex={isDraft}
|
|
311
321
|
jsonLd={jsonLd}
|
|
322
|
+
structuredData={structuredData}
|
|
312
323
|
>
|
|
313
324
|
<div class="container-prose pt-6">
|
|
314
325
|
<Breadcrumbs crumbs={crumbs} />
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
rootOwnsDiscoveryPathV1,
|
|
26
26
|
} from "@/lib/publication/mountedDiscovery.js";
|
|
27
27
|
import { collectionPathsV1, blogPathOptsV1 } from "@/lib/blog/collectionPaths.js";
|
|
28
|
+
import { pageUrlForm } from "@/lib/seo/urlForm.js";
|
|
28
29
|
|
|
29
30
|
interface SitemapUrl {
|
|
30
31
|
loc: string;
|
|
@@ -154,7 +155,7 @@ export async function GET(context: APIContext): Promise<Response> {
|
|
|
154
155
|
${urls
|
|
155
156
|
.map(
|
|
156
157
|
(u) =>
|
|
157
|
-
` <url>\n <loc>${escapeXml(u.loc)}</loc>${
|
|
158
|
+
` <url>\n <loc>${escapeXml(pageUrlForm(u.loc, tenant.seo))}</loc>${
|
|
158
159
|
u.lastmod ? `\n <lastmod>${escapeXml(u.lastmod)}</lastmod>` : ""
|
|
159
160
|
}\n </url>`,
|
|
160
161
|
)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.76",
|
|
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",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
export const TOT_CONFIG_KEYS = [
|
|
19
19
|
"$schema", "tenant", "scope", "mappings", "hostPlatform", "sample", "seed",
|
|
20
|
-
"displayName", "siteType", "compliance", "capabilities", "features", "embeds",
|
|
20
|
+
"displayName", "siteType", "compliance", "capabilities", "features", "embeds", "seo",
|
|
21
21
|
];
|
|
22
22
|
// Keys other tooling has written that the platform does NOT read, with the key it does.
|
|
23
23
|
const RENAMED_CONFIG_KEYS = { appDomain: "scope", tenantId: "tenant" };
|
|
@@ -30,6 +30,115 @@ export const FEATURE_KEYS = [
|
|
|
30
30
|
];
|
|
31
31
|
export const SITE_TYPES = ["commerce", "marketing"];
|
|
32
32
|
|
|
33
|
+
/** Every `seo` key (mirrors packages/public-runtime/src/declared-tenant-config.mjs). */
|
|
34
|
+
export const SEO_KEYS = Object.freeze(["trailingSlash", "structuredData"]);
|
|
35
|
+
|
|
36
|
+
/** Every `seo.structuredData` key. */
|
|
37
|
+
export const STRUCTURED_DATA_KEYS = Object.freeze(["graphs", "names", "reviews"]);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The page types a structured-data profile declares a graph for. `productGroup` is a product
|
|
41
|
+
* with more than one variant; without its own graph it renders the `product` graph.
|
|
42
|
+
* `collection` covers every archive: a catalog collection, the collections index, and the
|
|
43
|
+
* blog index and its category/tag/author archives.
|
|
44
|
+
*/
|
|
45
|
+
export const STRUCTURED_DATA_PAGE_TYPES = Object.freeze([
|
|
46
|
+
"home", "product", "productGroup", "collection", "post", "page",
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The JSON-LD nodes a profile may list, as the `@type` each emits (a multi-type node joins its
|
|
51
|
+
* sorted types with `+`, the key a parity snapshot records). `Person:author` is the page's
|
|
52
|
+
* author; every other Organization/Person token is the site entity.
|
|
53
|
+
*/
|
|
54
|
+
export const STRUCTURED_DATA_NODE_TOKENS = Object.freeze([
|
|
55
|
+
"Organization", "Person", "Organization+Person",
|
|
56
|
+
"WebSite", "ImageObject",
|
|
57
|
+
"WebPage", "ItemPage", "CollectionPage", "AboutPage", "ContactPage",
|
|
58
|
+
"Person:author",
|
|
59
|
+
"Article", "BlogPosting",
|
|
60
|
+
"Product", "ProductGroup",
|
|
61
|
+
"FAQPage", "BreadcrumbList", "ItemList",
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
/** Node types whose `name` a profile may source, and the sources. */
|
|
65
|
+
export const STRUCTURED_DATA_NAMED_TYPES = Object.freeze(["Product", "ProductGroup", "Article", "BlogPosting"]);
|
|
66
|
+
export const STRUCTURED_DATA_NAME_SOURCES = Object.freeze(["documentTitle", "title"]);
|
|
67
|
+
|
|
68
|
+
const isPlainObject = (value) =>
|
|
69
|
+
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* What is wrong with a declared `seo` value, one sentence per problem; `[]` when it is valid.
|
|
73
|
+
* A profile that names a node or page type the renderer does not build is refused whole: a
|
|
74
|
+
* partly-applied profile emits a graph nobody declared.
|
|
75
|
+
*
|
|
76
|
+
* @param {unknown} seo
|
|
77
|
+
* @returns {string[]}
|
|
78
|
+
*/
|
|
79
|
+
export function seoDeclarationProblems(seo) {
|
|
80
|
+
if (!isPlainObject(seo)) return ["`seo` must be an object"];
|
|
81
|
+
const problems = [];
|
|
82
|
+
const source = /** @type {Record<string, unknown>} */ (seo);
|
|
83
|
+
for (const key of Object.keys(source)) {
|
|
84
|
+
if (!SEO_KEYS.includes(key)) problems.push(`\`seo.${key}\` is not a seo key (${SEO_KEYS.join(", ")})`);
|
|
85
|
+
}
|
|
86
|
+
if (source.trailingSlash !== undefined && typeof source.trailingSlash !== "boolean") {
|
|
87
|
+
problems.push("`seo.trailingSlash` must be true or false");
|
|
88
|
+
}
|
|
89
|
+
const sd = source.structuredData;
|
|
90
|
+
if (sd === undefined) return problems;
|
|
91
|
+
if (!isPlainObject(sd)) return [...problems, "`seo.structuredData` must be an object"];
|
|
92
|
+
const data = /** @type {Record<string, unknown>} */ (sd);
|
|
93
|
+
for (const key of Object.keys(data)) {
|
|
94
|
+
if (!STRUCTURED_DATA_KEYS.includes(key)) {
|
|
95
|
+
problems.push(`\`seo.structuredData.${key}\` is not a structuredData key (${STRUCTURED_DATA_KEYS.join(", ")})`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (!isPlainObject(data.graphs) || Object.keys(/** @type {object} */ (data.graphs)).length === 0) {
|
|
99
|
+
problems.push("`seo.structuredData.graphs` must map at least one page type to its node list");
|
|
100
|
+
} else {
|
|
101
|
+
for (const [pageType, tokens] of Object.entries(/** @type {Record<string, unknown>} */ (data.graphs))) {
|
|
102
|
+
const at = `seo.structuredData.graphs.${pageType}`;
|
|
103
|
+
if (!STRUCTURED_DATA_PAGE_TYPES.includes(pageType)) {
|
|
104
|
+
problems.push(`\`${at}\`: unknown page type (${STRUCTURED_DATA_PAGE_TYPES.join(", ")})`);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (!Array.isArray(tokens) || tokens.length === 0) {
|
|
108
|
+
problems.push(`\`${at}\` must be a non-empty array of node types`);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const seen = new Set();
|
|
112
|
+
for (const token of tokens) {
|
|
113
|
+
if (typeof token !== "string" || !STRUCTURED_DATA_NODE_TOKENS.includes(token)) {
|
|
114
|
+
problems.push(`\`${at}\`: ${JSON.stringify(token)} is not a node type the renderer builds (${STRUCTURED_DATA_NODE_TOKENS.join(", ")})`);
|
|
115
|
+
} else if (seen.has(token)) {
|
|
116
|
+
problems.push(`\`${at}\` lists ${token} twice`);
|
|
117
|
+
}
|
|
118
|
+
seen.add(token);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (data.names !== undefined) {
|
|
123
|
+
if (!isPlainObject(data.names)) {
|
|
124
|
+
problems.push("`seo.structuredData.names` must map a node type to documentTitle or title");
|
|
125
|
+
} else {
|
|
126
|
+
for (const [type, from] of Object.entries(/** @type {Record<string, unknown>} */ (data.names))) {
|
|
127
|
+
if (!STRUCTURED_DATA_NAMED_TYPES.includes(type)) {
|
|
128
|
+
problems.push(`\`seo.structuredData.names.${type}\`: only ${STRUCTURED_DATA_NAMED_TYPES.join(", ")} take a declared name`);
|
|
129
|
+
} else if (typeof from !== "string" || !STRUCTURED_DATA_NAME_SOURCES.includes(from)) {
|
|
130
|
+
problems.push(`\`seo.structuredData.names.${type}\` must be one of ${STRUCTURED_DATA_NAME_SOURCES.join(", ")}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (data.reviews !== undefined && typeof data.reviews !== "boolean") {
|
|
136
|
+
problems.push("`seo.structuredData.reviews` must be true or false");
|
|
137
|
+
}
|
|
138
|
+
return problems;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
33
142
|
/** What each compliance key puts on the page — the thing that is missing when it is. */
|
|
34
143
|
export const COMPLIANCE_NOTICES = {
|
|
35
144
|
ruleProfile: "checkout compliance rules",
|
|
@@ -361,6 +470,11 @@ export function validateDeclaredConfig(config, file = ".tot/config.json") {
|
|
|
361
470
|
&& !(Array.isArray(config.embeds) && config.embeds.every((/** @type {unknown} */ s) => typeof s === "string"))) {
|
|
362
471
|
out.push(mk(ERROR, "config-embeds", file, '`embeds` must be an array of embed slugs, e.g. ["youtube"]'));
|
|
363
472
|
}
|
|
473
|
+
if (config.seo !== undefined) {
|
|
474
|
+
for (const problem of seoDeclarationProblems(config.seo)) {
|
|
475
|
+
out.push(mk(ERROR, "config-seo", `${file} seo`, `${problem} — the whole \`seo\` declaration is ignored until it is fixed`));
|
|
476
|
+
}
|
|
477
|
+
}
|
|
364
478
|
return out;
|
|
365
479
|
}
|
|
366
480
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Types for the dependency-free declared-tenant-config module (declared-tenant-config.mjs). */
|
|
2
2
|
import type { TenantConfig } from "./tenant.js";
|
|
3
3
|
|
|
4
|
-
export type DeclaredTenantConfigField = "displayName" | "siteType" | "compliance" | "capabilities" | "features" | "embeds";
|
|
4
|
+
export type DeclaredTenantConfigField = "displayName" | "siteType" | "compliance" | "capabilities" | "features" | "embeds" | "seo";
|
|
5
5
|
export type DeclaredTenantConfig = Partial<Pick<TenantConfig, DeclaredTenantConfigField>>;
|
|
6
6
|
|
|
7
7
|
export const DECLARED_TENANT_CONFIG_FIELDS: readonly DeclaredTenantConfigField[];
|
|
@@ -9,6 +9,13 @@ export const TOT_CONFIG_KEYS: readonly string[];
|
|
|
9
9
|
export const COMPLIANCE_KEYS: readonly string[];
|
|
10
10
|
export const FEATURE_KEYS: readonly string[];
|
|
11
11
|
export const SITE_TYPES_LIST: readonly string[];
|
|
12
|
+
export const SEO_KEYS: readonly string[];
|
|
13
|
+
export const STRUCTURED_DATA_KEYS: readonly string[];
|
|
14
|
+
export const STRUCTURED_DATA_PAGE_TYPES: readonly import("./tenant.js").StructuredDataPageType[];
|
|
15
|
+
export const STRUCTURED_DATA_NODE_TOKENS: readonly import("./tenant.js").StructuredDataNodeToken[];
|
|
16
|
+
export const STRUCTURED_DATA_NAMED_TYPES: readonly string[];
|
|
17
|
+
export const STRUCTURED_DATA_NAME_SOURCES: readonly string[];
|
|
18
|
+
export function seoDeclarationProblems(seo: unknown): string[];
|
|
12
19
|
export function declaredTenantConfig(config: unknown): DeclaredTenantConfig;
|
|
13
20
|
export function isEmptyDeclaredTenantConfig(declared: DeclaredTenantConfig | null | undefined): boolean;
|
|
14
21
|
export function applyDeclaredTenantConfig<T extends Partial<Pick<TenantConfig, DeclaredTenantConfigField>>>(
|
|
@@ -25,6 +25,7 @@ export const DECLARED_TENANT_CONFIG_FIELDS = Object.freeze([
|
|
|
25
25
|
"capabilities",
|
|
26
26
|
"features",
|
|
27
27
|
"embeds",
|
|
28
|
+
"seo",
|
|
28
29
|
]);
|
|
29
30
|
|
|
30
31
|
/**
|
|
@@ -73,11 +74,116 @@ export const FEATURE_KEYS = Object.freeze([
|
|
|
73
74
|
/** Every `siteType` value (SiteType, tenant.ts). */
|
|
74
75
|
export const SITE_TYPES_LIST = Object.freeze(["commerce", "marketing"]);
|
|
75
76
|
|
|
77
|
+
/** Every `seo` key (SeoDeclaration, tenant.ts). */
|
|
78
|
+
export const SEO_KEYS = Object.freeze(["trailingSlash", "structuredData"]);
|
|
79
|
+
|
|
80
|
+
/** Every `seo.structuredData` key. */
|
|
81
|
+
export const STRUCTURED_DATA_KEYS = Object.freeze(["graphs", "names", "reviews"]);
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The page types a structured-data profile declares a graph for. `productGroup` is a product
|
|
85
|
+
* with more than one variant; without its own graph it renders the `product` graph.
|
|
86
|
+
* `collection` covers every archive: a catalog collection, the collections index, and the
|
|
87
|
+
* blog index and its category/tag/author archives.
|
|
88
|
+
*/
|
|
89
|
+
export const STRUCTURED_DATA_PAGE_TYPES = Object.freeze([
|
|
90
|
+
"home", "product", "productGroup", "collection", "post", "page",
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The JSON-LD nodes a profile may list, as the `@type` each emits (a multi-type node joins its
|
|
95
|
+
* sorted types with `+`, the key a parity snapshot records). `Person:author` is the page's
|
|
96
|
+
* author; every other Organization/Person token is the site entity.
|
|
97
|
+
*/
|
|
98
|
+
export const STRUCTURED_DATA_NODE_TOKENS = Object.freeze([
|
|
99
|
+
"Organization", "Person", "Organization+Person",
|
|
100
|
+
"WebSite", "ImageObject",
|
|
101
|
+
"WebPage", "ItemPage", "CollectionPage", "AboutPage", "ContactPage",
|
|
102
|
+
"Person:author",
|
|
103
|
+
"Article", "BlogPosting",
|
|
104
|
+
"Product", "ProductGroup",
|
|
105
|
+
"FAQPage", "BreadcrumbList", "ItemList",
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
/** Node types whose `name` a profile may source, and the sources. */
|
|
109
|
+
export const STRUCTURED_DATA_NAMED_TYPES = Object.freeze(["Product", "ProductGroup", "Article", "BlogPosting"]);
|
|
110
|
+
export const STRUCTURED_DATA_NAME_SOURCES = Object.freeze(["documentTitle", "title"]);
|
|
111
|
+
|
|
76
112
|
const SITE_TYPES = new Set(SITE_TYPES_LIST);
|
|
77
113
|
|
|
78
114
|
const isPlainObject = (value) =>
|
|
79
115
|
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
80
116
|
|
|
117
|
+
/**
|
|
118
|
+
* What is wrong with a declared `seo` value, one sentence per problem; `[]` when it is valid.
|
|
119
|
+
* A profile that names a node or page type the renderer does not build is refused whole: a
|
|
120
|
+
* partly-applied profile emits a graph nobody declared.
|
|
121
|
+
*
|
|
122
|
+
* @param {unknown} seo
|
|
123
|
+
* @returns {string[]}
|
|
124
|
+
*/
|
|
125
|
+
export function seoDeclarationProblems(seo) {
|
|
126
|
+
if (!isPlainObject(seo)) return ["`seo` must be an object"];
|
|
127
|
+
const problems = [];
|
|
128
|
+
const source = /** @type {Record<string, unknown>} */ (seo);
|
|
129
|
+
for (const key of Object.keys(source)) {
|
|
130
|
+
if (!SEO_KEYS.includes(key)) problems.push(`\`seo.${key}\` is not a seo key (${SEO_KEYS.join(", ")})`);
|
|
131
|
+
}
|
|
132
|
+
if (source.trailingSlash !== undefined && typeof source.trailingSlash !== "boolean") {
|
|
133
|
+
problems.push("`seo.trailingSlash` must be true or false");
|
|
134
|
+
}
|
|
135
|
+
const sd = source.structuredData;
|
|
136
|
+
if (sd === undefined) return problems;
|
|
137
|
+
if (!isPlainObject(sd)) return [...problems, "`seo.structuredData` must be an object"];
|
|
138
|
+
const data = /** @type {Record<string, unknown>} */ (sd);
|
|
139
|
+
for (const key of Object.keys(data)) {
|
|
140
|
+
if (!STRUCTURED_DATA_KEYS.includes(key)) {
|
|
141
|
+
problems.push(`\`seo.structuredData.${key}\` is not a structuredData key (${STRUCTURED_DATA_KEYS.join(", ")})`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (!isPlainObject(data.graphs) || Object.keys(/** @type {object} */ (data.graphs)).length === 0) {
|
|
145
|
+
problems.push("`seo.structuredData.graphs` must map at least one page type to its node list");
|
|
146
|
+
} else {
|
|
147
|
+
for (const [pageType, tokens] of Object.entries(/** @type {Record<string, unknown>} */ (data.graphs))) {
|
|
148
|
+
const at = `seo.structuredData.graphs.${pageType}`;
|
|
149
|
+
if (!STRUCTURED_DATA_PAGE_TYPES.includes(pageType)) {
|
|
150
|
+
problems.push(`\`${at}\`: unknown page type (${STRUCTURED_DATA_PAGE_TYPES.join(", ")})`);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (!Array.isArray(tokens) || tokens.length === 0) {
|
|
154
|
+
problems.push(`\`${at}\` must be a non-empty array of node types`);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const seen = new Set();
|
|
158
|
+
for (const token of tokens) {
|
|
159
|
+
if (typeof token !== "string" || !STRUCTURED_DATA_NODE_TOKENS.includes(token)) {
|
|
160
|
+
problems.push(`\`${at}\`: ${JSON.stringify(token)} is not a node type the renderer builds (${STRUCTURED_DATA_NODE_TOKENS.join(", ")})`);
|
|
161
|
+
} else if (seen.has(token)) {
|
|
162
|
+
problems.push(`\`${at}\` lists ${token} twice`);
|
|
163
|
+
}
|
|
164
|
+
seen.add(token);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (data.names !== undefined) {
|
|
169
|
+
if (!isPlainObject(data.names)) {
|
|
170
|
+
problems.push("`seo.structuredData.names` must map a node type to documentTitle or title");
|
|
171
|
+
} else {
|
|
172
|
+
for (const [type, from] of Object.entries(/** @type {Record<string, unknown>} */ (data.names))) {
|
|
173
|
+
if (!STRUCTURED_DATA_NAMED_TYPES.includes(type)) {
|
|
174
|
+
problems.push(`\`seo.structuredData.names.${type}\`: only ${STRUCTURED_DATA_NAMED_TYPES.join(", ")} take a declared name`);
|
|
175
|
+
} else if (typeof from !== "string" || !STRUCTURED_DATA_NAME_SOURCES.includes(from)) {
|
|
176
|
+
problems.push(`\`seo.structuredData.names.${type}\` must be one of ${STRUCTURED_DATA_NAME_SOURCES.join(", ")}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (data.reviews !== undefined && typeof data.reviews !== "boolean") {
|
|
182
|
+
problems.push("`seo.structuredData.reviews` must be true or false");
|
|
183
|
+
}
|
|
184
|
+
return problems;
|
|
185
|
+
}
|
|
186
|
+
|
|
81
187
|
/**
|
|
82
188
|
* The declared TenantConfig fields from a parsed `.tot/config.json`. A field whose
|
|
83
189
|
* value has the wrong shape is omitted, never coerced (`tot validate` reports it).
|
|
@@ -88,7 +194,7 @@ export function declaredTenantConfig(config) {
|
|
|
88
194
|
/** @type {Record<string, unknown>} */
|
|
89
195
|
const out = {};
|
|
90
196
|
if (!isPlainObject(config)) return out;
|
|
91
|
-
const { displayName, siteType, compliance, capabilities, features, embeds } = /** @type {Record<string, unknown>} */ (config);
|
|
197
|
+
const { displayName, siteType, compliance, capabilities, features, embeds, seo } = /** @type {Record<string, unknown>} */ (config);
|
|
92
198
|
// The store's name as visitors read it: the `<title>` suffix, og:site_name,
|
|
93
199
|
// Organization JSON-LD, breadcrumbs, the default blog heading, the copyright line.
|
|
94
200
|
if (typeof displayName === "string" && displayName.trim()) out.displayName = displayName.trim();
|
|
@@ -97,6 +203,8 @@ export function declaredTenantConfig(config) {
|
|
|
97
203
|
if (isPlainObject(capabilities)) out.capabilities = capabilities;
|
|
98
204
|
if (isPlainObject(features)) out.features = features;
|
|
99
205
|
if (Array.isArray(embeds) && embeds.every((slug) => typeof slug === "string")) out.embeds = embeds;
|
|
206
|
+
// URL form and the structured-data profile: refused whole when any part is malformed.
|
|
207
|
+
if (seo !== undefined && seoDeclarationProblems(seo).length === 0) out.seo = seo;
|
|
100
208
|
return out;
|
|
101
209
|
}
|
|
102
210
|
|
|
@@ -750,6 +750,46 @@ export interface TenantConfig {
|
|
|
750
750
|
* `none` are ever set. It reaches the browser through the server-stamped plan.
|
|
751
751
|
*/
|
|
752
752
|
euUkVisitors?: Exclude<EuUkVisitorPolicy, "count-only">;
|
|
753
|
+
/**
|
|
754
|
+
* URL form and structured data, declared in `.tot/config.json` (validated by
|
|
755
|
+
* `seoDeclarationProblems`, declared-tenant-config.mjs). A migrated store keeps the
|
|
756
|
+
* canonical URL shape and the JSON-LD graph its rankings were earned under.
|
|
757
|
+
*/
|
|
758
|
+
seo?: SeoDeclaration;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** Page types a structured-data profile declares a graph for (STRUCTURED_DATA_PAGE_TYPES). */
|
|
762
|
+
export type StructuredDataPageType = "home" | "product" | "productGroup" | "collection" | "post" | "page";
|
|
763
|
+
|
|
764
|
+
/** JSON-LD nodes a profile may list (STRUCTURED_DATA_NODE_TOKENS). */
|
|
765
|
+
export type StructuredDataNodeToken =
|
|
766
|
+
| "Organization" | "Person" | "Organization+Person"
|
|
767
|
+
| "WebSite" | "ImageObject"
|
|
768
|
+
| "WebPage" | "ItemPage" | "CollectionPage" | "AboutPage" | "ContactPage"
|
|
769
|
+
| "Person:author"
|
|
770
|
+
| "Article" | "BlogPosting"
|
|
771
|
+
| "Product" | "ProductGroup"
|
|
772
|
+
| "FAQPage" | "BreadcrumbList" | "ItemList";
|
|
773
|
+
|
|
774
|
+
export interface SeoDeclaration {
|
|
775
|
+
/**
|
|
776
|
+
* Canonicals, og:url, JSON-LD urls, sitemap locs and platform-generated page links end in
|
|
777
|
+
* `/` (`/products/foo/`). Absent or false ⇒ no trailing slash (`/products/foo`).
|
|
778
|
+
*/
|
|
779
|
+
trailingSlash?: boolean;
|
|
780
|
+
/**
|
|
781
|
+
* The structured-data profile. When present it is the ONLY JSON-LD a declared page type
|
|
782
|
+
* emits: one `@graph` holding exactly the listed nodes, in order. A node whose data the
|
|
783
|
+
* page lacks (no image, no author, no FAQ) is omitted. A page type without a graph keeps
|
|
784
|
+
* the platform default.
|
|
785
|
+
*/
|
|
786
|
+
structuredData?: {
|
|
787
|
+
graphs: Partial<Record<StructuredDataPageType, StructuredDataNodeToken[]>>;
|
|
788
|
+
/** Where a node's `name` comes from: the page's `<title>` or the entity's own title. */
|
|
789
|
+
names?: Partial<Record<"Product" | "ProductGroup" | "Article" | "BlogPosting", "documentTitle" | "title">>;
|
|
790
|
+
/** Emit real sourced aggregateRating/review on Product. Absent ⇒ false. */
|
|
791
|
+
reviews?: boolean;
|
|
792
|
+
};
|
|
753
793
|
}
|
|
754
794
|
|
|
755
795
|
/**
|