@tokenoftrust/storefront-runner 2.4.73 → 2.4.75

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/apps/storefront/src/components/Breadcrumbs.astro +3 -2
  2. package/apps/storefront/src/components/CollectionCard.astro +3 -2
  3. package/apps/storefront/src/components/ProductCard.astro +2 -1
  4. package/apps/storefront/src/components/Seo.astro +6 -4
  5. package/apps/storefront/src/components/blog/BlogIndexView.astro +7 -2
  6. package/apps/storefront/src/components/blog/BlogPostArticle.astro +156 -0
  7. package/apps/storefront/src/components/home/Hero.astro +2 -1
  8. package/apps/storefront/src/components/marketing/Faq.astro +7 -2
  9. package/apps/storefront/src/layouts/Layout.astro +25 -5
  10. package/apps/storefront/src/lib/blog/collectionPaths.ts +23 -2
  11. package/apps/storefront/src/lib/blog/postView.ts +141 -0
  12. package/apps/storefront/src/lib/blog/types.ts +10 -0
  13. package/apps/storefront/src/lib/publication/rootDiscovery.ts +5 -4
  14. package/apps/storefront/src/lib/seo/faqItems.ts +63 -0
  15. package/apps/storefront/src/lib/seo/structuredData.ts +267 -0
  16. package/apps/storefront/src/lib/seo/urlForm.ts +40 -0
  17. package/apps/storefront/src/lib/storyblok/content-model.ts +7 -0
  18. package/apps/storefront/src/lib/storyblok/provider.ts +4 -0
  19. package/apps/storefront/src/pages/[...slug].astro +122 -78
  20. package/apps/storefront/src/pages/blog/[slug].astro +8 -218
  21. package/apps/storefront/src/pages/blog/author/[author].astro +5 -3
  22. package/apps/storefront/src/pages/blog/category/[category].astro +5 -3
  23. package/apps/storefront/src/pages/blog/index.astro +1 -0
  24. package/apps/storefront/src/pages/blog/page/[n].astro +1 -0
  25. package/apps/storefront/src/pages/blog/tag/[tag].astro +5 -3
  26. package/apps/storefront/src/pages/collections/[handle].astro +8 -1
  27. package/apps/storefront/src/pages/collections/index.astro +1 -0
  28. package/apps/storefront/src/pages/index.astro +8 -0
  29. package/apps/storefront/src/pages/llms.txt.ts +5 -4
  30. package/apps/storefront/src/pages/products/[handle].astro +11 -1
  31. package/apps/storefront/src/pages/sitemap.xml.ts +5 -4
  32. package/package.json +1 -1
  33. package/packages/cli/src/declared-config.mjs +115 -1
  34. package/packages/public-runtime/src/declared-tenant-config.d.mts +8 -1
  35. package/packages/public-runtime/src/declared-tenant-config.mjs +109 -1
  36. package/packages/public-runtime/src/tenant.ts +40 -0
@@ -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
  /**