@tokenoftrust/storefront-runner 2.4.74 → 2.4.75

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/apps/storefront/src/components/Breadcrumbs.astro +3 -2
  2. package/apps/storefront/src/components/CollectionCard.astro +3 -2
  3. package/apps/storefront/src/components/ProductCard.astro +2 -1
  4. package/apps/storefront/src/components/Seo.astro +6 -4
  5. package/apps/storefront/src/components/blog/BlogIndexView.astro +1 -0
  6. package/apps/storefront/src/components/blog/BlogPostArticle.astro +10 -0
  7. package/apps/storefront/src/components/home/Hero.astro +2 -1
  8. package/apps/storefront/src/components/marketing/Faq.astro +7 -2
  9. package/apps/storefront/src/layouts/Layout.astro +25 -5
  10. package/apps/storefront/src/lib/seo/faqItems.ts +63 -0
  11. package/apps/storefront/src/lib/seo/structuredData.ts +267 -0
  12. package/apps/storefront/src/lib/seo/urlForm.ts +40 -0
  13. package/apps/storefront/src/lib/storyblok/content-model.ts +7 -0
  14. package/apps/storefront/src/lib/storyblok/provider.ts +4 -0
  15. package/apps/storefront/src/pages/[...slug].astro +15 -0
  16. package/apps/storefront/src/pages/blog/author/[author].astro +1 -0
  17. package/apps/storefront/src/pages/blog/category/[category].astro +1 -0
  18. package/apps/storefront/src/pages/blog/tag/[tag].astro +1 -0
  19. package/apps/storefront/src/pages/collections/[handle].astro +8 -1
  20. package/apps/storefront/src/pages/collections/index.astro +1 -0
  21. package/apps/storefront/src/pages/index.astro +8 -0
  22. package/apps/storefront/src/pages/products/[handle].astro +11 -1
  23. package/apps/storefront/src/pages/sitemap.xml.ts +2 -1
  24. package/package.json +1 -1
  25. package/packages/cli/src/declared-config.mjs +115 -1
  26. package/packages/public-runtime/src/declared-tenant-config.d.mts +8 -1
  27. package/packages/public-runtime/src/declared-tenant-config.mjs +109 -1
  28. 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 ?? new URL(path, canonicalBase + "/").toString();
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: new URL(pagination.prev, canonicalBase + "/").toString() }] : []),
95
- ...(pagination?.next ? [{ rel: "next", href: new URL(pagination.next, canonicalBase + "/").toString() }] : []),
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
 
