diff --git a/packages/yoastseo/spec/contract/normalizeProductDataSpec.js b/packages/yoastseo/spec/contract/normalizeProductDataSpec.js new file mode 100644 index 00000000000..148f2f8904b --- /dev/null +++ b/packages/yoastseo/spec/contract/normalizeProductDataSpec.js @@ -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 ); + } ); +} ); diff --git a/packages/yoastseo/spec/contract/paperDtoSpec.js b/packages/yoastseo/spec/contract/paperDtoSpec.js index 6a6704d7997..aefb51db790 100644 --- a/packages/yoastseo/spec/contract/paperDtoSpec.js +++ b/packages/yoastseo/spec/contract/paperDtoSpec.js @@ -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( { diff --git a/packages/yoastseo/spec/contract/productDataSpec.js b/packages/yoastseo/spec/contract/productDataSpec.js new file mode 100644 index 00000000000..df7f4061c85 --- /dev/null +++ b/packages/yoastseo/spec/contract/productDataSpec.js @@ -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(); + } ); +} ); diff --git a/packages/yoastseo/spec/scoring/assessments/seo/ProductIdentifiersAssessmentSpec.js b/packages/yoastseo/spec/scoring/assessments/seo/ProductIdentifiersAssessmentSpec.js index 7d05175a209..f117a9d2932 100644 --- a/packages/yoastseo/spec/scoring/assessments/seo/ProductIdentifiersAssessmentSpec.js +++ b/packages/yoastseo/spec/scoring/assessments/seo/ProductIdentifiersAssessmentSpec.js @@ -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 ); + } ); } ); diff --git a/packages/yoastseo/spec/scoring/assessments/seo/ProductSKUAssessmentSpec.js b/packages/yoastseo/spec/scoring/assessments/seo/ProductSKUAssessmentSpec.js index 00adcf4c5b6..9a8e9717187 100644 --- a/packages/yoastseo/spec/scoring/assessments/seo/ProductSKUAssessmentSpec.js +++ b/packages/yoastseo/spec/scoring/assessments/seo/ProductSKUAssessmentSpec.js @@ -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 ); + } ); } ); diff --git a/packages/yoastseo/spec/values/PaperSpec.js b/packages/yoastseo/spec/values/PaperSpec.js index 43fa0c5e8e2..996ec99070e 100644 --- a/packages/yoastseo/spec/values/PaperSpec.js +++ b/packages/yoastseo/spec/values/PaperSpec.js @@ -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", @@ -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() { diff --git a/packages/yoastseo/src/contract/index.js b/packages/yoastseo/src/contract/index.js index 847d2a7f021..43f27e5ecfc 100644 --- a/packages/yoastseo/src/contract/index.js +++ b/packages/yoastseo/src/contract/index.js @@ -1 +1,2 @@ export { paperDtoSchema, toPaper } from "./paperDto.js"; +export { default as normalizeProductData } from "./normalizeProductData"; diff --git a/packages/yoastseo/src/contract/normalizeProductData.js b/packages/yoastseo/src/contract/normalizeProductData.js new file mode 100644 index 00000000000..ce224258faa --- /dev/null +++ b/packages/yoastseo/src/contract/normalizeProductData.js @@ -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" ), + }; +} diff --git a/packages/yoastseo/src/contract/paperDto.js b/packages/yoastseo/src/contract/paperDto.js index 64a19061012..5995d132393 100644 --- a/packages/yoastseo/src/contract/paperDto.js +++ b/packages/yoastseo/src/contract/paperDto.js @@ -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. @@ -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. @@ -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, diff --git a/packages/yoastseo/src/contract/productData.js b/packages/yoastseo/src/contract/productData.js new file mode 100644 index 00000000000..2b9551c9ec8 --- /dev/null +++ b/packages/yoastseo/src/contract/productData.js @@ -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} ProductData + */ diff --git a/packages/yoastseo/src/scoring/assessments/seo/ProductIdentifiersAssessment.js b/packages/yoastseo/src/scoring/assessments/seo/ProductIdentifiersAssessment.js index 65826f0bd5a..f018ba77aa9 100644 --- a/packages/yoastseo/src/scoring/assessments/seo/ProductIdentifiersAssessment.js +++ b/packages/yoastseo/src/scoring/assessments/seo/ProductIdentifiersAssessment.js @@ -2,6 +2,7 @@ import { mapValues, merge } from "lodash"; import Assessment from "../assessment"; import AssessmentResult from "../../../values/AssessmentResult"; import { createAnchorOpeningTag } from "../../../helpers"; +import normalizeProductData from "../../../contract/normalizeProductData"; /** * Represents the assessment that checks whether a product has identifier(s). @@ -52,7 +53,7 @@ export default class ProductIdentifiersAssessment extends Assessment { * @returns {AssessmentResult} An assessment result with the score and formatted text. */ getResult( paper ) { - const productIdentifierData = paper.getCustomData(); + const productIdentifierData = normalizeProductData( paper ); const result = this.scoreProductIdentifier( productIdentifierData, this._config ); @@ -82,25 +83,25 @@ export default class ProductIdentifiersAssessment extends Assessment { * @returns {Boolean} Whether the assessment is applicable. */ isApplicable( paper ) { - const customData = paper.getCustomData(); + const productData = normalizeProductData( paper ); /* - * If the global identifier cannot be retrieved, the assessment shouldn't be applicable if the product is a simple - * or external product, or doesn't have variants. Even though in reality a simple or external product doesn't have variants, + * If the global identifier cannot be retrieved, the assessment shouldn't be applicable if the product is not a + * variable product, or doesn't have variants. Even though in reality a non-variable product doesn't have variants, * this double check is added because the hasVariants variable doesn't always update correctly when changing product type. */ - if ( customData.canRetrieveGlobalIdentifier === false && - ( [ "simple", "external", "grouped" ].includes( customData.productType ) || customData.hasVariants === false ) ) { + if ( productData.canRetrieveGlobalIdentifier === false && + ( ! productData.isVariableProduct || productData.hasVariants === false ) ) { return false; } // If variant identifiers cannot be retrieved for a variable product with variants, the assessment shouldn't be applicable. - if ( customData.canRetrieveVariantIdentifiers === false && customData.hasVariants === true && customData.productType === "variable" ) { + if ( productData.canRetrieveVariantIdentifiers === false && productData.hasVariants === true && productData.isVariableProduct ) { return false; } // Assessment is not applicable if we don't want to assess variants and the product has variants. - return ! ( this._config.assessVariants === false && customData.hasVariants ); + return ! ( this._config.assessVariants === false && productData.hasVariants ); } /** @@ -115,9 +116,8 @@ export default class ProductIdentifiersAssessment extends Assessment { scoreProductIdentifier( productIdentifierData, config ) { const { good, okay } = this.getFeedbackStrings(); - // Apply the following scoring conditions to products without variants. - if ( [ "simple", "grouped", "external" ].includes( productIdentifierData.productType ) || - ( productIdentifierData.productType === "variable" && ! productIdentifierData.hasVariants ) ) { + // Apply the following scoring conditions to products that are assessed as a single unit (i.e. not as variants). + if ( ! ( productIdentifierData.isVariableProduct && productIdentifierData.hasVariants ) ) { if ( ! productIdentifierData.hasGlobalIdentifier ) { return { score: config.scores.ok, @@ -129,7 +129,7 @@ export default class ProductIdentifiersAssessment extends Assessment { score: config.scores.good, text: good.withoutVariants, }; - } else if ( productIdentifierData.productType === "variable" && productIdentifierData.hasVariants ) { + } else if ( productIdentifierData.isVariableProduct && productIdentifierData.hasVariants ) { if ( ! productIdentifierData.doAllVariantsHaveIdentifier ) { // If we want to assess variants, and if product has variants but not all variants have an identifier, return orange bullet. // If all variants have an identifier, return green bullet. diff --git a/packages/yoastseo/src/scoring/assessments/seo/ProductSKUAssessment.js b/packages/yoastseo/src/scoring/assessments/seo/ProductSKUAssessment.js index 99ac2086c74..6a8682f477c 100644 --- a/packages/yoastseo/src/scoring/assessments/seo/ProductSKUAssessment.js +++ b/packages/yoastseo/src/scoring/assessments/seo/ProductSKUAssessment.js @@ -3,6 +3,7 @@ import { mapValues, merge } from "lodash"; import Assessment from "../assessment"; import AssessmentResult from "../../../values/AssessmentResult"; import { createAnchorOpeningTag } from "../../../helpers"; +import normalizeProductData from "../../../contract/normalizeProductData"; /** * Represents the assessment checks whether the product has a SKU. @@ -53,7 +54,7 @@ export default class ProductSKUAssessment extends Assessment { * @returns {AssessmentResult} An assessment result with the score and formatted text. */ getResult( paper ) { - const productSKUData = paper.getCustomData(); + const productSKUData = normalizeProductData( paper ); const result = this.scoreProductSKU( productSKUData, this._config ); @@ -86,25 +87,25 @@ export default class ProductSKUAssessment extends Assessment { * @returns {Boolean} Whether the assessment is applicable. */ isApplicable( paper ) { - const customData = paper.getCustomData(); + const productData = normalizeProductData( paper ); /* - * If the global SKU cannot be retrieved, the assessment shouldn't be applicable if the product is a simple - * or external product, or doesn't have variants. Even though in reality a simple or external product doesn't have variants, + * If the global SKU cannot be retrieved, the assessment shouldn't be applicable if the product is not a + * variable product, or doesn't have variants. Even though in reality a non-variable product doesn't have variants, * this double check is added because the hasVariants variable doesn't always update correctly when changing product type. */ - if ( customData.canRetrieveGlobalSku === false && - ( [ "simple", "external" ].includes( customData.productType ) || customData.hasVariants === false ) ) { + if ( productData.canRetrieveGlobalSku === false && + ( ! productData.isVariableProduct || productData.hasVariants === false ) ) { return false; } // If variant identifiers cannot be retrieved for a variable product with variants, the assessment shouldn't be applicable. - if ( customData.canRetrieveVariantSkus === false && customData.hasVariants === true && customData.productType === "variable" ) { + if ( productData.canRetrieveVariantSkus === false && productData.hasVariants === true && productData.isVariableProduct ) { return false; } // Assessment is not applicable if we don't want to assess variants and the product has variants. - return ! ( this._config.assessVariants === false && customData.hasVariants ); + return ! ( this._config.assessVariants === false && productData.hasVariants ); } /** @@ -118,9 +119,8 @@ export default class ProductSKUAssessment extends Assessment { */ scoreProductSKU( productSKUData, config ) { const { good, okay } = this.getFeedbackStrings(); - // Apply the following scoring conditions to products without variants. - if ( [ "simple", "external", "grouped" ].includes( productSKUData.productType ) || - ( productSKUData.productType === "variable" && ! productSKUData.hasVariants ) ) { + // Apply the following scoring conditions to products that are assessed as a single unit (i.e. not as variants). + if ( ! ( productSKUData.isVariableProduct && productSKUData.hasVariants ) ) { if ( ! productSKUData.hasGlobalSKU ) { return { score: config.scores.ok, @@ -131,7 +131,7 @@ export default class ProductSKUAssessment extends Assessment { score: config.scores.good, text: good.withoutVariants, }; - } else if ( productSKUData.productType === "variable" && productSKUData.hasVariants ) { + } else if ( productSKUData.isVariableProduct && productSKUData.hasVariants ) { // If we want to assess variants, if product has variants and not all variants have a SKU, return orange bullet. // If all variants have a SKU, return green bullet. if ( ! productSKUData.doAllVariantsHaveSKU ) { diff --git a/packages/yoastseo/src/values/Paper.js b/packages/yoastseo/src/values/Paper.js index 39095f67486..ccd238f31a5 100644 --- a/packages/yoastseo/src/values/Paper.js +++ b/packages/yoastseo/src/values/Paper.js @@ -7,8 +7,8 @@ import { defaults, isEmpty, isEqual, isNil } from "lodash"; /** * Default attributes to be used by the Paper if they are left undefined. * @type {{keyword: string, synonyms: string, description: string, title: string, titleWidth: number, - * slug: string, locale: string, permalink: string, date: string, customData: object, textTitle: string, - * writingDirection: "LTR", isFrontPage: boolean, wpBlocks: [], shortcodes: []}} + * slug: string, locale: string, permalink: string, date: string, customData: object, productData: object, + * textTitle: string, writingDirection: "LTR", isFrontPage: boolean, wpBlocks: [], shortcodes: []}} */ const defaultAttributes = { keyword: "", @@ -21,6 +21,7 @@ const defaultAttributes = { permalink: "", date: "", customData: {}, + productData: {}, textTitle: "", writingDirection: "LTR", wpBlocks: [], @@ -48,6 +49,7 @@ export default class Paper { * @param {string} [attributes.date] The date. * @param {Object[]} [attributes.wpBlocks] The array of texts, encoded in WordPress block editor blocks. * @param {Object} [attributes.customData] Custom data. + * @param {Object} [attributes.productData] Product analysis data consumed by the native product assessments. * @param {string} [attributes.textTitle] The title of the text. * @param {string} [attributes.writingDirection=LTR] The writing direction of the paper. Defaults to left to right (LTR). * @param {boolean} [attributes.isFrontPage=false] Whether the current page is the front page of the site. Defaults to false. @@ -65,7 +67,7 @@ export default class Paper { attributes.locale = defaultAttributes.locale; } - if ( attributes.hasOwnProperty( "url" ) ) { + if ( Object.hasOwn( attributes, "url" ) ) { // The 'url' attribute has been deprecated since version 1.19.1, refer to hasUrl and getUrl below. console.warn( "The 'url' attribute is deprecated, use 'slug' instead." ); attributes.slug = attributes.url || attributes.slug; @@ -315,6 +317,19 @@ export default class Paper { return this._attributes.customData; } + /** + * Returns the product data, or an empty object if no data is available. + * + * Unlike `customData`, this is a first-class field for the analysis data consumed by the native product + * assessments (Product identifiers, SKU). The raw, un-narrowed attribute is returned here; narrowing to the + * documented shape happens at the consumption boundary (see `normalizeProductData`). + * + * @returns {Object} Returns the product data. + */ + getProductData() { + return this._attributes.productData; + } + /** * Checks whether a text title is available. * @returns {boolean} Returns true if the Paper has a text title.