Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions packages/yoastseo/spec/contract/normalizeProductDataSpec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import Paper from "../../src/values/Paper";
import normalizeProductData from "../../src/contract/normalizeProductData";

describe( "normalizeProductData", () => {
it( "reads from the first-class productData field when present", () => {
const paper = new Paper( "", { productData: { isVariableProduct: true, hasVariants: true, hasGlobalSKU: false } } );
expect( normalizeProductData( paper ) ).toEqual( { isVariableProduct: true, hasVariants: true, hasGlobalSKU: false } );
} );

it( "falls back to the legacy product keys in customData when the productData field is empty", () => {
const paper = new Paper( "", { customData: { productType: "variable", hasVariants: true, hasGlobalSKU: false } } );
const result = normalizeProductData( paper );
expect( result.isVariableProduct ).toBe( true );
expect( result.hasVariants ).toBe( true );
expect( result.hasGlobalSKU ).toBe( false );
} );

it( "prefers the productData field over legacy customData when both are present", () => {
const paper = new Paper( "", {
productData: { isVariableProduct: false, hasVariants: false },
customData: { productType: "variable", hasVariants: true },
} );
const result = normalizeProductData( paper );
expect( result.isVariableProduct ).toBe( false );
expect( result.hasVariants ).toBe( false );
} );

describe( "deriving isVariableProduct (back-compat shim)", () => {
it( "derives true from a legacy productType of 'variable'", () => {
const paper = new Paper( "", { customData: { productType: "variable" } } );
expect( normalizeProductData( paper ).isVariableProduct ).toBe( true );
} );

it.each( [ "simple", "external", "grouped" ] )( "derives false from a legacy productType of '%s'", ( productType ) => {
const paper = new Paper( "", { customData: { productType } } );
expect( normalizeProductData( paper ).isVariableProduct ).toBe( false );
} );

it( "derives false when neither isVariableProduct nor productType is provided", () => {
const paper = new Paper( "", { productData: { hasVariants: false } } );
expect( normalizeProductData( paper ).isVariableProduct ).toBe( false );
} );

it( "keeps an explicit isVariableProduct of false rather than deriving it", () => {
const paper = new Paper( "", { productData: { isVariableProduct: false, productType: "variable" } } );
expect( normalizeProductData( paper ).isVariableProduct ).toBe( false );
} );
} );

it( "preserves undefined for absent optional keys (does not coerce to false)", () => {
const paper = new Paper( "", { productData: { isVariableProduct: false, hasVariants: false } } );
const result = normalizeProductData( paper );
expect( result.canRetrieveGlobalSku ).toBeUndefined();
expect( result.canRetrieveVariantSkus ).toBeUndefined();
expect( result.canRetrieveGlobalIdentifier ).toBeUndefined();
expect( result.doAllVariantsHaveSKU ).toBeUndefined();
} );

it( "returns isVariableProduct false for a paper with no product data at all", () => {
const paper = new Paper( "" );
expect( normalizeProductData( paper ).isVariableProduct ).toBe( false );
} );
} );
30 changes: 30 additions & 0 deletions packages/yoastseo/spec/contract/paperDtoSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,36 @@ describe( "the Paper input contract (PaperDTO)", function() {
expect( () => toPaper( { text: "x", customData: "not an object" } ) ).toThrow();
} );

it( "maps a typed productData object onto the Paper", function() {
const productData = {
isVariableProduct: true,
hasVariants: true,
hasGlobalSKU: false,
doAllVariantsHaveSKU: true,
};
const paper = toPaper( { text: "x", productData } );

expect( paper.getProductData() ).toEqual( productData );
} );

it( "accepts the deprecated `productType` in productData (back-compat source for isVariableProduct)", function() {
const paper = toPaper( { text: "x", productData: { productType: "variable" } } );

expect( paper.getProductData() ).toEqual( { productType: "variable" } );
} );

it( "leaves absent productData to Paper's default empty object", function() {
const paper = toPaper( { text: "x" } );

expect( paper.getProductData() ).toEqual( {} );
} );

it( "type-checks productData fields (booleans) and rejects unknown keys (strict)", function() {
expect( () => toPaper( { text: "x", productData: { isVariableProduct: "yes" } } ) ).toThrow();
expect( () => toPaper( { text: "x", productData: { hasGlobalSKU: 1 } } ) ).toThrow();
expect( () => toPaper( { text: "x", productData: { hasGlobalSku: true } } ) ).toThrow();
} );