@@ -60,6 +60,7 @@ const itemList = {
60
60
  path={path}
61
61
  fullBleed
62
62
  jsonLd={itemList}
63
+ structuredData={{ pageType: "collection" }}
63
64
  pagination={pagination}
64
65
  >
65
66
  <header class:list={["blog-hero", { "blog-hero--plain": !presentation.hero }]}>
@@ -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. Answers are plain text (auto-escaped).
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 allJsonLd = [
128
- organizationLd(tenant, canonicalBase),
129
- websiteLd(tenant, canonicalBase),
130
- ...pageGraphs,
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(/&nbsp;/g, " ").replace(/&quot;/g, '"').replace(/&#0?39;|&rsquo;|&#8217;/g, "'")
22
+ .replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
23
+ }
24
+
25
+ const textOf = (html: string) => decode(html.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
26
+
27
+ function atLeastTwo(items: FaqItem[]): FaqItem[] {
28
+ const answered = items.filter((it) => it.q && it.a);
29
+ return answered.length >= MIN_PAIRS ? answered : [];
30
+ }
31
+
32
+ /** FAQ pairs from sanitized body HTML: each question-phrased h2–h4 and the text after it. */
33
+ export function faqFromHtml(html: string | undefined): FaqItem[] {
34
+ if (!html) return [];
35
+ const parts = html.split(/(<h[2-4][^>]*>[\s\S]*?<\/h[2-4]>)/i);
36
+ const items: FaqItem[] = [];
37
+ for (let i = 1; i < parts.length; i += 2) {
38
+ const q = textOf(parts[i]!);
39
+ if (isQuestion(q)) items.push({ q, a: textOf(parts[i + 1] ?? "") });
40
+ }
41
+ return atLeastTwo(items);
42
+ }
43
+
44
+ /** FAQ pairs from structured prose: each question-phrased heading and the paragraphs/lists after it. */
45
+ export function faqFromSections(sections: ProseSection[] | undefined): FaqItem[] {
46
+ const items: FaqItem[] = [];
47
+ let current: FaqItem | null = null;
48
+ for (const section of sections ?? []) {
49
+ if (section.kind === "heading") {
50
+ current = isQuestion(section.text) ? { q: section.text.trim(), a: "" } : null;
51
+ if (current) items.push(current);
52
+ continue;
53
+ }
54
+ if (!current) continue;
55
+ const text = section.kind === "paragraph"
56
+ ? plainText(section.text)
57
+ : section.kind === "list"
58
+ ? section.items.map(plainText).join(" ")
59
+ : "";
60
+ if (text) current.a = current.a ? `${current.a} ${text}` : text;
61
+ }
62
+ return atLeastTwo(items);
63
+ }
@@ -0,0 +1,267 @@
1
+ /**
2
+ * The declared structured-data profile (`seo.structuredData` in `.tot/config.json`): per page
3
+ * type, exactly which JSON-LD nodes the page emits, in one `@graph`. A migrated store keeps the
4
+ * graph its rankings were earned under — the same node types, ids linking them, and the key
5
+ * fields (name, url, sku, price) a parity check compares — instead of whatever the platform
6
+ * default happens to emit.
7
+ *
8
+ * When a tenant declares a graph for a page type, `composeStructuredData` is the ONLY JSON-LD
9
+ * that page emits: Layout drops the platform defaults and the page's own graphs, and widget
10
+ * emitters (Faq.astro) defer to it. A node whose data the page lacks is omitted, never invented.
11
+ * Pure: pages hand it the data, Layout hands it the tenant and the canonical.
12
+ */
13
+ import type {
14
+ CatalogProduct,
15
+ StructuredDataNodeToken,
16
+ StructuredDataPageType,
17
+ TenantConfig,
18
+ } from "@tot/public-runtime";
19
+ import { productLd } from "../jsonld.js";
20
+ import { pageUrlForm } from "./urlForm.js";
21
+
22
+ type Node = Record<string, unknown>;
23
+ type Profile = NonNullable<NonNullable<TenantConfig["seo"]>["structuredData"]>;
24
+
25
+ /** What a page knows about itself, for the nodes that describe it. Every field is optional. */
26
+ export interface StructuredDataPage {
27
+ pageType: StructuredDataPageType;
28
+ /** The page's primary image, site-relative or absolute. */
29
+ image?: string | null;
30
+ author?: { name: string; url?: string } | null;
31
+ /** The entity's own title (a post or page heading). */
32
+ headline?: string;
33
+ description?: string;
34
+ datePublished?: string;
35
+ dateModified?: string;
36
+ product?: CatalogProduct;
37
+ /** Real sourced `aggregateRating` / `review` (reviewLd output); emitted only when declared. */
38
+ reviews?: Record<string, unknown>;
39
+ faq?: { q: string; a: string }[];
40
+ crumbs?: { label: string; href: string }[];
41
+ items?: { title: string; url: string }[];
42
+ }
43
+
44
+ /** The tenant's declared profile, or null when it declares none. */
45
+ export function structuredDataProfile(tenant: Pick<TenantConfig, "seo"> | undefined): Profile | null {
46
+ return tenant?.seo?.structuredData ?? null;
47
+ }
48
+
49
+ /**
50
+ * A product with more than one variant is a `productGroup` when the profile declares that graph;
51
+ * every other page type maps to itself.
52
+ */
53
+ export function productPageType(profile: Profile | null, product: CatalogProduct): StructuredDataPageType {
54
+ return product.variants.length > 1 && profile?.graphs.productGroup ? "productGroup" : "product";
55
+ }
56
+
57
+ /** The declared node list for a page type; `productGroup` falls back to `product`. */
58
+ export function graphFor(profile: Profile | null, pageType: StructuredDataPageType): StructuredDataNodeToken[] | null {
59
+ if (!profile) return null;
60
+ return profile.graphs[pageType] ?? (pageType === "productGroup" ? profile.graphs.product ?? null : null);
61
+ }
62
+
63
+ const SITE_TOKENS = new Set<StructuredDataNodeToken>(["Organization", "Person", "Organization+Person"]);
64
+ const PAGE_ENTITY_TOKENS = new Set<StructuredDataNodeToken>(["WebPage", "ItemPage", "CollectionPage", "AboutPage", "ContactPage"]);
65
+
66
+ function absolute(value: string, canonicalBase: string): string {
67
+ return new URL(value, canonicalBase + "/").toString();
68
+ }
69
+
70
+ export interface ComposeInput {
71
+ profile: Profile;
72
+ page: StructuredDataPage;
73
+ tenant: TenantConfig;
74
+ canonicalBase: string;
75
+ /** The page's absolute canonical URL, already in the declared URL form. */
76
+ canonical: string;
77
+ /** The page's full `<title>`. */
78
+ documentTitle: string;
79
+ }
80
+
81
+ /** One `@graph` holding exactly the declared nodes the page has data for, or null when the page type declares none. */
82
+ export function composeStructuredData(input: ComposeInput): Node | null {
83
+ const { profile, page, tenant, canonicalBase, canonical, documentTitle } = input;
84
+ const tokens = graphFor(profile, page.pageType);
85
+ if (!tokens) return null;
86
+ const base = canonicalBase.replace(/\/$/, "");
87
+ const lang = tenant.locale || "en";
88
+ const nameOf = (type: "Product" | "ProductGroup" | "Article" | "BlogPosting", own: string | undefined, fallback: "documentTitle" | "title") =>
89
+ (profile.names?.[type] ?? fallback) === "documentTitle" ? documentTitle : own || documentTitle;
90
+
91
+ const siteToken = tokens.find((t) => SITE_TOKENS.has(t));
92
+ const siteTypes = siteToken ? siteToken.split("+") : [];
93
+ const siteId = `${base}/#${siteTypes.includes("Person") ? "person" : "organization"}`;
94
+ const imageUrl = page.image ? absolute(page.image, canonicalBase) : null;
95
+ const imageId = imageUrl ?? undefined;
96
+ const pageToken = tokens.find((t) => PAGE_ENTITY_TOKENS.has(t));
97
+ const pageId = `${canonical}#webpage`;
98
+ const authorId = page.author ? `${page.author.url ? absolute(page.author.url, canonicalBase) : canonical}#author` : undefined;
99
+ const ref = (id: string | undefined) => (id ? { "@id": id } : undefined);
100
+ const pageUrl = (href: string) => pageUrlForm(absolute(href, canonicalBase), tenant.seo);
101
+
102
+ const nodes: Node[] = [];
103
+ for (const token of tokens) {
104
+ const node = buildNode(token);
105
+ if (node) nodes.push(node);
106
+ }
107
+ return { "@context": "https://schema.org", "@graph": nodes };
108
+
109
+ function buildNode(token: StructuredDataNodeToken): Node | null {
110
+ if (SITE_TOKENS.has(token)) {
111
+ const logo = tenant.theme_tokens?.brand?.logoLight ?? tenant.theme_tokens?.brand?.ogImage;
112
+ return {
113
+ "@type": siteTypes.length > 1 ? siteTypes : siteTypes[0],
114
+ "@id": siteId,
115
+ name: tenant.displayName,
116
+ url: base,
117
+ ...(logo && siteTypes.includes("Organization") ? { logo: { "@type": "ImageObject", url: absolute(logo, canonicalBase) } } : {}),
118
+ };
119
+ }
120
+ if (PAGE_ENTITY_TOKENS.has(token)) {
121
+ return {
122
+ "@type": token,
123
+ "@id": pageId,
124
+ url: canonical,
125
+ name: documentTitle,
126
+ ...(page.description ? { description: page.description } : {}),
127
+ isPartOf: { "@id": `${base}/#website` },
128
+ ...(imageId ? { primaryImageOfPage: { "@id": imageId } } : {}),
129
+ ...(page.datePublished ? { datePublished: page.datePublished } : {}),
130
+ ...(page.dateModified ? { dateModified: page.dateModified } : {}),
131
+ inLanguage: lang,
132
+ };
133
+ }
134
+ switch (token) {
135
+ case "WebSite":
136
+ return {
137
+ "@type": "WebSite",
138
+ "@id": `${base}/#website`,
139
+ url: base,
140
+ name: tenant.displayName,
141
+ ...(siteToken ? { publisher: { "@id": siteId } } : {}),
142
+ inLanguage: lang,
143
+ };
144
+ case "ImageObject":
145
+ return imageUrl ? { "@type": "ImageObject", "@id": imageUrl, url: imageUrl, inLanguage: lang } : null;
146
+ case "Person:author":
147
+ return page.author
148
+ ? {
149
+ "@type": "Person",
150
+ "@id": authorId,
151
+ name: page.author.name,
152
+ ...(page.author.url ? { url: pageUrl(page.author.url) } : {}),
153
+ }
154
+ : null;
155
+ case "Article":
156
+ case "BlogPosting": {
157
+ const name = nameOf(token, page.headline, token === "Article" ? "documentTitle" : "title");
158
+ return {
159
+ "@type": token,
160
+ "@id": `${canonical}#richSnippet`,
161
+ headline: page.headline || name,
162
+ name,
163
+ ...(page.description ? { description: page.description } : {}),
164
+ ...(page.datePublished ? { datePublished: page.datePublished } : {}),
165
+ ...(page.dateModified ? { dateModified: page.dateModified } : {}),
166
+ ...(authorId ? { author: { "@id": authorId, name: page.author!.name } } : {}),
167
+ ...(siteToken ? { publisher: { "@id": siteId } } : {}),
168
+ ...(imageId ? { image: { "@id": imageId } } : {}),
169
+ ...(pageToken ? { isPartOf: { "@id": pageId }, mainEntityOfPage: { "@id": pageId } } : {}),
170
+ inLanguage: lang,
171
+ };
172
+ }
173
+ case "Product":
174
+ return page.product ? productNode(page.product) : null;
175
+ case "ProductGroup":
176
+ return page.product ? productGroupNode(page.product) : null;
177
+ case "FAQPage":
178
+ return page.faq?.length
179
+ ? {
180
+ "@type": "FAQPage",
181
+ "@id": `${canonical}#faq`,
182
+ mainEntity: page.faq.map((it) => ({
183
+ "@type": "Question",
184
+ name: it.q,
185
+ acceptedAnswer: { "@type": "Answer", text: it.a },
186
+ })),
187
+ }
188
+ : null;
189
+ case "BreadcrumbList":
190
+ return page.crumbs?.length
191
+ ? {
192
+ "@type": "BreadcrumbList",
193
+ "@id": `${canonical}#breadcrumb`,
194
+ itemListElement: page.crumbs.map((c, i) => ({
195
+ "@type": "ListItem",
196
+ position: i + 1,
197
+ name: c.label,
198
+ item: pageUrl(c.href),
199
+ })),
200
+ }
201
+ : null;
202
+ case "ItemList":
203
+ return page.items?.length
204
+ ? {
205
+ "@type": "ItemList",
206
+ numberOfItems: page.items.length,
207
+ itemListElement: page.items.map((it, i) => ({
208
+ "@type": "ListItem",
209
+ position: i + 1,
210
+ name: it.title,
211
+ url: pageUrlForm(it.url, tenant.seo),
212
+ })),
213
+ }
214
+ : null;
215
+ default:
216
+ return null;
217
+ }
218
+ }
219
+
220
+ function offer(v: CatalogProduct["variants"][number]): Node {
221
+ return {
222
+ "@type": "Offer",
223
+ ...(v.sku ? { sku: v.sku } : {}),
224
+ price: v.price.toFixed(2),
225
+ priceCurrency: v.currency,
226
+ availability: v.inventory.available ? "https://schema.org/InStock" : "https://schema.org/OutOfStock",
227
+ url: canonical,
228
+ ...(siteToken ? { seller: { "@id": siteId } } : {}),
229
+ };
230
+ }
231
+
232
+ function productNode(p: CatalogProduct): Node {
233
+ const { "@context": _context, ...ld } = productLd(p, tenant, canonicalBase) as Node;
234
+ const single = p.variants.length === 1 ? p.variants[0] : undefined;
235
+ return {
236
+ ...ld,
237
+ "@id": `${canonical}#product`,
238
+ name: nameOf("Product", p.title, "title"),
239
+ url: canonical,
240
+ ...(pageToken ? { mainEntityOfPage: { "@id": pageId } } : {}),
241
+ ...(single ? { offers: offer(single) } : {}),
242
+ ...(profile.reviews ? page.reviews ?? {} : {}),
243
+ };
244
+ }
245
+
246
+ function productGroupNode(p: CatalogProduct): Node {
247
+ const name = nameOf("ProductGroup", p.title, "title");
248
+ return {
249
+ "@type": "ProductGroup",
250
+ "@id": `${canonical}#product`,
251
+ name,
252
+ url: canonical,
253
+ productGroupID: p.handle,
254
+ ...(p.seo.description || p.description_text ? { description: p.seo.description || p.description_text.slice(0, 300) } : {}),
255
+ ...(p.vendor ? { brand: { "@type": "Brand", name: p.vendor } } : {}),
256
+ ...(p.options.length ? { variesBy: p.options.map((o) => o.name) } : {}),
257
+ ...(pageToken ? { mainEntityOfPage: { "@id": pageId } } : {}),
258
+ hasVariant: p.variants.map((v) => ({
259
+ "@type": "Product",
260
+ name: `${p.title} - ${v.title}`,
261
+ ...(v.sku ? { sku: v.sku } : {}),
262
+ offers: offer(v),
263
+ })),
264
+ ...(profile.reviews ? page.reviews ?? {} : {}),
265
+ };
266
+ }
267
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The tenant's declared URL form (`seo.trailingSlash` in `.tot/config.json`). A migrated store
3
+ * keeps the URL shape its rankings were earned under: canonicals, og:url, JSON-LD urls, sitemap
4
+ * locs and platform-generated page links all take the same form. Both forms serve the same
5
+ * directory-index file on every substrate, so this changes what we ADVERTISE, never what resolves.
6
+ */
7
+ import type { SeoDeclaration } from "@tot/public-runtime";
8
+
9
+ /** A last segment with a file extension names a file (`/sitemap.xml`), never a page. */
10
+ const FILE_SEGMENT = /\.[a-z0-9]{1,8}$/i;
11
+
12
+ function slashPathname(pathname: string): string {
13
+ if (pathname === "" || pathname.endsWith("/")) return pathname || "/";
14
+ const last = pathname.slice(pathname.lastIndexOf("/") + 1);
15
+ return FILE_SEGMENT.test(last) ? pathname : `${pathname}/`;
16
+ }
17
+
18
+ /**
19
+ * A page path or absolute page URL in the tenant's declared form. Without `trailingSlash` the
20
+ * value is returned unchanged. Query and fragment are kept; non-page values (files, `#x`, `?x`,
21
+ * `mailto:`, protocol-relative) pass through.
22
+ */
23
+ export function pageUrlForm(value: string, seo: SeoDeclaration | undefined): string {
24
+ if (!seo?.trailingSlash || !value) return value;
25
+ if (/^https?:\/\//i.test(value)) {
26
+ const url = new URL(value);
27
+ url.pathname = slashPathname(url.pathname);
28
+ return url.toString();
29
+ }
30
+ if (!value.startsWith("/") || value.startsWith("//")) return value;
31
+ const cut = value.search(/[?#]/);
32
+ const path = cut < 0 ? value : value.slice(0, cut);
33
+ const rest = cut < 0 ? "" : value.slice(cut);
34
+ return slashPathname(path) + rest;
35
+ }
36
+
37
+ /** The absolute canonical URL for a path-only page path, in the declared form. */
38
+ export function canonicalUrl(path: string, canonicalBase: string, seo: SeoDeclaration | undefined): string {
39
+ return pageUrlForm(new URL(path, canonicalBase + "/").toString(), seo);
40
+ }
@@ -304,6 +304,9 @@ export interface HomeContent {
304
304
  /** SEO + social. */
305
305
  seoTitle?: string;
306
306
  seoDescription?: string;
307
+ /** The home page's byline author and primary image, for its structured data. */
308
+ author?: { name: string; url?: string };
309
+ image?: string;
307
310
  /** Commerce home blocks OR marketing blocks — the page branches on tenant
308
311
  * siteType/features and renders the matching set. */
309
312
  blocks: (HomeBlock | MarketingBlock)[];
@@ -344,6 +347,10 @@ export interface EditorialPage {
344
347
  updated?: string;
345
348
  /** When true, listed in the footer policy column. */
346
349
  isPolicy?: boolean;
350
+ /** The page's byline author, named by its structured data (a profile's `Person:author`). */
351
+ author?: { name: string; url?: string };
352
+ /** The page's primary image, site-relative or absolute (a profile's `ImageObject`). */
353
+ image?: string;
347
354
  }
348
355
 
349
356
  // Site chrome (header/footer) is the ONE consolidated schema in
@@ -472,6 +472,8 @@ function mapHome(content: Record<string, any>): HomeContent {
472
472
  return {
473
473
  seoTitle: content.seoTitle ?? content.seo_title,
474
474
  seoDescription: content.seoDescription ?? content.seo_description,
475
+ author: content.author,
476
+ image: content.image,
475
477
  blocks: blocks as HomeContent["blocks"],
476
478
  };
477
479
  }
@@ -497,6 +499,8 @@ function mapPage(content: Record<string, any>, slug: string): EditorialPage {
497
499
  intro: content.intro,
498
500
  updated: content.updated,
499
501
  isPolicy: !!content.isPolicy,
502
+ author: content.author,
503
+ image: content.image,
500
504
  };
501
505
  }
502
506
 
@@ -12,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,13 @@ 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
+ crumbs,
310
+ };
302
311
  ---
303
312
 
304
313
  <Layout
@@ -309,6 +318,7 @@ const jsonLd = [productJsonLd, breadcrumbLd(crumbs, canonicalBase)];
309
318
  ogImage={ogImage}
310
319
  noindex={isDraft}
311
320
  jsonLd={jsonLd}
321
+ structuredData={structuredData}
312
322
  >
313
323
  <div class="container-prose pt-6">
314
324
  <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.74",
3
+ "version": "2.4.75",
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
  /**