@tokenoftrust/storefront-runner 2.5.2 → 2.5.3

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.
@@ -88,26 +88,28 @@ export function composeStructuredData(input: ComposeInput): Node | null {
88
88
  const nameOf = (type: "Product" | "ProductGroup" | "Article" | "BlogPosting", own: string | undefined, fallback: "documentTitle" | "title") =>
89
89
  (profile.names?.[type] ?? fallback) === "documentTitle" ? documentTitle : own || documentTitle;
90
90
 
91
- const siteToken = tokens.find((t) => SITE_TOKENS.has(t));
92
- const siteTypes = siteToken ? siteToken.split("+") : [];
93
- const siteId = `${base}/#${siteTypes.includes("Person") ? "person" : "organization"}`;
94
- const imageUrl = page.image ? absolute(page.image, canonicalBase) : null;
95
- const imageId = imageUrl ?? undefined;
96
- const pageToken = tokens.find((t) => PAGE_ENTITY_TOKENS.has(t));
97
- const pageId = `${canonical}#webpage`;
98
- const authorId = page.author ? `${page.author.url ? absolute(page.author.url, canonicalBase) : canonical}#author` : undefined;
99
- const ref = (id: string | undefined) => (id ? { "@id": id } : undefined);
100
- const pageUrl = (href: string) => pageUrlForm(absolute(href, canonicalBase), tenant.seo);
101
-
102
91
  const pathOf = (value: string) => value.replace(/\/+$/, "") || "/";
103
92
  const here = pathOf(new URL(canonical).pathname);
104
93
  const scoped = (token: StructuredDataNodeToken) => {
105
94
  const paths = profile.onlyOn?.[token];
106
95
  return !paths || paths.some((p) => pathOf(p) === here);
107
96
  };
97
+ const imageUrl = page.image ? absolute(page.image, canonicalBase) : null;
98
+ // References and node emission share the same effective selection, including data availability.
99
+ const emittedTokens = tokens.filter(scoped).filter((token) =>
100
+ token === "ImageObject" ? imageUrl !== null : token === "Person:author" ? Boolean(page.author) : true);
101
+ const siteToken = emittedTokens.find((t) => SITE_TOKENS.has(t));
102
+ const siteTypes = siteToken ? siteToken.split("+") : [];
103
+ const siteId = `${base}/#${siteTypes.includes("Person") ? "person" : "organization"}`;
104
+ const imageId = emittedTokens.includes("ImageObject") ? imageUrl : undefined;
105
+ const pageToken = emittedTokens.find((t) => PAGE_ENTITY_TOKENS.has(t));
106
+ const pageId = `${canonical}#webpage`;
107
+ const authorId = emittedTokens.includes("Person:author") && page.author
108
+ ? `${page.author.url ? absolute(page.author.url, canonicalBase) : canonical}#author` : undefined;
109
+ const pageUrl = (href: string) => pageUrlForm(absolute(href, canonicalBase), tenant.seo);
108
110
 
109
111
  const nodes: Node[] = [];
110
- for (const token of tokens.filter(scoped)) {
112
+ for (const token of emittedTokens) {
111
113
  const node = buildNode(token);
112
114
  if (node) nodes.push(node);
113
115
  }
@@ -120,7 +122,7 @@ export function composeStructuredData(input: ComposeInput): Node | null {
120
122
  "@type": siteTypes.length > 1 ? siteTypes : siteTypes[0],
121
123
  "@id": siteId,
122
124
  name: tenant.displayName,
123
- url: base,
125
+ url: pageUrlForm(base, tenant.seo),
124
126
  ...(logo && siteTypes.includes("Organization") ? { logo: { "@type": "ImageObject", url: absolute(logo, canonicalBase) } } : {}),
125
127
  };
126
128
  }
@@ -131,7 +133,7 @@ export function composeStructuredData(input: ComposeInput): Node | null {
131
133
  url: canonical,
132
134
  name: documentTitle,
133
135
  ...(page.description ? { description: page.description } : {}),
134
- isPartOf: { "@id": `${base}/#website` },
136
+ ...(emittedTokens.includes("WebSite") ? { isPartOf: { "@id": `${base}/#website` } } : {}),
135
137
  ...(imageId ? { primaryImageOfPage: { "@id": imageId } } : {}),
136
138
  ...(page.datePublished ? { datePublished: page.datePublished } : {}),
137
139
  ...(page.dateModified ? { dateModified: page.dateModified } : {}),
@@ -143,7 +145,7 @@ export function composeStructuredData(input: ComposeInput): Node | null {
143
145
  return {
144
146
  "@type": "WebSite",
145
147
  "@id": `${base}/#website`,
146
- url: base,
148
+ url: pageUrlForm(base, tenant.seo),
147
149
  name: tenant.displayName,
148
150
  ...(siteToken ? { publisher: { "@id": siteId } } : {}),
149
151
  inLanguage: lang,
@@ -3,6 +3,7 @@
3
3
  * BreadcrumbList, Organization. Kept pure so pages compose them and pass to <Seo>.
4
4
  */
5
5
  import type { CatalogProduct, Review, TenantConfig } from "@tot/public-runtime";
6
+ import { pageUrlForm } from "../seo/urlForm.js";
6
7
  import { isAvailable } from "../../catalog/tot/query.js";
7
8
 
8
9
  export function organizationLd(tenant: TenantConfig, base: string) {
@@ -20,7 +21,7 @@ export function organizationLd(tenant: TenantConfig, base: string) {
20
21
  "@context": "https://schema.org",
21
22
  "@type": "Organization",
22
23
  name: tenant.displayName,
23
- url: base,
24
+ url: pageUrlForm(base, tenant.seo),
24
25
  logo,
25
26
  };
26
27
  }
@@ -53,7 +54,7 @@ export function websiteLd(tenant: TenantConfig, base: string) {
53
54
  "@context": "https://schema.org",
54
55
  "@type": "WebSite",
55
56
  name: tenant.displayName,
56
- url: base,
57
+ url: pageUrlForm(base, tenant.seo),
57
58
  potentialAction: {
58
59
  "@type": "SearchAction",
59
60
  target: {
@@ -204,7 +205,7 @@ export function productLd(
204
205
  tenant: TenantConfig,
205
206
  base: string,
206
207
  ) {
207
- const url = new URL(`/products/${p.handle}`, base + "/").toString();
208
+ const url = pageUrlForm(new URL(`/products/${p.handle}`, base + "/").toString(), tenant.seo);
208
209
  const images = p.images
209
210
  .map((i) => i.url)
210
211
  .filter((u) => !/cdn\.shopify\.com/.test(u)); // skip dead fixture URLs
@@ -0,0 +1,22 @@
1
+ /** Content redirects for ordinary preview/local renders, bound to the page's selected provider. */
2
+ import { buildRedirectSet, COLLECTION_REDIRECTS_PATH, declaredRedirectsFromContent, resolveRedirect } from "@tot/public-runtime/publication-redirects";
3
+ import type { ContentProvider } from "../storyblok/provider.js";
4
+
5
+ export async function contentRedirectLocation(
6
+ content: Pick<ContentProvider, "readArtifact" | "listArtifactPaths">,
7
+ requestPath: string,
8
+ ): Promise<string | null> {
9
+ try {
10
+ const paths = await content.listArtifactPaths();
11
+ const declarations = ["redirects.json", ...(paths ?? []).filter((path) => COLLECTION_REDIRECTS_PATH.test(`content/${path}`))];
12
+ const entries = await Promise.all(declarations.map(async (relative) => {
13
+ const raw = await content.readArtifact(relative);
14
+ return raw === null ? null : { path: `content/${relative}`, json: JSON.parse(raw) as unknown };
15
+ }));
16
+ const set = buildRedirectSet(declaredRedirectsFromContent(entries.filter((entry) => entry !== null)));
17
+ return set.ok ? resolveRedirect(set.redirects, requestPath)?.location ?? null : null;
18
+ } catch {
19
+ // An unreadable/invalid map must never produce an unsafe or partial redirect set.
20
+ return null;
21
+ }
22
+ }
@@ -83,6 +83,7 @@ import {
83
83
  HTML_CACHE_POLICY_CAPTURE_HEADER,
84
84
  HTML_CACHE_POLICY_CAPTURE_PROBE,
85
85
  } from "@tot/public-runtime";
86
+ import { contentRedirectLocation } from "@/lib/content/shared/redirects.js";
86
87
  import { publicationContentRedirectLocationV1 } from "@tot/public-runtime/publication-content-redirects";
87
88
  import { loadTenantIntegrationDeclarations } from "@/lib/integrations/integration/tenantDeclarations";
88
89
  import { resolveTenantCommerce } from "@/lib/checkout/shared/checkoutCommerce";
@@ -269,6 +270,57 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
269
270
  kv ? (key: string) => kv.get(key) : async () => null,
270
271
  { env: customization.env, ...(candidatePin ? { pinnedVersionId: candidatePin.versionId } : {}) },
271
272
  );
273
+ // Request-scoped read memo: every content read resolves the same pointer and
274
+ // version manifest, and a catalog render reads one artifact per catalog file.
275
+ const kvReads = new Map<string, Promise<string | null>>();
276
+ const kvGet = kv
277
+ ? (key: string) => {
278
+ let read = kvReads.get(key);
279
+ if (!read) {
280
+ read = kv.get(key);
281
+ kvReads.set(key, read);
282
+ }
283
+ return read;
284
+ }
285
+ : async () => null;
286
+ const readRuntimeArtifact = (path: string) => candidatePin
287
+ ? readWithImagePreviewRewriteOverlay(
288
+ kvGet,
289
+ tenant.tenant_id,
290
+ candidatePin.versionId,
291
+ path,
292
+ () => readVersionArtifact(kvGet, tenant.tenant_id, candidatePin.versionId, path),
293
+ )
294
+ : readPublishedArtifact(kvGet, tenant.tenant_id, customization.env, path);
295
+
296
+ // Resolve the shared root adapter first. An opened publication version then
297
+ // becomes authoritative for selected content while a mounted space may still
298
+ // reuse root-owned chrome. Neither source falls through to another space.
299
+ const sharedRootContent = candidatePin
300
+ ? createContentProvider(tenant, undefined, { get: kvGet, versionId: candidatePin.versionId })
301
+ : createContentProvider(tenant, {
302
+ get: kvGet,
303
+ env: customization.env,
304
+ });
305
+ locals.content = bindPublicationSpaceContentProviderV1(
306
+ sharedRootContent,
307
+ publicationSource.status === "selected" ? publicationSource.artifact : undefined,
308
+ // A discovery document lists the whole space it is bound to, not one selected page.
309
+ { wholeSpace: isPlatformPublicDiscoveryPath(renderPath) },
310
+ );
311
+ // Ordinary host, path-prefixed and pinned previews read the same declarations as their pages.
312
+ // Public CDN redirects remain precomputed in the publication artifact.
313
+ if (publicationSource.status === "legacy-root") {
314
+ const location = await contentRedirectLocation(locals.content, renderPath);
315
+ if (location) {
316
+ const response = new Response(null, {
317
+ status: 308,
318
+ headers: { location: location.startsWith("/") ? `${url.origin}${basePath}${location}${url.search}` : location },
319
+ });
320
+ applySecurityHeaders(response.headers);
321
+ return response;
322
+ }
323
+ }
272
324
  const publicationTimestamp = publicationSource.status === "selected"
273
325
  ? publicationSource.publicationTimestamp
274
326
  : exactPublicationTimestamp(await readEnv("TOT_PUBLICATION_TIMESTAMP"));
@@ -484,29 +536,6 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
484
536
  // test->preview) and resolves it through the immutable site version. A
485
537
  // candidate must bypass that pointer so a reviewer sees the candidate's own
486
538
  // theme, capabilities, and embed opt-in with its content.
487
- // Request-scoped read memo: every content read resolves the same pointer and
488
- // version manifest, and a catalog render reads one artifact per catalog file.
489
- const kvReads = new Map<string, Promise<string | null>>();
490
- const kvGet = kv
491
- ? (key: string) => {
492
- let read = kvReads.get(key);
493
- if (!read) {
494
- read = kv.get(key);
495
- kvReads.set(key, read);
496
- }
497
- return read;
498
- }
499
- : async () => null;
500
- const readRuntimeArtifact = (path: string) => candidatePin
501
- ? readWithImagePreviewRewriteOverlay(
502
- kvGet,
503
- tenant.tenant_id,
504
- candidatePin.versionId,
505
- path,
506
- () => readVersionArtifact(kvGet, tenant.tenant_id, candidatePin.versionId, path),
507
- )
508
- : readPublishedArtifact(kvGet, tenant.tenant_id, customization.env, path);
509
-
510
539
  // Self-heal a DANGLING channel pointer: the pointer for this env's channel may
511
540
  // name a version that was never built (or whose manifest was evicted).
512
541
  // `readVersionedArtifact` already serves last-built content instead of emptying
@@ -622,21 +651,6 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
622
651
  ? await resolveTenantCommerce(tenant)
623
652
  : null;
624
653
  locals.commerce = commerce ?? undefined;
625
- // Resolve the shared root adapter first. An opened publication version then
626
- // becomes authoritative for selected content while a mounted space may still
627
- // reuse root-owned chrome. Neither source falls through to another space.
628
- const sharedRootContent = candidatePin
629
- ? createContentProvider(tenant, undefined, { get: kvGet, versionId: candidatePin.versionId })
630
- : createContentProvider(tenant, {
631
- get: kvGet,
632
- env: customization.env,
633
- });
634
- locals.content = bindPublicationSpaceContentProviderV1(
635
- sharedRootContent,
636
- publicationSource.status === "selected" ? publicationSource.artifact : undefined,
637
- // A discovery document lists the whole space it is bound to, not one selected page.
638
- { wholeSpace: isPlatformPublicDiscoveryPath(renderPath) },
639
- );
640
654
  // Developer-draft content overlay — serves the AI-edited artifacts to the draft
641
655
  // owner only; every other viewer keeps the published/base chain above.
642
656
  if (draft) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.5.2",
3
+ "version": "2.5.3",
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",
@@ -107,8 +107,8 @@ export function seoDeclarationProblems(seo) {
107
107
  problems.push(`\`${at}\`: unknown page type (${STRUCTURED_DATA_PAGE_TYPES.join(", ")})`);
108
108
  continue;
109
109
  }
110
- if (!Array.isArray(tokens) || tokens.length === 0) {
111
- problems.push(`\`${at}\` must be a non-empty array of node types`);
110
+ if (!Array.isArray(tokens)) {
111
+ problems.push(`\`${at}\` must be an array of node types (empty emits no nodes)`);
112
112
  continue;
113
113
  }
114
114
  const seen = new Set();
@@ -153,8 +153,8 @@ export function seoDeclarationProblems(seo) {
153
153
  problems.push(`\`${at}\`: unknown page type (${STRUCTURED_DATA_PAGE_TYPES.join(", ")})`);
154
154
  continue;
155
155
  }
156
- if (!Array.isArray(tokens) || tokens.length === 0) {
157
- problems.push(`\`${at}\` must be a non-empty array of node types`);
156
+ if (!Array.isArray(tokens)) {
157
+ problems.push(`\`${at}\` must be an array of node types (empty emits no nodes)`);
158
158
  continue;
159
159
  }
160
160
  const seen = new Set();
@@ -790,7 +790,7 @@ export interface SeoDeclaration {
790
790
  * The structured-data profile. When present it is the ONLY JSON-LD a declared page type
791
791
  * emits: one `@graph` holding exactly the listed nodes, in order. A node whose data the
792
792
  * page lacks (no image, no author, no FAQ) is omitted. A page type without a graph keeps
793
- * the platform default.
793
+ * the platform default. An explicit empty list emits no nodes for that page type.
794
794
  */
795
795
  structuredData?: {
796
796
  graphs: Partial<Record<StructuredDataPageType, StructuredDataNodeToken[]>>;