@decocms/blocks 7.26.2 → 7.27.0
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/package.json +1 -1
- package/src/cms/schema.test.ts +54 -22
- package/src/cms/schema.ts +47 -31
package/package.json
CHANGED
package/src/cms/schema.test.ts
CHANGED
|
@@ -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
|
|
257
|
-
//
|
|
258
|
-
expect(props.jsonLD.
|
|
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,10 +269,14 @@ 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.
|
|
272
|
+
expect(Array.isArray(props.jsonLD.anyOf)).toBe(true);
|
|
273
|
+
expect(props.jsonLD.title).toBe("Data Source");
|
|
272
274
|
});
|
|
273
275
|
|
|
274
|
-
it("
|
|
276
|
+
it("registers the defs + manifest blocks and offers them as page.seo options", () => {
|
|
277
|
+
// Unconditional (no commerce gate): meta.gen.json is baked at generation
|
|
278
|
+
// time when the loader registry is empty, and the runtime serves that baked
|
|
279
|
+
// meta as-is, so a registry-based gate would never bake the options.
|
|
275
280
|
const meta = composeMeta(emptySiteMeta());
|
|
276
281
|
for (const key of [
|
|
277
282
|
"commerce/sections/Seo/SeoPLPV2.tsx",
|
|
@@ -279,26 +284,53 @@ describe("commerce SEO section schemas", () => {
|
|
|
279
284
|
]) {
|
|
280
285
|
expect(meta.manifest.blocks.sections).toHaveProperty(key);
|
|
281
286
|
expect(meta.schema.definitions).toHaveProperty(b64(key));
|
|
287
|
+
expect(meta.schema.root.sections.anyOf).toContainEqual(b64Ref(key));
|
|
282
288
|
}
|
|
283
289
|
});
|
|
284
290
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
},
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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));
|
|
302
334
|
});
|
|
303
335
|
});
|
|
304
336
|
|
package/src/cms/schema.ts
CHANGED
|
@@ -731,17 +731,11 @@ function buildPageSchema(sectionAnyOf: any[]) {
|
|
|
731
731
|
// Framework sections
|
|
732
732
|
// ---------------------------------------------------------------------------
|
|
733
733
|
|
|
734
|
-
function buildFrameworkSections(sectionAnyOf: any[]) {
|
|
734
|
+
function buildFrameworkSections(sectionAnyOf: any[], loaderUnion: any[]) {
|
|
735
735
|
const definitions: Record<string, any> = {};
|
|
736
736
|
const manifestBlocks: Record<string, any> = {};
|
|
737
737
|
const extraAnyOf: any[] = [];
|
|
738
738
|
|
|
739
|
-
// A site is "commerce" when it registered product-list loaders (same signal
|
|
740
|
-
// wrapResolvableProperties uses). Gates whether the commerce SEO section types
|
|
741
|
-
// below are offered as pickable page.seo options — a blog site should not see
|
|
742
|
-
// "Product listing SEO" in the SEO type dropdown.
|
|
743
|
-
const hasCommerce = getProductListLoaderKeys().length > 0;
|
|
744
|
-
|
|
745
739
|
// --- website/sections/Rendering/Lazy.tsx ---
|
|
746
740
|
const LAZY_TYPE = "website/sections/Rendering/Lazy.tsx";
|
|
747
741
|
const lazyKey = toBase64(LAZY_TYPE);
|
|
@@ -845,13 +839,20 @@ function buildFrameworkSections(sectionAnyOf: any[]) {
|
|
|
845
839
|
// there is no manifest to emit these props from. The Studio's SEO editor
|
|
846
840
|
// already knows this type as its "plp" mode (see seo-form-mode.ts); it just
|
|
847
841
|
// needs the props schema to render the override fields. `jsonLD` is the
|
|
848
|
-
// data-source
|
|
849
|
-
// the
|
|
842
|
+
// data-source loader picker (mirrors deco-cx's `@title Data Source`) — an
|
|
843
|
+
// inline anyOf over the loader union so the editor lets you pick the PLP/PDP
|
|
844
|
+
// loader that feeds the structured data. Inlined (a fresh copy) rather than a
|
|
845
|
+
// `$ref: "#/root/loaders"` because the Studio drops the prop-level title when
|
|
846
|
+
// resolving a `$ref` union, which would lose the "Data Source" label; the
|
|
847
|
+
// copy also avoids sharing one array instance across the two defs + root.
|
|
850
848
|
//
|
|
851
|
-
//
|
|
852
|
-
//
|
|
853
|
-
//
|
|
854
|
-
//
|
|
849
|
+
// Added to `extraAnyOf` (offered as a selectable page.seo type) unconditionally,
|
|
850
|
+
// like the website Seo sections above. Gating this on "is a commerce site" via
|
|
851
|
+
// the loader registry does NOT work: meta.gen.json is baked at generation time
|
|
852
|
+
// (blocks-cli), when the registry is empty, and the runtime serves that baked,
|
|
853
|
+
// already-composed meta as-is (composeMeta is idempotent on it) — so a gate
|
|
854
|
+
// would drop the options at bake and never re-add them. Non-commerce sites
|
|
855
|
+
// seeing these two extra options is a minor, acceptable cost.
|
|
855
856
|
const SEO_PLP_V2_TYPE = "commerce/sections/Seo/SeoPLPV2.tsx";
|
|
856
857
|
const seoPlpV2Key = toBase64(SEO_PLP_V2_TYPE);
|
|
857
858
|
definitions[seoPlpV2Key] = {
|
|
@@ -864,12 +865,7 @@ function buildFrameworkSections(sectionAnyOf: any[]) {
|
|
|
864
865
|
enum: [SEO_PLP_V2_TYPE],
|
|
865
866
|
default: SEO_PLP_V2_TYPE,
|
|
866
867
|
},
|
|
867
|
-
jsonLD: {
|
|
868
|
-
type: "object",
|
|
869
|
-
title: "Data Source",
|
|
870
|
-
additionalProperties: true,
|
|
871
|
-
hide: true,
|
|
872
|
-
},
|
|
868
|
+
jsonLD: { title: "Data Source", anyOf: [...loaderUnion] },
|
|
873
869
|
title: { type: "string", title: "Title Override" },
|
|
874
870
|
description: { type: "string", title: "Description Override" },
|
|
875
871
|
noIndexing: { type: "boolean", title: "Disable indexing" },
|
|
@@ -887,7 +883,7 @@ function buildFrameworkSections(sectionAnyOf: any[]) {
|
|
|
887
883
|
$ref: `#/definitions/${seoPlpV2Key}`,
|
|
888
884
|
namespace: "commerce",
|
|
889
885
|
};
|
|
890
|
-
|
|
886
|
+
extraAnyOf.push({ $ref: `#/definitions/${seoPlpV2Key}` });
|
|
891
887
|
|
|
892
888
|
// --- commerce/sections/Seo/SeoPDPV2.tsx ---
|
|
893
889
|
// Product-details SEO. Studio "pdp" mode counterpart of the PLP section above.
|
|
@@ -903,12 +899,7 @@ function buildFrameworkSections(sectionAnyOf: any[]) {
|
|
|
903
899
|
enum: [SEO_PDP_V2_TYPE],
|
|
904
900
|
default: SEO_PDP_V2_TYPE,
|
|
905
901
|
},
|
|
906
|
-
jsonLD: {
|
|
907
|
-
type: "object",
|
|
908
|
-
title: "Data Source",
|
|
909
|
-
additionalProperties: true,
|
|
910
|
-
hide: true,
|
|
911
|
-
},
|
|
902
|
+
jsonLD: { title: "Data Source", anyOf: [...loaderUnion] },
|
|
912
903
|
omitVariants: { type: "boolean", title: "Omit variants" },
|
|
913
904
|
title: { type: "string", title: "Title Override" },
|
|
914
905
|
description: { type: "string", title: "Description Override" },
|
|
@@ -920,7 +911,7 @@ function buildFrameworkSections(sectionAnyOf: any[]) {
|
|
|
920
911
|
$ref: `#/definitions/${seoPdpV2Key}`,
|
|
921
912
|
namespace: "commerce",
|
|
922
913
|
};
|
|
923
|
-
|
|
914
|
+
extraAnyOf.push({ $ref: `#/definitions/${seoPdpV2Key}` });
|
|
924
915
|
|
|
925
916
|
// --- website/flags/multivariate/section.ts ---
|
|
926
917
|
const MV_SECTION_TYPE = "website/flags/multivariate/section.ts";
|
|
@@ -1112,11 +1103,36 @@ export function composeMeta(
|
|
|
1112
1103
|
|
|
1113
1104
|
const siteAnyOf = siteMeta.schema?.root?.sections?.anyOf || [];
|
|
1114
1105
|
|
|
1115
|
-
// Build all framework components
|
|
1116
|
-
|
|
1106
|
+
// Build all framework components. Loaders first: the commerce SEO sections'
|
|
1107
|
+
// `jsonLD` data-source picker references the loader union, and that union must
|
|
1108
|
+
// include the site's OWN loaders. The runtime registry (loaderRegistry) is
|
|
1109
|
+
// empty at generation time, so `buildLoaderDefinitions` alone yields just
|
|
1110
|
+
// `Resolvable` — merge in the site's own loaders so loader-backed pickers have
|
|
1111
|
+
// real options in the baked meta, not only at runtime.
|
|
1112
|
+
//
|
|
1113
|
+
// Seed from the site's loader UNION (`schema.root.loaders.anyOf`), NOT
|
|
1114
|
+
// `manifest.blocks.loaders`: the generator keeps `@ignore`d loaders in the
|
|
1115
|
+
// manifest (they still need a def) while withholding them from the union so
|
|
1116
|
+
// they can't be picked — reading the manifest would resurrect them. This
|
|
1117
|
+
// source is also already `{ $ref }`-shaped and only points at defs that exist
|
|
1118
|
+
// (no dangling refs).
|
|
1119
|
+
//
|
|
1120
|
+
// TODO: `root.matchers`/`root.actions` have the same empty-union-at-gen-time
|
|
1121
|
+
// gap; only loaders are merged here (commerce SEO scope). Generalize if a
|
|
1122
|
+
// matcher/action picker ever needs baked options.
|
|
1123
|
+
const loaders = buildLoaderDefinitions();
|
|
1124
|
+
const siteLoaderRefs = (siteMeta.schema?.root?.loaders?.anyOf ?? []).filter(
|
|
1125
|
+
(ref: any): ref is { $ref: string } => Boolean(ref) && typeof ref.$ref === "string",
|
|
1126
|
+
);
|
|
1127
|
+
const seenLoaderRefs = new Set<string>(loaders.loaderAnyOf.map((r: any) => r.$ref));
|
|
1128
|
+
const loaderUnion = [
|
|
1129
|
+
...loaders.loaderAnyOf,
|
|
1130
|
+
...siteLoaderRefs.filter((r) => !seenLoaderRefs.has(r.$ref)),
|
|
1131
|
+
];
|
|
1132
|
+
|
|
1133
|
+
const fwSections = buildFrameworkSections(siteAnyOf, loaderUnion);
|
|
1117
1134
|
const fullSectionAnyOf = [...siteAnyOf, ...fwSections.extraAnyOf];
|
|
1118
1135
|
const page = buildPageSchema(fullSectionAnyOf);
|
|
1119
|
-
const loaders = buildLoaderDefinitions();
|
|
1120
1136
|
const actions = buildActionDefinitions();
|
|
1121
1137
|
const matchers = buildMatcherDefinitions();
|
|
1122
1138
|
|
|
@@ -1177,7 +1193,7 @@ export function composeMeta(
|
|
|
1177
1193
|
...(siteMeta.schema?.root || {}),
|
|
1178
1194
|
sections: { anyOf: fullSectionAnyOf },
|
|
1179
1195
|
pages: { anyOf: page.rootAnyOf },
|
|
1180
|
-
loaders: { anyOf:
|
|
1196
|
+
loaders: { anyOf: loaderUnion },
|
|
1181
1197
|
matchers: { anyOf: matchers.matcherAnyOf },
|
|
1182
1198
|
},
|
|
1183
1199
|
},
|