@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.
@@ -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.76",
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