it( "accepts the deprecated WP-transitional fields and maps them onto the Paper", function() {
const wpBlocks = [ { name: "core/paragraph" } ];
const paper = toPaper( {
Expand Down
46 changes: 46 additions & 0 deletions packages/yoastseo/spec/contract/productDataSpec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { productDataSchema } from "../../src/contract/productData";

describe( "productDataSchema", () => {
it( "accepts an empty object (every field is optional)", () => {
expect( productDataSchema.parse( {} ) ).toEqual( {} );
} );

it( "accepts and passes through a fully-populated valid object", () => {
const productData = {
isVariableProduct: true,
hasVariants: true,
hasGlobalIdentifier: false,
hasGlobalSKU: true,
doAllVariantsHaveIdentifier: false,
doAllVariantsHaveSKU: true,
canRetrieveGlobalIdentifier: true,
canRetrieveGlobalSku: true,
canRetrieveVariantIdentifiers: false,
canRetrieveVariantSkus: false,
};
expect( productDataSchema.parse( productData ) ).toEqual( productData );
} );

it( "accepts the deprecated `productType` string (back-compat source)", () => {
expect( productDataSchema.parse( { productType: "variable" } ) ).toEqual( { productType: "variable" } );
} );

it( "does not inject defaults — absent fields stay absent (defaulting is normalizeProductData's job)", () => {
const result = productDataSchema.parse( { hasVariants: true } );
expect( result ).toEqual( { hasVariants: true } );
expect( result.isVariableProduct ).toBeUndefined();
expect( result.canRetrieveGlobalSku ).toBeUndefined();
} );

it( "rejects unknown or typo'd keys (strict)", () => {
// Wrong casing of `hasGlobalSKU`.
expect( () => productDataSchema.parse( { hasGlobalSku: true } ) ).toThrow();
expect( () => productDataSchema.parse( { unexpected: true } ) ).toThrow();
} );

it( "rejects wrong field types", () => {
expect( () => productDataSchema.parse( { isVariableProduct: "yes" } ) ).toThrow();
expect( () => productDataSchema.parse( { hasGlobalSKU: 1 } ) ).toThrow();
expect( () => productDataSchema.parse( { productType: 5 } ) ).toThrow();
} );
} );
Original file line number Diff line number Diff line change
Expand Up @@ -425,4 +425,37 @@ describe( "a test for the applicability of the assessment", function() {

expect( isApplicable ).toBe( true );
} );

it( "stays applicable when canRetrieve keys are absent (undefined ≠ false)", function() {
const assessment = new ProductIdentifiersAssessment( { assessVariants: false } );
// Mirrors Shopify, which never sets the canRetrieve* keys.
const productPaper = new Paper( "", { productData: { isVariableProduct: false, hasVariants: false, hasGlobalIdentifier: false } } );

expect( assessment.isApplicable( productPaper ) ).toBe( true );
} );
} );

describe( "a test for the Product identifiers assessment reading the first-class product field", () => {
const assessment = new ProductIdentifiersAssessment( { assessVariants: true } );

it( "scores 9 for a single-unit product with a global identifier, without a legacy productType", () => {
const productPaper = new Paper( "", { productData: { isVariableProduct: false, hasVariants: false, hasGlobalIdentifier: true } } );
expect( assessment.getResult( productPaper ).getScore() ).toEqual( 9 );
} );

it( "scores 6 for a variable product with variants when not all variants have an identifier", () => {
const productPaper = new Paper( "", {
productData: { isVariableProduct: true, hasVariants: true, hasGlobalIdentifier: false, doAllVariantsHaveIdentifier: false },
} );
expect( assessment.getResult( productPaper ).getScore() ).toEqual( 6 );
} );

it( "prefers the product field over legacy customData", () => {
const productPaper = new Paper( "", {
productData: { isVariableProduct: false, hasVariants: false, hasGlobalIdentifier: true },
customData: { productType: "simple", hasVariants: false, hasGlobalIdentifier: false },
} );
// The product field reports a global identifier (score 9); the legacy customData would score 6.
expect( assessment.getResult( productPaper ).getScore() ).toEqual( 9 );
} );
} );
Original file line number Diff line number Diff line change
Expand Up @@ -390,4 +390,55 @@ describe( "a test for the applicability of the assessment", function() {

expect( isApplicable ).toBe( true );
} );

/*
* Aligns ProductSKUAssessment with ProductIdentifiersAssessment: a grouped product is treated as non-variable
* (isVariableProduct === false), so when its global SKU cannot be retrieved the assessment is not applicable.
* Previously isApplicable only listed "simple"/"external" and omitted "grouped".
*/
it( "is not applicable for a grouped product whose global SKU cannot be retrieved", function() {
const assessment = new ProductSKUAssessment( { assessVariants: true } );
const customData = {
canRetrieveGlobalSku: false,
hasGlobalSKU: false,
hasVariants: false,
productType: "grouped",
};
const paperWithCustomData = new Paper( "", { customData } );

expect( assessment.isApplicable( paperWithCustomData ) ).toBe( false );
} );

it( "stays applicable when canRetrieve keys are absent (undefined ≠ false)", function() {
const assessment = new ProductSKUAssessment( { assessVariants: false } );
// Mirrors Shopify, which never sets the canRetrieve* keys.
const paper = new Paper( "", { productData: { isVariableProduct: false, hasVariants: false, hasGlobalSKU: false } } );

expect( assessment.isApplicable( paper ) ).toBe( true );
} );
} );

