@decocms/blocks 7.28.0-beta.5 → 7.28.1

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.
@@ -1,3 +1,10 @@
1
+ import {
2
+ type ActionConfig,
3
+ inferLoaderTags,
4
+ type LoaderConfig,
5
+ registerActionSchemas,
6
+ registerLoaderSchemas,
7
+ } from "./schema";
1
8
  import { getMatchersOverride, getRuleOverrideId, hasMatchersOverride } from "../matchers/override";
2
9
  import { getMeter, MetricNames, withTracing } from "../middleware/observability";
3
10
  import { djb2Hex } from "../sdk/djb2";
@@ -6,13 +13,6 @@ import { withInflightTimeout } from "../sdk/inflightTimeout";
6
13
  import { normalizeUrlsInObject } from "../sdk/normalizeUrls";
7
14
  import { findPageByPath, loadBlocks } from "./loader";
8
15
  import { getOnBeforeResolveProps, getSection, registerOnBeforeResolveProps } from "./registry";
9
- import {
10
- type ActionConfig,
11
- inferLoaderTags,
12
- type LoaderConfig,
13
- registerActionSchemas,
14
- registerLoaderSchemas,
15
- } from "./schema";
16
16
  import { isLayoutSection, markSectionDegraded, runSingleSectionLoader } from "./sectionLoaders";
17
17
 
18
18
  // globalThis-backed: share state across Vite server function split modules
