@decocms/blocks 7.25.0 → 7.26.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/blocks",
3
- "version": "7.25.0",
3
+ "version": "7.26.1",
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,56 @@ 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("registers the sections so existing pages resolve, without polluting the picker", () => {
275
+ const meta = composeMeta(emptySiteMeta());
276
+ for (const key of [
277
+ "commerce/sections/Seo/SeoPLPV2.tsx",
278
+ "commerce/sections/Seo/SeoPDPV2.tsx",
279
+ ]) {
280
+ // Registered as a manifest block + def → the SEO editor can resolve a
281
+ // schema for a page already on this type.
282
+ expect(meta.manifest.blocks.sections).toHaveProperty(key);
283
+ expect(meta.schema.definitions).toHaveProperty(b64(key));
284
+ // But deliberately NOT offered as a new pickable section everywhere
285
+ // (legacy commerce types have no component in start).
286
+ expect(meta.schema.root.sections.anyOf).not.toContainEqual(b64Ref(key));
287
+ }
288
+ });
289
+ });
290
+
241
291
  describe("composeMeta idempotency", () => {
242
292
  it("returns an already-composed meta unchanged (no double-compose)", () => {
243
293
  const once = composeMeta(emptySiteMeta());
package/src/cms/schema.ts CHANGED
@@ -833,6 +833,88 @@ function buildFrameworkSections(sectionAnyOf: any[]) {
833
833
  };
834
834
  extraAnyOf.push({ $ref: `#/definitions/${seoV2Key}` });
835
835
 
836
+ // --- commerce/sections/Seo/SeoPLPV2.tsx ---
837
+ // Product-listing SEO. Its schema is synthesized here (like the website Seo
838
+ // sections) because the commerce apps ship loaders/types, not sections — so
839
+ // there is no manifest to emit these props from. The Studio's SEO editor
840
+ // already knows this type as its "plp" mode (see seo-form-mode.ts); it just
841
+ // 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.
844
+ //
845
+ // NOT pushed to `extraAnyOf`: unlike the website Seo sections these are legacy
846
+ // deco-cx types with no component in start, so we only register the def +
847
+ // manifest block (enough for the SEO editor to resolve a schema for pages
848
+ // already on these types) without offering them as a new pickable section on
849
+ // every — including non-commerce — site.
850
+ const SEO_PLP_V2_TYPE = "commerce/sections/Seo/SeoPLPV2.tsx";
851
+ const seoPlpV2Key = toBase64(SEO_PLP_V2_TYPE);
852
+ definitions[seoPlpV2Key] = {
853
+ title: SEO_PLP_V2_TYPE,
854
+ type: "object",
855
+ required: ["__resolveType"],
856
+ properties: {
857
+ __resolveType: {
858
+ type: "string",
859
+ enum: [SEO_PLP_V2_TYPE],
860
+ default: SEO_PLP_V2_TYPE,
861
+ },
862
+ jsonLD: {
863
+ type: "object",
864
+ title: "Data Source",
865
+ additionalProperties: true,
866
+ hide: true,
867
+ },
868
+ title: { type: "string", title: "Title Override" },
869
+ description: { type: "string", title: "Description Override" },
870
+ noIndexing: { type: "boolean", title: "Disable indexing" },
871
+ configJsonLD: {
872
+ type: "object",
873
+ title: "Structured Data",
874
+ properties: {
875
+ removeVideos: { type: "boolean", title: "Remove videos" },
876
+ ignoreStructuredData: { type: "boolean", title: "Ignore Structured Data" },
877
+ },
878
+ },
879
+ },
880
+ };
881
+ manifestBlocks[SEO_PLP_V2_TYPE] = {
882
+ $ref: `#/definitions/${seoPlpV2Key}`,
883
+ namespace: "commerce",
884
+ };
885
+
886
+ // --- commerce/sections/Seo/SeoPDPV2.tsx ---
887
+ // Product-details SEO. Studio "pdp" mode counterpart of the PLP section above.
888
+ const SEO_PDP_V2_TYPE = "commerce/sections/Seo/SeoPDPV2.tsx";
889
+ const seoPdpV2Key = toBase64(SEO_PDP_V2_TYPE);
890
+ definitions[seoPdpV2Key] = {
891
+ title: SEO_PDP_V2_TYPE,
892
+ type: "object",
893
+ required: ["__resolveType"],
894
+ properties: {
895
+ __resolveType: {
896
+ type: "string",
897
+ enum: [SEO_PDP_V2_TYPE],
898
+ default: SEO_PDP_V2_TYPE,
899
+ },
900
+ jsonLD: {
901
+ type: "object",
902
+ title: "Data Source",
903
+ additionalProperties: true,
904
+ hide: true,
905
+ },
906
+ omitVariants: { type: "boolean", title: "Omit variants" },
907
+ title: { type: "string", title: "Title Override" },
908
+ description: { type: "string", title: "Description Override" },
909
+ noIndexing: { type: "boolean", title: "Disable indexing" },
910
+ ignoreStructuredData: { type: "boolean", title: "Ignore Structured Data" },
911
+ },
912
+ };
913
+ manifestBlocks[SEO_PDP_V2_TYPE] = {
914
+ $ref: `#/definitions/${seoPdpV2Key}`,
915
+ namespace: "commerce",
916
+ };
917
+
836
918
  // --- website/flags/multivariate/section.ts ---
837
919
  const MV_SECTION_TYPE = "website/flags/multivariate/section.ts";
838
920
  const mvSectionKey = toBase64(MV_SECTION_TYPE);