describe( "a test for the SKU assessment reading the first-class product field", () => {
const assessment = new ProductSKUAssessment( { assessVariants: true } );

it( "scores 9 for a single-unit product with a global SKU, without a legacy productType", () => {
const paper = new Paper( "", { productData: { isVariableProduct: false, hasVariants: false, hasGlobalSKU: true } } );
expect( assessment.getResult( paper ).getScore() ).toEqual( 9 );
} );

it( "scores 6 for a variable product with variants when not all variants have a SKU", () => {
const paper = new Paper( "", {
productData: { isVariableProduct: true, hasVariants: true, hasGlobalSKU: false, doAllVariantsHaveSKU: false },
} );
expect( assessment.getResult( paper ).getScore() ).toEqual( 6 );
} );

it( "prefers the product field over legacy customData", () => {
const paper = new Paper( "", {
productData: { isVariableProduct: false, hasVariants: false, hasGlobalSKU: true },
customData: { productType: "simple", hasVariants: false, hasGlobalSKU: false },
} );
// The product field reports a global SKU (score 9); the legacy customData would score 6.
expect( assessment.getResult( paper ).getScore() ).toEqual( 9 );
} );
} );
20 changes: 20 additions & 0 deletions packages/yoastseo/spec/values/PaperSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,19 @@ describe( "Paper", function() {
expect( paper.getCustomData() ).toEqual( { hasGlobalIdentifier: false, hasVariants: true, doAllVariantsHaveIdentifier: true } );
} );

it( "returns product data", function() {
const attributes = {
productData: { isVariableProduct: true, hasVariants: true, hasGlobalSKU: false },
};
const paper = new Paper( "", attributes );
expect( paper.getProductData() ).toEqual( { isVariableProduct: true, hasVariants: true, hasGlobalSKU: false } );
} );

it( "returns an empty object for the product data when none is provided", function() {
const paper = new Paper( "" );
expect( paper.getProductData() ).toEqual( {} );
} );

