@tokenoftrust/storefront-runner 2.4.73 → 2.4.75
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/storefront/src/components/Breadcrumbs.astro +3 -2
- package/apps/storefront/src/components/CollectionCard.astro +3 -2
- package/apps/storefront/src/components/ProductCard.astro +2 -1
- package/apps/storefront/src/components/Seo.astro +6 -4
- package/apps/storefront/src/components/blog/BlogIndexView.astro +7 -2
- package/apps/storefront/src/components/blog/BlogPostArticle.astro +156 -0
- package/apps/storefront/src/components/home/Hero.astro +2 -1
- package/apps/storefront/src/components/marketing/Faq.astro +7 -2
- package/apps/storefront/src/layouts/Layout.astro +25 -5
- package/apps/storefront/src/lib/blog/collectionPaths.ts +23 -2
- package/apps/storefront/src/lib/blog/postView.ts +141 -0
- package/apps/storefront/src/lib/blog/types.ts +10 -0
- package/apps/storefront/src/lib/publication/rootDiscovery.ts +5 -4
- package/apps/storefront/src/lib/seo/faqItems.ts +63 -0
- package/apps/storefront/src/lib/seo/structuredData.ts +267 -0
- package/apps/storefront/src/lib/seo/urlForm.ts +40 -0
- package/apps/storefront/src/lib/storyblok/content-model.ts +7 -0
- package/apps/storefront/src/lib/storyblok/provider.ts +4 -0
- package/apps/storefront/src/pages/[...slug].astro +122 -78
- package/apps/storefront/src/pages/blog/[slug].astro +8 -218
- package/apps/storefront/src/pages/blog/author/[author].astro +5 -3
- package/apps/storefront/src/pages/blog/category/[category].astro +5 -3
- package/apps/storefront/src/pages/blog/index.astro +1 -0
- package/apps/storefront/src/pages/blog/page/[n].astro +1 -0
- package/apps/storefront/src/pages/blog/tag/[tag].astro +5 -3
- package/apps/storefront/src/pages/collections/[handle].astro +8 -1
- package/apps/storefront/src/pages/collections/index.astro +1 -0
- package/apps/storefront/src/pages/index.astro +8 -0
- package/apps/storefront/src/pages/llms.txt.ts +5 -4
- package/apps/storefront/src/pages/products/[handle].astro +11 -1
- package/apps/storefront/src/pages/sitemap.xml.ts +5 -4
- package/package.json +1 -1
- package/packages/cli/src/declared-config.mjs +115 -1
- package/packages/public-runtime/src/declared-tenant-config.d.mts +8 -1
- package/packages/public-runtime/src/declared-tenant-config.mjs +109 -1
- package/packages/public-runtime/src/tenant.ts +40 -0
|
@@ -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
|
/**
|