@decocms/blocks 7.26.0 → 7.26.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/blocks",
3
- "version": "7.26.0",
3
+ "version": "7.26.2",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -23,6 +23,7 @@ vi.mock("./registry", () => ({
23
23
  import {
24
24
  clearCommerceLoaders,
25
25
  DEFAULT_FOLD_THRESHOLD,
26
+ extractSeoFromProps,
26
27
  getAsyncRenderingConfig,
27
28
  isEagerRequest,
28
29
  registerCommerceLoader,
@@ -632,6 +633,105 @@ describe("resolvePageSeoBlock — bot-aware commerce SEO", () => {
632
633
  // deferredSections array that loadDeferredSection would then try to resolve
633
634
  // without the per-request commerce app context.
634
635
 
636
+ describe("extractSeoFromProps — commerce jsonLD structured data", () => {
637
+ const plp = (overrides: Record<string, unknown> = {}) => ({
638
+ "@type": "ProductListingPage",
639
+ breadcrumb: {
640
+ "@type": "BreadcrumbList",
641
+ itemListElement: [
642
+ { "@type": "ListItem", position: 1, item: "https://x.com/a" },
643
+ { "@type": "ListItem", position: 2, item: "https://x.com/a/b" },
644
+ ],
645
+ },
646
+ products: [{ "@type": "Product", name: "P1" }],
647
+ seo: { title: "PLP Title", description: "PLP Desc" },
648
+ ...overrides,
649
+ });
650
+
651
+ it("derives title/description/canonical and emits the ItemList JSON-LD (PLP)", () => {
652
+ // The casaevideo.com.br/eletroportateis regression: a page.seo pointing at
653
+ // commerce/sections/Seo/SeoPLPV2.tsx resolved its jsonLD but emitted no
654
+ // schema.org. There is no section component in start to run this transform.
655
+ const seo = extractSeoFromProps({
656
+ __resolveType: "commerce/sections/Seo/SeoPLPV2.tsx",
657
+ jsonLD: plp(),
658
+ });
659
+ expect(seo.title).toBe("PLP Title");
660
+ expect(seo.description).toBe("PLP Desc");
661
+ // Highest-position breadcrumb item wins.
662
+ expect(seo.canonical).toBe("https://x.com/a/b");
663
+ expect(seo.noIndexing).toBe(false);
664
+ expect(seo.jsonLDs).toHaveLength(1);
665
+ expect(seo.jsonLDs?.[0]).toMatchObject({ "@type": "ProductListingPage" });
666
+ });
667
+
668
+ it("emits the product image and JSON-LD for a PDP", () => {
669
+ const seo = extractSeoFromProps({
670
+ __resolveType: "commerce/sections/Seo/SeoPDPV2.tsx",
671
+ jsonLD: {
672
+ "@type": "ProductDetailsPage",
673
+ breadcrumbList: { "@type": "BreadcrumbList", itemListElement: [] },
674
+ product: {
675
+ "@type": "Product",
676
+ name: "Widget",
677
+ image: [{ url: "https://x.com/w.jpg" }],
678
+ },
679
+ seo: { title: "Widget", description: "A widget" },
680
+ },
681
+ });
682
+ expect(seo.title).toBe("Widget");
683
+ expect(seo.image).toBe("https://x.com/w.jpg");
684
+ expect(seo.jsonLDs?.[0]).toMatchObject({ "@type": "ProductDetailsPage" });
685
+ });
686
+
687
+ it("lets manual override fields win over the jsonLD-derived ones", () => {
688
+ const seo = extractSeoFromProps({
689
+ jsonLD: plp(),
690
+ title: "Manual Title",
691
+ canonical: "https://x.com/manual",
692
+ });
693
+ expect(seo.title).toBe("Manual Title");
694
+ expect(seo.canonical).toBe("https://x.com/manual");
695
+ expect(seo.jsonLDs).toHaveLength(1);
696
+ });
697
+
698
+ it("omits structured data when ignoreStructuredData is set", () => {
699
+ const seo = extractSeoFromProps({
700
+ jsonLD: plp(),
701
+ configJsonLD: { ignoreStructuredData: true },
702
+ });
703
+ expect(seo.jsonLDs).toBeUndefined();
704
+ // Metadata is still derived — only the ItemList is suppressed.
705
+ expect(seo.title).toBe("PLP Title");
706
+ });
707
+
708
+ it("marks an empty listing noIndexing and emits no ItemList", () => {
709
+ const seo = extractSeoFromProps({ jsonLD: plp({ products: [] }) });
710
+ expect(seo.noIndexing).toBe(true);
711
+ expect(seo.jsonLDs).toBeUndefined();
712
+ });
713
+
714
+ it("does not touch a section that already emitted jsonLDs", () => {
715
+ const existing = [{ "@type": "WebSite" }];
716
+ const seo = extractSeoFromProps({ jsonLD: plp(), jsonLDs: existing });
717
+ expect(seo.jsonLDs).toBe(existing);
718
+ });
719
+
720
+ it("removeVideos clones rather than mutating the source jsonLD", () => {
721
+ const source = plp({
722
+ products: [{ "@type": "Product", name: "P1", video: [{ "@type": "VideoObject" }] }],
723
+ });
724
+ const seo = extractSeoFromProps({
725
+ jsonLD: source,
726
+ configJsonLD: { removeVideos: true },
727
+ });
728
+ expect(seo.jsonLDs?.[0].products[0].video).toBeUndefined();
729
+ // Source untouched — it may be shared with a body section.
730
+ // biome-ignore lint/suspicious/noExplicitAny: test fixture
731
+ expect((source.products[0] as any).video).toBeDefined();
732
+ });
733
+ });
734
+
635
735
  describe("resolveDecoPage — #277 client-side navigation disables deferral", () => {
636
736
  const lazySec = {
637
737
  __resolveType: WELL_KNOWN_TYPES.LAZY,
@@ -1693,6 +1693,110 @@ export function isSeoSection(key: string): boolean {
1693
1693
  return seoSectionKeys.has(key);
1694
1694
  }
1695
1695
 
1696
+ /**
1697
+ * Canonical URL from a schema.org BreadcrumbList — the `item` of its
1698
+ * highest-position entry. Inlined from `@decocms/apps-commerce/utils/canonical`
1699
+ * because `blocks` cannot depend on `apps-commerce` (apps-commerce depends on
1700
+ * blocks, so the edge would be circular).
1701
+ */
1702
+ function canonicalFromBreadcrumb(breadcrumb: unknown): string | undefined {
1703
+ const items = (
1704
+ breadcrumb as { itemListElement?: { position: number; item: string }[] } | undefined
1705
+ )?.itemListElement;
1706
+ if (!Array.isArray(items) || items.length === 0) return undefined;
1707
+ return items.reduce((acc, curr) => (acc.position < curr.position ? curr : acc)).item;
1708
+ }
1709
+
1710
+ /**
1711
+ * Shallow-prune a commerce `jsonLD` before emitting it as structured data.
1712
+ * Clones only when a prune option is set (the common path emits as-is), so a
1713
+ * jsonLD shared with a body section is never mutated out from under it.
1714
+ */
1715
+ function pruneCommerceJsonLD(
1716
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1717
+ jsonLD: Record<string, any>,
1718
+ opts: { removeVideos: boolean; omitVariants: boolean },
1719
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1720
+ ): Record<string, any> {
1721
+ if (!opts.removeVideos && !opts.omitVariants) return jsonLD;
1722
+ const clone = structuredClone(jsonLD);
1723
+ if (opts.removeVideos && Array.isArray(clone.products)) {
1724
+ for (const product of clone.products) {
1725
+ product.video = undefined;
1726
+ for (const variant of product.isVariantOf?.hasVariant ?? []) variant.video = undefined;
1727
+ }
1728
+ }
1729
+ if (opts.omitVariants && clone.product?.isVariantOf?.hasVariant) {
1730
+ clone.product.isVariantOf.hasVariant = [];
1731
+ }
1732
+ return clone;
1733
+ }
1734
+
1735
+ /**
1736
+ * Derive SEO metadata from a commerce `jsonLD` data source (a resolved
1737
+ * ProductListingPage / ProductDetailsPage), mirroring the deco-cx
1738
+ * `commerce/sections/Seo/Seo{PLP,PDP}V2.tsx` section loaders.
1739
+ *
1740
+ * Those legacy sections have no component in @decocms/start (the commerce apps
1741
+ * ship loaders/types, not sections), so nothing else runs this transform.
1742
+ * Without it, PLP/PDP pages whose `page.seo` points at those types emit a
1743
+ * <title>/canonical (from top-level override props) but NO schema.org
1744
+ * structured data — the casaevideo.com.br/eletroportateis regression.
1745
+ *
1746
+ * Manual override fields already read into `seo` always win; this only fills
1747
+ * gaps and appends the JSON-LD. The caller guards on `seo.jsonLDs` so a real
1748
+ * site SEO section that already computed structured data is never touched.
1749
+ */
1750
+ function deriveCommerceSeoFromJsonLD(seo: PageSeo, props: Record<string, unknown>): void {
1751
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1752
+ const jsonLD = props.jsonLD as Record<string, any> | undefined;
1753
+ if (!jsonLD) return;
1754
+ const type = jsonLD["@type"];
1755
+ if (type !== "ProductListingPage" && type !== "ProductDetailsPage") return;
1756
+
1757
+ const jsonLDSeo = jsonLD.seo as
1758
+ | { title?: string; description?: string; canonical?: string; noIndexing?: boolean }
1759
+ | null
1760
+ | undefined;
1761
+
1762
+ if (!seo.title && jsonLDSeo?.title) seo.title = jsonLDSeo.title;
1763
+ if (!seo.description && jsonLDSeo?.description) seo.description = jsonLDSeo.description;
1764
+ if (!seo.canonical) {
1765
+ seo.canonical =
1766
+ jsonLDSeo?.canonical ||
1767
+ canonicalFromBreadcrumb(
1768
+ type === "ProductListingPage" ? jsonLD.breadcrumb : jsonLD.breadcrumbList,
1769
+ );
1770
+ }
1771
+ if (type === "ProductDetailsPage" && !seo.image) {
1772
+ seo.image = jsonLD.product?.image?.[0]?.url;
1773
+ }
1774
+
1775
+ const isEmpty =
1776
+ type === "ProductListingPage"
1777
+ ? !Array.isArray(jsonLD.products) || jsonLD.products.length === 0
1778
+ : !jsonLD.product;
1779
+ if (seo.noIndexing === undefined) {
1780
+ seo.noIndexing = isEmpty || jsonLDSeo?.noIndexing || false;
1781
+ }
1782
+
1783
+ // Structured data — opt out via `ignoreStructuredData` (top-level on PDP,
1784
+ // under `configJsonLD` on PLP). An empty page contributes no ItemList.
1785
+ const configJsonLD = props.configJsonLD as
1786
+ | { ignoreStructuredData?: boolean; removeVideos?: boolean }
1787
+ | undefined;
1788
+ const ignore =
1789
+ props.ignoreStructuredData === true || configJsonLD?.ignoreStructuredData === true;
1790
+ if (ignore || isEmpty) return;
1791
+
1792
+ seo.jsonLDs = [
1793
+ pruneCommerceJsonLD(jsonLD, {
1794
+ removeVideos: configJsonLD?.removeVideos === true,
1795
+ omitVariants: props.omitVariants === true,
1796
+ }),
1797
+ ];
1798
+ }
1799
+
1696
1800
  /**
1697
1801
  * Pick standard SEO fields from a props object.
1698
1802
  * Works for both framework SEO types (SeoV2) and site SEO sections (SEOPDP).
@@ -1709,6 +1813,10 @@ export function extractSeoFromProps(props: Record<string, unknown>): PageSeo {
1709
1813
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1710
1814
  seo.jsonLDs = props.jsonLDs as Record<string, any>[];
1711
1815
  }
1816
+ // Legacy commerce PLP/PDP sections carry a `jsonLD` data source with no
1817
+ // component to turn it into structured data — derive it here (only when a
1818
+ // real SEO section hasn't already emitted `jsonLDs`).
1819
+ if (!seo.jsonLDs) deriveCommerceSeoFromJsonLD(seo, props);
1712
1820
  return seo;
1713
1821
  }
1714
1822
 
@@ -238,6 +238,70 @@ describe("composeMeta framework option", () => {
238
238
  });
239
239
  });
240
240
 
241
+ describe("commerce SEO section schemas", () => {
242
+ const b64Ref = (key: string) => ({ $ref: `#/definitions/${b64(key)}` });
243
+
244
+ it("emits editable props for the product-listing SEO section (plp mode)", () => {
245
+ const def = definitionFor("commerce/sections/Seo/SeoPLPV2.tsx");
246
+ // Without a schema here, resolveSchema() returns null and the Studio SEO
247
+ // editor renders no fields — the "não tem props" bug.
248
+ expect(def).toBeDefined();
249
+ const props = def.properties;
250
+ expect(props).toHaveProperty("title");
251
+ expect(props).toHaveProperty("description");
252
+ expect(props).toHaveProperty("noIndexing");
253
+ // PLP-specific structured-data config (see seo-form-mode.ts PLP_SEO_FIELD_KEYS).
254
+ expect(props.configJsonLD.properties).toHaveProperty("removeVideos");
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);
259
+ });
260
+
261
+ it("emits editable props for the product-details SEO section (pdp mode)", () => {
262
+ const def = definitionFor("commerce/sections/Seo/SeoPDPV2.tsx");
263
+ expect(def).toBeDefined();
264
+ const props = def.properties;
265
+ expect(props).toHaveProperty("title");
266
+ expect(props).toHaveProperty("description");
267
+ expect(props).toHaveProperty("noIndexing");
268
+ // PDP-specific fields (see seo-form-mode.ts PDP_SEO_FIELD_KEYS).
269
+ expect(props).toHaveProperty("omitVariants");
270
+ expect(props).toHaveProperty("ignoreStructuredData");
271
+ expect(props.jsonLD.hide).toBe(true);
272
+ });
273
+
274
+ it("always registers the defs + manifest blocks so existing pages resolve", () => {
275
+ const meta = composeMeta(emptySiteMeta());
276
+ for (const key of [
277
+ "commerce/sections/Seo/SeoPLPV2.tsx",
278
+ "commerce/sections/Seo/SeoPDPV2.tsx",
279
+ ]) {
280
+ expect(meta.manifest.blocks.sections).toHaveProperty(key);
281
+ expect(meta.schema.definitions).toHaveProperty(b64(key));
282
+ }
283
+ });
284
+
285
+ it("offers them as page.seo options only on a commerce site", () => {
286
+ // "commerce site" = product-list loaders registered (the signal
287
+ // buildFrameworkSections gates the picker on). The key must match
288
+ // inferLoaderTags so it is tagged "product-list".
289
+ registerAppSchemas({
290
+ namespace: "vtex",
291
+ loaders: {
292
+ "vtex/loaders/test/ProductListForSeoPicker.ts": { type: "object", properties: {} },
293
+ },
294
+ });
295
+ const meta = composeMeta(emptySiteMeta());
296
+ for (const key of [
297
+ "commerce/sections/Seo/SeoPLPV2.tsx",
298
+ "commerce/sections/Seo/SeoPDPV2.tsx",
299
+ ]) {
300
+ expect(meta.schema.root.sections.anyOf).toContainEqual(b64Ref(key));
301
+ }
302
+ });
303
+ });
304
+
241
305
  describe("composeMeta idempotency", () => {
242
306
  it("returns an already-composed meta unchanged (no double-compose)", () => {
243
307
  const once = composeMeta(emptySiteMeta());
package/src/cms/schema.ts CHANGED
@@ -736,6 +736,12 @@ function buildFrameworkSections(sectionAnyOf: 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
+
739
745
  // --- website/sections/Rendering/Lazy.tsx ---
740
746
  const LAZY_TYPE = "website/sections/Rendering/Lazy.tsx";
741
747
  const lazyKey = toBase64(LAZY_TYPE);
@@ -833,6 +839,89 @@ function buildFrameworkSections(sectionAnyOf: any[]) {
833
839
  };
834
840
  extraAnyOf.push({ $ref: `#/definitions/${seoV2Key}` });
835
841
 
842
+ // --- commerce/sections/Seo/SeoPLPV2.tsx ---
843
+ // Product-listing SEO. Its schema is synthesized here (like the website Seo
844
+ // sections) because the commerce apps ship loaders/types, not sections — so
845
+ // there is no manifest to emit these props from. The Studio's SEO editor
846
+ // already knows this type as its "plp" mode (see seo-form-mode.ts); it just
847
+ // needs the props schema to render the override fields. `jsonLD` is the
848
+ // data-source block-ref set once when the page is created; it round-trips via
849
+ // the form (kept `hide`) rather than being re-picked in the SEO panel.
850
+ //
851
+ // The def + manifest block are always registered so the SEO editor can resolve
852
+ // a schema for pages already on these types. They are added to `extraAnyOf`
853
+ // (offered as a selectable page.seo type) only on commerce sites, so a blog
854
+ // does not get "Product listing SEO" in its SEO type dropdown.
855
+ const SEO_PLP_V2_TYPE = "commerce/sections/Seo/SeoPLPV2.tsx";
856
+ const seoPlpV2Key = toBase64(SEO_PLP_V2_TYPE);
857
+ definitions[seoPlpV2Key] = {
858
+ title: SEO_PLP_V2_TYPE,
859
+ type: "object",
860
+ required: ["__resolveType"],
861
+ properties: {
862
+ __resolveType: {
863
+ type: "string",
864
+ enum: [SEO_PLP_V2_TYPE],
865
+ default: SEO_PLP_V2_TYPE,
866
+ },
867
+ jsonLD: {
868
+ type: "object",
869
+ title: "Data Source",
870
+ additionalProperties: true,
871
+ hide: true,
872
+ },
873
+ title: { type: "string", title: "Title Override" },
874
+ description: { type: "string", title: "Description Override" },
875
+ noIndexing: { type: "boolean", title: "Disable indexing" },
876
+ configJsonLD: {
877
+ type: "object",
878
+ title: "Structured Data",
879
+ properties: {
880
+ removeVideos: { type: "boolean", title: "Remove videos" },
881
+ ignoreStructuredData: { type: "boolean", title: "Ignore Structured Data" },
882
+ },
883
+ },
884
+ },
885
+ };
886
+ manifestBlocks[SEO_PLP_V2_TYPE] = {
887
+ $ref: `#/definitions/${seoPlpV2Key}`,
888
+ namespace: "commerce",
889
+ };
890
+ if (hasCommerce) extraAnyOf.push({ $ref: `#/definitions/${seoPlpV2Key}` });
891
+
892
+ // --- commerce/sections/Seo/SeoPDPV2.tsx ---
893
+ // Product-details SEO. Studio "pdp" mode counterpart of the PLP section above.
894
+ const SEO_PDP_V2_TYPE = "commerce/sections/Seo/SeoPDPV2.tsx";
895
+ const seoPdpV2Key = toBase64(SEO_PDP_V2_TYPE);
896
+ definitions[seoPdpV2Key] = {
897
+ title: SEO_PDP_V2_TYPE,
898
+ type: "object",
899
+ required: ["__resolveType"],
900
+ properties: {
901
+ __resolveType: {
902
+ type: "string",
903
+ enum: [SEO_PDP_V2_TYPE],
904
+ default: SEO_PDP_V2_TYPE,
905
+ },
906
+ jsonLD: {
907
+ type: "object",
908
+ title: "Data Source",
909
+ additionalProperties: true,
910
+ hide: true,
911
+ },
912
+ omitVariants: { type: "boolean", title: "Omit variants" },
913
+ title: { type: "string", title: "Title Override" },
914
+ description: { type: "string", title: "Description Override" },
915
+ noIndexing: { type: "boolean", title: "Disable indexing" },
916
+ ignoreStructuredData: { type: "boolean", title: "Ignore Structured Data" },
917
+ },
918
+ };
919
+ manifestBlocks[SEO_PDP_V2_TYPE] = {
920
+ $ref: `#/definitions/${seoPdpV2Key}`,
921
+ namespace: "commerce",
922
+ };
923
+ if (hasCommerce) extraAnyOf.push({ $ref: `#/definitions/${seoPdpV2Key}` });
924
+
836
925
  // --- website/flags/multivariate/section.ts ---
837
926
  const MV_SECTION_TYPE = "website/flags/multivariate/section.ts";
838
927
  const mvSectionKey = toBase64(MV_SECTION_TYPE);