it( "returns the text title", function() {
const attributes = {
textTitle: "A text title",
Expand Down Expand Up @@ -330,6 +343,13 @@ describe( "Paper", function() {
expect( paper._attributes._parseClass ).not.toBeDefined();
} );
} );

it( "round-trips the product data through serialize and parse", () => {
const productData = { isVariableProduct: true, hasVariants: true, hasGlobalSKU: false, canRetrieveGlobalSku: false };
const paper = new Paper( "text", { productData } );
const parsed = Paper.parse( paper.serialize() );
expect( parsed.getProductData() ).toEqual( productData );
} );
} );
describe( "A test for setters and getters", function() {
it( "should properly set and get a tree.", function() {
Expand Down
1 change: 1 addition & 0 deletions packages/yoastseo/src/contract/index.js
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { paperDtoSchema, toPaper } from "./paperDto.js";
export { default as normalizeProductData } from "./normalizeProductData";
37 changes: 37 additions & 0 deletions packages/yoastseo/src/contract/normalizeProductData.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { isEmpty } from "lodash";

/**
* @typedef {import("../values/Paper").default} Paper
* @typedef {import("./productData").ProductData} ProductData
*/

/**
* Narrows a Paper's product analysis data to the `ProductData` shape consumed by the product assessments.
*
* Reads the first-class `productData` attribute, falling back to the legacy flat product keys in `customData` for
* producers and npm consumers that have not migrated to the `productData` field yet. The platform-neutral
* `isVariableProduct` boolean is derived from a legacy `productType === "variable"` when a producer has not sent it,
* so the assessments never read `productType` directly. Optional keys are passed through untouched to preserve their
* `undefined ≠ false` applicability semantics.
*
* This lives apart from `productData.js` on purpose: the schema there imports `zod`, but the assessments (which are
* in the core analysis bundle) need only this narrower, so keeping it zod-free — and importing it directly rather
* than via the contract barrel — keeps `zod` out of the core graph. It also deliberately does not run
* `productDataSchema.parse()`: the in-editor path builds the Paper directly and never crosses the contract boundary,
* so the defaulting/derivation must happen here at the point of consumption rather than as a schema transform.
*
* @param {Paper} paper The paper to read the product data from.
*
* @returns {ProductData} The normalized product data.
*/
export default function normalizeProductData( paper ) {
const productData = paper.getProductData();
// Fall back to the legacy flat product keys in customData for unmigrated producers and npm consumers.
const source = isEmpty( productData ) ? paper.getCustomData() : productData;

return {
...source,
// Derive the platform-neutral boolean from the legacy productType enum when a producer has not sent it.
isVariableProduct: source.isVariableProduct ?? ( source.productType === "variable" ),
};
}
5 changes: 5 additions & 0 deletions packages/yoastseo/src/contract/paperDto.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { z } from "zod";
import Paper from "../values/Paper.js";
import { productDataSchema } from "./productData.js";

/**
* Serializable input contract for the analysis engine.
Expand Down Expand Up @@ -41,6 +42,9 @@ export const paperDtoSchema = z.object( {
// its contents are intentionally unchecked because typing the inner keys would couple the contract to
// consumer-specific shapes.
customData: z.record( z.unknown() ).optional().describe( "Opaque data for consumer-defined custom assessments; contents are not validated." ),
// Typed e-commerce slice consumed by the native product assessments. Unlike `customData`, its shape IS validated
// (see productData.js); producers that have not migrated may still send the legacy flat keys via `customData`.
productData: productDataSchema.optional().describe( "Product analysis data for the native e-commerce assessments (Product identifiers, SKU)." ),
// WordPress-transitional fields — optional and DEPRECATED. They are real analysis inputs (they change
// WP scores), so they're in the contract for browser/remote result parity.
// Kept optional so non-WP consumers simply omit them.
Expand Down Expand Up @@ -84,6 +88,7 @@ export function toPaper( dto ) {
date: data.date,
writingDirection: data.writingDirection,
customData: data.customData,
productData: data.productData,
wpBlocks: data.wpBlocks,
shortcodes: data.shortcodes,
isFrontPage: data.isFrontPage,
Expand Down
42 changes: 42 additions & 0 deletions packages/yoastseo/src/contract/productData.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { z } from "zod";

/**
* Serializable contract for the product analysis data consumed by the native e-commerce SEO assessments
* (Product identifiers, SKU). It is the e-commerce slice of the {@link PaperDTO} input contract: a producer
* (WooCommerce, Shopify, or any headless consumer) maps its own product model onto these fields, and the
* assessments score from them without knowing the platform.
*
* The narrower that applies this contract at the consumption boundary, `normalizeProductData`, lives in its own
* zod-free module so the core analysis assessments don't pull `zod` into their bundle.
*
* Field semantics that are load-bearing:
* - `isVariableProduct` replaces the WooCommerce-specific `productType` enum — it is the single binary
* distinction the assessments need: whether the product can carry independently identified variants. It is
* optional on input; `normalizeProductData` resolves it (defaulting to `false`, and deriving it from a
* legacy `productType === "variable"` for producers that have not migrated).
* - `productType` is accepted only as a deprecated back-compat source for `isVariableProduct`; the assessments
* never read it directly.
* - The `canRetrieve*` flags are checked with a strict `=== false` guard at the call sites, so an *absent* flag
* means "retrieval is possible" and must never be coerced to `false`. They are kept optional here so a
* producer (e.g. Shopify) that omits them keeps the assessment applicable.
*
* `.strict()` rejects unknown keys, catching typos. Consumers that need open-ended extra data should use the
* Paper's opaque `customData` bag instead.
*/
export const productDataSchema = z.object( {
isVariableProduct: z.boolean().optional().describe( "Whether the product can carry independently-identified variants. Absent ⇒ false." ),
productType: z.string().optional().describe( "Deprecated: legacy product-type slug, accepted only as a back-compat source for `isVariableProduct`." ),
hasVariants: z.boolean().optional().describe( "Whether the product currently has variants. Absent ⇒ false." ),
hasGlobalIdentifier: z.boolean().optional().describe( "Whether the product has a global identifier." ),
hasGlobalSKU: z.boolean().optional().describe( "Whether the product has a global SKU." ),
doAllVariantsHaveIdentifier: z.boolean().optional().describe( "Whether every variant has an identifier." ),
doAllVariantsHaveSKU: z.boolean().optional().describe( "Whether every variant has a SKU." ),
canRetrieveGlobalIdentifier: z.boolean().optional().describe( "Whether the global identifier can be retrieved. Absent ⇒ possible." ),
canRetrieveGlobalSku: z.boolean().optional().describe( "Whether the global SKU can be retrieved. Absent ⇒ possible." ),
canRetrieveVariantIdentifiers: z.boolean().optional().describe( "Whether variant identifiers can be retrieved. Absent ⇒ possible." ),
canRetrieveVariantSkus: z.boolean().optional().describe( "Whether variant SKUs can be retrieved. Absent ⇒ possible." ),
} ).strict();

/**
* @typedef {import("zod").infer<typeof productDataSchema>} ProductData
*/
Loading
Loading