@@ -127,17 +127,25 @@ export interface AsyncRenderingConfig {
127
127
  /** Section component keys that must always be rendered eagerly. */
128
128
  alwaysEager: Set<string>;
129
129
  /**
130
- * Bot-aware page SEO. When true, the page-level `seo` block's
131
- * commerce-loader-backed props (e.g. `jsonLD: { __resolveType: "PLP Loader" }`)
132
- * are SKIPPED for human (non-bot) requests — so SSR doesn't run the heavy
133
- * upstream fetch and the product payload never reaches the human HTML. Bots
134
- * still get the full SEO (JSON-LD) for indexing.
130
+ * Site-wide bot-aware page SEO shortcut. When true, the page-level `seo`
131
+ * block's commerce-loader-backed props (e.g. `jsonLD: { __resolveType: "PLP
132
+ * Loader" }`) are SKIPPED for human (non-bot) requests — so SSR doesn't run
133
+ * the heavy upstream fetch and the product payload never reaches the human
134
+ * HTML. Bots still get the full SEO (JSON-LD) for indexing.
135
+ *
136
+ * This is a blanket, developer-set shortcut. The PRIMARY, client-facing lever
137
+ * is the per-section `ignoreStructuredData` toggle in the admin (on the PLP/PDP
138
+ * SEO section): it drives the exact same fetch-skip + bot-aware JSON-LD, but
139
+ * per section and without a code change. This flag simply turns the behavior
140
+ * on for every commerce-backed SEO block at once.
135
141
  *
136
142
  * OFF by default: the optimization removes the commerce data a human `<title>`
137
- * may derive from, so it is only safe once the site provides a lightweight
138
- * title fallback (e.g. a cheap category-metadata loader). Sites that just bump
139
- * the framework keep the previous behavior (full SEO for everyone) and are not
140
- * regressed. Opt in with `setAsyncRenderingConfig({ botAwareSeo: true })`.
143
+ * may derive from, so a blanket opt-in is only safe once the site provides a
144
+ * lightweight title fallback (e.g. a cheap category-metadata loader). Sites
145
+ * that just bump the framework keep the previous behavior (full SEO for
146
+ * everyone) and are not regressed. Opt in with
147
+ * `setAsyncRenderingConfig({ botAwareSeo: true })`, or leave it off and let
148
+ * editors flip `ignoreStructuredData` per section.
141
149
  * @default false
142
150
  */
143
151
  botAwareSeo: boolean;
@@ -227,7 +235,7 @@ function isEagerSection(key: string): boolean {
227
235
  * during hydration (search filters, configurators, etc.).
228
236
  */
229
237
  export function registerNeverDeferSections(keys: string[]): void {
230
- const set: Set<string> = (G.__deco.neverDeferSectionKeys ??= new Set());
238
+ const set: Set<string> = G.__deco.neverDeferSectionKeys ??= new Set();
231
239
  for (const k of keys) set.add(k);
232
240
  }
233
241
 
@@ -342,7 +350,8 @@ function hasForceEagerParam(ctx?: MatcherContext): boolean {
342
350
  */
343
351
  function isProgrammaticFetch(ctx?: MatcherContext): boolean {
344
352
  if (ctx?.isClientNavigation) return false;
345
- const dest = ctx?.request?.headers.get("sec-fetch-dest") ?? ctx?.headers?.["sec-fetch-dest"];
353
+ const dest = ctx?.request?.headers.get("sec-fetch-dest") ??
354
+ ctx?.headers?.["sec-fetch-dest"];
346
355
  return dest === "empty";
347
356
  }
348
357
 
@@ -353,7 +362,8 @@ function isProgrammaticFetch(ctx?: MatcherContext): boolean {
353
362
  * to gate both section deferral and page-SEO commerce resolution.
354
363
  */
355
364
  export function isEagerRequest(ctx?: MatcherContext): boolean {
356
- return isBot(ctx?.userAgent) || hasForceEagerParam(ctx) || isProgrammaticFetch(ctx);
365
+ return isBot(ctx?.userAgent) || hasForceEagerParam(ctx) ||
366
+ isProgrammaticFetch(ctx);
357
367
  }
358
368
 
359
369
  /**
@@ -733,9 +743,7 @@ function evaluateVariantRule(
733
743
  const already = ctx.flags?.find((f) => f.name === meta.name && f.pct === meta.pct);
734
744
  if (already) return already.value;
735
745
 
736
- const stored = parseSegmentCookie(ctx.cookies?.[SEGMENT_COOKIE]).find(
737
- (f) => f.name === meta.name,
738
- );
746
+ const stored = parseSegmentCookie(ctx.cookies?.[SEGMENT_COOKIE]).find((f) => f.name === meta.name);
739
747
  // pct === -1 marks a classic-deco segment without a fingerprint — honor it
740
748
  // (stay sticky) instead of re-rolling. A stale fingerprint re-rolls.
741
749
  const useStored = stored && (stored.pct === -1 || stored.pct === meta.pct);
@@ -1124,18 +1132,21 @@ export async function resolvePageSeoBlock(
1124
1132
  ): Promise<ResolvedSection | null> {
1125
1133
  if (!seoBlock || typeof seoBlock !== "object") return null;
1126
1134
 
1127
- // Bot-aware SEO is OPT-IN (`setAsyncRenderingConfig({ botAwareSeo: true })`).
1128
- // When disabled (default), resolve the SEO block fully for EVERYONE — the
1129
- // previous behavior — so sites that bump the framework without a lightweight
1130
- // title fallback are not regressed to a generic `<title>`.
1131
- //
1132
- // When enabled: crawlers get the SEO block fully resolved (e.g. a
1135
+ // Bot-aware SEO: crawlers get the SEO block fully resolved (e.g. a
1133
1136
  // ProductListingPage for JSON-LD ItemList) — that content exists for indexing;
1134
- // humans get only the lightweight metadata — SEO props backed by a commerce
1135
- // loader are skipped (see `stripCommerceLoaderProps`) so SSR doesn't block on
1136
- // the heavy upstream call and the bulky payload never reaches the human HTML.
1137
- const botAwareSeo = getAsyncConfig()?.botAwareSeo ?? false;
1138
- const seoForBot = !botAwareSeo || isEagerRequest(rctx.matcherCtx);
1137
+ // humans get only the lightweight metadata, with SEO props backed by a commerce
1138
+ // loader skipped (see `stripCommerceLoaderProps`) so SSR doesn't block on the
1139
+ // heavy upstream call and the bulky payload never reaches the human HTML.
1140
+ //
1141
+ // The skip is driven per-section by the editor's `ignoreStructuredData` toggle
1142
+ // (the primary, client-facing lever — see `sectionIgnoresStructuredData`), OR
1143
+ // site-wide by the legacy `setAsyncRenderingConfig({ botAwareSeo: true })`
1144
+ // shortcut. When neither is set, resolve fully for EVERYONE (the previous
1145
+ // default) so sites without a lightweight title fallback are not regressed to
1146
+ // a generic `<title>`. The final decision is made at the terminal section
1147
+ // below, where the section's raw props are known.
1148
+ const isEagerReq = isEagerRequest(rctx.matcherCtx);
1149
+ const globalBotAware = getAsyncConfig()?.botAwareSeo ?? false;
1139
1150
 
1140
1151
  const blocks = loadBlocks();
1141
1152
  let current = seoBlock;
@@ -1195,7 +1206,11 @@ export async function resolvePageSeoBlock(
1195
1206
  // (e.g. `jsonLD: { __resolveType: "PLP Loader" }`). This avoids the heavy
1196
1207
  // SSR fetch and keeps the product payload out of the human HTML. Bots keep
1197
1208
  // the full props so JSON-LD/rich metadata is still emitted for indexing.
1198
- const propsToResolve = seoForBot ? rawProps : stripCommerceLoaderProps(rawProps);
1209
+ // Skip only when the editor turned on `ignoreStructuredData` for this
1210
+ // section, or the site-wide `botAwareSeo` shortcut is enabled.
1211
+ const skipCommerceForHuman =
1212
+ !isEagerReq && (globalBotAware || sectionIgnoresStructuredData(rawProps));
1213
+ const propsToResolve = skipCommerceForHuman ? stripCommerceLoaderProps(rawProps) : rawProps;
1199
1214
  try {
1200
1215
  const resolvedProps = await resolveProps(propsToResolve, rctx);
1201
1216
  return {
@@ -1257,6 +1272,24 @@ function resolvesToCommerceLoader(value: unknown, depth = 0): boolean {
1257
1272
  return false;
1258
1273
  }
1259
1274
 
1275
+ /**
1276
+ * True when a commerce SEO section's props carry the editor's "Ignore Structured
1277
+ * Data" toggle. This is the primary, client-facing lever for bot-aware SEO: with
1278
+ * it on, humans receive no JSON-LD (and, on the page's SEO block, skip the heavy
1279
+ * commerce fetch) while crawlers still get the full structured data.
1280
+ *
1281
+ * Two shapes, mirroring the Studio schema: top-level `ignoreStructuredData` on
1282
+ * the PDP section, and nested under `configJsonLD` on the PLP section. Shared by
1283
+ * the fetch-skip path (`resolvePageSeoBlock`, pre-resolution) and the
1284
+ * output-strip path (`deriveCommerceSeoFromJsonLD`, post-resolution) so the two
1285
+ * decisions can never read the toggle differently.
1286
+ */
1287
+ function sectionIgnoresStructuredData(props: Record<string, unknown>): boolean {
1288
+ if (props.ignoreStructuredData === true) return true;
1289
+ const configJsonLD = props.configJsonLD as { ignoreStructuredData?: boolean } | undefined;
1290
+ return configJsonLD?.ignoreStructuredData === true;
1291
+ }
1292
+
1260
1293
  /**
1261
1294
  * Return a copy of `rawProps` with every top-level field that resolves to a
1262
1295
  * commerce loader removed. Applied to page SEO props for human (non-bot)
@@ -1264,7 +1297,9 @@ function resolvesToCommerceLoader(value: unknown, depth = 0): boolean {
1264
1297
  * serialized into the HTML. Lightweight literal props (title, description,
1265
1298
  * canonical, …) are preserved.
1266
1299
  */
1267
- function stripCommerceLoaderProps(rawProps: Record<string, unknown>): Record<string, unknown> {
1300
+ function stripCommerceLoaderProps(
1301
+ rawProps: Record<string, unknown>,
1302
+ ): Record<string, unknown> {
1268
1303
  const out: Record<string, unknown> = {};
1269
1304
  for (const [k, v] of Object.entries(rawProps)) {
1270
1305
  if (resolvesToCommerceLoader(v)) continue;
@@ -1745,7 +1780,11 @@ function pruneCommerceJsonLD(
1745
1780
  * gaps and appends the JSON-LD. The caller guards on `seo.jsonLDs` so a real
1746
1781
  * site SEO section that already computed structured data is never touched.
1747
1782
  */
1748
- function deriveCommerceSeoFromJsonLD(seo: PageSeo, props: Record<string, unknown>): void {
1783
+ function deriveCommerceSeoFromJsonLD(
1784
+ seo: PageSeo,
1785
+ props: Record<string, unknown>,
1786
+ isEager: boolean,
1787
+ ): void {
1749
1788
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1750
1789
  const jsonLD = props.jsonLD as Record<string, any> | undefined;
1751
1790
  if (!jsonLD) return;
@@ -1779,12 +1818,15 @@ function deriveCommerceSeoFromJsonLD(seo: PageSeo, props: Record<string, unknown
1779
1818
  }
1780
1819
 
1781
1820
  // Structured data — opt out via `ignoreStructuredData` (top-level on PDP,
1782
- // under `configJsonLD` on PLP). An empty page contributes no ItemList.
1821
+ // under `configJsonLD` on PLP). Bot-aware: the toggle suppresses JSON-LD for
1822
+ // humans only; crawlers (and the `?__deco_ssr=1` audit override) still get it
1823
+ // so indexing/rich results are unaffected. An empty page contributes no
1824
+ // ItemList regardless.
1783
1825
  const configJsonLD = props.configJsonLD as
1784
1826
  | { ignoreStructuredData?: boolean; removeVideos?: boolean }
1785
1827
  | undefined;
1786
- const ignore = props.ignoreStructuredData === true || configJsonLD?.ignoreStructuredData === true;
1787
- if (ignore || isEmpty) return;
1828
+ const ignore = sectionIgnoresStructuredData(props);
1829
+ if ((ignore && !isEager) || isEmpty) return;
1788
1830
 
1789
1831
  seo.jsonLDs = [
1790
1832
  pruneCommerceJsonLD(jsonLD, {
@@ -1798,7 +1840,11 @@ function deriveCommerceSeoFromJsonLD(seo: PageSeo, props: Record<string, unknown
1798
1840
  * Pick standard SEO fields from a props object.
1799
1841
  * Works for both framework SEO types (SeoV2) and site SEO sections (SEOPDP).
1800
1842
  */
1801
- export function extractSeoFromProps(props: Record<string, unknown>): PageSeo {
1843
+ export function extractSeoFromProps(
1844
+ props: Record<string, unknown>,
1845
+ opts?: { isEager?: boolean },
1846
+ ): PageSeo {
1847
+ const isEager = opts?.isEager ?? false;
1802
1848
  const seo: PageSeo = {};
1803
1849
  if (props.title) seo.title = props.title as string;
1804
1850
  if (props.description) seo.description = props.description as string;
@@ -1813,7 +1859,7 @@ export function extractSeoFromProps(props: Record<string, unknown>): PageSeo {
1813
1859
  // Legacy commerce PLP/PDP sections carry a `jsonLD` data source with no
1814
1860
  // component to turn it into structured data — derive it here (only when a
1815
1861
  // real SEO section hasn't already emitted `jsonLDs`).
1816
- if (!seo.jsonLDs) deriveCommerceSeoFromJsonLD(seo, props);
1862
+ if (!seo.jsonLDs) deriveCommerceSeoFromJsonLD(seo, props, isEager);
1817
1863
  return seo;
1818
1864
  }
1819
1865
 
@@ -1822,11 +1868,14 @@ export function extractSeoFromProps(props: Record<string, unknown>): PageSeo {
1822
1868
  * `registerSeoSections`. Later sections override earlier ones
1823
1869
  * (e.g., a PDP SEO section overrides a generic page SEO).
1824
1870
  */
1825
- export function extractSeoFromSections(sections: ResolvedSection[]): PageSeo {
1871
+ export function extractSeoFromSections(
1872
+ sections: ResolvedSection[],
1873
+ opts?: { isEager?: boolean },
1874
+ ): PageSeo {
1826
1875
  const seo: PageSeo = {};
1827
1876
  for (const section of sections) {
1828
1877
  if (!seoSectionKeys.has(section.component)) continue;
1829
- const extracted = extractSeoFromProps(section.props);
1878
+ const extracted = extractSeoFromProps(section.props, opts);
1830
1879
  if (extracted.jsonLDs) {
1831
1880
  extracted.jsonLDs = [...(seo.jsonLDs ?? []), ...extracted.jsonLDs];
1832
1881
  }
@@ -253,9 +253,10 @@ describe("commerce SEO section schemas", () => {
253
253
  // PLP-specific structured-data config (see seo-form-mode.ts PLP_SEO_FIELD_KEYS).
254
254
  expect(props.configJsonLD.properties).toHaveProperty("removeVideos");
255
255
  expect(props.configJsonLD.properties).toHaveProperty("ignoreStructuredData");
256
- // The data source is set once at creation and stays hidden in the SEO panel;
257
- // it round-trips via the form rather than being re-picked here.
258
- expect(props.jsonLD.hide).toBe(true);
256
+ // The data source is a loader picker (anyOf over the loader union), not
257
+ // hidden — the user picks the PLP loader that feeds the structured data.
258
+ expect(Array.isArray(props.jsonLD.anyOf)).toBe(true);
259
+ expect(props.jsonLD.title).toBe("Data Source");
259
260
  });
260
261
 
261
262
  it("emits editable props for the product-details SEO section (pdp mode)", () => {
@@ -268,7 +269,8 @@ describe("commerce SEO section schemas", () => {
268
269
  // PDP-specific fields (see seo-form-mode.ts PDP_SEO_FIELD_KEYS).
269
270
  expect(props).toHaveProperty("omitVariants");
270
271
  expect(props).toHaveProperty("ignoreStructuredData");
271
- expect(props.jsonLD.hide).toBe(true);
272
+ expect(Array.isArray(props.jsonLD.anyOf)).toBe(true);
273
+ expect(props.jsonLD.title).toBe("Data Source");
272
274
  });
273
275
 
274
276
  it("registers the defs + manifest blocks and offers them as page.seo options", () => {
@@ -285,6 +287,51 @@ describe("commerce SEO section schemas", () => {
285
287
  expect(meta.schema.root.sections.anyOf).toContainEqual(b64Ref(key));
286
288
  }
287
289
  });
290
+
291
+ // Mirrors a generated site meta: a loader carries a real def AND is listed in
292
+ // the site's loader union. `@ignore`d loaders are the exception — they get a
293
+ // def but are withheld from the union.
294
+ function siteMetaWithLoader(key: string, opts: { inUnion: boolean }) {
295
+ const site = emptySiteMeta();
296
+ site.schema.definitions[b64(key)] = {
297
+ title: key,
298
+ type: "object",
299
+ properties: { __resolveType: { type: "string", enum: [key] } },
300
+ };
301
+ site.manifest.blocks.loaders = {
302
+ [key]: { $ref: `#/definitions/${b64(key)}`, namespace: "site" },
303
+ };
304
+ site.schema.root = { loaders: { anyOf: opts.inUnion ? [b64Ref(key)] : [] } };
305
+ return site;
306
+ }
307
+
308
+ it("feeds the site's own loaders into the jsonLD data-source picker", () => {
309
+ // The runtime loader registry is empty at generation time, so the union must
310
+ // be seeded from the site's own loader union — else the picker has no
311
+ // options and the user can't wire the data source.
312
+ const loaderKey = "site/loaders/product/productDetailsPage.ts";
313
+ const meta = composeMeta(siteMetaWithLoader(loaderKey, { inUnion: true }));
314
+
315
+ // The picker option is real, not a dangling $ref: its def survives.
316
+ expect(meta.schema.definitions).toHaveProperty(b64(loaderKey));
317
+ // …offered in both the root union and the commerce SEO jsonLD picker.
318
+ expect(meta.schema.root.loaders.anyOf).toContainEqual(b64Ref(loaderKey));
319
+ const seoDef =
320
+ meta.schema.definitions[b64("commerce/sections/Seo/SeoPDPV2.tsx")];
321
+ expect(seoDef.properties.jsonLD.anyOf).toContainEqual(b64Ref(loaderKey));
322
+ });
323
+
324
+ it("keeps @ignore'd loaders (in the manifest, not the union) out of the picker", () => {
325
+ // Seeding from `manifest.blocks.loaders` would resurrect hidden loaders;
326
+ // seeding from the union keeps them out.
327
+ const hiddenKey = "site/loaders/internal/hidden.ts";
328
+ const meta = composeMeta(siteMetaWithLoader(hiddenKey, { inUnion: false }));
329
+
330
+ expect(meta.schema.root.loaders.anyOf).not.toContainEqual(b64Ref(hiddenKey));
331
+ const seoDef =
332
+ meta.schema.definitions[b64("commerce/sections/Seo/SeoPDPV2.tsx")];
333
+ expect(seoDef.properties.jsonLD.anyOf).not.toContainEqual(b64Ref(hiddenKey));
334
+ });
288
335
  });
289
336
 
290
337
  describe("composeMeta idempotency", () => {
package/src/cms/schema.ts CHANGED
@@ -731,7 +731,16 @@ function buildPageSchema(sectionAnyOf: any[]) {
731
731
  // Framework sections
732
732
  // ---------------------------------------------------------------------------
733
733
 
734
- function buildFrameworkSections(sectionAnyOf: any[]) {
734
+ // Merchant-facing help for the per-section `ignoreStructuredData` toggle, shared
735
+ // by the PLP (`configJsonLD`) and PDP (top-level) SEO sections. The fetch-skip is
736
+ // deliberately scoped to "the page's SEO source" — it only fires when this block
737
+ // is wired as the page's SEO (`page.seo`), not when the section is dropped inline
738
+ // in the sections array (there the commerce loader still runs; only the JSON-LD
739
+ // output is omitted for visitors).
740
+ const IGNORE_STRUCTURED_DATA_DESCRIPTION =
741
+ "By default, Structured Data (JSON-LD) is sent to everyone. Turn this on to omit it for regular visitors while crawlers and bots still receive the full Structured Data. When this block is the page's SEO source, the product fetch is also skipped for visitors, so the page loads faster. Some integrations may rely on Structured Data being present for all users.";
742
+
743
+ function buildFrameworkSections(sectionAnyOf: any[], loaderUnion: any[]) {
735
744
  const definitions: Record<string, any> = {};
736
745
  const manifestBlocks: Record<string, any> = {};
737
746
  const extraAnyOf: any[] = [];
@@ -839,8 +848,12 @@ function buildFrameworkSections(sectionAnyOf: any[]) {
839
848
  // there is no manifest to emit these props from. The Studio's SEO editor
840
849
  // already knows this type as its "plp" mode (see seo-form-mode.ts); it just
841
850
  // needs the props schema to render the override fields. `jsonLD` is the
842
- // data-source block-ref set once when the page is created; it round-trips via
843
- // the form (kept `hide`) rather than being re-picked in the SEO panel.
851
+ // data-source loader picker (mirrors deco-cx's `@title Data Source`) — an
852
+ // inline anyOf over the loader union so the editor lets you pick the PLP/PDP
853
+ // loader that feeds the structured data. Inlined (a fresh copy) rather than a
854
+ // `$ref: "#/root/loaders"` because the Studio drops the prop-level title when
855
+ // resolving a `$ref` union, which would lose the "Data Source" label; the
856
+ // copy also avoids sharing one array instance across the two defs + root.
844
857
  //
845
858
  // Added to `extraAnyOf` (offered as a selectable page.seo type) unconditionally,
846
859
  // like the website Seo sections above. Gating this on "is a commerce site" via
@@ -861,12 +874,7 @@ function buildFrameworkSections(sectionAnyOf: any[]) {
861
874
  enum: [SEO_PLP_V2_TYPE],
862
875
  default: SEO_PLP_V2_TYPE,
863
876
  },
864
- jsonLD: {
865
- type: "object",
866
- title: "Data Source",
867
- additionalProperties: true,
868
- hide: true,
869
- },
877
+ jsonLD: { title: "Data Source", anyOf: [...loaderUnion] },
870
878
  title: { type: "string", title: "Title Override" },
871
879
  description: { type: "string", title: "Description Override" },
872
880
  noIndexing: { type: "boolean", title: "Disable indexing" },
@@ -875,7 +883,11 @@ function buildFrameworkSections(sectionAnyOf: any[]) {
875
883
  title: "Structured Data",
876
884
  properties: {
877
885
  removeVideos: { type: "boolean", title: "Remove videos" },
878
- ignoreStructuredData: { type: "boolean", title: "Ignore Structured Data" },
886
+ ignoreStructuredData: {
887
+ type: "boolean",
888
+ title: "Ignore Structured Data",
889
+ description: IGNORE_STRUCTURED_DATA_DESCRIPTION,
890
+ },
879
891
  },
880
892
  },
881
893
  },
@@ -900,17 +912,16 @@ function buildFrameworkSections(sectionAnyOf: any[]) {
900
912
  enum: [SEO_PDP_V2_TYPE],
901
913
  default: SEO_PDP_V2_TYPE,
902
914
  },
903
- jsonLD: {
904
- type: "object",
905
- title: "Data Source",
906
- additionalProperties: true,
907
- hide: true,
908
- },
915
+ jsonLD: { title: "Data Source", anyOf: [...loaderUnion] },
909
916
  omitVariants: { type: "boolean", title: "Omit variants" },
910
917
  title: { type: "string", title: "Title Override" },
911
918
  description: { type: "string", title: "Description Override" },
912
919
  noIndexing: { type: "boolean", title: "Disable indexing" },
913
- ignoreStructuredData: { type: "boolean", title: "Ignore Structured Data" },
920
+ ignoreStructuredData: {
921
+ type: "boolean",
922
+ title: "Ignore Structured Data",
923
+ description: IGNORE_STRUCTURED_DATA_DESCRIPTION,
924
+ },
914
925
  },
915
926
  };
916
927
  manifestBlocks[SEO_PDP_V2_TYPE] = {
@@ -1089,7 +1100,10 @@ export interface ComposeMetaOptions {
1089
1100
  framework?: string;
1090
1101
  }
1091
1102
 
1092
- export function composeMeta(siteMeta: MetaResponse, options?: ComposeMetaOptions): MetaResponse {
1103
+ export function composeMeta(
1104
+ siteMeta: MetaResponse,
1105
+ options?: ComposeMetaOptions,
1106
+ ): MetaResponse {
1093
1107
  // Idempotency guard. composeMeta is NOT structurally idempotent — it appends
1094
1108
  // the framework section refs to `root.sections.anyOf` (and `__SECTION_REF__`),
1095
1109
  // so composing an already-composed meta a second time duplicates those refs.
@@ -1106,11 +1120,36 @@ export function composeMeta(siteMeta: MetaResponse, options?: ComposeMetaOptions
1106
1120
 
1107
1121
  const siteAnyOf = siteMeta.schema?.root?.sections?.anyOf || [];
1108
1122
 
1109
- // Build all framework components
1110
- const fwSections = buildFrameworkSections(siteAnyOf);
1123
+ // Build all framework components. Loaders first: the commerce SEO sections'
1124
+ // `jsonLD` data-source picker references the loader union, and that union must
1125
+ // include the site's OWN loaders. The runtime registry (loaderRegistry) is
1126
+ // empty at generation time, so `buildLoaderDefinitions` alone yields just
1127
+ // `Resolvable` — merge in the site's own loaders so loader-backed pickers have
1128
+ // real options in the baked meta, not only at runtime.
1129
+ //
1130
+ // Seed from the site's loader UNION (`schema.root.loaders.anyOf`), NOT
1131
+ // `manifest.blocks.loaders`: the generator keeps `@ignore`d loaders in the
1132
+ // manifest (they still need a def) while withholding them from the union so
1133
+ // they can't be picked — reading the manifest would resurrect them. This
1134
+ // source is also already `{ $ref }`-shaped and only points at defs that exist
1135
+ // (no dangling refs).
1136
+ //
1137
+ // TODO: `root.matchers`/`root.actions` have the same empty-union-at-gen-time
1138
+ // gap; only loaders are merged here (commerce SEO scope). Generalize if a
1139
+ // matcher/action picker ever needs baked options.
1140
+ const loaders = buildLoaderDefinitions();
1141
+ const siteLoaderRefs = (siteMeta.schema?.root?.loaders?.anyOf ?? []).filter(
1142
+ (ref: any): ref is { $ref: string } => Boolean(ref) && typeof ref.$ref === "string",
1143
+ );
1144
+ const seenLoaderRefs = new Set<string>(loaders.loaderAnyOf.map((r: any) => r.$ref));
1145
+ const loaderUnion = [
1146
+ ...loaders.loaderAnyOf,
1147
+ ...siteLoaderRefs.filter((r) => !seenLoaderRefs.has(r.$ref)),
1148
+ ];
1149
+
1150
+ const fwSections = buildFrameworkSections(siteAnyOf, loaderUnion);
1111
1151
  const fullSectionAnyOf = [...siteAnyOf, ...fwSections.extraAnyOf];
1112
1152
  const page = buildPageSchema(fullSectionAnyOf);
1113
- const loaders = buildLoaderDefinitions();
1114
1153
  const actions = buildActionDefinitions();
1115
1154
  const matchers = buildMatcherDefinitions();
1116
1155
 
@@ -1171,7 +1210,7 @@ export function composeMeta(siteMeta: MetaResponse, options?: ComposeMetaOptions
1171
1210
  ...(siteMeta.schema?.root || {}),
1172
1211
  sections: { anyOf: fullSectionAnyOf },
1173
1212
  pages: { anyOf: page.rootAnyOf },
1174
- loaders: { anyOf: loaders.loaderAnyOf },
1213
+ loaders: { anyOf: loaderUnion },
1175
1214
  matchers: { anyOf: matchers.matcherAnyOf },
1176
1215
  },
1177
1216
  },
@@ -17,7 +17,8 @@ import {
17
17
  // request must opt in by setting this flag — and `compose` must propagate
18
18
  // it whenever any input has it.
19
19
  const isRequestDependent = (fn: unknown): boolean =>
20
- typeof fn === "function" && (fn as { __requestDependent?: boolean }).__requestDependent === true;
20
+ typeof fn === "function" &&
21
+ (fn as { __requestDependent?: boolean }).__requestDependent === true;
21
22
 
22
23
  const makeReq = (url = "https://store.example/foo?q=hello") =>
23
24
  new Request(url, { headers: { "user-agent": "vitest" } });
@@ -86,10 +87,12 @@ describe("withSectionLoader", () => {
86
87
 
87
88
  it("composes alongside mixins: mixins run first, then the section loader sees the enriched props", async () => {
88
89
  const seen: Array<Record<string, unknown>> = [];
89
- const sectionLoader = vi.fn(async (props: Record<string, unknown>) => {
90
- seen.push({ ...props });
91
- return { ...props, sectionLoaderRan: true };
92
- });
90
+ const sectionLoader = vi.fn(
91
+ async (props: Record<string, unknown>) => {
92
+ seen.push({ ...props });
93
+ return { ...props, sectionLoaderRan: true };
94
+ },
95
+ );
93
96
 
94
97
  const composed = compose(
95
98
  withSearchParam(),
@@ -151,14 +154,7 @@ describe("request-dependent tagging (#206)", () => {
151
154
  });
152
155
 
153
156
  it("compose does NOT set the flag when no input is request-dependent", () => {
154
- expect(
155
- isRequestDependent(
156
- compose(
157
- async (p) => p,
158
- async (p) => p,
159
- ),
160
- ),
161
- ).toBe(false);
157
+ expect(isRequestDependent(compose(async (p) => p, async (p) => p))).toBe(false);
162
158
  });
163
159
 
164
160
  it("empty compose() is not request-dependent", () => {
@@ -135,7 +135,9 @@ export function compose(...mixins: SectionLoaderFn[]): SectionLoaderFn {
135
135
  * });
136
136
  * ```
137
137
  */
138
- export function withSectionLoader(modImport: () => Promise<unknown>): SectionLoaderFn {
138
+ export function withSectionLoader(
139
+ modImport: () => Promise<unknown>,
140
+ ): SectionLoaderFn {
139
141
  return async (props, req, ctx) => {
140
142
  const mod = (await modImport()) as { loader?: unknown } | undefined;
141
143
  const loader = mod?.loader;