@tokenoftrust/storefront-runner 2.4.76 → 2.4.78
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/lib/seo/structuredData.ts +8 -1
- package/package.json +1 -1
- package/packages/cli/src/declared-config.mjs +16 -1
- package/packages/public-runtime/src/declared-tenant-config.mjs +16 -1
- package/packages/public-runtime/src/product-details.ts +28 -0
- package/packages/public-runtime/src/tenant.ts +5 -0
|
@@ -99,8 +99,15 @@ export function composeStructuredData(input: ComposeInput): Node | null {
|
|
|
99
99
|
const ref = (id: string | undefined) => (id ? { "@id": id } : undefined);
|
|
100
100
|
const pageUrl = (href: string) => pageUrlForm(absolute(href, canonicalBase), tenant.seo);
|
|
101
101
|
|
|
102
|
+
const pathOf = (value: string) => value.replace(/\/+$/, "") || "/";
|
|
103
|
+
const here = pathOf(new URL(canonical).pathname);
|
|
104
|
+
const scoped = (token: StructuredDataNodeToken) => {
|
|
105
|
+
const paths = profile.onlyOn?.[token];
|
|
106
|
+
return !paths || paths.some((p) => pathOf(p) === here);
|
|
107
|
+
};
|
|
108
|
+
|
|
102
109
|
const nodes: Node[] = [];
|
|
103
|
-
for (const token of tokens) {
|
|
110
|
+
for (const token of tokens.filter(scoped)) {
|
|
104
111
|
const node = buildNode(token);
|
|
105
112
|
if (node) nodes.push(node);
|
|
106
113
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.78",
|
|
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",
|
|
@@ -34,7 +34,7 @@ export const SITE_TYPES = ["commerce", "marketing"];
|
|
|
34
34
|
export const SEO_KEYS = Object.freeze(["trailingSlash", "structuredData"]);
|
|
35
35
|
|
|
36
36
|
/** Every `seo.structuredData` key. */
|
|
37
|
-
export const STRUCTURED_DATA_KEYS = Object.freeze(["graphs", "names", "reviews"]);
|
|
37
|
+
export const STRUCTURED_DATA_KEYS = Object.freeze(["graphs", "names", "reviews", "onlyOn"]);
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
40
|
* The page types a structured-data profile declares a graph for. `productGroup` is a product
|
|
@@ -135,6 +135,21 @@ export function seoDeclarationProblems(seo) {
|
|
|
135
135
|
if (data.reviews !== undefined && typeof data.reviews !== "boolean") {
|
|
136
136
|
problems.push("`seo.structuredData.reviews` must be true or false");
|
|
137
137
|
}
|
|
138
|
+
if (data.onlyOn !== undefined) {
|
|
139
|
+
if (!isPlainObject(data.onlyOn)) {
|
|
140
|
+
problems.push("`seo.structuredData.onlyOn` must map a node type to the paths it is emitted on");
|
|
141
|
+
} else {
|
|
142
|
+
for (const [token, paths] of Object.entries(/** @type {Record<string, unknown>} */ (data.onlyOn))) {
|
|
143
|
+
const at = `seo.structuredData.onlyOn.${token}`;
|
|
144
|
+
if (!STRUCTURED_DATA_NODE_TOKENS.includes(token)) {
|
|
145
|
+
problems.push(`\`${at}\`: ${JSON.stringify(token)} is not a node type the renderer builds`);
|
|
146
|
+
} else if (!Array.isArray(paths) || paths.length === 0
|
|
147
|
+
|| !paths.every((p) => typeof p === "string" && p.startsWith("/") && !p.startsWith("//"))) {
|
|
148
|
+
problems.push(`\`${at}\` must be a non-empty array of root-absolute paths such as "/faq/"`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
138
153
|
return problems;
|
|
139
154
|
}
|
|
140
155
|
|
|
@@ -78,7 +78,7 @@ export const SITE_TYPES_LIST = Object.freeze(["commerce", "marketing"]);
|
|
|
78
78
|
export const SEO_KEYS = Object.freeze(["trailingSlash", "structuredData"]);
|
|
79
79
|
|
|
80
80
|
/** Every `seo.structuredData` key. */
|
|
81
|
-
export const STRUCTURED_DATA_KEYS = Object.freeze(["graphs", "names", "reviews"]);
|
|
81
|
+
export const STRUCTURED_DATA_KEYS = Object.freeze(["graphs", "names", "reviews", "onlyOn"]);
|
|
82
82
|
|
|
83
83
|
/**
|
|
84
84
|
* The page types a structured-data profile declares a graph for. `productGroup` is a product
|
|
@@ -181,6 +181,21 @@ export function seoDeclarationProblems(seo) {
|
|
|
181
181
|
if (data.reviews !== undefined && typeof data.reviews !== "boolean") {
|
|
182
182
|
problems.push("`seo.structuredData.reviews` must be true or false");
|
|
183
183
|
}
|
|
184
|
+
if (data.onlyOn !== undefined) {
|
|
185
|
+
if (!isPlainObject(data.onlyOn)) {
|
|
186
|
+
problems.push("`seo.structuredData.onlyOn` must map a node type to the paths it is emitted on");
|
|
187
|
+
} else {
|
|
188
|
+
for (const [token, paths] of Object.entries(/** @type {Record<string, unknown>} */ (data.onlyOn))) {
|
|
189
|
+
const at = `seo.structuredData.onlyOn.${token}`;
|
|
190
|
+
if (!STRUCTURED_DATA_NODE_TOKENS.includes(token)) {
|
|
191
|
+
problems.push(`\`${at}\`: ${JSON.stringify(token)} is not a node type the renderer builds`);
|
|
192
|
+
} else if (!Array.isArray(paths) || paths.length === 0
|
|
193
|
+
|| !paths.every((p) => typeof p === "string" && p.startsWith("/") && !p.startsWith("//"))) {
|
|
194
|
+
problems.push(`\`${at}\` must be a non-empty array of root-absolute paths such as "/faq/"`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
184
199
|
return problems;
|
|
185
200
|
}
|
|
186
201
|
|
|
@@ -47,9 +47,37 @@ const DEFAULT_SPEC_HEADING = "Product Details";
|
|
|
47
47
|
const DEFAULT_FAQ_HEADING = "Frequently Asked Questions";
|
|
48
48
|
const DEFAULT_BOX_CONTENTS_LABEL = "What's in the box";
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* True for a metafield that carries an unresolved platform reference rather
|
|
52
|
+
* than displayable content — a Shopify metaobject/product/variant/file/
|
|
53
|
+
* collection/page reference (bare or `list.*`), surfaced either through its
|
|
54
|
+
* declared `type` or, when type is absent/untrusted, through the `gid://`
|
|
55
|
+
* shape of its value itself (bare or as a JSON array of them, the `list.*`
|
|
56
|
+
* on-disk form). No declared section may render one: a raw `gid://...`
|
|
57
|
+
* identifier is never product content, no matter which section asked for it.
|
|
58
|
+
*/
|
|
59
|
+
function isUnresolvedReference(m: Metafield): boolean {
|
|
60
|
+
if (m.type && /_reference$/.test(m.type)) return true;
|
|
61
|
+
const v = m.value?.trim();
|
|
62
|
+
if (!v) return false;
|
|
63
|
+
if (v.startsWith("gid://")) return true;
|
|
64
|
+
if (v.startsWith("[")) {
|
|
65
|
+
try {
|
|
66
|
+
const parsed = JSON.parse(v);
|
|
67
|
+
if (Array.isArray(parsed) && parsed.length > 0 && parsed.every((x) => typeof x === "string" && x.startsWith("gid://"))) {
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
/* not JSON — fall through, not a reference by this check */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
50
77
|
function metafieldLookup(metafields: readonly Metafield[]): (namespace: string, key: string) => string | undefined {
|
|
51
78
|
const map = new Map<string, string>();
|
|
52
79
|
for (const m of metafields) {
|
|
80
|
+
if (isUnresolvedReference(m)) continue;
|
|
53
81
|
const v = m.value?.trim();
|
|
54
82
|
if (v) map.set(`${m.namespace}.${m.key}`, v);
|
|
55
83
|
}
|
|
@@ -789,6 +789,11 @@ export interface SeoDeclaration {
|
|
|
789
789
|
names?: Partial<Record<"Product" | "ProductGroup" | "Article" | "BlogPosting", "documentTitle" | "title">>;
|
|
790
790
|
/** Emit real sourced aggregateRating/review on Product. Absent ⇒ false. */
|
|
791
791
|
reviews?: boolean;
|
|
792
|
+
/**
|
|
793
|
+
* Node types emitted only on the listed paths (trailing slash ignored), wherever a graph
|
|
794
|
+
* lists them — the per-URL presence a source site had (an FAQPage on some products only).
|
|
795
|
+
*/
|
|
796
|
+
onlyOn?: Partial<Record<StructuredDataNodeToken, string[]>>;
|
|
792
797
|
};
|
|
793
798
|
}
|
|
794
799
|
|