@tokenoftrust/storefront-runner 2.4.77 → 2.4.79
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/documentTitle.ts +11 -0
- package/apps/storefront/src/pages/collections/[handle].astro +2 -0
- package/apps/storefront/src/pages/products/[handle].astro +2 -0
- package/package.json +1 -1
- package/packages/cli/src/declared-config.mjs +4 -1
- package/packages/public-runtime/src/declared-tenant-config.mjs +4 -1
- package/packages/public-runtime/src/product-details.ts +28 -0
- package/packages/public-runtime/src/tenant.ts +6 -0
|
@@ -12,6 +12,17 @@
|
|
|
12
12
|
* posts pass it as `title`: one that names the store ("… - Prime Vapor Club")
|
|
13
13
|
* renders as written, any other is suffixed.
|
|
14
14
|
*/
|
|
15
|
+
/**
|
|
16
|
+
* A product's or collection's own catalog SEO title, as the `seoTitle` to render verbatim, when
|
|
17
|
+
* the tenant declares `seo.verbatimCatalogTitles`; otherwise undefined, so it is suffixed.
|
|
18
|
+
*/
|
|
19
|
+
export function catalogSeoTitle(
|
|
20
|
+
seo: { verbatimCatalogTitles?: boolean } | undefined,
|
|
21
|
+
ownSeoTitle: string | undefined,
|
|
22
|
+
): string | undefined {
|
|
23
|
+
return seo?.verbatimCatalogTitles && ownSeoTitle?.trim() ? ownSeoTitle : undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
15
26
|
export function documentTitle(title: string, seoTitle: string | undefined, storeName: string): string {
|
|
16
27
|
const verbatim = seoTitle?.trim();
|
|
17
28
|
if (verbatim) return verbatim;
|
|
@@ -19,6 +19,7 @@ import { parseQuery, buildQueryString } from "@/lib/url";
|
|
|
19
19
|
import { breadcrumbLd, itemListLd } from "@/lib/jsonld";
|
|
20
20
|
import { truncate } from "@/lib/format";
|
|
21
21
|
import { pageUrlForm } from "@/lib/seo/urlForm";
|
|
22
|
+
import { catalogSeoTitle } from "@/lib/seo/documentTitle";
|
|
22
23
|
import { withBase } from "@/lib/basePath";
|
|
23
24
|
import { isBlankDescriptionHtml, sanitizeDescriptionHtml } from "@/lib/content/sanitizeDescriptionHtml";
|
|
24
25
|
|
|
@@ -91,6 +92,7 @@ if (query.priceMin != null || query.priceMax != null) {
|
|
|
91
92
|
|
|
92
93
|
<Layout
|
|
93
94
|
title={collection.seo.title || collection.title}
|
|
95
|
+
seoTitle={catalogSeoTitle(tenant.seo, collection.seo.title)}
|
|
94
96
|
description={collection.seo.description || (desc ? truncate(desc, 160) : `Shop ${collection.title} at ${tenant.displayName}.`)}
|
|
95
97
|
path={basePath}
|
|
96
98
|
noindex={isEmptyCollection}
|
|
@@ -47,6 +47,7 @@ import { isUsableImageUrl } from "@/lib/placeholder";
|
|
|
47
47
|
import { withBase } from "@/lib/basePath";
|
|
48
48
|
import { productPageType, structuredDataProfile } from "@/lib/seo/structuredData";
|
|
49
49
|
import { pageUrlForm } from "@/lib/seo/urlForm";
|
|
50
|
+
import { catalogSeoTitle } from "@/lib/seo/documentTitle";
|
|
50
51
|
import { shippingPolicyPath } from "@/lib/commerce/shippingPolicy";
|
|
51
52
|
import AppWidgetFrame from "@/components/apps/AppWidgetFrame.astro";
|
|
52
53
|
import { getRegistryService } from "@/lib/apps/registryAccess.js";
|
|
@@ -313,6 +314,7 @@ const structuredData = {
|
|
|
313
314
|
|
|
314
315
|
<Layout
|
|
315
316
|
title={product.seo.title || product.title}
|
|
317
|
+
seoTitle={catalogSeoTitle(tenant.seo, product.seo.title)}
|
|
316
318
|
description={product.seo.description || truncate(product.description_text, 160)}
|
|
317
319
|
path={`/products/${product.handle}`}
|
|
318
320
|
ogType="product"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.79",
|
|
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",
|
|
@@ -31,7 +31,7 @@ export const FEATURE_KEYS = [
|
|
|
31
31
|
export const SITE_TYPES = ["commerce", "marketing"];
|
|
32
32
|
|
|
33
33
|
/** Every `seo` key (mirrors packages/public-runtime/src/declared-tenant-config.mjs). */
|
|
34
|
-
export const SEO_KEYS = Object.freeze(["trailingSlash", "structuredData"]);
|
|
34
|
+
export const SEO_KEYS = Object.freeze(["trailingSlash", "verbatimCatalogTitles", "structuredData"]);
|
|
35
35
|
|
|
36
36
|
/** Every `seo.structuredData` key. */
|
|
37
37
|
export const STRUCTURED_DATA_KEYS = Object.freeze(["graphs", "names", "reviews", "onlyOn"]);
|
|
@@ -86,6 +86,9 @@ export function seoDeclarationProblems(seo) {
|
|
|
86
86
|
if (source.trailingSlash !== undefined && typeof source.trailingSlash !== "boolean") {
|
|
87
87
|
problems.push("`seo.trailingSlash` must be true or false");
|
|
88
88
|
}
|
|
89
|
+
if (source.verbatimCatalogTitles !== undefined && typeof source.verbatimCatalogTitles !== "boolean") {
|
|
90
|
+
problems.push("`seo.verbatimCatalogTitles` must be true or false");
|
|
91
|
+
}
|
|
89
92
|
const sd = source.structuredData;
|
|
90
93
|
if (sd === undefined) return problems;
|
|
91
94
|
if (!isPlainObject(sd)) return [...problems, "`seo.structuredData` must be an object"];
|
|
@@ -75,7 +75,7 @@ export const FEATURE_KEYS = Object.freeze([
|
|
|
75
75
|
export const SITE_TYPES_LIST = Object.freeze(["commerce", "marketing"]);
|
|
76
76
|
|
|
77
77
|
/** Every `seo` key (SeoDeclaration, tenant.ts). */
|
|
78
|
-
export const SEO_KEYS = Object.freeze(["trailingSlash", "structuredData"]);
|
|
78
|
+
export const SEO_KEYS = Object.freeze(["trailingSlash", "verbatimCatalogTitles", "structuredData"]);
|
|
79
79
|
|
|
80
80
|
/** Every `seo.structuredData` key. */
|
|
81
81
|
export const STRUCTURED_DATA_KEYS = Object.freeze(["graphs", "names", "reviews", "onlyOn"]);
|
|
@@ -132,6 +132,9 @@ export function seoDeclarationProblems(seo) {
|
|
|
132
132
|
if (source.trailingSlash !== undefined && typeof source.trailingSlash !== "boolean") {
|
|
133
133
|
problems.push("`seo.trailingSlash` must be true or false");
|
|
134
134
|
}
|
|
135
|
+
if (source.verbatimCatalogTitles !== undefined && typeof source.verbatimCatalogTitles !== "boolean") {
|
|
136
|
+
problems.push("`seo.verbatimCatalogTitles` must be true or false");
|
|
137
|
+
}
|
|
135
138
|
const sd = source.structuredData;
|
|
136
139
|
if (sd === undefined) return problems;
|
|
137
140
|
if (!isPlainObject(sd)) return [...problems, "`seo.structuredData` must be an object"];
|
|
@@ -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
|
}
|
|
@@ -777,6 +777,12 @@ export interface SeoDeclaration {
|
|
|
777
777
|
* `/` (`/products/foo/`). Absent or false ⇒ no trailing slash (`/products/foo`).
|
|
778
778
|
*/
|
|
779
779
|
trailingSlash?: boolean;
|
|
780
|
+
/**
|
|
781
|
+
* A product's or collection's own catalog SEO title is the complete `<title>`, rendered as
|
|
782
|
+
* written (a source that already ends its titles in "| Brand"). Absent or false ⇒ it is
|
|
783
|
+
* suffixed with the store name like any other page title.
|
|
784
|
+
*/
|
|
785
|
+
verbatimCatalogTitles?: boolean;
|
|
780
786
|
/**
|
|
781
787
|
* The structured-data profile. When present it is the ONLY JSON-LD a declared page type
|
|
782
788
|
* emits: one `@graph` holding exactly the listed nodes, in order. A node whose data the
|