From 2bce4141d0c471641e329f91c0447132d0707d32 Mon Sep 17 00:00:00 2001 From: rossbrandon Date: Fri, 13 Mar 2026 14:11:24 -0500 Subject: [PATCH 01/12] Add render-all-categories action for ACO PLP pre-rendering --- actions/categories.js | 97 +++- actions/common-renderer/lib.js | 113 ++++ actions/lib/runtimeConfig.js | 6 +- actions/pdp-renderer/lib.js | 97 +--- actions/plp-renderer/ldJson.js | 64 +++ actions/plp-renderer/render.js | 102 ++++ actions/plp-renderer/templates/page.hbs | 11 + actions/plp-renderer/templates/plp-head.hbs | 13 + .../templates/product-listing.hbs | 37 ++ actions/queries.js | 73 ++- actions/render-all-categories/index.js | 88 +++ actions/render-all-categories/poller.js | 503 ++++++++++++++++++ actions/utils.js | 33 ++ app.config.yaml | 23 + test/render-all-categories.test.js | 369 +++++++++++++ 15 files changed, 1502 insertions(+), 127 deletions(-) create mode 100644 actions/common-renderer/lib.js create mode 100644 actions/plp-renderer/ldJson.js create mode 100644 actions/plp-renderer/render.js create mode 100644 actions/plp-renderer/templates/page.hbs create mode 100644 actions/plp-renderer/templates/plp-head.hbs create mode 100644 actions/plp-renderer/templates/product-listing.hbs create mode 100644 actions/render-all-categories/index.js create mode 100644 actions/render-all-categories/poller.js create mode 100644 test/render-all-categories.test.js diff --git a/actions/categories.js b/actions/categories.js index dcb8cb09..099268f6 100644 --- a/actions/categories.js +++ b/actions/categories.js @@ -32,7 +32,8 @@ function hasFamilies(families) { } /** - * Resolves all category slugs belonging to the given ACO category families. + * Resolves all categories belonging to the given ACO category families, + * returning a Map of slug → full category metadata. * * Uses BFS traversal of the categoryTree API: * 1. Query each family's root categories and their immediate childrenSlugs. @@ -42,16 +43,18 @@ function hasFamilies(families) { * Handles trees of arbitrary depth even when the API caps depth at * MAX_TREE_DEPTH per call — each iteration advances up to that many levels. * + * Shared by getCategorySlugsFromFamilies and getCategoryDataFromFamilies. + * * @param {Object} context - Request context (config, logger, headers, etc.). * @param {string[]} families - ACO category family identifiers. - * @returns {Promise} Flat array of all unique category slugs. + * @returns {Promise>} Map of category slug to category metadata. */ -async function getCategorySlugsFromFamilies(context, families) { - console.debug("Getting category slugs from families:", families); - const allSlugs = new Set(); +async function fetchCategoryTree(context, families) { + console.debug("Getting category data from families:", families); + const categoryMap = new Map(); for (const family of families) { - console.debug("Getting category slugs from family:", family); + console.debug("Getting category data from family:", family); // Get root-level categories for this family const firstLevel = await requestSaaS( CategoryTreeQuery, @@ -62,14 +65,16 @@ async function getCategorySlugsFromFamilies(context, families) { let pending = []; for (const cat of firstLevel.data.categoryTree) { - allSlugs.add(cat.slug); + categoryMap.set(cat.slug, cat); pending.push(...(cat.childrenSlugs || [])); } // BFS: resolve children level by level until no new slugs remain while (pending.length) { // Mark pending as seen before querying to prevent re-processing - for (const slug of pending) allSlugs.add(slug); + for (const slug of pending) { + if (!categoryMap.has(slug)) categoryMap.set(slug, null); + } const childrenRes = await requestSaaS( CategoryTreeBySlugsQuery, @@ -80,21 +85,81 @@ async function getCategorySlugsFromFamilies(context, families) { // First pass: capture any descendant slugs included due to depth traversal for (const cat of childrenRes.data.categoryTree) { - allSlugs.add(cat.slug); + categoryMap.set(cat.slug, cat); } // Second pass: collect only new childrenSlugs for next iteration pending = []; for (const cat of childrenRes.data.categoryTree) { for (const child of cat.childrenSlugs || []) { - if (!allSlugs.has(child)) pending.push(child); + if (!categoryMap.has(child)) pending.push(child); } } } } - console.debug("Category slugs resolved:", [...allSlugs]); + console.debug("Category slugs resolved:", [...categoryMap.keys()]); + + return categoryMap; +} + +/** + * Resolves all category slugs belonging to the given ACO category families. + * + * Uses BFS traversal of the categoryTree API via fetchCategoryTree. + * + * @param {Object} context - Request context (config, logger, headers, etc.). + * @param {string[]} families - ACO category family identifiers. + * @returns {Promise} Flat array of all unique category slugs. + */ +async function getCategorySlugsFromFamilies(context, families) { + const categoryMap = await fetchCategoryTree(context, families); + return [...categoryMap.keys()]; +} + +/** + * Resolves all categories with full metadata from the given ACO category families. + * + * Uses BFS traversal of the categoryTree API via fetchCategoryTree. + * + * @param {Object} context - Request context (config, logger, headers, etc.). + * @param {string[]} families - ACO category family identifiers. + * @returns {Promise>} Map of category slug to category metadata. + */ +async function getCategoryDataFromFamilies(context, families) { + return fetchCategoryTree(context, families); +} + +/** + * Last-resort fallback: converts a slug segment to a human-readable name + * when the category is not found in the map (e.g. if a childSlug was + * referenced but not returned by the API). + * E.g. "computers-tablets" → "Computers Tablets" + * + * Callers should always prefer category.name from the API response. + */ +function humanizeSlugSegment(segment) { + return segment.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); +} + +/** + * Derives breadcrumb trail from a category slug path. + * + * @param {string} slug - The category slug (e.g. "electronics/computers-tablets/laptops"). + * @param {Map} categoryMap - The category map for name resolution. + * @returns {Array<{name: string, slug: string}>} Breadcrumb entries. + */ +function buildBreadcrumbs(slug, categoryMap) { + const segments = slug.split("/"); + const breadcrumbs = []; + + for (let i = 0; i < segments.length; i++) { + const ancestorSlug = segments.slice(0, i + 1).join("/"); + const category = categoryMap.get(ancestorSlug); + const name = category?.name || humanizeSlugSegment(segments[i]); + breadcrumbs.push({ name, slug: ancestorSlug }); + } - return [...allSlugs]; + return breadcrumbs; } /** @@ -123,4 +188,10 @@ async function getCategories(context) { return byLevel; } -module.exports = { getCategorySlugsFromFamilies, getCategories, hasFamilies }; +module.exports = { + getCategorySlugsFromFamilies, + getCategoryDataFromFamilies, + getCategories, + hasFamilies, + buildBreadcrumbs, +}; diff --git a/actions/common-renderer/lib.js b/actions/common-renderer/lib.js new file mode 100644 index 00000000..211e4044 --- /dev/null +++ b/actions/common-renderer/lib.js @@ -0,0 +1,113 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +const striptags = require('striptags'); +const cheerio = require('cheerio'); + +/** + * Extracts details from the path based on the provided format. + * @param {string} path The path. + * @param {string} format The format to extract details from the path. + * @returns {Object} An object containing the extracted details. + * @throws Throws an error if the path is invalid. + */ +function extractPathDetails(path, format) { + if (!path) { + return {}; + } + + const formatParts = format.split('/').filter(Boolean); + const pathParts = path.split('/').filter(Boolean); + + if (formatParts.length !== pathParts.length) { + throw new Error(`Invalid path. Expected '${format}' format.`); + } + + const result = {}; + formatParts.forEach((part, index) => { + if (part.startsWith('{') && part.endsWith('}')) { + const key = part.substring(1, part.length - 1); + result[key] = pathParts[index]; + } else if (part !== pathParts[index]) { + throw new Error(`Invalid path. Expected '${format}' format.`); + } + }); + + return result; +} + +/** + * Returns the base template for a page. It loads an Edge Delivery page and replaces + * specified blocks with Handlebars partials. + * + * @param {string} url The URL to fetch the base template HTML from. + * @param {Array} blocks The list of block class names to replace with Handlebars partials. + * @param {Object} context The context object. + * @returns {Promise} The adapted base template HTML as a string. + */ +async function prepareBaseTemplate(url, blocks, context) { + if(context.locale && context.locale !== 'default') { + url = url.replace(/\s+/g, '').replace(/\/$/, '').replace('{locale}', context.locale); + } + + const { siteToken } = context; + + let options = undefined; + + // Site Validation: needs to be a non empty string + if (typeof siteToken === 'string' && siteToken.trim()) { + options = {headers:{'authorization': `token ${siteToken}`}} + } + + const baseTemplateHtml = await fetch(`${url}.plain.html`, {...options}).then(resp => resp.text()); + + const $ = cheerio.load(`
${baseTemplateHtml}
`); + + blocks.forEach(block => { + $(`.${block}`).replaceWith(`{{> ${block} }}`); + }); + + let adaptedBaseTemplate = $('main').prop('innerHTML'); + adaptedBaseTemplate = adaptedBaseTemplate.replace(/>/g, '>') + '\n'; + + return adaptedBaseTemplate; +} + + /** + * Sanitizes HTML content by removing disallowed or unbalanced tags. + * Supports three modes: 'all', 'inline', 'no'. + * 'all': allows all block and inline tags supported by edge delivery. + * 'inline': allows all inline tags supported by edge delivery. + * 'no': allows no tags + * + * @param {string} html - HTML string to sanitize + * @param {string} [mode='all'] - Sanitization mode + * @returns {string} Sanitized HTML string + */ +function sanitize(html, mode = 'all') { + const allowedInlineTags = [ 'a', 'br', 'code', 'del', 'em', 'img', 'strong', 'sub', 'sup', 'u' ]; + const allowedAllTags = [ + 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'pre', + ...allowedInlineTags, + 'table', 'tbody', 'td', 'th', 'thead', 'tr', + ]; + + if (mode === 'all') { + return striptags(html, allowedAllTags); + } else if (mode === 'inline') { + return striptags(html, allowedInlineTags); + } else if (mode === 'no') { + return striptags(html); + } +} + +module.exports = { extractPathDetails, prepareBaseTemplate, sanitize }; diff --git a/actions/lib/runtimeConfig.js b/actions/lib/runtimeConfig.js index abb0bfbf..2158b298 100644 --- a/actions/lib/runtimeConfig.js +++ b/actions/lib/runtimeConfig.js @@ -20,7 +20,8 @@ const DEFAULTS = { STORE_URL: undefined, PRODUCTS_TEMPLATE: undefined, LOCALES: undefined, - SITE_TOKEN: undefined + SITE_TOKEN: undefined, + PLP_PRODUCTS_PER_PAGE: undefined }; /** @@ -116,7 +117,8 @@ function getRuntimeConfig(params = {}, options = {}) { configSheet: merged.CONFIG_SHEET, pathFormat: merged.PRODUCT_PAGE_URL_FORMAT, locales: localesArr, - categoryFamilies: categoryFamiliesArr + categoryFamilies: categoryFamiliesArr, + plpProductsPerPage: parseInt(merged.PLP_PRODUCTS_PER_PAGE, 10) || 9 }; // URL sanity checks diff --git a/actions/pdp-renderer/lib.js b/actions/pdp-renderer/lib.js index 6270baa2..f18b607e 100644 --- a/actions/pdp-renderer/lib.js +++ b/actions/pdp-renderer/lib.js @@ -1,37 +1,5 @@ const striptags = require('striptags'); -const cheerio = require('cheerio'); - -/** - * Extracts details from the path based on the provided format. - * @param {string} path The path. - * @param {string} format The format to extract details from the path. - * @returns {Object} An object containing the extracted details. - * @throws Throws an error if the path is invalid. - */ -function extractPathDetails(path, format) { - if (!path) { - return {}; - } - - const formatParts = format.split('/').filter(Boolean); - const pathParts = path.split('/').filter(Boolean); - - if (formatParts.length !== pathParts.length) { - throw new Error(`Invalid path. Expected '${format}' format.`); - } - - const result = {}; - formatParts.forEach((part, index) => { - if (part.startsWith('{') && part.endsWith('}')) { - const key = part.substring(1, part.length - 1); - result[key] = pathParts[index]; - } else if (part !== pathParts[index]) { - throw new Error(`Invalid path. Expected '${format}' format.`); - } - }); - - return result; -} +const { extractPathDetails, prepareBaseTemplate, sanitize } = require('../common-renderer/lib'); /** * Finds the description of a product based on a priority list of fields. @@ -61,41 +29,6 @@ function getPrimaryImage(product, role = 'image') { return product?.images?.length > 0 ? product?.images?.[0] : undefined; } -/** - * Returns the base template for a product detail page. It loads a Edge Delivery page and replaces specified blocks with Handlebars partials. - * - * @param {string} url The URL to fetch the base template HTML from. - * @param {Array} blocks The list of block class names to replace with Handlebars partials. - * @returns {Promise} The adapted base template HTML as a string. - */ -async function prepareBaseTemplate(url, blocks, context) { - if(context.locale && context.locale !== 'default') { - url = url.replace(/\s+/g, '').replace(/\/$/, '').replace('{locale}', context.locale); - } - - const { siteToken } = context; - - let options = undefined; - - // Site Validation: needs to be a non empty string - if (typeof siteToken === 'string' && siteToken.trim()) { - options = {headers:{'authorization': `token ${siteToken}`}} - } - - const baseTemplateHtml = await fetch(`${url}.plain.html`, {...options}).then(resp => resp.text()); - - const $ = cheerio.load(`
${baseTemplateHtml}
`); - - blocks.forEach(block => { - $(`.${block}`).replaceWith(`{{> ${block} }}`); - }); - - let adaptedBaseTemplate = $('main').prop('innerHTML'); - adaptedBaseTemplate = adaptedBaseTemplate.replace(/>/g, '>') + '\n'; - - return adaptedBaseTemplate; -} - /** * Returns a number formatter for the specified locale and currency. * @@ -178,32 +111,4 @@ function getImageList(primary, images) { return imageList; } - /** -* Sanitizes HTML content by removing disallowed or unbalanced tags. -* Suppoorts three modes: 'all', 'inline', 'no'. -* 'all': allows all block and inline tags supported by edge delivery. -* 'inline': allows all inline tags supported by edge delivery. -* 'no': allows no tags -* -* @param {string} html - HTML string to sanitize -* @param {string} [mode='all'] - Sanitization mode -* @returns {string} Sanitized HTML string -*/ -function sanitize(html, mode = 'all') { - const allowedInlineTags = [ 'a', 'br', 'code', 'del', 'em', 'img', 'strong', 'sub', 'sup', 'u' ]; - const allowedAllTags = [ - 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'pre', - ...allowedInlineTags, - 'table', 'tbody', 'td', 'th', 'thead', 'tr', - ]; - - if (mode === 'all') { - return striptags(html, allowedAllTags); - } else if (mode === 'inline') { - return striptags(html, allowedInlineTags); - } else if (mode === 'no') { - return striptags(html); - } -} - module.exports = { extractPathDetails, findDescription, getPrimaryImage, prepareBaseTemplate, generatePriceString, getImageList, sanitize }; diff --git a/actions/plp-renderer/ldJson.js b/actions/plp-renderer/ldJson.js new file mode 100644 index 00000000..3a165aae --- /dev/null +++ b/actions/plp-renderer/ldJson.js @@ -0,0 +1,64 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +const { getCategoryUrl, getProductUrl } = require('../utils'); + +/** + * Generates ItemList and BreadcrumbList JSON-LD for a PLP page. + * + * @param {Object} categoryData - Category metadata from the category tree. + * @param {Array} products - Product items from productSearch. + * @param {Array} breadcrumbs - Breadcrumb entries with { name, slug }. + * @param {Object} context - The context object with storeUrl, locale, pathFormat. + * @returns {{ itemListLdJson: string, breadcrumbLdJson: string }} + */ +function generatePlpLdJson(categoryData, products, breadcrumbs, context) { + const itemList = { + '@context': 'https://schema.org', + '@type': 'ItemList', + name: categoryData.name, + numberOfItems: products.length, + itemListElement: products.map((product, index) => { + const productUrl = getProductUrl( + { sku: product.sku, urlKey: product.urlKey }, + context + ); + const image = product.images?.find(img => img.roles?.includes('image'))?.url || null; + + return { + '@type': 'ListItem', + position: index + 1, + name: product.name, + url: productUrl, + ...(image ? { image } : {}), + }; + }), + }; + + const breadcrumbList = { + '@context': 'https://schema.org', + '@type': 'BreadcrumbList', + itemListElement: breadcrumbs.map((crumb, index) => ({ + '@type': 'ListItem', + position: index + 1, + name: crumb.name, + item: getCategoryUrl(crumb.slug, context), + })), + }; + + return { + itemListLdJson: JSON.stringify(itemList), + breadcrumbLdJson: JSON.stringify(breadcrumbList), + }; +} + +module.exports = { generatePlpLdJson }; diff --git a/actions/plp-renderer/render.js b/actions/plp-renderer/render.js new file mode 100644 index 00000000..229878b6 --- /dev/null +++ b/actions/plp-renderer/render.js @@ -0,0 +1,102 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +const fs = require("fs"); +const path = require("path"); +const Handlebars = require("handlebars"); +const { sanitize } = require("../common-renderer/lib"); +const { generatePlpLdJson } = require("./ldJson"); +const { getCategoryUrl } = require("../utils"); +const { buildBreadcrumbs } = require("../categories"); + +/** + * Generates the HTML for a category listing page. + * + * @param {Object} categoryData - Category metadata from the category tree. + * @param {Array} products - Product items from productSearch (productView objects). + * @param {Map} categoryMap - Full category map for breadcrumb resolution. + * @param {Object} context - The context object with storeUrl, locale, pathFormat, logger. + * @returns {string} Rendered HTML string. + */ +function generateCategoryHtml(categoryData, products, categoryMap, context) { + const breadcrumbs = buildBreadcrumbs(categoryData.slug, categoryMap); + + // Build template data + const categoryDescription = categoryData.metaTags?.description + ? sanitize(categoryData.metaTags.description, "all") + : null; + + const categoryImage = categoryData.images?.find( + (img) => img.roles?.includes("image") || img.customRoles?.includes("hero"), + ); + + const templateData = { + categoryName: sanitize(categoryData.name, "inline"), + categoryDescription, + slug: categoryData.slug, + metaTitle: sanitize( + categoryData.metaTags?.title || categoryData.name, + "no", + ), + metaDescription: categoryData.metaTags?.description + ? sanitize(categoryData.metaTags.description, "no") + : null, + metaKeywords: categoryData.metaTags?.keywords + ? sanitize(categoryData.metaTags.keywords, "no") + : null, + metaImage: categoryImage?.url || null, + breadcrumbs: breadcrumbs.map((crumb) => ({ + name: sanitize(crumb.name, "inline"), + url: getCategoryUrl(crumb.slug, context), + })), + products: products.map((product) => ({ + name: sanitize(product.name, "inline"), + url: getCategoryUrl(product.urlKey, context), + image: + product.images?.find((img) => img.roles?.includes("image"))?.url || + null, + })), + hasProducts: products.length > 0, + }; + + // Generate JSON-LD + const { itemListLdJson, breadcrumbLdJson } = generatePlpLdJson( + categoryData, + products, + breadcrumbs, + context, + ); + + // Load and compile Handlebars templates + const [pageHbs, headHbs, productListingHbs] = [ + "page", + "plp-head", + "product-listing", + ].map((template) => + fs.readFileSync( + path.join(__dirname, "templates", `${template}.hbs`), + "utf8", + ), + ); + + const pageTemplate = Handlebars.compile(pageHbs); + Handlebars.registerPartial("head", headHbs); + Handlebars.registerPartial("content", productListingHbs); + + return pageTemplate({ + ...templateData, + itemListLdJson, + breadcrumbLdJson, + }); +} + +module.exports = { generateCategoryHtml }; diff --git a/actions/plp-renderer/templates/page.hbs b/actions/plp-renderer/templates/page.hbs new file mode 100644 index 00000000..eca96bd1 --- /dev/null +++ b/actions/plp-renderer/templates/page.hbs @@ -0,0 +1,11 @@ + + +{{> head }} + +
+
+ {{> content}} +
+
+ + \ No newline at end of file diff --git a/actions/plp-renderer/templates/plp-head.hbs b/actions/plp-renderer/templates/plp-head.hbs new file mode 100644 index 00000000..eeeed441 --- /dev/null +++ b/actions/plp-renderer/templates/plp-head.hbs @@ -0,0 +1,13 @@ + + + {{metaTitle}} + + {{#if metaDescription ~}}{{/if ~}} + {{#if metaKeywords ~}}{{/if ~}} + {{#if metaImage ~}}{{/if ~}} + + + + + + diff --git a/actions/plp-renderer/templates/product-listing.hbs b/actions/plp-renderer/templates/product-listing.hbs new file mode 100644 index 00000000..b610e22d --- /dev/null +++ b/actions/plp-renderer/templates/product-listing.hbs @@ -0,0 +1,37 @@ +
+
+
+ +

{{categoryName}}

+
+
+ {{#if categoryDescription}} +
+

Description

+
{{{categoryDescription}}}
+
+ {{/if}} + {{#if hasProducts}} +
+

Products

+
+ +
+
+ {{/if}} +
\ No newline at end of file diff --git a/actions/queries.js b/actions/queries.js index 587a41cc..c8597b4a 100644 --- a/actions/queries.js +++ b/actions/queries.js @@ -253,19 +253,19 @@ const CategoryTreeQuery = ` query getCategoryTree($family: String!) { categoryTree(family: $family) { slug - # name - # level - # metaTags { - # title - # description - # keywords - # } - # images { - # url - # label - # roles - # customRoles - # } + name + level + metaTags { + title + description + keywords + } + images { + url + label + roles + customRoles + } childrenSlugs } } @@ -275,14 +275,54 @@ const CategoryTreeBySlugsQuery = ` query getCategoryTreeBySlugs($family: String!, $slugs: [String!], $depth: Int!) { categoryTree(family: $family, slugs: $slugs, depth: $depth) { slug - # name - # level - # parentSlug + name + level + parentSlug + metaTags { + title + description + keywords + } + images { + url + label + roles + customRoles + } childrenSlugs } } `; +const PlpProductSearchQuery = ` + query plpProductSearch($categoryPath: String!, $pageSize: Int!, $currentPage: Int!) { + productSearch( + phrase: "", + filter: [{ attribute: "categoryPath", eq: $categoryPath }], + page_size: $pageSize, + current_page: $currentPage + ) { + items { + productView { + name + sku + urlKey + images(roles: ["image"]) { + url + label + roles + } + } + } + total_count + page_info { + current_page + total_pages + } + } + } +`; + module.exports = { ProductQuery, ProductByUrlKeyQuery, @@ -295,4 +335,5 @@ module.exports = { GetUrlKeyQuery, CategoryTreeQuery, CategoryTreeBySlugsQuery, + PlpProductSearchQuery, }; diff --git a/actions/render-all-categories/index.js b/actions/render-all-categories/index.js new file mode 100644 index 00000000..ee1853a3 --- /dev/null +++ b/actions/render-all-categories/index.js @@ -0,0 +1,88 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +const { Core, State, Files } = require('@adobe/aio-sdk'); +const { poll } = require('./poller'); +const { StateManager } = require('../lib/state'); +const { ObservabilityClient } = require('../lib/observability'); +const { getRuntimeConfig } = require('../lib/runtimeConfig'); +const { handleActionError } = require('../lib/errorHandler'); + +/** + * Entry point for the "Render all categories" action. + * @param {Object} params + * @returns {Promise} + */ +async function main(params) { + let logger; + + try { + const cfg = getRuntimeConfig(params, { validateToken: true }); + logger = Core.Logger('main', { level: cfg.logLevel }); + + const observabilityClient = new ObservabilityClient(logger, { + token: cfg.adminAuthToken, + endpoint: cfg.logIngestorEndpoint, + org: cfg.org, + site: cfg.site + }); + + const stateLib = await State.init(params.libInit || {}); + const filesLib = await Files.init(params.libInit || {}); + const stateMgr = new StateManager(stateLib, { logger }); + + let activationResult; + + const running = await stateMgr.get('plp-running'); + if (running?.value === 'true') { + activationResult = { state: 'skipped' }; + + try { + await observabilityClient.sendActivationResult(activationResult); + } catch (obsErr) { + logger.warn('Failed to send activation result (skipped).', obsErr); + } + + return activationResult; + } + + try { + await stateMgr.put('plp-running', 'true', { ttl: 3600 }); + + activationResult = await poll(cfg, { stateLib: stateMgr, filesLib }, logger); + } finally { + try { + await stateMgr.put('plp-running', 'false'); + } catch (stateErr) { + (logger || Core.Logger('main', { level: 'error' })) + .error('Failed to reset running state.', stateErr); + } + } + + try { + await observabilityClient.sendActivationResult(activationResult); + } catch (obsErr) { + logger.warn('Failed to send activation result.', obsErr); + } + + return activationResult; + } catch (error) { + logger = logger || Core.Logger('main', { level: 'error' }); + + return handleActionError(error, { + logger, + actionName: 'Render all categories' + }); + } +} + +exports.main = main; diff --git a/actions/render-all-categories/poller.js b/actions/render-all-categories/poller.js new file mode 100644 index 00000000..f974c32c --- /dev/null +++ b/actions/render-all-categories/poller.js @@ -0,0 +1,503 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +const crypto = require("crypto"); +const { Timings, aggregate } = require("../lib/benchmark"); +const { AdminAPI } = require("../lib/aem"); +const { + requestSaaS, + isValidUrl, + getCategoryUrl, + formatMemoryUsage, + PLP_FILE_PREFIX, + STATE_FILE_EXT, +} = require("../utils"); +const { PlpProductSearchQuery } = require("../queries"); +const { getCategoryDataFromFamilies } = require("../categories"); +const { generateCategoryHtml } = require("../plp-renderer/render"); +const { JobFailedError, ERROR_CODES } = require("../lib/errorHandler"); + +const BATCH_SIZE = 50; +const PLP_FILE_EXT = "html"; + +function getFileLocation(stateKey, extension) { + return `${PLP_FILE_PREFIX}/${stateKey}.${extension}`; +} + +/** + * Loads the PLP state from the cloud file system. + * + * @param {string} locale - The locale. + * @param {Object} aioLibs - { filesLib, stateLib }. + * @returns {Promise} State object with { locale, categories: { [slug]: { lastRenderedAt, hash } } }. + */ +async function loadState(locale, aioLibs) { + const { filesLib } = aioLibs; + const stateObj = { locale, categories: {} }; + try { + const stateKey = locale || "default"; + const fileLocation = getFileLocation(stateKey, STATE_FILE_EXT); + const buffer = await filesLib.read(fileLocation); + const stateData = buffer?.toString(); + if (stateData) { + const lines = stateData.split("\n"); + stateObj.categories = lines.reduce((acc, line) => { + // format: ,, + const [slug, time, hash] = line.split(","); + if (slug) { + acc[slug] = { lastRenderedAt: new Date(parseInt(time)), hash }; + } + return acc; + }, {}); + } + // eslint-disable-next-line no-unused-vars + } catch (e) { + stateObj.categories = {}; + } + return stateObj; +} + +/** + * Saves the PLP state to the cloud file system. + * + * @param {Object} state - State object with { locale, categories }. + * @param {Object} aioLibs - { filesLib, stateLib }. + * @returns {Promise} + */ +async function saveState(state, aioLibs) { + const { filesLib } = aioLibs; + const stateKey = state.locale || "default"; + const fileLocation = getFileLocation(stateKey, STATE_FILE_EXT); + const csvData = Object.entries(state.categories) + .filter(([, { lastRenderedAt }]) => Boolean(lastRenderedAt)) + .map( + ([slug, { lastRenderedAt, hash }]) => + `${slug},${lastRenderedAt.getTime()},${hash || ""}`, + ) + .join("\n"); + return await filesLib.write(fileLocation, csvData); +} + +function shouldPreviewAndPublish({ currentHash, newHash }) { + return newHash && currentHash !== newHash; +} + +function createBatches(items) { + return items.reduce((acc, item) => { + if (!acc.length || acc[acc.length - 1].length === BATCH_SIZE) { + acc.push([]); + } + acc[acc.length - 1].push(item); + return acc; + }, []); +} + +function checkParams(params) { + const requiredParams = [ + "site", + "org", + "adminAuthToken", + "configName", + "contentUrl", + "storeUrl", + ]; + const missingParams = requiredParams.filter((param) => !params[param]); + if (missingParams.length > 0) { + throw new JobFailedError( + `Missing required parameters: ${missingParams.join(", ")}`, + ERROR_CODES.VALIDATION_ERROR, + 400, + { missingParams }, + ); + } + + if (params.storeUrl && !isValidUrl(params.storeUrl)) { + throw new JobFailedError( + "Invalid storeUrl", + ERROR_CODES.VALIDATION_ERROR, + 400, + ); + } + + if (!params.categoryFamilies?.length) { + throw new JobFailedError( + "Missing ACO_CATEGORY_FAMILIES configuration", + ERROR_CODES.VALIDATION_ERROR, + 400, + ); + } +} + +/** + * Renders a single category and returns enriched data with hash. + */ +let renderLimit$; +async function renderCategory(categoryData, categoryMap, context) { + const { logger } = context; + + if (!renderLimit$) { + renderLimit$ = import("p-limit").then(({ default: pLimit }) => pLimit(50)); + } + + return (await renderLimit$)(async () => { + const slug = categoryData.slug; + const result = { + slug, + path: getCategoryUrl(slug, context, false).toLowerCase(), + currentHash: context.state.categories[slug]?.hash || null, + }; + + try { + // Fetch first page of products for this category + const productsRes = await requestSaaS( + PlpProductSearchQuery, + "plpProductSearch", + { + categoryPath: slug, + pageSize: context.plpProductsPerPage, + currentPage: 1, + }, + context, + ); + + const products = productsRes.data.productSearch.items.map( + (item) => item.productView, + ); + + // Render HTML + const html = generateCategoryHtml( + categoryData, + products, + categoryMap, + context, + ); + result.renderedAt = new Date(); + result.newHash = crypto.createHash("sha256").update(html).digest("hex"); + + // Save HTML if changed + if (shouldPreviewAndPublish(result) && html) { + try { + const { filesLib } = context.aioLibs; + const htmlPath = `/public/plps${result.path}.${PLP_FILE_EXT}`; + await filesLib.write(htmlPath, html); + logger.debug(`Saved HTML for category ${slug} to ${htmlPath}`); + } catch (e) { + result.newHash = null; + logger.error(`Error saving HTML for category ${slug}:`, e); + } + } + } catch (e) { + logger.error(`Error rendering category ${slug}:`, e); + } + + return result; + }); +} + +/** + * Processes a published batch and updates state. + */ +async function processPublishedBatch( + publishedBatch, + state, + counts, + renderedCategories, + aioLibs, +) { + const { records } = publishedBatch; + records.forEach((record) => { + if (record.previewedAt && record.publishedAt) { + const category = renderedCategories.find((c) => c.slug === record.slug); + state.categories[record.slug] = { + lastRenderedAt: record.renderedAt, + hash: category?.newHash, + }; + counts.published++; + } else { + counts.failed++; + } + }); + await saveState(state, aioLibs); +} + +/** + * Main poll function for category rendering. + */ +async function poll(params, aioLibs, logger) { + try { + checkParams(params); + + const counts = { published: 0, ignored: 0, failed: 0 }; + const { + org, + site, + pathFormat, + siteToken, + configName, + configSheet, + adminAuthToken, + storeUrl, + contentUrl, + logLevel, + logIngestorEndpoint, + locales: rawLocales, + categoryFamilies, + plpProductsPerPage, + } = params; + + const locales = Array.isArray(rawLocales) + ? rawLocales + : typeof rawLocales === "string" && rawLocales.trim() + ? rawLocales + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + : [null]; + + const sharedContext = { + siteToken, + storeUrl, + contentUrl, + configName, + configSheet, + logger, + counts, + pathFormat, + aioLibs, + logLevel, + logIngestorEndpoint, + plpProductsPerPage: plpProductsPerPage || 9, + }; + + const timings = new Timings(); + + const adminApi = new AdminAPI({ org, site }, sharedContext, { + authToken: adminAuthToken, + }); + + logger.info(`Starting PLP poll from ${storeUrl} for locales ${locales}`); + + let stateText = "completed"; + + try { + await adminApi.startProcessing(); + + const results = await Promise.all( + locales.map(async (locale) => { + const timings = new Timings(); + const context = { ...sharedContext, startTime: new Date() }; + if (locale) context.locale = locale; + + logger.info(`PLP polling for locale ${locale}`); + + // Discover all categories + const categoryMap = await getCategoryDataFromFamilies( + context, + categoryFamilies, + ); + timings.sample("discover-categories"); + + // Save category list for reference + const { filesLib } = aioLibs; + const categoriesFileName = getFileLocation( + `${locale || "default"}-categories`, + "json", + ); + const categoryList = [...categoryMap.entries()] + .filter(([, data]) => data != null) + .map(([slug, data]) => ({ + slug, + name: data.name, + level: data.level, + })); + await filesLib.write( + categoriesFileName, + JSON.stringify(categoryList), + ); + + // Load state + const state = await loadState(locale, aioLibs); + context.state = state; + + logger.info( + `Discovered ${categoryMap.size} categories for locale ${locale}`, + ); + + // Render all categories in batches + const categorySlugs = [...categoryMap.keys()].filter( + (slug) => categoryMap.get(slug) != null, + ); + const batches = createBatches(categorySlugs); + const pendingBatches = batches.map((batch, batchNumber) => { + return Promise.all( + batch.map((slug) => + renderCategory(categoryMap.get(slug), categoryMap, context), + ), + ) + .then(async (renderedCategories) => { + // Filter to only those that changed + const toPublish = []; + const toIgnore = []; + for (const category of renderedCategories) { + if (shouldPreviewAndPublish(category)) { + toPublish.push(category); + } else { + counts.ignored++; + // Update lastRenderedAt even if hash unchanged + if (category.renderedAt) { + state.categories[category.slug] = { + lastRenderedAt: category.renderedAt, + hash: category.currentHash, + }; + } + toIgnore.push(category); + } + } + + if (toIgnore.length) { + await saveState(state, aioLibs); + } + + return toPublish; + }) + .then((categories) => { + if (categories.length) { + const records = categories.map( + ({ slug, path, renderedAt }) => ({ + slug, + path, + renderedAt, + }), + ); + return adminApi + .previewAndPublish(records, locale, batchNumber + 1) + .then((publishedBatch) => + processPublishedBatch( + publishedBatch, + state, + counts, + categories, + aioLibs, + ), + ) + .catch((error) => { + if (error.code === ERROR_CODES.BATCH_ERROR) { + logger.warn( + `Batch ${batchNumber + 1} failed, continuing:`, + { + error: error.message, + details: error.details, + }, + ); + counts.failed += categories.length; + return { + failed: true, + batchNumber: batchNumber + 1, + error: error.message, + }; + } else { + throw error; + } + }); + } + return Promise.resolve(); + }); + }); + await Promise.all(pendingBatches); + timings.sample("rendered-categories"); + + return timings.measures; + }), + ); + + await adminApi.stopProcessing(); + + // Aggregate timings + for (const measure of results) { + for (const [name, value] of Object.entries(measure)) { + if (!timings.measures[name]) timings.measures[name] = []; + if (!Array.isArray(timings.measures[name])) + timings.measures[name] = [timings.measures[name]]; + timings.measures[name].push(value); + } + } + for (const [name, values] of Object.entries(timings.measures)) { + timings.measures[name] = aggregate(values); + } + timings.measures.previewDuration = aggregate(adminApi.previewDurations); + } catch (e) { + logger.error("Error during PLP poll processing:", { + message: e.message, + code: e.code, + stack: e.stack, + }); + await adminApi.stopProcessing(); + stateText = "failure"; + + if (e.isJobFailed) { + throw e; + } + + throw new JobFailedError( + `PLP poll processing failed: ${e.message}`, + e.code || ERROR_CODES.PROCESSING_ERROR, + e.statusCode || 500, + { originalError: e.message }, + ); + } + + // get memory usage + const memoryData = process.memoryUsage(); + const memoryUsage = { + rss: `${formatMemoryUsage(memoryData.rss)}`, + heapTotal: `${formatMemoryUsage(memoryData.heapTotal)}`, + heapUsed: `${formatMemoryUsage(memoryData.heapUsed)}`, + external: `${formatMemoryUsage(memoryData.external)}`, + }; + logger.info(`Memory usage: ${JSON.stringify(memoryUsage)}`); + + const elapsed = new Date() - timings.now; + logger.info(`Finished PLP polling, elapsed: ${elapsed}ms`); + + return { + state: stateText, + elapsed, + status: { ...counts }, + timings: timings.measures, + memoryUsage, + }; + } catch (error) { + logger.error("PLP poll failed with error:", { + message: error.message, + code: error.code, + stack: error.stack, + }); + + if (error.isJobFailed) { + throw error; + } + + throw new JobFailedError( + `PLP poll operation failed: ${error.message}`, + error.code || ERROR_CODES.PROCESSING_ERROR, + error.statusCode || 500, + { originalError: error.message }, + ); + } +} + +module.exports = { + poll, + loadState, + saveState, + getFileLocation, +}; diff --git a/actions/utils.js b/actions/utils.js index 7586ff32..c54d38bf 100644 --- a/actions/utils.js +++ b/actions/utils.js @@ -22,6 +22,7 @@ const SITE_TYPES = Object.freeze({ /* This file exposes some common utilities for your actions */ const FILE_PREFIX = 'check-product-changes'; +const PLP_FILE_PREFIX = 'render-all-categories'; const [STATE_FILE_EXT, PDP_FILE_EXT] = ['csv', 'html']; /** @@ -507,6 +508,36 @@ function getProductUrl(product, context, addStore = true) { * @param {object} params The parameters object. * @returns {string} The default store URL. */ +/** + * Constructs the URL path for a category page. + * + * For ACO, the category slug IS the URL path (e.g. "electronics/computers-tablets"), + * so no configurable path format is needed (unlike PDP's PRODUCT_PAGE_URL_FORMAT). + * If a configurable PLP URL format is needed in the future, a CATEGORY_PAGE_URL_FORMAT + * env variable can be added following the same pattern as getProductUrl. + * + * @param {string} categorySlug - The category slug (e.g. "electronics/computers-tablets"). + * @param {Object} context - The context object containing locale and storeUrl. + * @param {boolean} [addStore=true] - Whether to prepend the store URL. + * @returns {string} The category URL path. + */ +function getCategoryUrl(categorySlug, context, addStore = true) { + const { storeUrl } = context; + const segments = []; + + if (context.locale) { + segments.push(context.locale); + } + segments.push(categorySlug); + + const path = helixSharedStringLib.sanitizePath(`/${segments.join('/')}`); + + if (addStore && storeUrl) { + return `${storeUrl}${path}`; + } + return path; +} + function getDefaultStoreURL(params) { const { ORG: orgName, @@ -536,12 +567,14 @@ module.exports = { requestSpreadsheet, isValidUrl, getProductUrl, + getCategoryUrl, getDefaultStoreURL, formatMemoryUsage, requestPublishedProductsIndex, getSiteType, SITE_TYPES, FILE_PREFIX, + PLP_FILE_PREFIX, PDP_FILE_EXT, STATE_FILE_EXT, } diff --git a/app.config.yaml b/app.config.yaml index 495756c1..cece7ab9 100644 --- a/app.config.yaml +++ b/app.config.yaml @@ -17,6 +17,7 @@ application: LOCALES: "${LOCALES}" SITE_TOKEN: "${SITE_TOKEN}" ACO_CATEGORY_FAMILIES: "${ACO_CATEGORY_FAMILIES}" + PLP_PRODUCTS_PER_PAGE: "${PLP_PRODUCTS_PER_PAGE}" actions: pdp-renderer: function: "actions/pdp-renderer/index.js" @@ -59,6 +60,20 @@ application: AEM_ADMIN_API_AUTH_TOKEN: "${AEM_ADMIN_API_AUTH_TOKEN}" annotations: final: true + render-all-categories: + function: "actions/render-all-categories/index.js" + web: "no" + runtime: "nodejs:22" + include: + - - "actions/plp-renderer/templates/*.hbs" + - "templates/" + limits: + memorySize: 256 + timeout: 3600000 + inputs: + AEM_ADMIN_API_AUTH_TOKEN: "${AEM_ADMIN_API_AUTH_TOKEN}" + annotations: + final: true # triggers: # productPollerTrigger: # feed: "/whisk.system/alarms/interval" @@ -82,3 +97,11 @@ application: # markUpCleanUpRule: # trigger: "markUpCleanUpTrigger" # action: "mark-up-clean-up" +# renderAllCategoriesTrigger: +# feed: "/whisk.system/alarms/interval" +# inputs: +# minutes: 15 +# rules: +# renderAllCategoriesRule: +# trigger: "renderAllCategoriesTrigger" +# action: "render-all-categories" diff --git a/test/render-all-categories.test.js b/test/render-all-categories.test.js new file mode 100644 index 00000000..a3fc2405 --- /dev/null +++ b/test/render-all-categories.test.js @@ -0,0 +1,369 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +const { loadState, saveState, getFileLocation } = require('../actions/render-all-categories/poller'); +const { generatePlpLdJson } = require('../actions/plp-renderer/ldJson'); +const { buildBreadcrumbs, getCategorySlugsFromFamilies, getCategoryDataFromFamilies } = require('../actions/categories'); +const { getCategoryUrl } = require('../actions/utils'); +const Files = require('./__mocks__/files'); +const { useMockServer } = require('./mock-server'); +const { http, HttpResponse } = require('msw'); + +// ─── loadState / saveState ────────────────────────────────────────────────── + +describe('PLP poller state', () => { + let filesLib; + + beforeEach(() => { + filesLib = new Files(0); + }); + + test('loadState returns empty categories when no state file exists', async () => { + const state = await loadState('en', { filesLib }); + expect(state).toEqual({ locale: 'en', categories: {} }); + }); + + test('loadState parses CSV state correctly', async () => { + const csv = 'electronics,1000,abc123\nelectronics/laptops,2000,def456'; + await filesLib.write('render-all-categories/en.csv', csv); + + const state = await loadState('en', { filesLib }); + expect(state.locale).toBe('en'); + expect(state.categories['electronics']).toEqual({ + lastRenderedAt: new Date(1000), + hash: 'abc123', + }); + expect(state.categories['electronics/laptops']).toEqual({ + lastRenderedAt: new Date(2000), + hash: 'def456', + }); + }); + + test('loadState uses "default" when locale is null', async () => { + const csv = 'electronics,1000,abc123'; + await filesLib.write('render-all-categories/default.csv', csv); + + const state = await loadState(null, { filesLib }); + expect(state.locale).toBe(null); + expect(state.categories['electronics']).toBeDefined(); + }); + + test('saveState writes CSV in expected format', async () => { + const state = { + locale: 'en', + categories: { + 'electronics': { lastRenderedAt: new Date(1000), hash: 'abc123' }, + 'electronics/laptops': { lastRenderedAt: new Date(2000), hash: 'def456' }, + }, + }; + + await saveState(state, { filesLib }); + + const written = await filesLib.read('render-all-categories/en.csv'); + const lines = written.split('\n'); + expect(lines).toHaveLength(2); + expect(lines).toContain('electronics,1000,abc123'); + expect(lines).toContain('electronics/laptops,2000,def456'); + }); + + test('saveState filters entries without lastRenderedAt', async () => { + const state = { + locale: 'en', + categories: { + 'electronics': { lastRenderedAt: new Date(1000), hash: 'abc123' }, + 'stale-slug': { lastRenderedAt: null, hash: 'xyz' }, + }, + }; + + await saveState(state, { filesLib }); + + const written = await filesLib.read('render-all-categories/en.csv'); + expect(written).toBe('electronics,1000,abc123'); + }); + + test('saveState round-trips with loadState', async () => { + const original = { + locale: 'de', + categories: { + 'kleidung': { lastRenderedAt: new Date(5000), hash: 'hash1' }, + 'kleidung/schuhe': { lastRenderedAt: new Date(6000), hash: 'hash2' }, + }, + }; + + await saveState(original, { filesLib }); + const loaded = await loadState('de', { filesLib }); + + expect(loaded.categories['kleidung'].hash).toBe('hash1'); + expect(loaded.categories['kleidung'].lastRenderedAt).toEqual(new Date(5000)); + expect(loaded.categories['kleidung/schuhe'].hash).toBe('hash2'); + }); +}); + +// ─── getFileLocation ──────────────────────────────────────────────────────── + +describe('getFileLocation', () => { + test('constructs path with prefix', () => { + expect(getFileLocation('en', 'csv')).toBe('render-all-categories/en.csv'); + }); + + test('constructs path for json', () => { + expect(getFileLocation('en-categories', 'json')).toBe('render-all-categories/en-categories.json'); + }); +}); + +// ─── generatePlpLdJson ────────────────────────────────────────────────────── + +describe('generatePlpLdJson', () => { + const context = { + storeUrl: 'https://example.com', + pathFormat: '/products/{urlKey}/{sku}', + locale: 'en', + }; + + const categoryData = { + name: 'Laptops', + slug: 'electronics/laptops', + }; + + const products = [ + { + name: 'MacBook Pro', + sku: 'mbp-16', + urlKey: 'macbook-pro', + images: [{ url: 'https://img.com/mbp.jpg', roles: ['image'], label: 'MacBook' }], + }, + { + name: 'ThinkPad X1', + sku: 'tp-x1', + urlKey: 'thinkpad-x1', + images: [{ url: 'https://img.com/tp.jpg', roles: ['image'], label: 'ThinkPad' }], + }, + ]; + + const breadcrumbs = [ + { name: 'Electronics', slug: 'electronics' }, + { name: 'Laptops', slug: 'electronics/laptops' }, + ]; + + test('generates ItemList JSON-LD with correct structure', () => { + const { itemListLdJson } = generatePlpLdJson(categoryData, products, breadcrumbs, context); + const parsed = JSON.parse(itemListLdJson); + + expect(parsed['@context']).toBe('https://schema.org'); + expect(parsed['@type']).toBe('ItemList'); + expect(parsed.name).toBe('Laptops'); + expect(parsed.numberOfItems).toBe(2); + expect(parsed.itemListElement).toHaveLength(2); + }); + + test('ItemList items have correct position, name, url, image', () => { + const { itemListLdJson } = generatePlpLdJson(categoryData, products, breadcrumbs, context); + const parsed = JSON.parse(itemListLdJson); + const first = parsed.itemListElement[0]; + + expect(first['@type']).toBe('ListItem'); + expect(first.position).toBe(1); + expect(first.name).toBe('MacBook Pro'); + expect(first.url).toBe('https://example.com/products/macbook-pro/mbp-16'); + expect(first.image).toBe('https://img.com/mbp.jpg'); + }); + + test('ItemList omits image key when product has no images', () => { + const noImageProducts = [{ name: 'No Image Product', sku: 'nip', urlKey: 'nip', images: [] }]; + const { itemListLdJson } = generatePlpLdJson(categoryData, noImageProducts, breadcrumbs, context); + const parsed = JSON.parse(itemListLdJson); + + expect(parsed.itemListElement[0]).not.toHaveProperty('image'); + }); + + test('generates BreadcrumbList JSON-LD with correct structure', () => { + const { breadcrumbLdJson } = generatePlpLdJson(categoryData, products, breadcrumbs, context); + const parsed = JSON.parse(breadcrumbLdJson); + + expect(parsed['@context']).toBe('https://schema.org'); + expect(parsed['@type']).toBe('BreadcrumbList'); + expect(parsed.itemListElement).toHaveLength(2); + + const first = parsed.itemListElement[0]; + expect(first['@type']).toBe('ListItem'); + expect(first.position).toBe(1); + expect(first.name).toBe('Electronics'); + expect(first.item).toBe('https://example.com/en/electronics'); + }); + + test('handles empty product list', () => { + const { itemListLdJson } = generatePlpLdJson(categoryData, [], breadcrumbs, context); + const parsed = JSON.parse(itemListLdJson); + + expect(parsed.numberOfItems).toBe(0); + expect(parsed.itemListElement).toHaveLength(0); + }); +}); + +// ─── buildBreadcrumbs ─────────────────────────────────────────────────────── + +describe('buildBreadcrumbs', () => { + test('builds breadcrumbs from a top-level slug', () => { + const categoryMap = new Map([ + ['electronics', { name: 'Electronics', slug: 'electronics' }], + ]); + + const crumbs = buildBreadcrumbs('electronics', categoryMap); + expect(crumbs).toEqual([ + { name: 'Electronics', slug: 'electronics' }, + ]); + }); + + test('builds breadcrumbs from a nested slug', () => { + const categoryMap = new Map([ + ['electronics', { name: 'Electronics', slug: 'electronics' }], + ['electronics/computers', { name: 'Computers', slug: 'electronics/computers' }], + ['electronics/computers/laptops', { name: 'Laptops', slug: 'electronics/computers/laptops' }], + ]); + + const crumbs = buildBreadcrumbs('electronics/computers/laptops', categoryMap); + expect(crumbs).toHaveLength(3); + expect(crumbs[0]).toEqual({ name: 'Electronics', slug: 'electronics' }); + expect(crumbs[1]).toEqual({ name: 'Computers', slug: 'electronics/computers' }); + expect(crumbs[2]).toEqual({ name: 'Laptops', slug: 'electronics/computers/laptops' }); + }); + + test('falls back to humanized slug when category not in map', () => { + const categoryMap = new Map([ + ['electronics', { name: 'Electronics', slug: 'electronics' }], + ]); + + const crumbs = buildBreadcrumbs('electronics/computers-tablets', categoryMap); + expect(crumbs).toHaveLength(2); + expect(crumbs[0].name).toBe('Electronics'); + // Second segment is not in the map, so falls back to humanized slug segment + expect(crumbs[1].name).toBe('Computers Tablets'); + expect(crumbs[1].slug).toBe('electronics/computers-tablets'); + }); + + test('handles single-segment slug', () => { + const categoryMap = new Map(); + const crumbs = buildBreadcrumbs('clothing', categoryMap); + expect(crumbs).toHaveLength(1); + expect(crumbs[0]).toEqual({ name: 'Clothing', slug: 'clothing' }); + }); +}); + +// ─── getCategoryUrl ───────────────────────────────────────────────────────── + +describe('getCategoryUrl', () => { + test('builds full URL with store and locale', () => { + const context = { storeUrl: 'https://example.com', locale: 'en' }; + expect(getCategoryUrl('electronics/laptops', context)).toBe('https://example.com/en/electronics/laptops'); + }); + + test('builds path-only when addStore is false', () => { + const context = { storeUrl: 'https://example.com', locale: 'en' }; + expect(getCategoryUrl('electronics/laptops', context, false)).toBe('/en/electronics/laptops'); + }); + + test('builds URL without locale when locale is absent', () => { + const context = { storeUrl: 'https://example.com' }; + expect(getCategoryUrl('electronics', context)).toBe('https://example.com/electronics'); + }); + + test('builds path-only without locale', () => { + const context = { storeUrl: 'https://example.com' }; + expect(getCategoryUrl('electronics', context, false)).toBe('/electronics'); + }); +}); + +// ─── getCategoryDataFromFamilies / getCategorySlugsFromFamilies ───────────── + +describe('category tree fetching', () => { + const server = useMockServer(); + + const mockContext = { + storeUrl: 'https://store.com', + config: { + 'commerce-endpoint': 'https://commerce.com/graphql', + 'commerce.headers.cs.x-api-key': 'test-key', + 'commerce.headers.cs.Magento-Environment-Id': 'test-env', + 'commerce.headers.cs.Magento-Customer-Group': 'test-group', + 'commerce.headers.cs.Magento-Store-Code': 'default', + 'commerce.headers.cs.Magento-Store-View-Code': 'default', + 'commerce.headers.cs.Magento-Website-Code': 'base', + __hasLegacyFormat: true, + }, + logger: { debug: jest.fn(), error: jest.fn() }, + }; + + test('getCategorySlugsFromFamilies returns flat array of slugs', async () => { + server.use( + http.post('https://commerce.com/graphql', async ({ request }) => { + const body = await request.json(); + if (body.operationName === 'getCategoryTree') { + return HttpResponse.json({ + data: { + categoryTree: [ + { slug: 'electronics', name: 'Electronics', level: 1, childrenSlugs: ['electronics/laptops'], metaTags: null, images: [] }, + ], + }, + }); + } + if (body.operationName === 'getCategoryTreeBySlugs') { + return HttpResponse.json({ + data: { + categoryTree: [ + { slug: 'electronics/laptops', name: 'Laptops', level: 2, parentSlug: 'electronics', childrenSlugs: [], metaTags: null, images: [] }, + ], + }, + }); + } + return HttpResponse.json({ data: {} }); + }), + ); + + const slugs = await getCategorySlugsFromFamilies(mockContext, ['electronics']); + expect(slugs).toEqual(expect.arrayContaining(['electronics', 'electronics/laptops'])); + expect(slugs).toHaveLength(2); + }); + + test('getCategoryDataFromFamilies returns Map with full metadata', async () => { + server.use( + http.post('https://commerce.com/graphql', async ({ request }) => { + const body = await request.json(); + if (body.operationName === 'getCategoryTree') { + return HttpResponse.json({ + data: { + categoryTree: [ + { + slug: 'electronics', + name: 'Electronics', + level: 1, + childrenSlugs: [], + metaTags: { title: 'Electronics', description: 'All electronics', keywords: 'tech' }, + images: [{ url: 'https://img.com/elec.jpg', label: 'Electronics', roles: ['image'], customRoles: [] }], + }, + ], + }, + }); + } + return HttpResponse.json({ data: { categoryTree: [] } }); + }), + ); + + const categoryMap = await getCategoryDataFromFamilies(mockContext, ['electronics']); + expect(categoryMap).toBeInstanceOf(Map); + expect(categoryMap.has('electronics')).toBe(true); + + const data = categoryMap.get('electronics'); + expect(data.name).toBe('Electronics'); + expect(data.metaTags.title).toBe('Electronics'); + expect(data.images).toHaveLength(1); + }); +}); From ea255f6fe40ddce7f5e9bddb0a3f80b5c381abba Mon Sep 17 00:00:00 2001 From: rossbrandon Date: Mon, 16 Mar 2026 15:59:50 -0500 Subject: [PATCH 02/12] Improve PLP pre-rendering logic; refactor reusable functions; add Prettier; general cleanup --- .prettierrc | 7 + actions/categories.js | 44 +- actions/check-product-changes/poller.js | 485 +++++++------------- actions/common-renderer/lib.js | 113 ----- actions/mark-up-clean-up/index.js | 7 +- actions/pdp-renderer/index.js | 23 +- actions/pdp-renderer/ldJson.js | 56 +-- actions/pdp-renderer/lib.js | 114 ----- actions/pdp-renderer/render.js | 18 +- actions/plp-renderer/ldJson.js | 65 ++- actions/plp-renderer/render.js | 84 ++-- actions/plp-renderer/templates/plp-head.hbs | 3 +- actions/queries.js | 29 ++ actions/render-all-categories/index.js | 113 +++-- actions/render-all-categories/poller.js | 361 ++++++--------- actions/renderUtils.js | 463 +++++++++++++++++++ actions/utils.js | 176 +++---- package-lock.json | 18 +- package.json | 9 +- test/check-product-changes.test.js | 239 +++++----- test/lib.test.js | 2 +- test/render-all-categories.test.js | 271 ++++++++--- 22 files changed, 1470 insertions(+), 1230 deletions(-) create mode 100644 .prettierrc delete mode 100644 actions/common-renderer/lib.js delete mode 100644 actions/pdp-renderer/lib.js create mode 100644 actions/renderUtils.js diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..d16bc4c2 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "printWidth": 120, + "tabWidth": 2, + "semi": true +} diff --git a/actions/categories.js b/actions/categories.js index 099268f6..deeb3b1c 100644 --- a/actions/categories.js +++ b/actions/categories.js @@ -12,12 +12,8 @@ governing permissions and limitations under the License. */ -const { requestSaaS } = require("./utils"); -const { - CategoriesQuery, - CategoryTreeQuery, - CategoryTreeBySlugsQuery, -} = require("./queries"); +const { requestSaaS } = require('./utils'); +const { CategoriesQuery, CategoryTreeQuery, CategoryTreeBySlugsQuery } = require('./queries'); const MAX_TREE_DEPTH = 3; @@ -50,18 +46,13 @@ function hasFamilies(families) { * @returns {Promise>} Map of category slug to category metadata. */ async function fetchCategoryTree(context, families) { - console.debug("Getting category data from families:", families); + console.debug('Getting category data from families:', families); const categoryMap = new Map(); for (const family of families) { - console.debug("Getting category data from family:", family); + console.debug('Getting category data from family:', family); // Get root-level categories for this family - const firstLevel = await requestSaaS( - CategoryTreeQuery, - "getCategoryTree", - { family }, - context, - ); + const firstLevel = await requestSaaS(CategoryTreeQuery, 'getCategoryTree', { family }, context); let pending = []; for (const cat of firstLevel.data.categoryTree) { @@ -78,7 +69,7 @@ async function fetchCategoryTree(context, families) { const childrenRes = await requestSaaS( CategoryTreeBySlugsQuery, - "getCategoryTreeBySlugs", + 'getCategoryTreeBySlugs', { family, slugs: pending, depth: MAX_TREE_DEPTH }, context, ); @@ -97,7 +88,7 @@ async function fetchCategoryTree(context, families) { } } } - console.debug("Category slugs resolved:", [...categoryMap.keys()]); + console.debug('Category slugs resolved:', [...categoryMap.keys()]); return categoryMap; } @@ -130,15 +121,13 @@ async function getCategoryDataFromFamilies(context, families) { } /** - * Last-resort fallback: converts a slug segment to a human-readable name - * when the category is not found in the map (e.g. if a childSlug was - * referenced but not returned by the API). + * Converts a slug segment to a category name if a name is not provided. * E.g. "computers-tablets" → "Computers Tablets" * * Callers should always prefer category.name from the API response. */ -function humanizeSlugSegment(segment) { - return segment.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); +function getCategoryNameFromSlug(segment) { + return segment.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); } /** @@ -149,13 +138,13 @@ function humanizeSlugSegment(segment) { * @returns {Array<{name: string, slug: string}>} Breadcrumb entries. */ function buildBreadcrumbs(slug, categoryMap) { - const segments = slug.split("/"); + const segments = slug.split('/'); const breadcrumbs = []; for (let i = 0; i < segments.length; i++) { - const ancestorSlug = segments.slice(0, i + 1).join("/"); + const ancestorSlug = segments.slice(0, i + 1).join('/'); const category = categoryMap.get(ancestorSlug); - const name = category?.name || humanizeSlugSegment(segments[i]); + const name = category?.name || getCategoryNameFromSlug(segments[i]); breadcrumbs.push({ name, slug: ancestorSlug }); } @@ -173,12 +162,7 @@ function buildBreadcrumbs(slug, categoryMap) { * @returns {Promise} Sparse array where index N holds urlPath strings at level N. */ async function getCategories(context) { - const categoriesRes = await requestSaaS( - CategoriesQuery, - "getCategories", - {}, - context, - ); + const categoriesRes = await requestSaaS(CategoriesQuery, 'getCategories', {}, context); const byLevel = []; for (const { urlPath, level } of categoriesRes.data.categories) { const idx = parseInt(level); diff --git a/actions/check-product-changes/poller.js b/actions/check-product-changes/poller.js index a7ffb996..b95d7ca0 100644 --- a/actions/check-product-changes/poller.js +++ b/actions/check-product-changes/poller.js @@ -14,184 +14,46 @@ const { Timings, aggregate } = require('../lib/benchmark'); const { AdminAPI } = require('../lib/aem'); const { requestSaaS, - isValidUrl, getProductUrl, formatMemoryUsage, + createBatches, FILE_PREFIX, - STATE_FILE_EXT, - PDP_FILE_EXT, requestPublishedProductsIndex, } = require('../utils'); +const { + getHtmlFilePath, + getFileLocation, + loadState, + saveState, + shouldPreviewAndPublish, + processPublishedBatch, + validateRequiredParams, +} = require('../renderUtils'); const { GetLastModifiedQuery } = require('../queries'); const { generateProductHtml } = require('../pdp-renderer/render'); const { JobFailedError, ERROR_CODES } = require('../lib/errorHandler'); const crypto = require('crypto'); const BATCH_SIZE = 50; +const DATA_KEY = 'skus'; -function getFileLocation(stateKey, extension) { - return `${FILE_PREFIX}/${stateKey}.${extension}`; -} - -/** - * @typedef {Object} PollerState - * @property {string} locale - The locale (or store code). - * @property {Array} skus - The SKUs with last previewed timestamp and hash. - */ - -/** - * @typedef {import('@adobe/aio-sdk').Files.Files} FilesProvider - */ - -/** - * Saves the state to the cloud file system. - * - * @param {String} locale - The locale (or store code). - * @param {Object} aioLibs - The libraries required for loading the state. - * @param {Object} aioLibs.filesLib - The file library for reading state files. - * @param {Object} aioLibs.stateLib - The state library for retrieving state information. - * @returns {Promise} - A promise that resolves when the state is loaded, returning the state object. - */ -async function loadState(locale, aioLibs) { - const { filesLib } = aioLibs; - const stateObj = { locale }; - try { - const stateKey = locale || 'default'; - const fileLocation = getFileLocation(stateKey, STATE_FILE_EXT); - const buffer = await filesLib.read(fileLocation); - const stateData = buffer?.toString(); - if (stateData) { - const lines = stateData.split('\n'); - stateObj.skus = lines.reduce((acc, line) => { - // the format of the state object is: - // ,, - // ,, - // ... - // each row is a set of SKUs, last previewed timestamp and hash - const [sku, time, hash] = line.split(','); - acc[sku] = { lastRenderedAt: new Date(parseInt(time)), hash }; - return acc; - }, {}); - } else { - stateObj.skus = {}; - } - // eslint-disable-next-line no-unused-vars - } catch (e) { - stateObj.skus = {}; - } - return stateObj; -} - -/** - * Saves the state to the cloud file system. - * - * @param {PollerState} state - The object describing state and metadata. - * @param {Object} aioLibs - The libraries required for loading the state. - * @param {Object} aioLibs.filesLib - The file library for reading state files. - * @param {Object} aioLibs.stateLib - The state library for retrieving state information. - * @returns {Promise} - A promise that resolves when the state is saved. - */ -async function saveState(state, aioLibs) { - const { filesLib } = aioLibs; - let { locale } = state; - const stateKey = locale || 'default'; - const fileLocation = getFileLocation(stateKey, STATE_FILE_EXT); - const csvData = [ - ...Object.entries(state.skus) - // if lastRenderedAt is not set, skip the product - // this can happen i.e. if the product is not found - .filter(([, { lastRenderedAt }]) => Boolean(lastRenderedAt)) - .map(([sku, { lastRenderedAt, hash }]) => { - return `${sku},${lastRenderedAt.getTime()},${hash || ''}`; - }), - ].join('\n'); - return await filesLib.write(fileLocation, csvData); -} - -/** - * Deletes the state from the cloud file system. - * - * @param {String} locale - The key of the state to be deleted. - * @param {FilesProvider} filesLib - The Files library instance from '@adobe/aio-sdk'. - * @returns {Promise} - A promise that resolves when the state is deleted. - */ -async function deleteState(locale, filesLib) { - const stateKey = `${locale}`; - const fileLocation = getFileLocation(stateKey, STATE_FILE_EXT); - await filesLib.delete(fileLocation); -} - -/** - * Checks the Adobe Commerce store for product changes, performs - * preview/publish/delete operations if needed, then updates the state. - * - * Expected normalized params (camelCase): - * @param {Object} params - * @param {string} params.contentUrl - Base Edge Delivery URL (required). - * @param {string} params.configName - Store config name (required). - * @param {string} params.pathFormat - PDP URL pattern (required). - * @param {string} params.adminAuthToken - Admin API token (required). - * @param {string} [params.site] - Site name (optional; used to derive defaults if needed). - * @param {string} [params.org] - Org name (optional; used to derive defaults if needed). - * @param {string} [params.storeUrl] - Public store URL (defaults to contentUrl). - * @param {string} [params.productsTemplate] - Products template URL (defaults to `${contentUrl}/products/default`). - * @param {string[]} [params.locales] - Locales array, e.g., ['en','de'] (defaults to [null]). - * @param {string} [params.logLevel] - Log level (defaults to 'error'). - * @param {string} [params.logIngestorEndpoint]- Log ingestor endpoint. - */ function checkParams(params) { - const requiredParams = ['site', 'org', 'pathFormat', 'adminAuthToken', 'configName', 'contentUrl', 'storeUrl', 'productsTemplate']; - const missingParams = requiredParams.filter(param => !params[param]); - if (missingParams.length > 0) { - throw new JobFailedError( - `Missing required parameters: ${missingParams.join(', ')}`, - ERROR_CODES.VALIDATION_ERROR, - 400, - { missingParams } - ); - } - - if (params.storeUrl && !isValidUrl(params.storeUrl)) { - throw new JobFailedError( - 'Invalid storeUrl', - ERROR_CODES.VALIDATION_ERROR, - 400 - ); - } - - // Token validation is handled in getRuntimeConfig, no need to duplicate here -} - -/** - * Creates batches of products for processing - * @param products - * @param context - * @returns {*} - */ -function createBatches(products) { - return products.reduce((acc, product) => { - if (!acc.length || acc[acc.length - 1].length === BATCH_SIZE) { - acc.push([]); - } - acc[acc.length - 1].push(product); - return acc; - }, []); -} - -/** - * Checks if a product should be previweed & published - * - * @param product - * @returns {boolean} - */ -function shouldPreviewAndPublish({ currentHash, newHash }) { - return newHash && currentHash !== newHash; + validateRequiredParams(params, [ + 'site', + 'org', + 'pathFormat', + 'adminAuthToken', + 'configName', + 'contentUrl', + 'storeUrl', + 'productsTemplate', + ]); } /** * Checks if a product should be (re)rendered. - * - * @param {*} param0 - * @returns + * + * @param {*} param0 + * @returns */ function shouldRender({ urlKey, lastModifiedDate, lastRenderedDate }) { return urlKey?.match(/^[a-zA-Z0-9-]+$/) && lastModifiedDate >= lastRenderedDate; @@ -199,7 +61,7 @@ function shouldRender({ urlKey, lastModifiedDate, lastRenderedDate }) { /** * Enrich the product data with metadata from state and context. - * + * * @param {Object} product - The product to process * @param {Object} state - The current state * @param {Object} context - The context object with logger and other utilities @@ -224,9 +86,9 @@ function enrichProductWithMetadata(product, state, context) { /** * Generates the HTML for a product, saves it to the public storage and include the new hash in the product object. - * - * @param {*} param0 - * @returns + * + * @param {*} param0 + * @returns */ let renderLimit$; async function enrichProductWithRenderedHash(product, context) { @@ -247,7 +109,7 @@ async function enrichProductWithRenderedHash(product, context) { if (shouldPreviewAndPublish(product) && productHtml) { try { const { filesLib } = context.aioLibs; - const htmlPath = `/public/pdps${path}.${PDP_FILE_EXT}`; + const htmlPath = getHtmlFilePath(path); await filesLib.write(htmlPath, productHtml); logger.debug(`Saved HTML for product ${sku} to ${htmlPath}`); } catch (e) { @@ -264,26 +126,6 @@ async function enrichProductWithRenderedHash(product, context) { }); } -/** - * Processes publish batches and updates state - */ -async function processPublishedBatch(publishedBatch, state, counts, products, aioLibs) { - const { records } = publishedBatch; - records.map((record) => { - if (record.previewedAt && record.publishedAt) { - const product = products.find(p => p.sku === record.sku); - state.skus[record.sku] = { - lastRenderedAt: record.renderedAt, - hash: product?.newHash - }; - counts.published++; - } else { - counts.failed++; - } - }); - await saveState(state, aioLibs); -} - /** * Identifies and processes products that need to be deleted */ @@ -293,8 +135,9 @@ async function processDeletedProducts(remainingSkus, state, context, adminApi) { const { filesLib } = aioLibs; try { - const deletedProducts = (await requestPublishedProductsIndex(context)) - .data.filter(({ sku }) => remainingSkus.includes(sku)); + const deletedProducts = (await requestPublishedProductsIndex(context)).data.filter(({ sku }) => + remainingSkus.includes(sku), + ); // Process in batches if (deletedProducts.length) { @@ -303,30 +146,29 @@ async function processDeletedProducts(remainingSkus, state, context, adminApi) { const pendingBatches = []; for (let batchNumber = 0; batchNumber < batches.length; batchNumber++) { const records = batches[batchNumber]; - const pendingBatch = adminApi.unpublishAndDelete(records, locale, batchNumber + 1) - .then(({ records }) => { - records.forEach((record) => { - if (record.liveUnpublishedAt && record.previewUnpublishedAt) { - // Delete the HTML file from public storage - try { - const htmlPath = `/public/pdps${record.path}.${PDP_FILE_EXT}`; - filesLib.delete(htmlPath); - logger.debug(`Deleted HTML file for product ${record.sku} from ${htmlPath}`); - } catch (e) { - logger.warn(`Error deleting HTML file for product ${record.sku}:`, e); - } - - delete state.skus[record.sku]; - counts.unpublished++; - } else { - counts.failed++; + const pendingBatch = adminApi.unpublishAndDelete(records, locale, batchNumber + 1).then(({ records }) => { + records.forEach((record) => { + if (record.liveUnpublishedAt && record.previewUnpublishedAt) { + // Delete the HTML file from public storage + try { + const htmlPath = getHtmlFilePath(record.path); + filesLib.delete(htmlPath); + logger.debug(`Deleted HTML file for product ${record.sku} from ${htmlPath}`); + } catch (e) { + logger.warn(`Error deleting HTML file for product ${record.sku}:`, e); } - }); + + delete state.skus[record.sku]; + counts.unpublished++; + } else { + counts.failed++; + } }); + }); pendingBatches.push(pendingBatch); } await Promise.all(pendingBatches); - await saveState(state, aioLibs); + await saveState(state, aioLibs, FILE_PREFIX, DATA_KEY); } } catch (e) { logger.error('Error processing deleted products:', e); @@ -334,10 +176,10 @@ async function processDeletedProducts(remainingSkus, state, context, adminApi) { } /** - * Filters the given products based on the given condition, increments the ignored count if the + * Filters the given products based on the given condition, increments the ignored count if the * condition is not met and removes the sku from the given list of remaining skus. * Returns an object with included and ignored product lists. - * + * * @param {*} condition - the condition to filter the products by * @param {*} products - the products to filter * @param {*} remainingSkus - the list of remaining, known skus the filter logic will splice for every given product @@ -381,31 +223,40 @@ async function getLastModifiedDates(skus, context) { } return (await getLastModifiedDatesLimit$)(async () => { - return requestSaaS(GetLastModifiedQuery, 'getLastModified', { skus }, context) - .then(resp => resp.data.products); + return requestSaaS(GetLastModifiedQuery, 'getLastModified', { skus }, context).then((resp) => resp.data.products); }); } async function poll(params, aioLibs, logger) { try { checkParams(params); - + const counts = { published: 0, unpublished: 0, ignored: 0, failed: 0 }; const { - org, site, pathFormat, - siteToken, configName, configSheet, + org, + site, + pathFormat, + siteToken, + configName, + configSheet, adminAuthToken, - productsTemplate, storeUrl, contentUrl, - logLevel, logIngestorEndpoint, - locales: rawLocales + productsTemplate, + storeUrl, + contentUrl, + logLevel, + logIngestorEndpoint, + locales: rawLocales, } = params; // Normalize locales: accept array or "en,fr" string; default to [null] const locales = Array.isArray(rawLocales) + ? rawLocales + : typeof rawLocales === 'string' && rawLocales.trim() ? rawLocales - : (typeof rawLocales === 'string' && rawLocales.trim() - ? rawLocales.split(',').map(s => s.trim()).filter(Boolean) - : [null]); + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + : [null]; const sharedContext = { siteToken, @@ -419,7 +270,7 @@ async function poll(params, aioLibs, logger) { productsTemplate, aioLibs, logLevel, - logIngestorEndpoint + logIngestorEndpoint, }; const timings = new Timings(); @@ -437,89 +288,103 @@ async function poll(params, aioLibs, logger) { // start processing preview and publish queues await adminApi.startProcessing(); - const results = await Promise.all(locales.map(async (locale) => { - const timings = new Timings(); - const context = { ...sharedContext, startTime: new Date() }; - if (locale) context.locale = locale; - - logger.info(`Polling for locale ${locale}`); + const results = await Promise.all( + locales.map(async (locale) => { + const timings = new Timings(); + const context = { ...sharedContext, startTime: new Date() }; + if (locale) context.locale = locale; - // load state - const state = await loadState(locale, aioLibs); + logger.info(`Polling for locale ${locale}`); - // add newly discovered produts to the state if necessary - const productsFileName = getFileLocation(`${locale || 'default'}-products`, 'json'); - JSON.parse((await filesLib.read(productsFileName)).toString()).forEach(({ sku }) => { - if (!state.skus[sku]) { - state.skus[sku] = { lastRenderedAt: new Date(0), hash: null }; - } - }); - timings.sample('get-discovered-products'); - - // get last modified dates, filter out products that don't need to be (re)rendered - const knownSkus = Object.keys(state.skus); - let products = await getLastModifiedDates(knownSkus, context); - logger.info(`Fetched last modified date for ${products.length} skus, total ${knownSkus.length}`); - products = products.map(product => enrichProductWithMetadata(product, state, context)); - ({ included: products } = filterProducts(shouldRender, products, knownSkus, context)); - timings.sample('get-changed-products'); - - // create batches of products to preview and publish - const pendingBatches = createBatches(products).map((batch, batchNumber) => { - return Promise.all(batch.map(product => enrichProductWithRenderedHash(product, context))) - .then(async (enrichedProducts) => { - const { included: productsToPublish, ignored: productsToIgnore } = filterProducts(shouldPreviewAndPublish, enrichedProducts, knownSkus, context); - - // update the lastRenderedAt for the products to ignore anyway, to avoid re-rendering them everytime after - // the lastModifiedAt changed once - if (productsToIgnore.length) { - productsToIgnore.forEach(product => { - state.skus[product.sku].lastRenderedAt = product.renderedAt; - }); - await saveState(state, aioLibs); - } + // load state + const state = await loadState(locale, aioLibs, FILE_PREFIX, DATA_KEY); - return productsToPublish; - }) - .then(products => { - if (products.length) { - const records = products.map(({ sku, path, renderedAt }) => (({ sku, path, renderedAt }))); - return adminApi.previewAndPublish(records, locale, batchNumber + 1) - .then(publishedBatch => processPublishedBatch(publishedBatch, state, counts, products, aioLibs)) - .catch(error => { - // Handle batch errors gracefully - don't fail the entire job - if (error.code === ERROR_CODES.BATCH_ERROR) { - logger.warn(`Batch ${batchNumber + 1} failed, continuing with other batches:`, { - error: error.message, - details: error.details - }); - // Update counts to reflect failed batch - counts.failed += products.length; - return { failed: true, batchNumber: batchNumber + 1, error: error.message }; - } else { - // Re-throw global errors - throw error; - } + // add newly discovered produts to the state if necessary + const productsFileName = getFileLocation(FILE_PREFIX, `${locale || 'default'}-products`, 'json'); + JSON.parse((await filesLib.read(productsFileName)).toString()).forEach(({ sku }) => { + if (!state.skus[sku]) { + state.skus[sku] = { lastRenderedAt: new Date(0), hash: null }; + } + }); + timings.sample('get-discovered-products'); + + // get last modified dates, filter out products that don't need to be (re)rendered + const knownSkus = Object.keys(state.skus); + let products = await getLastModifiedDates(knownSkus, context); + logger.info(`Fetched last modified date for ${products.length} skus, total ${knownSkus.length}`); + products = products.map((product) => enrichProductWithMetadata(product, state, context)); + ({ included: products } = filterProducts(shouldRender, products, knownSkus, context)); + timings.sample('get-changed-products'); + + // create batches of products to preview and publish + const pendingBatches = createBatches(products).map((batch, batchNumber) => { + return Promise.all(batch.map((product) => enrichProductWithRenderedHash(product, context))) + .then(async (enrichedProducts) => { + const { included: productsToPublish, ignored: productsToIgnore } = filterProducts( + shouldPreviewAndPublish, + enrichedProducts, + knownSkus, + context, + ); + + // update the lastRenderedAt for the products to ignore anyway, to avoid re-rendering them everytime after + // the lastModifiedAt changed once + if (productsToIgnore.length) { + productsToIgnore.forEach((product) => { + state.skus[product.sku].lastRenderedAt = product.renderedAt; }); - } else { - return Promise.resolve(); - } - }); - }); - products = null; - await Promise.all(pendingBatches); - timings.sample('published-products'); - - // if there are still knownSkus left, they were not in Catalog Service anymore and may have been disabled/deleted - if (knownSkus.length) { - await processDeletedProducts(knownSkus, state, context, adminApi); - timings.sample('unpublished-products'); - } else { - timings.sample('unpublished-products', 0); - } + await saveState(state, aioLibs, FILE_PREFIX, DATA_KEY); + } + + return productsToPublish; + }) + .then((products) => { + if (products.length) { + const records = products.map(({ sku, path, renderedAt }) => ({ sku, path, renderedAt })); + return adminApi + .previewAndPublish(records, locale, batchNumber + 1) + .then((publishedBatch) => + processPublishedBatch(publishedBatch, state, counts, products, aioLibs, { + dataKey: DATA_KEY, + keyField: 'sku', + filePrefix: FILE_PREFIX, + }), + ) + .catch((error) => { + // Handle batch errors gracefully - don't fail the entire job + if (error.code === ERROR_CODES.BATCH_ERROR) { + logger.warn(`Batch ${batchNumber + 1} failed, continuing with other batches:`, { + error: error.message, + details: error.details, + }); + // Update counts to reflect failed batch + counts.failed += products.length; + return { failed: true, batchNumber: batchNumber + 1, error: error.message }; + } else { + // Re-throw global errors + throw error; + } + }); + } else { + return Promise.resolve(); + } + }); + }); + products = null; + await Promise.all(pendingBatches); + timings.sample('published-products'); + + // if there are still knownSkus left, they were not in Catalog Service anymore and may have been disabled/deleted + if (knownSkus.length) { + await processDeletedProducts(knownSkus, state, context, adminApi); + timings.sample('unpublished-products'); + } else { + timings.sample('unpublished-products', 0); + } - return timings.measures; - })); + return timings.measures; + }), + ); await adminApi.stopProcessing(); @@ -539,23 +404,23 @@ async function poll(params, aioLibs, logger) { logger.error('Error during poll processing:', { message: e.message, code: e.code, - stack: e.stack + stack: e.stack, }); // wait for queues to finish, even in error case await adminApi.stopProcessing(); stateText = 'failure'; - + // If it's a JobFailedError, re-throw it if (e.isJobFailed) { throw e; } - + // For other errors, wrap them as JobFailedError throw new JobFailedError( `Poll processing failed: ${e.message}`, e.code || ERROR_CODES.PROCESSING_ERROR, e.statusCode || 500, - { originalError: e.message } + { originalError: e.message }, ); } @@ -584,7 +449,7 @@ async function poll(params, aioLibs, logger) { logger.error('Poll failed with error:', { message: error.message, code: error.code, - stack: error.stack + stack: error.stack, }); // If it's a JobFailedError, re-throw it @@ -597,15 +462,9 @@ async function poll(params, aioLibs, logger) { `Poll operation failed: ${error.message}`, error.code || ERROR_CODES.PROCESSING_ERROR, error.statusCode || 500, - { originalError: error.message } + { originalError: error.message }, ); } } -module.exports = { - poll, - deleteState, - loadState, - saveState, - getFileLocation, -}; \ No newline at end of file +module.exports = { poll }; diff --git a/actions/common-renderer/lib.js b/actions/common-renderer/lib.js deleted file mode 100644 index 211e4044..00000000 --- a/actions/common-renderer/lib.js +++ /dev/null @@ -1,113 +0,0 @@ -/* -Copyright 2026 Adobe. All rights reserved. -This file is licensed to you under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. You may obtain a copy -of the License at http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software distributed under -the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS -OF ANY KIND, either express or implied. See the License for the specific language -governing permissions and limitations under the License. -*/ - -const striptags = require('striptags'); -const cheerio = require('cheerio'); - -/** - * Extracts details from the path based on the provided format. - * @param {string} path The path. - * @param {string} format The format to extract details from the path. - * @returns {Object} An object containing the extracted details. - * @throws Throws an error if the path is invalid. - */ -function extractPathDetails(path, format) { - if (!path) { - return {}; - } - - const formatParts = format.split('/').filter(Boolean); - const pathParts = path.split('/').filter(Boolean); - - if (formatParts.length !== pathParts.length) { - throw new Error(`Invalid path. Expected '${format}' format.`); - } - - const result = {}; - formatParts.forEach((part, index) => { - if (part.startsWith('{') && part.endsWith('}')) { - const key = part.substring(1, part.length - 1); - result[key] = pathParts[index]; - } else if (part !== pathParts[index]) { - throw new Error(`Invalid path. Expected '${format}' format.`); - } - }); - - return result; -} - -/** - * Returns the base template for a page. It loads an Edge Delivery page and replaces - * specified blocks with Handlebars partials. - * - * @param {string} url The URL to fetch the base template HTML from. - * @param {Array} blocks The list of block class names to replace with Handlebars partials. - * @param {Object} context The context object. - * @returns {Promise} The adapted base template HTML as a string. - */ -async function prepareBaseTemplate(url, blocks, context) { - if(context.locale && context.locale !== 'default') { - url = url.replace(/\s+/g, '').replace(/\/$/, '').replace('{locale}', context.locale); - } - - const { siteToken } = context; - - let options = undefined; - - // Site Validation: needs to be a non empty string - if (typeof siteToken === 'string' && siteToken.trim()) { - options = {headers:{'authorization': `token ${siteToken}`}} - } - - const baseTemplateHtml = await fetch(`${url}.plain.html`, {...options}).then(resp => resp.text()); - - const $ = cheerio.load(`
${baseTemplateHtml}
`); - - blocks.forEach(block => { - $(`.${block}`).replaceWith(`{{> ${block} }}`); - }); - - let adaptedBaseTemplate = $('main').prop('innerHTML'); - adaptedBaseTemplate = adaptedBaseTemplate.replace(/>/g, '>') + '\n'; - - return adaptedBaseTemplate; -} - - /** - * Sanitizes HTML content by removing disallowed or unbalanced tags. - * Supports three modes: 'all', 'inline', 'no'. - * 'all': allows all block and inline tags supported by edge delivery. - * 'inline': allows all inline tags supported by edge delivery. - * 'no': allows no tags - * - * @param {string} html - HTML string to sanitize - * @param {string} [mode='all'] - Sanitization mode - * @returns {string} Sanitized HTML string - */ -function sanitize(html, mode = 'all') { - const allowedInlineTags = [ 'a', 'br', 'code', 'del', 'em', 'img', 'strong', 'sub', 'sup', 'u' ]; - const allowedAllTags = [ - 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'pre', - ...allowedInlineTags, - 'table', 'tbody', 'td', 'th', 'thead', 'tr', - ]; - - if (mode === 'all') { - return striptags(html, allowedAllTags); - } else if (mode === 'inline') { - return striptags(html, allowedInlineTags); - } else if (mode === 'no') { - return striptags(html); - } -} - -module.exports = { extractPathDetails, prepareBaseTemplate, sanitize }; diff --git a/actions/mark-up-clean-up/index.js b/actions/mark-up-clean-up/index.js index b4dd721f..ec91bc6d 100644 --- a/actions/mark-up-clean-up/index.js +++ b/actions/mark-up-clean-up/index.js @@ -18,9 +18,9 @@ const { AdminAPI } = require('../lib/aem'); const { requestSaaS, requestPublishedProductsIndex, - PDP_FILE_EXT, createBatches, } = require('../utils'); +const { getHtmlFilePath } = require('../renderUtils'); /** * helper function for markUpCleanUP() below @@ -74,9 +74,10 @@ async function markUpCleanUP(context, filesLib, logger, adminApi) { for (const product of redundantpublishedProducts) { try { - const result = await filesLib.delete(`/public/pdps${product.path}.${PDP_FILE_EXT}`); + const htmlPath = getHtmlFilePath(product.path); + const result = await filesLib.delete(htmlPath); if (result.length > 0) { - logger.info(`Deleted redundant markup at ${product.path}.${PDP_FILE_EXT} for product ${product.sku}`); + logger.info(`Deleted redundant markup at ${htmlPath} for product ${product.sku}`); context.counts.deleted++; } } catch (e) { diff --git a/actions/pdp-renderer/index.js b/actions/pdp-renderer/index.js index 30cd5d03..4a654cf7 100644 --- a/actions/pdp-renderer/index.js +++ b/actions/pdp-renderer/index.js @@ -10,9 +10,9 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ -const { Core } = require('@adobe/aio-sdk') +const { Core } = require('@adobe/aio-sdk'); const { errorResponse } = require('../utils'); -const { extractPathDetails } = require('./lib'); +const { extractPathDetails } = require('../renderUtils'); const { generateProductHtml } = require('./render'); const { getRuntimeConfig } = require('../lib/runtimeConfig'); const { JobFailedError, ERROR_CODES } = require('../lib/errorHandler'); @@ -31,14 +31,14 @@ const { JobFailedError, ERROR_CODES } = require('../lib/errorHandler'); * @param {string} params.PRODUCTS_TEMPLATE URL to the products template page * @param {string} params.PRODUCT_PAGE_URL_FORMAT The path format to use for parsing */ -async function main (params) { +async function main(params) { const cfg = getRuntimeConfig(params); const logger = Core.Logger('main', { level: cfg.logLevel }); try { let { sku, urlKey, locale } = params; - const { __ow_path } = params; - + const { __ow_path } = params; + if (!sku && !urlKey) { // try to extract sku and urlKey from path const result = extractPathDetails(__ow_path, cfg.pathFormat); @@ -52,12 +52,12 @@ async function main (params) { throw new JobFailedError( 'Missing required parameters: sku or urlKey must be provided', ERROR_CODES.VALIDATION_ERROR, - 400 + 400, ); } const context = { ...cfg, logger }; - + if (locale) { context.locale = locale; } @@ -68,12 +68,11 @@ async function main (params) { const response = { statusCode: 200, body: productHtml, - } - logger.info(`${response.statusCode}: successful request`) + }; + logger.info(`${response.statusCode}: successful request`); return response; - } catch (error) { - logger.error(error) + logger.error(error); // Return appropriate status code if specified if (error.statusCode) { return errorResponse(error.statusCode, error.message, logger); @@ -82,4 +81,4 @@ async function main (params) { } } -exports.main = main +exports.main = main; diff --git a/actions/pdp-renderer/ldJson.js b/actions/pdp-renderer/ldJson.js index 31701d31..3bd5769a 100644 --- a/actions/pdp-renderer/ldJson.js +++ b/actions/pdp-renderer/ldJson.js @@ -1,5 +1,5 @@ const { requestSaaS, getProductUrl } = require('../utils'); -const { findDescription, getPrimaryImage } = require('./lib'); +const { findDescription, getPrimaryImage, getGTIN } = require('../renderUtils'); const { VariantsQuery } = require('../queries'); function lowercaseUrlPath(url) { @@ -11,8 +11,10 @@ function lowercaseUrlPath(url) { function getOffer(product, url) { const { sku, inStock, price } = product; - const finalPriceCurrency = (price?.final?.amount?.currency || 'NONE') === 'NONE' ? 'USD' : price?.final?.amount?.currency; - const regularPriceCurrency = (price?.regular?.amount?.currency || 'NONE') === 'NONE' ? 'USD' : price?.regular?.amount?.currency; + const finalPriceCurrency = + (price?.final?.amount?.currency || 'NONE') === 'NONE' ? 'USD' : price?.final?.amount?.currency; + const regularPriceCurrency = + (price?.regular?.amount?.currency || 'NONE') === 'NONE' ? 'USD' : price?.regular?.amount?.currency; const offer = { '@type': 'Offer', @@ -40,8 +42,10 @@ async function getVariants(baseProduct, url, axes, context) { const { logger } = context; // For bundle products, extract variants from options instead of using VariantsQuery // Bundle products have 'product' data in their option values, configurable products don't - if (baseProduct.__typename === 'ComplexProductView' && - baseProduct.options?.some(option => option.values?.some(value => value.product))) { + if ( + baseProduct.__typename === 'ComplexProductView' && + baseProduct.options?.some((option) => option.values?.some((value) => value.product)) + ) { return getBundleVariants(baseProduct, url); } @@ -49,9 +53,12 @@ async function getVariants(baseProduct, url, axes, context) { const variantsData = await requestSaaS(VariantsQuery, 'VariantsQuery', { sku: baseProduct.sku }, context); const variants = variantsData.data.variants.variants; - return variants.map(variant => { + return variants.map((variant) => { if (!variant.product) { - logger.error(`Variant of product ${baseProduct?.sku} is null. Variant data is not correctly synchronized.`, variant); + logger.error( + `Variant of product ${baseProduct?.sku} is null. Variant data is not correctly synchronized.`, + variant, + ); throw new Error('Product variant is null'); } @@ -64,14 +71,16 @@ async function getVariants(baseProduct, url, axes, context) { sku: variant.product.sku, name: variant.product.name, gtin: getGTIN(variant.product), - image: variantImage ? variantImage.url : (() => { - const fallbackImage = getPrimaryImage(baseProduct, null); - return fallbackImage ? fallbackImage.url : null; - })(), + image: variantImage + ? variantImage.url + : (() => { + const fallbackImage = getPrimaryImage(baseProduct, null); + return fallbackImage ? fallbackImage.url : null; + })(), offers: [getOffer(variant.product, variantUrl.toString())], }; for (let axis of axes) { - const attribute = variant.product.attributes.find(attr => attr.name === axis); + const attribute = variant.product.attributes.find((attr) => attr.name === axis); if (attribute) { ldJson[axis] = attribute.value; } @@ -87,8 +96,8 @@ function getBundleVariants(baseProduct, url) { // Extract all unique products from bundle options const productMap = new Map(); - baseProduct.options.forEach(option => { - option.values.forEach(value => { + baseProduct.options.forEach((option) => { + option.values.forEach((value) => { if (value.product) { const product = value.product; if (!productMap.has(product.sku)) { @@ -99,7 +108,7 @@ function getBundleVariants(baseProduct, url) { valueId: value.id, valueTitle: value.title, quantity: value.quantity, - isDefault: value.isDefault + isDefault: value.isDefault, }); } } @@ -141,21 +150,6 @@ function getBundleVariants(baseProduct, url) { return variants; } -/** - * Extracts the GTIN (Global Trade Item Number) from a product's attributes. - * Checks for GTIN, UPC, or EAN attributes as defined in the Catalog. - * - * @param {Object} product - The product object containing attributes - * @returns {string} The GTIN value if found, empty string otherwise - */ -function getGTIN(product) { - return product?.attributes?.find(attr => attr.name === 'gtin')?.value - || product?.attributes?.find(attr => attr.name === 'upc')?.value - || product?.attributes?.find(attr => attr.name === 'ean')?.value - || product?.attributes?.find(attr => attr.name === 'isbn')?.value - || ''; -} - async function generateLdJson(product, context) { const { name, sku, __typename } = product; const image = getPrimaryImage(product); @@ -186,7 +180,7 @@ async function generateLdJson(product, context) { productGroupId: sku, name, gtin, - variesBy: axes.map(axis => schemaOrgProperties.includes(axis) ? `https://schema.org/${axis}` : axis), + variesBy: axes.map((axis) => (schemaOrgProperties.includes(axis) ? `https://schema.org/${axis}` : axis)), description: findDescription(product, ['shortDescription', 'metaDescription', 'description']), '@id': lowercaseUrlPath(url), hasVariant: await getVariants(product, url, axes, context), diff --git a/actions/pdp-renderer/lib.js b/actions/pdp-renderer/lib.js deleted file mode 100644 index f18b607e..00000000 --- a/actions/pdp-renderer/lib.js +++ /dev/null @@ -1,114 +0,0 @@ -const striptags = require('striptags'); -const { extractPathDetails, prepareBaseTemplate, sanitize } = require('../common-renderer/lib'); - -/** - * Finds the description of a product based on a priority list of fields. - * @param {Object} product The product object. - * @param {Array} priority The list of fields to check for the description, in order of priority. - * @returns {string} The description of the product. - */ -function findDescription(product, priority = ['metaDescription', 'shortDescription', 'description']) { - return priority - .map(d => product[d]?.trim() || '') - .map(d => striptags(d)) - .map(d => d.replace(/\r?\n|\r/g, '')) - .find(d => d.length > 0) || ''; -} - -/** - * Returns the first image of a product based on the specified role or the first image if no role is specified. - * @param {Object} product The product. - * @param {string} [role='image'] The role of the image to find. - * @returns {Object|undefined} The primary image object or undefined if not found. - */ -function getPrimaryImage(product, role = 'image') { - if (role) { - return product?.images?.find(img => img.roles.includes(role)); - } - - return product?.images?.length > 0 ? product?.images?.[0] : undefined; -} - -/** - * Returns a number formatter for the specified locale and currency. - * - * @param {string} [locale] The locale to use for formatting. Defaults to us-en. - * @param {string} [currency] The currency code to use for formatting. Defaults to USD. - * @returns {Intl.NumberFormat} The number formatter. - */ -function getFormatter(locale = 'us-en', currency) { - return new Intl.NumberFormat(locale, { - style: 'currency', - currency: (!currency || currency === 'NONE') ? 'USD' : currency, - minimumFractionDigits: 2, - maximumFractionDigits: 2 - }); -}; - -/** - * Generates a formatted price string of a simple or complex product. - * - * @param {Object} product Product object. - * @returns {string} Formatted price string. - */ -function generatePriceString(product, localeCode = 'us-en') { - const { price, priceRange } = product; - let currency = priceRange ? priceRange?.minimum?.regular?.amount?.currency : price?.regular?.amount?.currency; - const format = getFormatter(localeCode, currency).format; - let priceString = ''; - - if (priceRange) { - const hasRange = priceRange.minimum.final.amount.value !== priceRange.maximum.final.amount.value; - if (hasRange) { - const minimumDiscounted = priceRange.minimum.regular.amount.value > priceRange.minimum.final.amount.value; - if (minimumDiscounted) { - priceString = `${format(priceRange.minimum.regular.amount.value)} ${format(priceRange.minimum.final.amount.value)}`; - } else { - priceString = `${format(priceRange.minimum.final.amount.value)}`; - } - priceString += '-'; - const maximumDiscounted = priceRange.maximum.regular.amount.value > priceRange.maximum.final.amount.value; - if (maximumDiscounted) { - priceString += `${format(priceRange.maximum.regular.amount.value)} ${format(priceRange.maximum.final.amount.value)}`; - } else { - priceString += `${format(priceRange.maximum.final.amount.value)}`; - } - } else { - const isDiscounted = priceRange.minimum.regular.amount.value > priceRange.minimum.final.amount.value; - if (isDiscounted) { - priceString = `${format(priceRange.minimum.regular.amount.value)} ${format(priceRange.minimum.final.amount.value)}`; - } else { - priceString = `${format(priceRange.minimum.final.amount.value)}`; - } - } - } else if (price) { - const isDiscounted = price.regular.amount.value > price.final.amount.value; - if (isDiscounted) { - priceString = `${format(price.regular.amount.value)} ${format(price.final.amount.value)}`; - } else { - priceString = `${format(price.final.amount.value)}`; - } - } - return priceString; -} - -/** - * Generates a list of image URLs for a product, ensuring the primary image is first. - * - * @param {string} primary The URL of the primary image. - * @param {Array} images The list of image objects. - * @returns {Array} The list of image URLs with the primary image first. - */ -function getImageList(primary, images) { - const imageList = images?.map(img => img.url); - if (primary) { - const primaryImageIndex = imageList.indexOf(primary); - if (primaryImageIndex > -1) { - imageList.splice(primaryImageIndex, 1); - imageList.unshift(primary); - } - } - return imageList; -} - -module.exports = { extractPathDetails, findDescription, getPrimaryImage, prepareBaseTemplate, generatePriceString, getImageList, sanitize }; diff --git a/actions/pdp-renderer/render.js b/actions/pdp-renderer/render.js index 32109c76..ff3a7f47 100644 --- a/actions/pdp-renderer/render.js +++ b/actions/pdp-renderer/render.js @@ -1,7 +1,14 @@ const fs = require('fs'); const path = require('path'); const Handlebars = require('handlebars'); -const { findDescription, prepareBaseTemplate, getPrimaryImage, generatePriceString, getImageList, sanitize } = require('./lib'); +const { + findDescription, + prepareBaseTemplate, + getPrimaryImage, + generatePriceString, + getImageList, + sanitize, +} = require('../renderUtils'); const { generateLdJson } = require('./ldJson'); const { requestSaaS, getProductUrl } = require('../utils'); const { ProductQuery, ProductByUrlKeyQuery } = require('../queries'); @@ -71,7 +78,7 @@ async function generateProductHtml(sku, urlKey, context) { title: sanitize(option.title, 'inline'), id: sanitize(option.id, 'no'), required: sanitize(String(option.required), 'no'), - values: option.values + values: option.values, }; }); } @@ -83,7 +90,9 @@ async function generateProductHtml(sku, urlKey, context) { const ldJson = await generateLdJson(baseProduct, context); // Load the Handlebars template - const [pageHbs, headHbs, productDetailsHbs] = ['page', 'head', 'product-details'].map((template) => fs.readFileSync(path.join(__dirname, 'templates', `${template}.hbs`), 'utf8')); + const [pageHbs, headHbs, productDetailsHbs] = ['page', 'head', 'product-details'].map((template) => + fs.readFileSync(path.join(__dirname, 'templates', `${template}.hbs`), 'utf8'), + ); const pageTemplate = Handlebars.compile(pageHbs); Handlebars.registerPartial('head', headHbs); Handlebars.registerPartial('product-details', productDetailsHbs); @@ -96,7 +105,8 @@ async function generateProductHtml(sku, urlKey, context) { if (context.productsTemplate) { const productsTemplateURL = context.productsTemplate.replace(/\s+/g, '').replace('{locale}', localeKey); if (!productTemplateCache[localeKey]) productTemplateCache[localeKey] = {}; - if (!productTemplateCache[localeKey].baseTemplate) productTemplateCache[localeKey].baseTemplate = prepareBaseTemplate(productsTemplateURL, blocksToReplace, context); + if (!productTemplateCache[localeKey].baseTemplate) + productTemplateCache[localeKey].baseTemplate = prepareBaseTemplate(productsTemplateURL, blocksToReplace, context); const baseTemplate = await productTemplateCache[localeKey].baseTemplate; Handlebars.registerPartial('content', baseTemplate); } else { diff --git a/actions/plp-renderer/ldJson.js b/actions/plp-renderer/ldJson.js index 3a165aae..f4eab828 100644 --- a/actions/plp-renderer/ldJson.js +++ b/actions/plp-renderer/ldJson.js @@ -10,42 +10,72 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ +const striptags = require('striptags'); const { getCategoryUrl, getProductUrl } = require('../utils'); +const { getProductPrice, getGTIN, getBrand } = require('../renderUtils'); + +function buildOffer(product, productUrl) { + const price = getProductPrice(product); + if (!price) return null; + + return { + '@type': 'Offer', + url: productUrl, + price: price.value, + priceCurrency: price.currency, + availability: product.inStock ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock', + itemCondition: 'https://schema.org/NewCondition', + }; +} /** - * Generates ItemList and BreadcrumbList JSON-LD for a PLP page. + * Generates CollectionPage JSON-LD for a PLP page, containing an ItemList + * of products and a BreadcrumbList. * * @param {Object} categoryData - Category metadata from the category tree. * @param {Array} products - Product items from productSearch. * @param {Array} breadcrumbs - Breadcrumb entries with { name, slug }. * @param {Object} context - The context object with storeUrl, locale, pathFormat. - * @returns {{ itemListLdJson: string, breadcrumbLdJson: string }} + * @returns {string} JSON-LD string. */ function generatePlpLdJson(categoryData, products, breadcrumbs, context) { const itemList = { - '@context': 'https://schema.org', '@type': 'ItemList', name: categoryData.name, numberOfItems: products.length, itemListElement: products.map((product, index) => { - const productUrl = getProductUrl( - { sku: product.sku, urlKey: product.urlKey }, - context - ); - const image = product.images?.find(img => img.roles?.includes('image'))?.url || null; + const productUrl = getProductUrl({ sku: product.sku, urlKey: product.urlKey }, context); + const image = product.images?.find((img) => img.roles?.includes('image'))?.url || null; + const description = product.shortDescription + ? striptags(product.shortDescription) + .replace(/\r?\n|\r/g, '') + .trim() + : null; + const offer = buildOffer(product, productUrl); + const gtin = getGTIN(product); + const brand = getBrand(product); - return { - '@type': 'ListItem', - position: index + 1, + const productItem = { + '@type': 'Product', name: product.name, + sku: product.sku, url: productUrl, + ...(gtin ? { gtin } : {}), + ...(brand ? { brand: { '@type': 'Brand', name: brand } } : {}), ...(image ? { image } : {}), + ...(description ? { description } : {}), + ...(offer ? { offers: [offer] } : {}), + }; + + return { + '@type': 'ListItem', + position: index + 1, + item: productItem, }; }), }; const breadcrumbList = { - '@context': 'https://schema.org', '@type': 'BreadcrumbList', itemListElement: breadcrumbs.map((crumb, index) => ({ '@type': 'ListItem', @@ -55,10 +85,15 @@ function generatePlpLdJson(categoryData, products, breadcrumbs, context) { })), }; - return { - itemListLdJson: JSON.stringify(itemList), - breadcrumbLdJson: JSON.stringify(breadcrumbList), + const collectionPage = { + '@context': 'https://schema.org', + '@type': 'CollectionPage', + name: categoryData.name, + breadcrumb: breadcrumbList, + mainEntity: itemList, }; + + return JSON.stringify(collectionPage); } module.exports = { generatePlpLdJson }; diff --git a/actions/plp-renderer/render.js b/actions/plp-renderer/render.js index 229878b6..90227ae5 100644 --- a/actions/plp-renderer/render.js +++ b/actions/plp-renderer/render.js @@ -10,13 +10,27 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ -const fs = require("fs"); -const path = require("path"); -const Handlebars = require("handlebars"); -const { sanitize } = require("../common-renderer/lib"); -const { generatePlpLdJson } = require("./ldJson"); -const { getCategoryUrl } = require("../utils"); -const { buildBreadcrumbs } = require("../categories"); +const fs = require('fs'); +const path = require('path'); +const Handlebars = require('handlebars'); +const { sanitize } = require('../renderUtils'); +const { generatePlpLdJson } = require('./ldJson'); +const { getCategoryUrl, getProductUrl } = require('../utils'); +const { buildBreadcrumbs } = require('../categories'); + +let compiledTemplate; +function getCompiledTemplate() { + if (!compiledTemplate) { + const [pageHbs, headHbs, productListingHbs] = ['page', 'plp-head', 'product-listing'].map((template) => + fs.readFileSync(path.join(__dirname, 'templates', `${template}.hbs`), 'utf8'), + ); + const handlebars = Handlebars.create(); + handlebars.registerPartial('head', headHbs); + handlebars.registerPartial('content', productListingHbs); + compiledTemplate = handlebars.compile(pageHbs); + } + return compiledTemplate; +} /** * Generates the HTML for a category listing page. @@ -32,70 +46,40 @@ function generateCategoryHtml(categoryData, products, categoryMap, context) { // Build template data const categoryDescription = categoryData.metaTags?.description - ? sanitize(categoryData.metaTags.description, "all") + ? sanitize(categoryData.metaTags.description, 'all') : null; const categoryImage = categoryData.images?.find( - (img) => img.roles?.includes("image") || img.customRoles?.includes("hero"), + (img) => img.roles?.includes('image') || img.customRoles?.includes('hero'), ); const templateData = { - categoryName: sanitize(categoryData.name, "inline"), + categoryName: sanitize(categoryData.name, 'inline'), categoryDescription, slug: categoryData.slug, - metaTitle: sanitize( - categoryData.metaTags?.title || categoryData.name, - "no", - ), - metaDescription: categoryData.metaTags?.description - ? sanitize(categoryData.metaTags.description, "no") - : null, - metaKeywords: categoryData.metaTags?.keywords - ? sanitize(categoryData.metaTags.keywords, "no") - : null, + metaTitle: sanitize(categoryData.metaTags?.title || categoryData.name, 'no'), + metaDescription: categoryData.metaTags?.description ? sanitize(categoryData.metaTags.description, 'no') : null, + metaKeywords: categoryData.metaTags?.keywords ? sanitize(categoryData.metaTags.keywords, 'no') : null, metaImage: categoryImage?.url || null, breadcrumbs: breadcrumbs.map((crumb) => ({ - name: sanitize(crumb.name, "inline"), + name: sanitize(crumb.name, 'inline'), url: getCategoryUrl(crumb.slug, context), })), products: products.map((product) => ({ - name: sanitize(product.name, "inline"), - url: getCategoryUrl(product.urlKey, context), - image: - product.images?.find((img) => img.roles?.includes("image"))?.url || - null, + name: sanitize(product.name, 'inline'), + url: getProductUrl({ urlKey: product.urlKey, sku: product.sku }, context), + image: product.images?.find((img) => img.roles?.includes('image'))?.url || null, })), hasProducts: products.length > 0, }; - // Generate JSON-LD - const { itemListLdJson, breadcrumbLdJson } = generatePlpLdJson( - categoryData, - products, - breadcrumbs, - context, - ); - - // Load and compile Handlebars templates - const [pageHbs, headHbs, productListingHbs] = [ - "page", - "plp-head", - "product-listing", - ].map((template) => - fs.readFileSync( - path.join(__dirname, "templates", `${template}.hbs`), - "utf8", - ), - ); + const ldJson = generatePlpLdJson(categoryData, products, breadcrumbs, context); - const pageTemplate = Handlebars.compile(pageHbs); - Handlebars.registerPartial("head", headHbs); - Handlebars.registerPartial("content", productListingHbs); + const pageTemplate = getCompiledTemplate(); return pageTemplate({ ...templateData, - itemListLdJson, - breadcrumbLdJson, + ldJson, }); } diff --git a/actions/plp-renderer/templates/plp-head.hbs b/actions/plp-renderer/templates/plp-head.hbs index eeeed441..39c8af0d 100644 --- a/actions/plp-renderer/templates/plp-head.hbs +++ b/actions/plp-renderer/templates/plp-head.hbs @@ -8,6 +8,5 @@ - - + diff --git a/actions/queries.js b/actions/queries.js index c8597b4a..330bda67 100644 --- a/actions/queries.js +++ b/actions/queries.js @@ -304,14 +304,43 @@ const PlpProductSearchQuery = ` ) { items { productView { + __typename name sku urlKey + inStock + shortDescription images(roles: ["image"]) { url label roles } + attributes(roles: []) { + name + value + } + ... on SimpleProductView { + price { + final { + amount { + value + currency + } + } + } + } + ... on ComplexProductView { + priceRange { + minimum { + final { + amount { + value + currency + } + } + } + } + } } } total_count diff --git a/actions/render-all-categories/index.js b/actions/render-all-categories/index.js index ee1853a3..5d967312 100644 --- a/actions/render-all-categories/index.js +++ b/actions/render-all-categories/index.js @@ -23,66 +23,65 @@ const { handleActionError } = require('../lib/errorHandler'); * @returns {Promise} */ async function main(params) { - let logger; + let logger; + + try { + const cfg = getRuntimeConfig(params, { validateToken: true }); + logger = Core.Logger('main', { level: cfg.logLevel }); + + const observabilityClient = new ObservabilityClient(logger, { + token: cfg.adminAuthToken, + endpoint: cfg.logIngestorEndpoint, + org: cfg.org, + site: cfg.site, + }); + + const stateLib = await State.init(params.libInit || {}); + const filesLib = await Files.init(params.libInit || {}); + const stateMgr = new StateManager(stateLib, { logger }); + + let activationResult; + + const running = await stateMgr.get('plp-running'); + if (running?.value === 'true') { + activationResult = { state: 'skipped' }; + + try { + await observabilityClient.sendActivationResult(activationResult); + } catch (obsErr) { + logger.warn('Failed to send activation result (skipped).', obsErr); + } + + return activationResult; + } + + try { + await stateMgr.put('plp-running', 'true', { ttl: 3600 }); + + activationResult = await poll(cfg, { stateLib: stateMgr, filesLib }, logger); + } finally { + try { + await stateMgr.put('plp-running', 'false'); + } catch (stateErr) { + (logger || Core.Logger('main', { level: 'error' })).error('Failed to reset running state.', stateErr); + } + } try { - const cfg = getRuntimeConfig(params, { validateToken: true }); - logger = Core.Logger('main', { level: cfg.logLevel }); - - const observabilityClient = new ObservabilityClient(logger, { - token: cfg.adminAuthToken, - endpoint: cfg.logIngestorEndpoint, - org: cfg.org, - site: cfg.site - }); - - const stateLib = await State.init(params.libInit || {}); - const filesLib = await Files.init(params.libInit || {}); - const stateMgr = new StateManager(stateLib, { logger }); - - let activationResult; - - const running = await stateMgr.get('plp-running'); - if (running?.value === 'true') { - activationResult = { state: 'skipped' }; - - try { - await observabilityClient.sendActivationResult(activationResult); - } catch (obsErr) { - logger.warn('Failed to send activation result (skipped).', obsErr); - } - - return activationResult; - } - - try { - await stateMgr.put('plp-running', 'true', { ttl: 3600 }); - - activationResult = await poll(cfg, { stateLib: stateMgr, filesLib }, logger); - } finally { - try { - await stateMgr.put('plp-running', 'false'); - } catch (stateErr) { - (logger || Core.Logger('main', { level: 'error' })) - .error('Failed to reset running state.', stateErr); - } - } - - try { - await observabilityClient.sendActivationResult(activationResult); - } catch (obsErr) { - logger.warn('Failed to send activation result.', obsErr); - } - - return activationResult; - } catch (error) { - logger = logger || Core.Logger('main', { level: 'error' }); - - return handleActionError(error, { - logger, - actionName: 'Render all categories' - }); + await observabilityClient.sendActivationResult(activationResult); + } catch (obsErr) { + logger.warn('Failed to send activation result.', obsErr); } + + return activationResult; + } catch (error) { + logger = logger || Core.Logger('main', { level: 'error' }); + + return handleActionError(error, { + logger, + actionName: 'Render all categories', + }); + } } exports.main = main; diff --git a/actions/render-all-categories/poller.js b/actions/render-all-categories/poller.js index f974c32c..362fdce0 100644 --- a/actions/render-all-categories/poller.js +++ b/actions/render-all-categories/poller.js @@ -10,130 +10,47 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ -const crypto = require("crypto"); -const { Timings, aggregate } = require("../lib/benchmark"); -const { AdminAPI } = require("../lib/aem"); +const crypto = require('crypto'); +const { Timings, aggregate } = require('../lib/benchmark'); +const { AdminAPI } = require('../lib/aem'); const { requestSaaS, - isValidUrl, getCategoryUrl, + getConfig, + getSiteType, formatMemoryUsage, + createBatches, PLP_FILE_PREFIX, - STATE_FILE_EXT, -} = require("../utils"); -const { PlpProductSearchQuery } = require("../queries"); -const { getCategoryDataFromFamilies } = require("../categories"); -const { generateCategoryHtml } = require("../plp-renderer/render"); -const { JobFailedError, ERROR_CODES } = require("../lib/errorHandler"); - -const BATCH_SIZE = 50; -const PLP_FILE_EXT = "html"; - -function getFileLocation(stateKey, extension) { - return `${PLP_FILE_PREFIX}/${stateKey}.${extension}`; -} - -/** - * Loads the PLP state from the cloud file system. - * - * @param {string} locale - The locale. - * @param {Object} aioLibs - { filesLib, stateLib }. - * @returns {Promise} State object with { locale, categories: { [slug]: { lastRenderedAt, hash } } }. - */ -async function loadState(locale, aioLibs) { - const { filesLib } = aioLibs; - const stateObj = { locale, categories: {} }; - try { - const stateKey = locale || "default"; - const fileLocation = getFileLocation(stateKey, STATE_FILE_EXT); - const buffer = await filesLib.read(fileLocation); - const stateData = buffer?.toString(); - if (stateData) { - const lines = stateData.split("\n"); - stateObj.categories = lines.reduce((acc, line) => { - // format: ,, - const [slug, time, hash] = line.split(","); - if (slug) { - acc[slug] = { lastRenderedAt: new Date(parseInt(time)), hash }; - } - return acc; - }, {}); - } - // eslint-disable-next-line no-unused-vars - } catch (e) { - stateObj.categories = {}; - } - return stateObj; -} - -/** - * Saves the PLP state to the cloud file system. - * - * @param {Object} state - State object with { locale, categories }. - * @param {Object} aioLibs - { filesLib, stateLib }. - * @returns {Promise} - */ -async function saveState(state, aioLibs) { - const { filesLib } = aioLibs; - const stateKey = state.locale || "default"; - const fileLocation = getFileLocation(stateKey, STATE_FILE_EXT); - const csvData = Object.entries(state.categories) - .filter(([, { lastRenderedAt }]) => Boolean(lastRenderedAt)) - .map( - ([slug, { lastRenderedAt, hash }]) => - `${slug},${lastRenderedAt.getTime()},${hash || ""}`, - ) - .join("\n"); - return await filesLib.write(fileLocation, csvData); -} - -function shouldPreviewAndPublish({ currentHash, newHash }) { - return newHash && currentHash !== newHash; -} - -function createBatches(items) { - return items.reduce((acc, item) => { - if (!acc.length || acc[acc.length - 1].length === BATCH_SIZE) { - acc.push([]); - } - acc[acc.length - 1].push(item); - return acc; - }, []); -} + SITE_TYPES, +} = require('../utils'); +const { + getHtmlFilePath, + getFileLocation, + loadState, + saveState, + shouldPreviewAndPublish, + processPublishedBatch, + validateRequiredParams, +} = require('../renderUtils'); +const { PlpProductSearchQuery } = require('../queries'); +const { getCategoryDataFromFamilies } = require('../categories'); +const { generateCategoryHtml } = require('../plp-renderer/render'); +const { JobFailedError, ERROR_CODES } = require('../lib/errorHandler'); +const DATA_KEY = 'categories'; function checkParams(params) { - const requiredParams = [ - "site", - "org", - "adminAuthToken", - "configName", - "contentUrl", - "storeUrl", - ]; - const missingParams = requiredParams.filter((param) => !params[param]); - if (missingParams.length > 0) { - throw new JobFailedError( - `Missing required parameters: ${missingParams.join(", ")}`, - ERROR_CODES.VALIDATION_ERROR, - 400, - { missingParams }, - ); - } - - if (params.storeUrl && !isValidUrl(params.storeUrl)) { - throw new JobFailedError( - "Invalid storeUrl", - ERROR_CODES.VALIDATION_ERROR, - 400, - ); - } + validateRequiredParams(params, [ + 'site', + 'org', + 'pathFormat', + 'adminAuthToken', + 'configName', + 'contentUrl', + 'storeUrl', + ]); if (!params.categoryFamilies?.length) { - throw new JobFailedError( - "Missing ACO_CATEGORY_FAMILIES configuration", - ERROR_CODES.VALIDATION_ERROR, - 400, - ); + throw new JobFailedError('Missing ACO_CATEGORY_FAMILIES configuration', ERROR_CODES.VALIDATION_ERROR, 400); } } @@ -145,7 +62,7 @@ async function renderCategory(categoryData, categoryMap, context) { const { logger } = context; if (!renderLimit$) { - renderLimit$ = import("p-limit").then(({ default: pLimit }) => pLimit(50)); + renderLimit$ = import('p-limit').then(({ default: pLimit }) => pLimit(50)); } return (await renderLimit$)(async () => { @@ -160,7 +77,7 @@ async function renderCategory(categoryData, categoryMap, context) { // Fetch first page of products for this category const productsRes = await requestSaaS( PlpProductSearchQuery, - "plpProductSearch", + 'plpProductSearch', { categoryPath: slug, pageSize: context.plpProductsPerPage, @@ -169,25 +86,18 @@ async function renderCategory(categoryData, categoryMap, context) { context, ); - const products = productsRes.data.productSearch.items.map( - (item) => item.productView, - ); + const products = productsRes.data.productSearch.items.map((item) => item.productView); // Render HTML - const html = generateCategoryHtml( - categoryData, - products, - categoryMap, - context, - ); + const html = generateCategoryHtml(categoryData, products, categoryMap, context); result.renderedAt = new Date(); - result.newHash = crypto.createHash("sha256").update(html).digest("hex"); + result.newHash = crypto.createHash('sha256').update(html).digest('hex'); // Save HTML if changed if (shouldPreviewAndPublish(result) && html) { try { const { filesLib } = context.aioLibs; - const htmlPath = `/public/plps${result.path}.${PLP_FILE_EXT}`; + const htmlPath = getHtmlFilePath(result.path); await filesLib.write(htmlPath, html); logger.debug(`Saved HTML for category ${slug} to ${htmlPath}`); } catch (e) { @@ -204,29 +114,53 @@ async function renderCategory(categoryData, categoryMap, context) { } /** - * Processes a published batch and updates state. + * Unpublishes and deletes categories that are no longer in the category tree. */ -async function processPublishedBatch( - publishedBatch, - state, - counts, - renderedCategories, - aioLibs, -) { - const { records } = publishedBatch; - records.forEach((record) => { - if (record.previewedAt && record.publishedAt) { - const category = renderedCategories.find((c) => c.slug === record.slug); - state.categories[record.slug] = { - lastRenderedAt: record.renderedAt, - hash: category?.newHash, - }; - counts.published++; - } else { - counts.failed++; +async function processRemovedCategories(discoveredSlugs, state, context, adminApi) { + const { locale, counts, logger, aioLibs } = context; + const { filesLib } = aioLibs; + const stateSlugs = Object.keys(state.categories); + const removedSlugs = stateSlugs.filter((slug) => !discoveredSlugs.has(slug)); + + if (!removedSlugs.length) return; + + logger.info(`Found ${removedSlugs.length} categories to unpublish for locale ${locale}`); + + try { + const records = removedSlugs.map((slug) => ({ + slug, + path: getCategoryUrl(slug, context, false).toLowerCase(), + })); + + const batches = createBatches(records); + const pendingBatches = []; + for (let batchNumber = 0; batchNumber < batches.length; batchNumber++) { + const batchRecords = batches[batchNumber]; + const pendingBatch = adminApi.unpublishAndDelete(batchRecords, locale, batchNumber + 1).then(({ records }) => { + records.forEach((record) => { + if (record.liveUnpublishedAt && record.previewUnpublishedAt) { + try { + const htmlPath = getHtmlFilePath(record.path); + filesLib.delete(htmlPath); + logger.debug(`Deleted HTML file for category ${record.slug} from ${htmlPath}`); + } catch (e) { + logger.warn(`Error deleting HTML file for category ${record.slug}:`, e); + } + + delete state.categories[record.slug]; + counts.unpublished++; + } else { + counts.failed++; + } + }); + }); + pendingBatches.push(pendingBatch); } - }); - await saveState(state, aioLibs); + await Promise.all(pendingBatches); + await saveState(state, aioLibs, PLP_FILE_PREFIX, DATA_KEY); + } catch (e) { + logger.error('Error processing removed categories:', e); + } } /** @@ -236,7 +170,7 @@ async function poll(params, aioLibs, logger) { try { checkParams(params); - const counts = { published: 0, ignored: 0, failed: 0 }; + const counts = { published: 0, unpublished: 0, ignored: 0, failed: 0 }; const { org, site, @@ -256,9 +190,9 @@ async function poll(params, aioLibs, logger) { const locales = Array.isArray(rawLocales) ? rawLocales - : typeof rawLocales === "string" && rawLocales.trim() + : typeof rawLocales === 'string' && rawLocales.trim() ? rawLocales - .split(",") + .split(',') .map((s) => s.trim()) .filter(Boolean) : [null]; @@ -286,7 +220,7 @@ async function poll(params, aioLibs, logger) { logger.info(`Starting PLP poll from ${storeUrl} for locales ${locales}`); - let stateText = "completed"; + let stateText = 'completed'; try { await adminApi.startProcessing(); @@ -295,23 +229,28 @@ async function poll(params, aioLibs, logger) { locales.map(async (locale) => { const timings = new Timings(); const context = { ...sharedContext, startTime: new Date() }; + const siteConfig = await getConfig(context); + const siteType = getSiteType(siteConfig); if (locale) context.locale = locale; logger.info(`PLP polling for locale ${locale}`); // Discover all categories - const categoryMap = await getCategoryDataFromFamilies( - context, - categoryFamilies, - ); - timings.sample("discover-categories"); + let categoryMap; + if (siteType === SITE_TYPES.ACO) { + categoryMap = await getCategoryDataFromFamilies(context, categoryFamilies); + } else { + throw new JobFailedError( + 'ACCS is not yet support for PLP pre-rendering', + ERROR_CODES.VALIDATION_ERROR, + 400, + ); + } + timings.sample('discover-categories'); // Save category list for reference const { filesLib } = aioLibs; - const categoriesFileName = getFileLocation( - `${locale || "default"}-categories`, - "json", - ); + const categoriesFileName = getFileLocation(PLP_FILE_PREFIX, `${locale || 'default'}-categories`, 'json'); const categoryList = [...categoryMap.entries()] .filter(([, data]) => data != null) .map(([slug, data]) => ({ @@ -319,30 +258,19 @@ async function poll(params, aioLibs, logger) { name: data.name, level: data.level, })); - await filesLib.write( - categoriesFileName, - JSON.stringify(categoryList), - ); + await filesLib.write(categoriesFileName, JSON.stringify(categoryList)); // Load state - const state = await loadState(locale, aioLibs); + const state = await loadState(locale, aioLibs, PLP_FILE_PREFIX, DATA_KEY); context.state = state; - logger.info( - `Discovered ${categoryMap.size} categories for locale ${locale}`, - ); + logger.info(`Discovered ${categoryMap.size} categories for locale ${locale}`); // Render all categories in batches - const categorySlugs = [...categoryMap.keys()].filter( - (slug) => categoryMap.get(slug) != null, - ); + const categorySlugs = [...categoryMap.keys()].filter((slug) => categoryMap.get(slug) != null); const batches = createBatches(categorySlugs); const pendingBatches = batches.map((batch, batchNumber) => { - return Promise.all( - batch.map((slug) => - renderCategory(categoryMap.get(slug), categoryMap, context), - ), - ) + return Promise.all(batch.map((slug) => renderCategory(categoryMap.get(slug), categoryMap, context))) .then(async (renderedCategories) => { // Filter to only those that changed const toPublish = []; @@ -350,54 +278,46 @@ async function poll(params, aioLibs, logger) { for (const category of renderedCategories) { if (shouldPreviewAndPublish(category)) { toPublish.push(category); + } else if (!category.renderedAt) { + counts.failed++; } else { counts.ignored++; - // Update lastRenderedAt even if hash unchanged - if (category.renderedAt) { - state.categories[category.slug] = { - lastRenderedAt: category.renderedAt, - hash: category.currentHash, - }; - } + state.categories[category.slug] = { + lastRenderedAt: category.renderedAt, + hash: category.currentHash, + }; toIgnore.push(category); } } if (toIgnore.length) { - await saveState(state, aioLibs); + await saveState(state, aioLibs, PLP_FILE_PREFIX, DATA_KEY); } return toPublish; }) .then((categories) => { if (categories.length) { - const records = categories.map( - ({ slug, path, renderedAt }) => ({ - slug, - path, - renderedAt, - }), - ); + const records = categories.map(({ slug, path, renderedAt }) => ({ + slug, + path, + renderedAt, + })); return adminApi .previewAndPublish(records, locale, batchNumber + 1) .then((publishedBatch) => - processPublishedBatch( - publishedBatch, - state, - counts, - categories, - aioLibs, - ), + processPublishedBatch(publishedBatch, state, counts, categories, aioLibs, { + dataKey: DATA_KEY, + keyField: 'slug', + filePrefix: PLP_FILE_PREFIX, + }), ) .catch((error) => { if (error.code === ERROR_CODES.BATCH_ERROR) { - logger.warn( - `Batch ${batchNumber + 1} failed, continuing:`, - { - error: error.message, - details: error.details, - }, - ); + logger.warn(`Batch ${batchNumber + 1} failed, continuing:`, { + error: error.message, + details: error.details, + }); counts.failed += categories.length; return { failed: true, @@ -413,7 +333,16 @@ async function poll(params, aioLibs, logger) { }); }); await Promise.all(pendingBatches); - timings.sample("rendered-categories"); + timings.sample('rendered-categories'); + + // Unpublish categories that are no longer in the tree + const discoveredSlugs = new Set(categorySlugs); + if (Object.keys(state.categories).some((slug) => !discoveredSlugs.has(slug))) { + await processRemovedCategories(discoveredSlugs, state, context, adminApi); + timings.sample('unpublished-categories'); + } else { + timings.sample('unpublished-categories', 0); + } return timings.measures; }), @@ -425,8 +354,7 @@ async function poll(params, aioLibs, logger) { for (const measure of results) { for (const [name, value] of Object.entries(measure)) { if (!timings.measures[name]) timings.measures[name] = []; - if (!Array.isArray(timings.measures[name])) - timings.measures[name] = [timings.measures[name]]; + if (!Array.isArray(timings.measures[name])) timings.measures[name] = [timings.measures[name]]; timings.measures[name].push(value); } } @@ -435,13 +363,13 @@ async function poll(params, aioLibs, logger) { } timings.measures.previewDuration = aggregate(adminApi.previewDurations); } catch (e) { - logger.error("Error during PLP poll processing:", { + logger.error('Error during PLP poll processing:', { message: e.message, code: e.code, stack: e.stack, }); await adminApi.stopProcessing(); - stateText = "failure"; + stateText = 'failure'; if (e.isJobFailed) { throw e; @@ -476,7 +404,7 @@ async function poll(params, aioLibs, logger) { memoryUsage, }; } catch (error) { - logger.error("PLP poll failed with error:", { + logger.error('PLP poll failed with error:', { message: error.message, code: error.code, stack: error.stack, @@ -495,9 +423,4 @@ async function poll(params, aioLibs, logger) { } } -module.exports = { - poll, - loadState, - saveState, - getFileLocation, -}; +module.exports = { poll }; diff --git a/actions/renderUtils.js b/actions/renderUtils.js new file mode 100644 index 00000000..b0983dbf --- /dev/null +++ b/actions/renderUtils.js @@ -0,0 +1,463 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +const striptags = require('striptags'); +const cheerio = require('cheerio'); +const { isValidUrl, STATE_FILE_EXT } = require('./utils'); +const { JobFailedError, ERROR_CODES } = require('./lib/errorHandler'); + +const PUBLIC_HTML_DIR = '/public/pdps'; + +/** + * Constructs the file-system path for a rendered HTML page. + * + * @param {string} pagePath - The page path (e.g. '/en/products/foo/sku123'). + * @returns {string} The full file path. + */ +function getHtmlFilePath(pagePath) { + return `${PUBLIC_HTML_DIR}${pagePath}.html`; +} + +/** + * Extracts details from the path based on the provided format. + * @param {string} path The path. + * @param {string} format The format to extract details from the path. + * @returns {Object} An object containing the extracted details. + * @throws Throws an error if the path is invalid. + */ +function extractPathDetails(path, format) { + if (!path) { + return {}; + } + + const formatParts = format.split('/').filter(Boolean); + const pathParts = path.split('/').filter(Boolean); + + if (formatParts.length !== pathParts.length) { + throw new Error(`Invalid path. Expected '${format}' format.`); + } + + const result = {}; + formatParts.forEach((part, index) => { + if (part.startsWith('{') && part.endsWith('}')) { + const key = part.substring(1, part.length - 1); + result[key] = pathParts[index]; + } else if (part !== pathParts[index]) { + throw new Error(`Invalid path. Expected '${format}' format.`); + } + }); + + return result; +} + +/** + * Returns the base template for a page. It loads an Edge Delivery page and replaces + * specified blocks with Handlebars partials. + * + * @param {string} url The URL to fetch the base template HTML from. + * @param {Array} blocks The list of block class names to replace with Handlebars partials. + * @param {Object} context The context object. + * @returns {Promise} The adapted base template HTML as a string. + */ +async function prepareBaseTemplate(url, blocks, context) { + if (context.locale && context.locale !== 'default') { + url = url.replace(/\s+/g, '').replace(/\/$/, '').replace('{locale}', context.locale); + } + + const { siteToken } = context; + + let options = undefined; + + // Site Validation: needs to be a non empty string + if (typeof siteToken === 'string' && siteToken.trim()) { + options = { headers: { authorization: `token ${siteToken}` } }; + } + + const baseTemplateHtml = await fetch(`${url}.plain.html`, { ...options }).then((resp) => resp.text()); + + const $ = cheerio.load(`
${baseTemplateHtml}
`); + + blocks.forEach((block) => { + $(`.${block}`).replaceWith(`{{> ${block} }}`); + }); + + let adaptedBaseTemplate = $('main').prop('innerHTML'); + adaptedBaseTemplate = adaptedBaseTemplate.replace(/>/g, '>') + '\n'; + + return adaptedBaseTemplate; +} + +/** + * Sanitizes HTML content by removing disallowed or unbalanced tags. + * Supports three modes: 'all', 'inline', 'no'. + * 'all': allows all block and inline tags supported by edge delivery. + * 'inline': allows all inline tags supported by edge delivery. + * 'no': allows no tags + * + * @param {string} html - HTML string to sanitize + * @param {string} [mode='all'] - Sanitization mode + * @returns {string} Sanitized HTML string + */ +function sanitize(html, mode = 'all') { + const allowedInlineTags = ['a', 'br', 'code', 'del', 'em', 'img', 'strong', 'sub', 'sup', 'u']; + const allowedAllTags = [ + 'p', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'ul', + 'ol', + 'li', + 'pre', + 'table', + 'tbody', + 'td', + 'th', + 'thead', + 'tr', + ...allowedInlineTags, + ]; + + if (mode === 'all') { + return striptags(html, allowedAllTags); + } else if (mode === 'inline') { + return striptags(html, allowedInlineTags); + } else if (mode === 'no') { + return striptags(html); + } +} + +/** + * Constructs a file path for state/data files. + * + * @param {string} prefix - The file prefix (e.g. FILE_PREFIX or PLP_FILE_PREFIX). + * @param {string} stateKey - The state key (typically locale or 'default'). + * @param {string} extension - The file extension. + * @returns {string} The constructed file path. + */ +function getFileLocation(prefix, stateKey, extension) { + return `${prefix}/${stateKey}.${extension}`; +} + +/** + * Loads poller state from the cloud file system. + * + * @param {string} locale - The locale (or store code). + * @param {Object} aioLibs - { filesLib }. + * @param {string} filePrefix - The file prefix for this poller's state files. + * @param {string} dataKey - The property name on the state object (e.g. 'skus' or 'categories'). + * @returns {Promise} State object with { locale, [dataKey]: { ... } }. + */ +async function loadState(locale, aioLibs, filePrefix, dataKey) { + const { filesLib } = aioLibs; + const stateObj = { locale, [dataKey]: {} }; + try { + const stateKey = locale || 'default'; + const fileLocation = getFileLocation(filePrefix, stateKey, STATE_FILE_EXT); + const buffer = await filesLib.read(fileLocation); + const stateData = buffer?.toString(); + if (stateData) { + const lines = stateData.split('\n'); + stateObj[dataKey] = lines.reduce((acc, line) => { + const [key, time, hash] = line.split(','); + if (key) { + acc[key] = { lastRenderedAt: new Date(parseInt(time)), hash }; + } + return acc; + }, {}); + } + // eslint-disable-next-line no-unused-vars + } catch (e) { + stateObj[dataKey] = {}; + } + return stateObj; +} + +/** + * Saves poller state to the cloud file system. + * + * @param {Object} state - The state object with { locale, [dataKey]: { ... } }. + * @param {Object} aioLibs - { filesLib }. + * @param {string} filePrefix - The file prefix for this poller's state files. + * @param {string} dataKey - The property name on the state object (e.g. 'skus' or 'categories'). + * @returns {Promise} + */ +async function saveState(state, aioLibs, filePrefix, dataKey) { + const { filesLib } = aioLibs; + const stateKey = state.locale || 'default'; + const fileLocation = getFileLocation(filePrefix, stateKey, STATE_FILE_EXT); + const csvData = Object.entries(state[dataKey]) + .filter(([, { lastRenderedAt }]) => Boolean(lastRenderedAt)) + .map(([key, { lastRenderedAt, hash }]) => `${key},${lastRenderedAt.getTime()},${hash || ''}`) + .join('\n'); + return await filesLib.write(fileLocation, csvData); +} + +/** + * Deletes poller state from the cloud file system. + * + * @param {string} locale - The locale key. + * @param {Object} filesLib - The Files library instance. + * @param {string} filePrefix - The file prefix for this poller's state files. + * @returns {Promise} + */ +async function deleteState(locale, filesLib, filePrefix) { + const stateKey = `${locale}`; + const fileLocation = getFileLocation(filePrefix, stateKey, STATE_FILE_EXT); + await filesLib.delete(fileLocation); +} + +/** + * Checks if an item should be previewed & published based on hash comparison. + * + * @param {{ currentHash: string, newHash: string }} item + * @returns {boolean} + */ +function shouldPreviewAndPublish({ currentHash, newHash }) { + return newHash && currentHash !== newHash; +} + +/** + * Processes a published batch: updates state entries for successfully + * published records and increments the appropriate counters. + * + * @param {Object} publishedBatch - The batch result from AdminAPI. + * @param {Object} state - The current poller state. + * @param {Object} counts - Mutable counters ({ published, failed, ... }). + * @param {Array} items - The rendered items that were submitted for publishing. + * @param {Object} aioLibs - { filesLib }. + * @param {Object} options + * @param {string} options.dataKey - State property name (e.g. 'skus' or 'categories'). + * @param {string} options.keyField - Record identifier field (e.g. 'sku' or 'slug'). + * @param {string} options.filePrefix - File prefix for saving state. + */ +async function processPublishedBatch(publishedBatch, state, counts, items, aioLibs, { dataKey, keyField, filePrefix }) { + const { records } = publishedBatch; + records.forEach((record) => { + if (record.previewedAt && record.publishedAt) { + const item = items.find((i) => i[keyField] === record[keyField]); + state[dataKey][record[keyField]] = { + lastRenderedAt: record.renderedAt, + hash: item?.newHash, + }; + counts.published++; + } else { + counts.failed++; + } + }); + await saveState(state, aioLibs, filePrefix, dataKey); +} + +/** + * Validates that all required parameters are present and that storeUrl (if + * provided) is a valid URL. Throws JobFailedError on failure. + * + * @param {Object} params - The parameters object. + * @param {string[]} requiredParams - List of required parameter names. + */ +function validateRequiredParams(params, requiredParams) { + const missingParams = requiredParams.filter((param) => !params[param]); + if (missingParams.length > 0) { + throw new JobFailedError( + `Missing required parameters: ${missingParams.join(', ')}`, + ERROR_CODES.VALIDATION_ERROR, + 400, + { missingParams }, + ); + } + + if (params.storeUrl && !isValidUrl(params.storeUrl)) { + throw new JobFailedError('Invalid storeUrl', ERROR_CODES.VALIDATION_ERROR, 400); + } +} + +/** + * Finds the description of a product based on a priority list of fields. + * @param {Object} product The product object. + * @param {Array} priority The list of fields to check for the description, in order of priority. + * @returns {string} The description of the product. + */ +function findDescription(product, priority = ['metaDescription', 'shortDescription', 'description']) { + return ( + priority + .map((d) => product[d]?.trim() || '') + .map((d) => striptags(d)) + .map((d) => d.replace(/\r?\n|\r/g, '')) + .find((d) => d.length > 0) || '' + ); +} + +/** + * Returns the first image of a product based on the specified role or the first image if no role is specified. + * @param {Object} product The product. + * @param {string} [role='image'] The role of the image to find. + * @returns {Object|undefined} The primary image object or undefined if not found. + */ +function getPrimaryImage(product, role = 'image') { + if (role) { + return product?.images?.find((img) => img.roles.includes(role)); + } + + return product?.images?.length > 0 ? product?.images?.[0] : undefined; +} + +/** + * Generates a list of image URLs for a product, ensuring the primary image is first. + * + * @param {string} primary The URL of the primary image. + * @param {Array} images The list of image objects. + * @returns {Array} The list of image URLs with the primary image first. + */ +function getImageList(primary, images) { + const imageList = images?.map((img) => img.url); + if (primary) { + const primaryImageIndex = imageList.indexOf(primary); + if (primaryImageIndex > -1) { + imageList.splice(primaryImageIndex, 1); + imageList.unshift(primary); + } + } + return imageList; +} + +/** + * Returns a number formatter for the specified locale and currency. + * + * @param {string} [locale] The locale to use for formatting. Defaults to us-en. + * @param {string} [currency] The currency code to use for formatting. Defaults to USD. + * @returns {Intl.NumberFormat} The number formatter. + */ +function getFormatter(locale = 'us-en', currency) { + return new Intl.NumberFormat(locale, { + style: 'currency', + currency: !currency || currency === 'NONE' ? 'USD' : currency, + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); +} + +/** + * Generates a formatted price string of a simple or complex product. + * + * @param {Object} product Product object. + * @param {string} [localeCode] Locale code for formatting. Defaults to us-en. + * @returns {string} Formatted price string. + */ +function generatePriceString(product, localeCode = 'us-en') { + const { price, priceRange } = product; + let currency = priceRange ? priceRange?.minimum?.regular?.amount?.currency : price?.regular?.amount?.currency; + const format = getFormatter(localeCode, currency).format; + let priceString = ''; + + if (priceRange) { + const hasRange = priceRange.minimum.final.amount.value !== priceRange.maximum.final.amount.value; + if (hasRange) { + const minimumDiscounted = priceRange.minimum.regular.amount.value > priceRange.minimum.final.amount.value; + if (minimumDiscounted) { + priceString = `${format(priceRange.minimum.regular.amount.value)} ${format(priceRange.minimum.final.amount.value)}`; + } else { + priceString = `${format(priceRange.minimum.final.amount.value)}`; + } + priceString += '-'; + const maximumDiscounted = priceRange.maximum.regular.amount.value > priceRange.maximum.final.amount.value; + if (maximumDiscounted) { + priceString += `${format(priceRange.maximum.regular.amount.value)} ${format(priceRange.maximum.final.amount.value)}`; + } else { + priceString += `${format(priceRange.maximum.final.amount.value)}`; + } + } else { + const isDiscounted = priceRange.minimum.regular.amount.value > priceRange.minimum.final.amount.value; + if (isDiscounted) { + priceString = `${format(priceRange.minimum.regular.amount.value)} ${format(priceRange.minimum.final.amount.value)}`; + } else { + priceString = `${format(priceRange.minimum.final.amount.value)}`; + } + } + } else if (price) { + const isDiscounted = price.regular.amount.value > price.final.amount.value; + if (isDiscounted) { + priceString = `${format(price.regular.amount.value)} ${format(price.final.amount.value)}`; + } else { + priceString = `${format(price.final.amount.value)}`; + } + } + return priceString; +} + +/** + * Extracts the final price amount from a product, handling both simple (price) + * and complex (priceRange) product types. For complex products the minimum + * price is used. + * + * @param {Object} product The product object. + * @returns {{ value: number, currency: string } | null} + */ +function getProductPrice(product) { + const priceAmount = product.price?.final?.amount || product.priceRange?.minimum?.final?.amount; + if (!priceAmount) return null; + + const currency = !priceAmount.currency || priceAmount.currency === 'NONE' ? 'USD' : priceAmount.currency; + return { value: priceAmount.value, currency }; +} + +/** + * Extracts the GTIN (Global Trade Item Number) from a product's attributes. + * Checks for GTIN, UPC, EAN, or ISBN attributes. + * + * @param {Object} product - The product object containing attributes. + * @returns {string} The GTIN value if found, empty string otherwise. + */ +function getGTIN(product) { + return ( + product?.attributes?.find((attr) => attr.name === 'gtin')?.value || + product?.attributes?.find((attr) => attr.name === 'upc')?.value || + product?.attributes?.find((attr) => attr.name === 'ean')?.value || + product?.attributes?.find((attr) => attr.name === 'isbn')?.value || + '' + ); +} + +/** + * Extracts the brand name from a product's attributes. + * + * @param {Object} product - The product object containing attributes. + * @returns {string} The brand value if found, empty string otherwise. + */ +function getBrand(product) { + return product?.attributes?.find((attr) => attr.name === 'brand')?.value || ''; +} + +module.exports = { + extractPathDetails, + prepareBaseTemplate, + sanitize, + PUBLIC_HTML_DIR, + getHtmlFilePath, + getFileLocation, + loadState, + saveState, + deleteState, + shouldPreviewAndPublish, + processPublishedBatch, + validateRequiredParams, + findDescription, + getPrimaryImage, + getImageList, + getFormatter, + generatePriceString, + getProductPrice, + getGTIN, + getBrand, +}; diff --git a/actions/utils.js b/actions/utils.js index c54d38bf..9ab33f3b 100644 --- a/actions/utils.js +++ b/actions/utils.js @@ -12,6 +12,7 @@ governing permissions and limitations under the License. const deepmerge = require('@fastify/deepmerge')(); const helixSharedStringLib = require('@adobe/helix-shared-string'); const { ERROR_CODES } = require('./lib/errorHandler'); + const BATCH_SIZE = 50; const SITE_TYPES = Object.freeze({ @@ -54,13 +55,16 @@ function createBatches(products) { * @returns {array} * @private */ -function getMissingKeys (obj, required) { - return required.filter(r => { - const splits = r.split('.') - const last = splits[splits.length - 1] - const traverse = splits.slice(0, -1).reduce((tObj, split) => { tObj = (tObj[split] || {}); return tObj }, obj) - return traverse[last] === undefined || traverse[last] === '' // missing default params are empty string - }) +function getMissingKeys(obj, required) { + return required.filter((r) => { + const splits = r.split('.'); + const last = splits[splits.length - 1]; + const traverse = splits.slice(0, -1).reduce((tObj, split) => { + tObj = tObj[split] || {}; + return tObj; + }, obj); + return traverse[last] === undefined || traverse[last] === ''; // missing default params are empty string + }); } /** @@ -77,29 +81,29 @@ function getMissingKeys (obj, required) { * @returns {string} if the return value is not null, then it holds an error message describing the missing inputs. * */ -function checkMissingRequestInputs (params, requiredParams = [], requiredHeaders = []) { - let errorMessage = null +function checkMissingRequestInputs(params, requiredParams = [], requiredHeaders = []) { + let errorMessage = null; // input headers are always lowercase - requiredHeaders = requiredHeaders.map(h => h.toLowerCase()) + requiredHeaders = requiredHeaders.map((h) => h.toLowerCase()); // check for missing headers - const missingHeaders = getMissingKeys(params.__ow_headers || {}, requiredHeaders) + const missingHeaders = getMissingKeys(params.__ow_headers || {}, requiredHeaders); if (missingHeaders.length > 0) { - errorMessage = `missing header(s) '${missingHeaders}'` + errorMessage = `missing header(s) '${missingHeaders}'`; } // check for missing parameters - const missingParams = getMissingKeys(params, requiredParams) + const missingParams = getMissingKeys(params, requiredParams); if (missingParams.length > 0) { if (errorMessage) { - errorMessage += ' and ' + errorMessage += ' and '; } else { - errorMessage = '' + errorMessage = ''; } - errorMessage += `missing parameter(s) '${missingParams}'` + errorMessage += `missing parameter(s) '${missingParams}'`; } - return errorMessage + return errorMessage; } /** @@ -111,13 +115,15 @@ function checkMissingRequestInputs (params, requiredParams = [], requiredHeaders * @returns {string|undefined} the token string or undefined if not set in request headers. * */ -function getBearerToken (params) { - if (params.__ow_headers && - params.__ow_headers.authorization && - params.__ow_headers.authorization.startsWith('Bearer ')) { - return params.__ow_headers.authorization.substring('Bearer '.length) +function getBearerToken(params) { + if ( + params.__ow_headers && + params.__ow_headers.authorization && + params.__ow_headers.authorization.startsWith('Bearer ') + ) { + return params.__ow_headers.authorization.substring('Bearer '.length); } - return undefined + return undefined; } /** @@ -134,18 +140,18 @@ function getBearerToken (params) { * @returns {object} the error object, ready to be returned from the action main's function. * */ -function errorResponse (statusCode, message, logger) { +function errorResponse(statusCode, message, logger) { if (logger && typeof logger.info === 'function') { - logger.info(`${statusCode}: ${message}`) + logger.info(`${statusCode}: ${message}`); } return { error: { statusCode, body: { - error: message - } - } - } + error: message, + }, + }, + }; } /** @@ -184,11 +190,15 @@ async function request(name, url, req, timeout = 60000) { } else { try { responseText = await resp.text(); - // eslint-disable-next-line no-unused-vars - } catch (e) { /* nothing to be done */ } + // eslint-disable-next-line no-unused-vars + } catch (e) { + /* nothing to be done */ + } } - throw new Error(`Request '${name}' to '${url}' failed (${resp.status}): ${resp.headers.get('x-error') || resp.statusText}${responseText.length > 0 ? ` responseText: ${responseText}` : ''}`); + throw new Error( + `Request '${name}' to '${url}' failed (${resp.status}): ${resp.headers.get('x-error') || resp.statusText}${responseText.length > 0 ? ` responseText: ${responseText}` : ''}`, + ); } /** @@ -207,10 +217,10 @@ async function requestSpreadsheet(name, sheet, context, offset = 0) { let options = undefined; // Site Token Validation: needs to be a non empty string if (typeof siteToken === 'string' && siteToken.trim()) { - options = {headers:{'authorization': `token ${siteToken}`}} + options = { headers: { authorization: `token ${siteToken}` } }; } - let sheetUrl = `${contentUrl}/${name}.json` + let sheetUrl = `${contentUrl}/${name}.json`; const requestURL = new URL(sheetUrl); if (sheet) { @@ -234,7 +244,6 @@ async function requestSpreadsheet(name, sheet, context, offset = 0) { * @returns {Promise} spreadsheet data as JSON. */ async function requestPublishedProductsIndex(context) { - const publishedProductsIndex = await requestSpreadsheet('published-products-index', null, context, 0); for (let offset = 1000; offset < publishedProductsIndex.total; offset += 1000) { @@ -242,7 +251,7 @@ async function requestPublishedProductsIndex(context) { publishedProductsIndex.data.push(...tempPublishedProductsIndex.data); } publishedProductsIndex.limit = publishedProductsIndex.total; - + return publishedProductsIndex; } @@ -252,16 +261,16 @@ async function requestConfigService(context) { let options = undefined; // Site Token Validation: needs to be a non empty string if (typeof siteToken === 'string' && siteToken.trim()) { - options = {headers:{'authorization': `token ${siteToken}`}} + options = { headers: { authorization: `token ${siteToken}` } }; } - let publicConfig = `${contentUrl}/${configName}.json` + let publicConfig = `${contentUrl}/${configName}.json`; return request('configservice', publicConfig, options); } /** * Returns the parsed configuration. It first tries to fetch the config from the config service, - * and if that fails, it falls back to the spreadsheet. The configuration returned from config + * and if that fails, it falls back to the spreadsheet. The configuration returned from config * service must contain a default config and may contain a specific config for the current locale. * In this case the configuration is merged. * @@ -278,12 +287,19 @@ async function getConfig(context) { try { const configObj = await requestConfigService(context); const defaultConfig = configObj?.public.default; - if (!defaultConfig){ + if (!defaultConfig) { throw new Error('No default config found'); } // get the matching root path // see https://github.com/hlxsites/aem-boilerplate-commerce/blob/53fb19440df441723c0c891d22e3a3396d2968ce/scripts/configs.js#L59-L81 - let pathname = `${getProductUrl({ /* no product */}, context, false)}` || ''; + let pathname = + `${getProductUrl( + { + /* no product */ + }, + context, + false, + )}` || ''; if (!pathname.endsWith('/')) pathname += '/'; let rootPath = Object.keys(configObj.public) @@ -294,18 +310,18 @@ async function getConfig(context) { return bSegments - aSegments; }) .find((key) => pathname === key || pathname.startsWith(key)); - + context.config = rootPath ? deepmerge(defaultConfig, configObj.public[rootPath]) : defaultConfig; return context.config; } catch (e) { logger.debug(`Failed to fetch public config. Falling back to spreadsheet`, e); } - + // fallback to spreadsheet in a locale specific folder if locale is provided let spreadsheetPath = locale ? `${locale}/${configName}` : configName; logger.debug(`Fetching config ${configName}`); const configData = await requestSpreadsheet(spreadsheetPath, configSheet, context); - if(configData.data) { + if (configData.data) { context.config = configData.data.reduce((acc, { key, value }) => ({ ...acc, [key]: value }), {}); context.config.__hasLegacyFormat = true; } else { @@ -317,20 +333,18 @@ async function getConfig(context) { /** * Converts the keys of an object to lowercase. - * + * * @param {Object} obj - The object to convert the keys of. * @returns {Object} The object with the keys converted to lowercase. */ function lowercaseKeys(obj) { if (!obj || typeof obj !== 'object') return {}; - return Object.fromEntries( - Object.entries(obj).map(([k, v]) => [k.toLowerCase(), v]), - ); + return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k.toLowerCase(), v])); } /** * Returns the Commerce Catalog Service headers based on the site type and configuration. - * + * * @param {Object} config - The site configuration object returned by getConfig(). * @returns {Object} The Commerce Catalog Service headers. */ @@ -341,8 +355,7 @@ function getCsHeaders(config) { if (siteType === SITE_TYPES.ACO) { const configHeaders = lowercaseKeys(config.headers?.cs); const policyHeaders = Object.fromEntries( - Object.entries(configHeaders) - .filter(([key]) => key.startsWith('ac-policy-')), + Object.entries(configHeaders).filter(([key]) => key.startsWith('ac-policy-')), ); csHeaders = { 'ac-view-id': configHeaders['ac-view-id'], @@ -352,10 +365,13 @@ function getCsHeaders(config) { } else { if (config.__hasLegacyFormat) { csHeaders = { - 'magento-customer-group': config['commerce.headers.cs.Magento-Customer-Group'] || config['commerce-customer-group'], - 'magento-environment-id': config['commerce.headers.cs.Magento-Environment-Id'] || config['commerce-environment-id'], + 'magento-customer-group': + config['commerce.headers.cs.Magento-Customer-Group'] || config['commerce-customer-group'], + 'magento-environment-id': + config['commerce.headers.cs.Magento-Environment-Id'] || config['commerce-environment-id'], 'magento-store-code': config['commerce.headers.cs.Magento-Store-Code'] || config['commerce-store-code'], - 'magento-store-view-code': config['commerce.headers.cs.Magento-Store-View-Code'] || config['commerce-store-view-code'], + 'magento-store-view-code': + config['commerce.headers.cs.Magento-Store-View-Code'] || config['commerce-store-view-code'], 'magento-website-code': config['commerce.headers.cs.Magento-Website-Code'] || config['commerce-website-code'], 'x-api-key': config['commerce.headers.cs.x-api-key'] || config['commerce-x-api-key'], }; @@ -388,31 +404,27 @@ function getCsHeaders(config) { async function requestSaaS(query, operationName, variables, context) { const { storeUrl, logger, configOverrides = {} } = context; const config = { - ... (await getConfig(context)), - ...configOverrides + ...(await getConfig(context)), + ...configOverrides, }; const headers = { 'Content-Type': 'application/json', - 'origin': storeUrl, + origin: storeUrl, ...getCsHeaders(config), 'Magento-Is-Preview': true, // bypass LiveSearch cache }; const method = 'POST'; - const response = await request( - `${operationName}(${JSON.stringify(variables)})`, - config['commerce-endpoint'], - { - method, - headers, - body: JSON.stringify({ - operationName, - query, - variables, - }) - } - ); + const response = await request(`${operationName}(${JSON.stringify(variables)})`, config['commerce-endpoint'], { + method, + headers, + body: JSON.stringify({ + operationName, + query, + variables, + }), + }); // Log GraphQL errors if (response?.errors) { @@ -437,9 +449,7 @@ function getSiteType(config) { return SITE_TYPES.ACO; } const csHeaders = config.headers?.cs; - if (csHeaders && Object.keys(csHeaders).some( - (key) => key.toLowerCase().startsWith('ac-') - )) { + if (csHeaders && Object.keys(csHeaders).some((key) => key.toLowerCase().startsWith('ac-'))) { return SITE_TYPES.ACO; } return SITE_TYPES.ACCS; @@ -476,15 +486,16 @@ function getProductUrl(product, context, addStore = true) { sku: product.sku, urlKey: product.urlKey, }; - + // Only add locale if it has a valid value if (context.locale) { availableParams.locale = context.locale; } - let path = pathFormat.split('/') + let path = pathFormat + .split('/') .filter(Boolean) - .map(part => { + .map((part) => { if (part.startsWith('{') && part.endsWith('}')) { const key = part.substring(1, part.length - 1); // Skip parts where we don't have a value @@ -539,21 +550,18 @@ function getCategoryUrl(categorySlug, context, addStore = true) { } function getDefaultStoreURL(params) { - const { - ORG: orgName, - SITE: siteName, - } = params; - return `https://main--${siteName}--${orgName}.aem.live`; + const { ORG: orgName, SITE: siteName } = params; + return `https://main--${siteName}--${orgName}.aem.live`; } /** * Formats a memory usage value in bytes to a human-readable string in megabytes. - * + * * @param {number} data - The memory usage value in bytes * @returns {string} The formatted memory usage string in MB with 2 decimal places */ function formatMemoryUsage(data) { - return `${Math.round(data / 1024 / 1024 * 100) / 100} MB`; + return `${Math.round((data / 1024 / 1024) * 100) / 100} MB`; } module.exports = { @@ -577,4 +585,4 @@ module.exports = { PLP_FILE_PREFIX, PDP_FILE_EXT, STATE_FILE_EXT, -} +}; diff --git a/package-lock.json b/package-lock.json index 9ed716b1..a4dfe872 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,7 +39,8 @@ "eslint-plugin-jest": "^29.0.0", "globals": "^16.0.0", "jest": "^30.0.0", - "msw": "^2.7.0" + "msw": "^2.7.0", + "prettier": "^3.8.1" }, "engines": { "node": ">=18" @@ -11426,6 +11427,21 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-bytes": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", diff --git a/package.json b/package.json index 7382af91..aa6b33a5 100644 --- a/package.json +++ b/package.json @@ -17,13 +17,13 @@ "diff": "^8.0.2", "dotenv-stringify": "^3.0.1", "handlebars": "^4.7.8", + "itty-router": "^5.0.0", "js-yaml": "^4.1.0", "node-fetch": "^2.6.0", "p-limit": "^6.2.0", "striptags": "^3.2.0", "vega": "^6.0.0", - "vega-lite": "^6.0.0", - "itty-router": "^5.0.0" + "vega-lite": "^6.0.0" }, "devDependencies": { "@eslint/js": "^9.17.0", @@ -34,11 +34,14 @@ "eslint-plugin-jest": "^29.0.0", "globals": "^16.0.0", "jest": "^30.0.0", - "msw": "^2.7.0" + "msw": "^2.7.0", + "prettier": "^3.8.1" }, "scripts": { "test": "c8 node --experimental-vm-modules node_modules/jest/bin/jest.js --passWithNoTests ./test", "e2e": "node --experimental-vm-modules node_modules/jest/bin/jest.js --collectCoverage=false --testRegex ./e2e", + "format": "prettier --write \"actions/**/*.js\" \"test/**/*.js\"", + "format:check": "prettier --check \"actions/**/*.js\" \"test/**/*.js\"", "lint": "eslint --ignore-pattern web-src --no-error-on-unmatched-pattern test src actions", "lint:fix": "npm run lint -- --fix", "deploy": "rm -r dist; aio app deploy", diff --git a/test/check-product-changes.test.js b/test/check-product-changes.test.js index 0ed147a5..8cbf3ad3 100644 --- a/test/check-product-changes.test.js +++ b/test/check-product-changes.test.js @@ -11,7 +11,9 @@ governing permissions and limitations under the License. */ const assert = require('node:assert/strict'); -const { loadState, saveState, getFileLocation, poll } = require('../actions/check-product-changes/poller'); +const { poll } = require('../actions/check-product-changes/poller'); +const { loadState, saveState, getFileLocation } = require('../actions/renderUtils'); +const { FILE_PREFIX } = require('../actions/utils'); const Files = require('./__mocks__/files'); const { AdminAPI } = require('../actions/lib/aem'); const { requestSaaS, isValidUrl, requestPublishedProductsIndex } = require('../actions/utils'); @@ -43,17 +45,21 @@ const mockLogger = { debug: jest.fn(), }; -jest.mock('../actions/utils', () => ({ - requestSaaS: jest.fn(), - requestPublishedProductsIndex: jest.fn(), - isValidUrl: jest.fn(() => true), - getProductUrl: jest.fn(({ urlKey }) => `/p/${urlKey}`), - getDefaultStoreURL: jest.fn(() => 'https://content.com'), - formatMemoryUsage: jest.fn(() => '100MB'), - FILE_PREFIX: 'check-product-changes', - STATE_FILE_EXT: 'csv', - PDP_FILE_EXT: 'html', -})); +jest.mock('../actions/utils', () => { + const actual = jest.requireActual('../actions/utils'); + return { + requestSaaS: jest.fn(), + requestPublishedProductsIndex: jest.fn(), + isValidUrl: jest.fn(() => true), + getProductUrl: jest.fn(({ urlKey }) => `/p/${urlKey}`), + getDefaultStoreURL: jest.fn(() => 'https://content.com'), + formatMemoryUsage: jest.fn(() => '100MB'), + createBatches: actual.createBatches, + FILE_PREFIX: 'check-product-changes', + STATE_FILE_EXT: 'csv', + PDP_FILE_EXT: 'html', + }; +}); jest.spyOn(AdminAPI.prototype, 'startProcessing').mockImplementation(jest.fn()); jest.spyOn(AdminAPI.prototype, 'stopProcessing').mockImplementation(jest.fn()); @@ -64,7 +70,7 @@ jest.spyOn(AdminAPI.prototype, 'previewAndPublish').mockImplementation((batch) = ...record, previewedAt: record.sku === 'sku-failed-due-preview' ? null : new Date(), publishedAt: record.sku === 'sku-failed-due-publishing' ? null : new Date(), - })) + })), }); }); @@ -93,11 +99,11 @@ jest.mock('crypto', () => { if (content === 'Product 456') return 'current-hash-for-product-456'; if (content === 'Product 789') return 'current-hash-for-product-789'; return 'default-hash'; - }) + }), }; - }) + }), }; - }) + }), }; }); @@ -127,17 +133,15 @@ describe('Poller', () => { }; const setupSkuData = (filesLib, stateLib, skuData, lastQueriedAt) => { - const skuEntries = Object.entries(skuData).map(([sku, { timestamp, hash = '' }]) => - `${sku},${timestamp},${hash}` - ).join('\n'); + const skuEntries = Object.entries(skuData) + .map(([sku, { timestamp, hash = '' }]) => `${sku},${timestamp},${hash}`) + .join('\n'); - let skuInfo = Object.entries(skuData).map(([sku]) => ( - { - sku: `${sku}` - } - )); + let skuInfo = Object.entries(skuData).map(([sku]) => ({ + sku: `${sku}`, + })); - skuInfo = JSON.stringify(skuInfo); + skuInfo = JSON.stringify(skuInfo); filesLib.read.mockResolvedValueOnce(skuEntries).mockResolvedValueOnce(skuInfo); stateLib.get.mockResolvedValueOnce({ value: lastQueriedAt }); }; @@ -148,18 +152,18 @@ describe('Poller', () => { return Promise.resolve({ data: { productSearch: { - items: skus.map(sku => ({ productView: {sku} })) - } + items: skus.map((sku) => ({ productView: { sku } })), + }, }, }); } if (operation === 'getLastModified') { return Promise.resolve({ data: { - products: variables.skus.map(sku => ({ - urlKey: `url-${sku}`, - sku, - lastModifiedAt: new Date().getTime() - lastModifiedOffset + products: variables.skus.map((sku) => ({ + urlKey: `url-${sku}`, + sku, + lastModifiedAt: new Date().getTime() - lastModifiedOffset, })), }, }); @@ -177,29 +181,26 @@ describe('Poller', () => { it('loadState returns default state', async () => { const filesLib = new Files(0); const stateLib = new MockState(0); - const state = await loadState('uk', { filesLib, stateLib }); - assert.deepEqual( - state, - { - locale: 'uk', - skus: {}, - } - ); + const state = await loadState('uk', { filesLib, stateLib }, FILE_PREFIX, 'skus'); + assert.deepEqual(state, { + locale: 'uk', + skus: {}, + }); }); it('loadState returns parsed state', async () => { const filesLib = new Files(0); const stateLib = new MockState(0); - await filesLib.write(getFileLocation('uk', 'csv'), EXAMPLE_STATE); - const state = await loadState('uk', { filesLib, stateLib }); + await filesLib.write(getFileLocation(FILE_PREFIX, 'uk', 'csv'), EXAMPLE_STATE); + const state = await loadState('uk', { filesLib, stateLib }, FILE_PREFIX, 'skus'); assert.deepEqual(state, EXAMPLE_EXPECTED_STATE); }); it('loadState after saveState', async () => { const filesLib = new Files(0); const stateLib = new MockState(0); - await filesLib.write(getFileLocation('uk', 'csv'), EXAMPLE_STATE); - const state = await loadState('uk', { filesLib, stateLib }); + await filesLib.write(getFileLocation(FILE_PREFIX, 'uk', 'csv'), EXAMPLE_STATE); + const state = await loadState('uk', { filesLib, stateLib }, FILE_PREFIX, 'skus'); assert.deepEqual(state, EXAMPLE_EXPECTED_STATE); state.skus['sku1'] = { lastRenderedAt: new Date(4), @@ -209,20 +210,20 @@ describe('Poller', () => { lastRenderedAt: new Date(5), hash: 'hash2', }; - await saveState(state, { filesLib, stateLib }); + await saveState(state, { filesLib, stateLib }, FILE_PREFIX, 'skus'); - const serializedState = await filesLib.read(getFileLocation('uk', 'csv')); + const serializedState = await filesLib.read(getFileLocation(FILE_PREFIX, 'uk', 'csv')); assert.equal(serializedState, 'sku1,4,hash1\nsku2,5,hash2\nsku3,3,'); - const newState = await loadState('uk', { filesLib, stateLib }); + const newState = await loadState('uk', { filesLib, stateLib }, FILE_PREFIX, 'skus'); assert.deepEqual(newState, state); }); it('loadState after saveState with null storeCode', async () => { const filesLib = new Files(0); const stateLib = new MockState(0); - await filesLib.write(getFileLocation('default', 'csv'), EXAMPLE_STATE); - const state = await loadState('default', { filesLib, stateLib }); + await filesLib.write(getFileLocation(FILE_PREFIX, 'default', 'csv'), EXAMPLE_STATE); + const state = await loadState('default', { filesLib, stateLib }, FILE_PREFIX, 'skus'); const expectedState = { ...EXAMPLE_EXPECTED_STATE, locale: 'default', @@ -236,9 +237,9 @@ describe('Poller', () => { lastRenderedAt: new Date(5), hash: 'hash2', }; - await saveState(state, { filesLib, stateLib }); + await saveState(state, { filesLib, stateLib }, FILE_PREFIX, 'skus'); - const serializedState = await filesLib.read(getFileLocation('default', 'csv')); + const serializedState = await filesLib.read(getFileLocation(FILE_PREFIX, 'default', 'csv')); assert.equal(serializedState, 'sku1,4,hash1\nsku2,5,hash2\nsku3,3,'); }); @@ -246,12 +247,13 @@ describe('Poller', () => { it('should throw an error if required parameters are missing', async () => { const params = { ...defaultParams }; delete params.configName; - + const filesLib = mockFiles(); const stateLib = mockState(); - await expect(poll(params, { filesLib, stateLib }, mockLogger)) - .rejects.toThrow('Missing required parameters: configName'); + await expect(poll(params, { filesLib, stateLib }, mockLogger)).rejects.toThrow( + 'Missing required parameters: configName', + ); }); it('should throw an error if STORE_URL is invalid', async () => { @@ -260,12 +262,11 @@ describe('Poller', () => { ...defaultParams, storeUrl: 'invalid-url', }; - + const filesLib = mockFiles(); const stateLib = mockState(); - await expect(poll(params, { filesLib, stateLib }, mockLogger)) - .rejects.toThrow('Invalid storeUrl'); + await expect(poll(params, { filesLib, stateLib }, mockLogger)).rejects.toThrow('Invalid storeUrl'); }); }); @@ -277,12 +278,12 @@ describe('Poller', () => { // Setup initial state with existing products setupSkuData( - filesLib, - stateLib, - { - 'sku-123': { timestamp: now - 100000, hash: 'old-hash-for-product-123' } - }, - now - 700000 + filesLib, + stateLib, + { + 'sku-123': { timestamp: now - 100000, hash: 'old-hash-for-product-123' }, + }, + now - 700000, ); // Mock catalog service responses @@ -297,23 +298,18 @@ describe('Poller', () => { // Verify hash was updated expect(filesLib.write).toHaveBeenCalledWith( - expect.any(String), - expect.stringContaining('current-hash-for-product-123') + expect.any(String), + expect.stringContaining('current-hash-for-product-123'), ); // Verify HTML file was saved - expect(filesLib.write).toHaveBeenCalledWith( - '/public/pdps/p/url-sku-123.html', - 'Product 123' - ); + expect(filesLib.write).toHaveBeenCalledWith('/public/pdps/p/url-sku-123.html', 'Product 123'); // Verify API calls expect(AdminAPI.prototype.previewAndPublish).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ path: '/p/url-sku-123', sku: 'sku-123' }) - ]), - null, - 1 + expect.arrayContaining([expect.objectContaining({ path: '/p/url-sku-123', sku: 'sku-123' })]), + null, + 1, ); expect(AdminAPI.prototype.startProcessing).toHaveBeenCalledTimes(1); expect(AdminAPI.prototype.stopProcessing).toHaveBeenCalledTimes(1); @@ -353,34 +349,34 @@ describe('Poller', () => { const filesLib = mockFiles(); const stateLib = mockState(); const lastRenderedAt = now - 10000; - + // Setup initial state with existing products that have current hash setupSkuData( - filesLib, - stateLib, + filesLib, + stateLib, { - 'sku-456': { timestamp: lastRenderedAt, hash: 'current-hash-for-product-456' } - }, - now - 700000 + 'sku-456': { timestamp: lastRenderedAt, hash: 'current-hash-for-product-456' }, + }, + now - 700000, ); - + // Mock catalog service responses mockSaaSResponse(['sku-456'], 5000); - + const result = await poll(defaultParams, { filesLib, stateLib }, mockLogger); // Verify results expect(result.state).toBe('completed'); expect(result.status.published).toBe(0); expect(result.status.ignored).toBe(1); - + // Verify no preview/publish was called expect(AdminAPI.prototype.previewAndPublish).not.toHaveBeenCalled(); // Verify state was updated with the lastRenderedAt expect(filesLib.write).toHaveBeenCalledWith( 'check-product-changes/default.csv', - expect.not.stringContaining(String(lastRenderedAt)) + expect.not.stringContaining(String(lastRenderedAt)), ); }); @@ -388,28 +384,28 @@ describe('Poller', () => { const now = new Date().getTime(); const filesLib = mockFiles(); const stateLib = mockState(); - + // Setup initial state with existing products setupSkuData( - filesLib, - stateLib, + filesLib, + stateLib, { 'sku-failed-due-preview': { timestamp: now - 100000 }, - 'sku-failed-due-publishing': { timestamp: now - 100000 } - }, - now - 700000 + 'sku-failed-due-publishing': { timestamp: now - 100000 }, + }, + now - 700000, ); - + // Mock catalog service responses mockSaaSResponse(['sku-failed-due-preview', 'sku-failed-due-publishing'], 20000); - + const result = await poll(defaultParams, { filesLib, stateLib }, mockLogger); // Verify results expect(result.state).toBe('completed'); expect(result.status.published).toBe(0); expect(result.status.failed).toBe(2); - + // Verify API calls expect(AdminAPI.prototype.previewAndPublish).toHaveBeenCalledTimes(1); }); @@ -418,19 +414,19 @@ describe('Poller', () => { const now = new Date().getTime(); const filesLib = mockFiles(); const stateLib = mockState(); - + // Setup initial state with recently processed products setupSkuData( - filesLib, - stateLib, + filesLib, + stateLib, { 'sku-123': { timestamp: now - 10000 }, 'sku-456': { timestamp: now - 10000 }, - 'sku-789': { timestamp: now - 10000 } - }, - now - 100000 // Recent query time + 'sku-789': { timestamp: now - 10000 }, + }, + now - 100000, // Recent query time ); - + // Mock catalog service responses with older modification times requestSaaS.mockImplementation((query, operation) => { if (operation === 'getLastModified') { @@ -446,14 +442,14 @@ describe('Poller', () => { } return Promise.resolve({}); }); - + const result = await poll(defaultParams, { filesLib, stateLib }, mockLogger); // Verify results expect(result.state).toBe('completed'); expect(result.status.published).toBe(0); expect(result.status.ignored).toBe(3); - + // Verify no processing occurred expect(AdminAPI.prototype.previewAndPublish).not.toHaveBeenCalled(); expect(filesLib.write).not.toHaveBeenCalled(); @@ -463,8 +459,15 @@ describe('Poller', () => { // Test: Poller › Product unpublishing › should unpublish products that are not in the catalog describe('Product unpublishing', () => { it.each([ - [[{ sku: 'sku-456', path: '/p/url-sku-456' }, { sku: 'sku-failed', path: '/p/url-sku-failed' }], 1, 1], - [[{ sku: 'sku-456', path: '/p/url-sku-456' }], 1, 0], + [ + [ + { sku: 'sku-456', path: '/p/url-sku-456' }, + { sku: 'sku-failed', path: '/p/url-sku-failed' }, + ], + 1, + 1, + ], + [[{ sku: 'sku-456', path: '/p/url-sku-456' }], 1, 0], ])('should unpublish products that are not in the catalog', async (spreadsheetResponse, unpublished, failed) => { const now = new Date().getTime(); const filesLib = mockFiles(); @@ -472,14 +475,14 @@ describe('Poller', () => { // Setup initial state with products that will be partially removed setupSkuData( - filesLib, - stateLib, - { - 'sku-123': { timestamp: now - 10000 }, - 'sku-456': { timestamp: now - 10000 }, - 'sku-failed': { timestamp: now - 10000 } - }, - now - 100000 + filesLib, + stateLib, + { + 'sku-123': { timestamp: now - 10000 }, + 'sku-456': { timestamp: now - 10000 }, + 'sku-failed': { timestamp: now - 10000 }, + }, + now - 100000, ); // Mock catalog service to only return one product @@ -487,9 +490,7 @@ describe('Poller', () => { if (operation === 'getLastModified') { return Promise.resolve({ data: { - products: [ - { urlKey: 'url-sku-123', sku: 'sku-123', lastModifiedAt: now - 20000 }, - ], + products: [{ urlKey: 'url-sku-123', sku: 'sku-123', lastModifiedAt: now - 20000 }], }, }); } @@ -544,9 +545,9 @@ describe('Poller', () => { stateLib, { 'sku-123': { timestamp: now - 10000 }, - 'sku-456': { timestamp: now - 10000 } + 'sku-456': { timestamp: now - 10000 }, }, - now - 100000 + now - 100000, ); // Mock catalog service to only return no products (all should be unpublished) @@ -566,7 +567,7 @@ describe('Poller', () => { return Promise.resolve({ data: [ { sku: 'sku-123', path: '/p/url-sku-123' }, - { sku: 'sku-456', path: '/p/url-sku-456' } + { sku: 'sku-456', path: '/p/url-sku-456' }, ], }); }); @@ -578,8 +579,8 @@ describe('Poller', () => { sku, path, liveUnpublishedAt: new Date(), - previewUnpublishedAt: new Date() - })) + previewUnpublishedAt: new Date(), + })), }); }); diff --git a/test/lib.test.js b/test/lib.test.js index c5955d8e..040a312b 100644 --- a/test/lib.test.js +++ b/test/lib.test.js @@ -10,7 +10,7 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ -const { findDescription, getPrimaryImage, extractPathDetails, generatePriceString, prepareBaseTemplate } = require('../actions/pdp-renderer/lib'); +const { findDescription, getPrimaryImage, extractPathDetails, generatePriceString, prepareBaseTemplate } = require('../actions/renderUtils'); describe('lib', () => { test('findDescription', () => { diff --git a/test/render-all-categories.test.js b/test/render-all-categories.test.js index a3fc2405..feccb0e3 100644 --- a/test/render-all-categories.test.js +++ b/test/render-all-categories.test.js @@ -10,9 +10,14 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ -const { loadState, saveState, getFileLocation } = require('../actions/render-all-categories/poller'); +const { loadState, saveState, getFileLocation } = require('../actions/renderUtils'); +const { PLP_FILE_PREFIX } = require('../actions/utils'); const { generatePlpLdJson } = require('../actions/plp-renderer/ldJson'); -const { buildBreadcrumbs, getCategorySlugsFromFamilies, getCategoryDataFromFamilies } = require('../actions/categories'); +const { + buildBreadcrumbs, + getCategorySlugsFromFamilies, + getCategoryDataFromFamilies, +} = require('../actions/categories'); const { getCategoryUrl } = require('../actions/utils'); const Files = require('./__mocks__/files'); const { useMockServer } = require('./mock-server'); @@ -28,7 +33,7 @@ describe('PLP poller state', () => { }); test('loadState returns empty categories when no state file exists', async () => { - const state = await loadState('en', { filesLib }); + const state = await loadState('en', { filesLib }, PLP_FILE_PREFIX, 'categories'); expect(state).toEqual({ locale: 'en', categories: {} }); }); @@ -36,7 +41,7 @@ describe('PLP poller state', () => { const csv = 'electronics,1000,abc123\nelectronics/laptops,2000,def456'; await filesLib.write('render-all-categories/en.csv', csv); - const state = await loadState('en', { filesLib }); + const state = await loadState('en', { filesLib }, PLP_FILE_PREFIX, 'categories'); expect(state.locale).toBe('en'); expect(state.categories['electronics']).toEqual({ lastRenderedAt: new Date(1000), @@ -52,7 +57,7 @@ describe('PLP poller state', () => { const csv = 'electronics,1000,abc123'; await filesLib.write('render-all-categories/default.csv', csv); - const state = await loadState(null, { filesLib }); + const state = await loadState(null, { filesLib }, PLP_FILE_PREFIX, 'categories'); expect(state.locale).toBe(null); expect(state.categories['electronics']).toBeDefined(); }); @@ -61,12 +66,15 @@ describe('PLP poller state', () => { const state = { locale: 'en', categories: { - 'electronics': { lastRenderedAt: new Date(1000), hash: 'abc123' }, - 'electronics/laptops': { lastRenderedAt: new Date(2000), hash: 'def456' }, + electronics: { lastRenderedAt: new Date(1000), hash: 'abc123' }, + 'electronics/laptops': { + lastRenderedAt: new Date(2000), + hash: 'def456', + }, }, }; - await saveState(state, { filesLib }); + await saveState(state, { filesLib }, PLP_FILE_PREFIX, 'categories'); const written = await filesLib.read('render-all-categories/en.csv'); const lines = written.split('\n'); @@ -79,12 +87,12 @@ describe('PLP poller state', () => { const state = { locale: 'en', categories: { - 'electronics': { lastRenderedAt: new Date(1000), hash: 'abc123' }, + electronics: { lastRenderedAt: new Date(1000), hash: 'abc123' }, 'stale-slug': { lastRenderedAt: null, hash: 'xyz' }, }, }; - await saveState(state, { filesLib }); + await saveState(state, { filesLib }, PLP_FILE_PREFIX, 'categories'); const written = await filesLib.read('render-all-categories/en.csv'); expect(written).toBe('electronics,1000,abc123'); @@ -94,13 +102,13 @@ describe('PLP poller state', () => { const original = { locale: 'de', categories: { - 'kleidung': { lastRenderedAt: new Date(5000), hash: 'hash1' }, + kleidung: { lastRenderedAt: new Date(5000), hash: 'hash1' }, 'kleidung/schuhe': { lastRenderedAt: new Date(6000), hash: 'hash2' }, }, }; - await saveState(original, { filesLib }); - const loaded = await loadState('de', { filesLib }); + await saveState(original, { filesLib }, PLP_FILE_PREFIX, 'categories'); + const loaded = await loadState('de', { filesLib }, PLP_FILE_PREFIX, 'categories'); expect(loaded.categories['kleidung'].hash).toBe('hash1'); expect(loaded.categories['kleidung'].lastRenderedAt).toEqual(new Date(5000)); @@ -112,11 +120,11 @@ describe('PLP poller state', () => { describe('getFileLocation', () => { test('constructs path with prefix', () => { - expect(getFileLocation('en', 'csv')).toBe('render-all-categories/en.csv'); + expect(getFileLocation(PLP_FILE_PREFIX, 'en', 'csv')).toBe('render-all-categories/en.csv'); }); test('constructs path for json', () => { - expect(getFileLocation('en-categories', 'json')).toBe('render-all-categories/en-categories.json'); + expect(getFileLocation(PLP_FILE_PREFIX, 'en-categories', 'json')).toBe('render-all-categories/en-categories.json'); }); }); @@ -136,16 +144,24 @@ describe('generatePlpLdJson', () => { const products = [ { + __typename: 'SimpleProductView', name: 'MacBook Pro', sku: 'mbp-16', urlKey: 'macbook-pro', + inStock: true, + shortDescription: 'A powerful laptop', images: [{ url: 'https://img.com/mbp.jpg', roles: ['image'], label: 'MacBook' }], + price: { final: { amount: { value: 1999, currency: 'USD' } } }, }, { + __typename: 'ComplexProductView', name: 'ThinkPad X1', sku: 'tp-x1', urlKey: 'thinkpad-x1', + inStock: false, + shortDescription: 'A business laptop', images: [{ url: 'https://img.com/tp.jpg', roles: ['image'], label: 'ThinkPad' }], + priceRange: { minimum: { final: { amount: { value: 1299, currency: 'EUR' } } } }, }, ]; @@ -154,46 +170,158 @@ describe('generatePlpLdJson', () => { { name: 'Laptops', slug: 'electronics/laptops' }, ]; - test('generates ItemList JSON-LD with correct structure', () => { - const { itemListLdJson } = generatePlpLdJson(categoryData, products, breadcrumbs, context); - const parsed = JSON.parse(itemListLdJson); + function parse(ldJsonString) { + return JSON.parse(ldJsonString); + } + + test('generates CollectionPage JSON-LD wrapping ItemList and BreadcrumbList', () => { + const parsed = parse(generatePlpLdJson(categoryData, products, breadcrumbs, context)); expect(parsed['@context']).toBe('https://schema.org'); - expect(parsed['@type']).toBe('ItemList'); + expect(parsed['@type']).toBe('CollectionPage'); expect(parsed.name).toBe('Laptops'); - expect(parsed.numberOfItems).toBe(2); - expect(parsed.itemListElement).toHaveLength(2); + + const itemList = parsed.mainEntity; + expect(itemList['@type']).toBe('ItemList'); + expect(itemList.name).toBe('Laptops'); + expect(itemList.numberOfItems).toBe(2); + expect(itemList.itemListElement).toHaveLength(2); + + const breadcrumb = parsed.breadcrumb; + expect(breadcrumb['@type']).toBe('BreadcrumbList'); + expect(breadcrumb.itemListElement).toHaveLength(2); }); - test('ItemList items have correct position, name, url, image', () => { - const { itemListLdJson } = generatePlpLdJson(categoryData, products, breadcrumbs, context); - const parsed = JSON.parse(itemListLdJson); - const first = parsed.itemListElement[0]; + test('ListItem contains nested Product with offer for simple product', () => { + const parsed = parse(generatePlpLdJson(categoryData, products, breadcrumbs, context)); + const first = parsed.mainEntity.itemListElement[0]; expect(first['@type']).toBe('ListItem'); expect(first.position).toBe(1); - expect(first.name).toBe('MacBook Pro'); - expect(first.url).toBe('https://example.com/products/macbook-pro/mbp-16'); - expect(first.image).toBe('https://img.com/mbp.jpg'); + + const item = first.item; + expect(item['@type']).toBe('Product'); + expect(item.name).toBe('MacBook Pro'); + expect(item.sku).toBe('mbp-16'); + expect(item.url).toBe('https://example.com/products/macbook-pro/mbp-16'); + expect(item.image).toBe('https://img.com/mbp.jpg'); + expect(item.description).toBe('A powerful laptop'); + expect(item.offers).toHaveLength(1); + expect(item.offers[0]).toEqual({ + '@type': 'Offer', + url: 'https://example.com/products/macbook-pro/mbp-16', + price: 1999, + priceCurrency: 'USD', + availability: 'https://schema.org/InStock', + itemCondition: 'https://schema.org/NewCondition', + }); }); - test('ItemList omits image key when product has no images', () => { - const noImageProducts = [{ name: 'No Image Product', sku: 'nip', urlKey: 'nip', images: [] }]; - const { itemListLdJson } = generatePlpLdJson(categoryData, noImageProducts, breadcrumbs, context); - const parsed = JSON.parse(itemListLdJson); + test('ListItem uses priceRange minimum for complex product', () => { + const parsed = parse(generatePlpLdJson(categoryData, products, breadcrumbs, context)); + const second = parsed.mainEntity.itemListElement[1]; - expect(parsed.itemListElement[0]).not.toHaveProperty('image'); + const item = second.item; + expect(item.offers[0].price).toBe(1299); + expect(item.offers[0].priceCurrency).toBe('EUR'); + expect(item.offers[0].availability).toBe('https://schema.org/OutOfStock'); }); - test('generates BreadcrumbList JSON-LD with correct structure', () => { - const { breadcrumbLdJson } = generatePlpLdJson(categoryData, products, breadcrumbs, context); - const parsed = JSON.parse(breadcrumbLdJson); + test('Product includes gtin when attribute is present', () => { + const productsWithGtin = [{ + ...products[0], + attributes: [{ name: 'gtin', value: '1234567890123' }], + }]; + const parsed = parse(generatePlpLdJson(categoryData, productsWithGtin, breadcrumbs, context)); - expect(parsed['@context']).toBe('https://schema.org'); - expect(parsed['@type']).toBe('BreadcrumbList'); - expect(parsed.itemListElement).toHaveLength(2); + expect(parsed.mainEntity.itemListElement[0].item.gtin).toBe('1234567890123'); + }); + + test('Product includes brand when attribute is present', () => { + const productsWithBrand = [{ + ...products[0], + attributes: [{ name: 'brand', value: 'Apple' }], + }]; + const parsed = parse(generatePlpLdJson(categoryData, productsWithBrand, breadcrumbs, context)); + + const item = parsed.mainEntity.itemListElement[0].item; + expect(item.brand).toEqual({ '@type': 'Brand', name: 'Apple' }); + }); + + test('Product omits gtin and brand when attributes are absent', () => { + const parsed = parse(generatePlpLdJson(categoryData, products, breadcrumbs, context)); + const item = parsed.mainEntity.itemListElement[0].item; + + expect(item).not.toHaveProperty('gtin'); + expect(item).not.toHaveProperty('brand'); + }); + + test('Product omits image when product has no images', () => { + const noImageProducts = [{ + __typename: 'SimpleProductView', + name: 'No Image Product', + sku: 'nip', + urlKey: 'nip', + inStock: true, + images: [], + price: { final: { amount: { value: 10, currency: 'USD' } } }, + }]; + const parsed = parse(generatePlpLdJson(categoryData, noImageProducts, breadcrumbs, context)); + + expect(parsed.mainEntity.itemListElement[0].item).not.toHaveProperty('image'); + }); + + test('Product omits description when shortDescription is absent', () => { + const noDescProducts = [{ + __typename: 'SimpleProductView', + name: 'No Desc Product', + sku: 'ndp', + urlKey: 'ndp', + inStock: true, + images: [{ url: 'https://img.com/ndp.jpg', roles: ['image'] }], + price: { final: { amount: { value: 20, currency: 'USD' } } }, + }]; + const parsed = parse(generatePlpLdJson(categoryData, noDescProducts, breadcrumbs, context)); + + expect(parsed.mainEntity.itemListElement[0].item).not.toHaveProperty('description'); + }); + + test('Product omits offers when price data is missing', () => { + const noPriceProducts = [{ + __typename: 'SimpleProductView', + name: 'No Price Product', + sku: 'npp', + urlKey: 'npp', + inStock: true, + images: [{ url: 'https://img.com/npp.jpg', roles: ['image'] }], + }]; + const parsed = parse(generatePlpLdJson(categoryData, noPriceProducts, breadcrumbs, context)); + + expect(parsed.mainEntity.itemListElement[0].item).not.toHaveProperty('offers'); + }); + + test('defaults currency to USD when currency is NONE', () => { + const noneCurrencyProducts = [{ + __typename: 'SimpleProductView', + name: 'None Currency', + sku: 'nc', + urlKey: 'nc', + inStock: true, + images: [], + price: { final: { amount: { value: 50, currency: 'NONE' } } }, + }]; + const parsed = parse(generatePlpLdJson(categoryData, noneCurrencyProducts, breadcrumbs, context)); + + expect(parsed.mainEntity.itemListElement[0].item.offers[0].priceCurrency).toBe('USD'); + }); + + test('BreadcrumbList has correct entries', () => { + const parsed = parse(generatePlpLdJson(categoryData, products, breadcrumbs, context)); + const breadcrumb = parsed.breadcrumb; + + expect(breadcrumb.itemListElement).toHaveLength(2); - const first = parsed.itemListElement[0]; + const first = breadcrumb.itemListElement[0]; expect(first['@type']).toBe('ListItem'); expect(first.position).toBe(1); expect(first.name).toBe('Electronics'); @@ -201,11 +329,10 @@ describe('generatePlpLdJson', () => { }); test('handles empty product list', () => { - const { itemListLdJson } = generatePlpLdJson(categoryData, [], breadcrumbs, context); - const parsed = JSON.parse(itemListLdJson); + const parsed = parse(generatePlpLdJson(categoryData, [], breadcrumbs, context)); - expect(parsed.numberOfItems).toBe(0); - expect(parsed.itemListElement).toHaveLength(0); + expect(parsed.mainEntity.numberOfItems).toBe(0); + expect(parsed.mainEntity.itemListElement).toHaveLength(0); }); }); @@ -213,14 +340,10 @@ describe('generatePlpLdJson', () => { describe('buildBreadcrumbs', () => { test('builds breadcrumbs from a top-level slug', () => { - const categoryMap = new Map([ - ['electronics', { name: 'Electronics', slug: 'electronics' }], - ]); + const categoryMap = new Map([['electronics', { name: 'Electronics', slug: 'electronics' }]]); const crumbs = buildBreadcrumbs('electronics', categoryMap); - expect(crumbs).toEqual([ - { name: 'Electronics', slug: 'electronics' }, - ]); + expect(crumbs).toEqual([{ name: 'Electronics', slug: 'electronics' }]); }); test('builds breadcrumbs from a nested slug', () => { @@ -233,14 +356,18 @@ describe('buildBreadcrumbs', () => { const crumbs = buildBreadcrumbs('electronics/computers/laptops', categoryMap); expect(crumbs).toHaveLength(3); expect(crumbs[0]).toEqual({ name: 'Electronics', slug: 'electronics' }); - expect(crumbs[1]).toEqual({ name: 'Computers', slug: 'electronics/computers' }); - expect(crumbs[2]).toEqual({ name: 'Laptops', slug: 'electronics/computers/laptops' }); + expect(crumbs[1]).toEqual({ + name: 'Computers', + slug: 'electronics/computers', + }); + expect(crumbs[2]).toEqual({ + name: 'Laptops', + slug: 'electronics/computers/laptops', + }); }); test('falls back to humanized slug when category not in map', () => { - const categoryMap = new Map([ - ['electronics', { name: 'Electronics', slug: 'electronics' }], - ]); + const categoryMap = new Map([['electronics', { name: 'Electronics', slug: 'electronics' }]]); const crumbs = buildBreadcrumbs('electronics/computers-tablets', categoryMap); expect(crumbs).toHaveLength(2); @@ -310,7 +437,14 @@ describe('category tree fetching', () => { return HttpResponse.json({ data: { categoryTree: [ - { slug: 'electronics', name: 'Electronics', level: 1, childrenSlugs: ['electronics/laptops'], metaTags: null, images: [] }, + { + slug: 'electronics', + name: 'Electronics', + level: 1, + childrenSlugs: ['electronics/laptops'], + metaTags: null, + images: [], + }, ], }, }); @@ -319,7 +453,15 @@ describe('category tree fetching', () => { return HttpResponse.json({ data: { categoryTree: [ - { slug: 'electronics/laptops', name: 'Laptops', level: 2, parentSlug: 'electronics', childrenSlugs: [], metaTags: null, images: [] }, + { + slug: 'electronics/laptops', + name: 'Laptops', + level: 2, + parentSlug: 'electronics', + childrenSlugs: [], + metaTags: null, + images: [], + }, ], }, }); @@ -346,8 +488,19 @@ describe('category tree fetching', () => { name: 'Electronics', level: 1, childrenSlugs: [], - metaTags: { title: 'Electronics', description: 'All electronics', keywords: 'tech' }, - images: [{ url: 'https://img.com/elec.jpg', label: 'Electronics', roles: ['image'], customRoles: [] }], + metaTags: { + title: 'Electronics', + description: 'All electronics', + keywords: 'tech', + }, + images: [ + { + url: 'https://img.com/elec.jpg', + label: 'Electronics', + roles: ['image'], + customRoles: [], + }, + ], }, ], }, From 263d14489aa5d49c3fc8594da50bc44cbdfc44e4 Mon Sep 17 00:00:00 2001 From: rossbrandon Date: Tue, 17 Mar 2026 12:38:28 -0500 Subject: [PATCH 03/12] Update e2e tests; Update docs for PLP rendering actions --- .prettierignore | 1 + README.md | 19 +- actions/categories.js | 7 +- actions/fetch-all-products/index.js | 122 +++----- actions/plp-renderer/index.js | 104 +++++++ actions/render-all-categories/index.js | 5 + actions/render-all-categories/poller.js | 23 +- app.config.yaml | 70 +++-- docs/CUSTOMIZE.md | 46 ++- docs/POST-SETUP.md | 4 +- docs/RUNBOOK.md | 14 + docs/USE-CASES.md | 7 +- e2e/pdp-ssg.e2e.test.js | 375 ++++++++++++------------ e2e/plp-ssg.e2e.test.js | 198 +++++++++++++ package.json | 2 +- 15 files changed, 669 insertions(+), 328 deletions(-) create mode 100644 .prettierignore create mode 100644 actions/plp-renderer/index.js create mode 100644 e2e/plp-ssg.e2e.test.js diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..2bf280b9 --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +**/*.yaml diff --git a/README.md b/README.md index 471f34f8..c8d6cee5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # AEM Commerce Prerender -The AEM Commerce Prerenderer is a tool to generate static product detail pages from dynamic data sources like Adobe Commerce Catalog Service for publishing via [AEM Edge Delivery Services](https://www.aem.live/). It integrates with [BYOM (Bring Your Own Markup)](https://www.aem.live/docs/byo-markup) and EDS indexes to deliver fast, SEO-friendly pages. +The AEM Commerce Prerenderer is a tool to generate static product detail pages (PDPs) and product listing pages (PLPs) from dynamic data sources like Adobe Commerce Catalog Service for publishing via [AEM Edge Delivery Services](https://www.aem.live/). It integrates with [BYOM (Bring Your Own Markup)](https://www.aem.live/docs/byo-markup) and EDS indexes to deliver fast, SEO-friendly pages. ## Key Benefits @@ -68,7 +68,8 @@ For detailed setup instructions, see the [Step-by-Step Configuration](#step-by-s * `PRODUCTS_TEMPLATE`: The URL for the product template page (auto-populated by wizard). For localized sites with URLs like `https://main--site--org.aem.page/en-us/products/default`, you can use the `{locale}` token: `https://main--site--org.aem.page/{locale}/products/default` * `PRODUCT_PAGE_URL_FORMAT`: The URL pattern for product pages (auto-populated by wizard). Supports tokens: `{locale}`, `{urlKey}`, `{sku}`. Default pattern: `/{locale}/products/{urlKey}`. For live environments, consider using a different prefix like `/{locale}/products-prerendered/{urlKey}` for logical separation * `LOCALES`: Comma-separated list of locales (e.g., `en-us,en-gb,fr-fr`) or empty for non-localized sites - * `ACO_CATEGORY_FAMILIES`: *(Commerce Optimizer only)* Comma-separated list of category family identifiers (e.g., `electronics,apparel`). Determines which categories are included for PLP pre-rendering. For PDP pre-rendering, catalogs with 10,000 or fewer products are fetched in full regardless of this setting. For larger catalogs, the system fetches the first 10,000 products plus up to 10,000 per category discovered from these families, deduplicated by SKU. If not configured, only the first 10,000 products are pre-rendered. + * `ACO_CATEGORY_FAMILIES`: *(Commerce Optimizer only)* Comma-separated list of category family identifiers (e.g., `electronics,apparel`). Determines which categories are included for both PLP and PDP pre-rendering. For PDP pre-rendering, catalogs with 10,000 or fewer products are fetched in full regardless of this setting. For larger catalogs, the system fetches the first 10,000 products plus up to 10,000 per category discovered from these families, deduplicated by SKU. If not configured, only the first 10,000 products are pre-rendered. + * `PLP_PRODUCTS_PER_PAGE`: Number of products to display per category listing page (default: `9`) * `AEM_ADMIN_API_AUTH_TOKEN`: Long-lived authentication token for AEM Admin API (valid for 1 year). During setup, the wizard will exchange your temporary 24-hour token from [admin.hlx.page](https://admin.hlx.page/) for this long-lived token automatically. You can modify the environment-specific variables by editing the `.env` file directly or by re-running the setup wizard with `npm run setup`. @@ -88,7 +89,9 @@ For detailed setup instructions, see the [Step-by-Step Configuration](#step-by-s } } ``` - 3. [Customize the code](/docs/CUSTOMIZE.md) that contains the rendering logic according to your requirements, for [structured data](/actions/pdp-renderer/ldJson.js), [markup](/actions/pdp-renderer/render.js) and [templates](https://github.com/adobe-rnd/aem-commerce-prerender/tree/main/actions/pdp-renderer/templates) + 3. [Customize the code](/docs/CUSTOMIZE.md) that contains the rendering logic according to your requirements: + * **PDP**: [structured data](/actions/pdp-renderer/ldJson.js), [markup](/actions/pdp-renderer/render.js) and [templates](https://github.com/adobe-rnd/aem-commerce-prerender/tree/main/actions/pdp-renderer/templates) + * **PLP**: [structured data](/actions/plp-renderer/ldJson.js), [markup](/actions/plp-renderer/render.js) and [templates](https://github.com/adobe-rnd/aem-commerce-prerender/tree/main/actions/plp-renderer/templates) 4. Deploy the solution with `npm run deploy` 5. **Testing Actions Manually**: Before enabling automated triggers, verify that each action works correctly by invoking them manually: ```bash @@ -100,6 +103,9 @@ For detailed setup instructions, see the [Step-by-Step Configuration](#step-by-s # Clean up and unpublish deleted products aio rt action invoke aem-commerce-ssg/mark-up-clean-up + + # Render all category listing pages + aio rt action invoke aem-commerce-ssg/render-all-categories ``` 6. **Enable Automated Triggers**: Once you've confirmed that all actions work correctly, uncomment the triggers and rules sections in `app.config.yaml`: ```yaml @@ -116,6 +122,10 @@ For detailed setup instructions, see the [Step-by-Step Configuration](#step-by-s feed: "/whisk.system/alarms/interval" inputs: minutes: 60 + renderAllCategoriesTrigger: + feed: "/whisk.system/alarms/interval" + inputs: + minutes: 15 rules: productPollerRule: trigger: "productPollerTrigger" @@ -126,6 +136,9 @@ For detailed setup instructions, see the [Step-by-Step Configuration](#step-by-s markUpCleanUpRule: trigger: "markUpCleanUpTrigger" action: "mark-up-clean-up" + renderAllCategoriesRule: + trigger: "renderAllCategoriesTrigger" + action: "render-all-categories" ``` Then redeploy the solution: `npm run deploy` 7. **Management UI Overview**: Navigate to the [Storefront Prerender Management UI](https://prerender.aem-storefront.com) to monitor and manage your prerender deployment. The UI provides several tabs: diff --git a/actions/categories.js b/actions/categories.js index deeb3b1c..ac414875 100644 --- a/actions/categories.js +++ b/actions/categories.js @@ -46,11 +46,12 @@ function hasFamilies(families) { * @returns {Promise>} Map of category slug to category metadata. */ async function fetchCategoryTree(context, families) { - console.debug('Getting category data from families:', families); + const { logger } = context; + logger.debug('Getting category data from families:', families); const categoryMap = new Map(); for (const family of families) { - console.debug('Getting category data from family:', family); + logger.debug('Getting category data from family:', family); // Get root-level categories for this family const firstLevel = await requestSaaS(CategoryTreeQuery, 'getCategoryTree', { family }, context); @@ -88,7 +89,7 @@ async function fetchCategoryTree(context, families) { } } } - console.debug('Category slugs resolved:', [...categoryMap.keys()]); + logger.debug('Category slugs resolved:', [...categoryMap.keys()]); return categoryMap; } diff --git a/actions/fetch-all-products/index.js b/actions/fetch-all-products/index.js index acdcdacd..230d75f7 100644 --- a/actions/fetch-all-products/index.js +++ b/actions/fetch-all-products/index.js @@ -12,30 +12,18 @@ governing permissions and limitations under the License. */ -const { Core, Files } = require("@adobe/aio-sdk"); -const { - getConfig, - getSiteType, - requestSaaS, - SITE_TYPES, - FILE_PREFIX, -} = require("../utils"); -const { ProductsQuery, ProductCountQuery } = require("../queries"); -const { Timings } = require("../lib/benchmark"); -const { getRuntimeConfig } = require("../lib/runtimeConfig"); -const { handleActionError } = require("../lib/errorHandler"); -const { - getCategorySlugsFromFamilies, - getCategories, - hasFamilies, -} = require("../categories"); +const { Core, Files } = require('@adobe/aio-sdk'); +const { getConfig, getSiteType, requestSaaS, SITE_TYPES, FILE_PREFIX } = require('../utils'); +const { ProductsQuery, ProductCountQuery } = require('../queries'); +const { Timings } = require('../lib/benchmark'); +const { getRuntimeConfig } = require('../lib/runtimeConfig'); +const { handleActionError } = require('../lib/errorHandler'); +const { getCategorySlugsFromFamilies, getCategories, hasFamilies } = require('../categories'); const MAX_PRODUCTS_PER_CATEGORY = 10000; // page_size: 500 * 20 pages = 10000 products const MAX_PAGES_FETCHED = 20; const CONCURRENCY = 5; -const pLimitPromise = import("p-limit").then(({ default: pLimit }) => - pLimit(CONCURRENCY), -); +const pLimitPromise = import('p-limit').then(({ default: pLimit }) => pLimit(CONCURRENCY)); const productMapper = ({ productView }) => ({ urlKey: productView.urlKey, @@ -50,21 +38,17 @@ const productMapper = ({ productView }) => ({ * @returns {Promise>} Products in this category. */ async function getProductsByCategory(categoryPath, context) { + const { logger } = context; const limit = await pLimitPromise; - const firstPage = await requestSaaS( - ProductsQuery, - "getProducts", - { currentPage: 1, categoryPath }, - context, - ); + const firstPage = await requestSaaS(ProductsQuery, 'getProducts', { currentPage: 1, categoryPath }, context); const products = firstPage.data.productSearch.items.map(productMapper); let maxPage = firstPage.data.productSearch.page_info.total_pages; const totalCount = firstPage.data.productSearch.total_count; if (maxPage > MAX_PAGES_FETCHED) { - console.warn( - `Category ${categoryPath || "(root)"} contains ${totalCount} products, which is more than the maximum supported of ${MAX_PRODUCTS_PER_CATEGORY}. + logger.warn( + `Category ${categoryPath || '(root)'} contains ${totalCount} products, which is more than the maximum supported of ${MAX_PRODUCTS_PER_CATEGORY}. Only the first ${MAX_PRODUCTS_PER_CATEGORY} products will be fetched for this category.`, ); maxPage = MAX_PAGES_FETCHED; @@ -73,14 +57,7 @@ async function getProductsByCategory(categoryPath, context) { const pages = Array.from({ length: maxPage - 1 }, (_, i) => i + 2); const results = await Promise.all( pages.map((page) => - limit(() => - requestSaaS( - ProductsQuery, - "getProducts", - { currentPage: page, categoryPath }, - context, - ), - ), + limit(() => requestSaaS(ProductsQuery, 'getProducts', { currentPage: page, categoryPath }, context)), ), ); for (const pageRes of results) { @@ -111,12 +88,7 @@ function collectProducts(productsBySku, batchResults) { * @returns {Promise} Total number of pages (each page = 1 product at page_size 1). */ async function getProductCount(context) { - const countRes = await requestSaaS( - ProductCountQuery, - "getProductCount", - { categoryPath: "" }, - context, - ); + const countRes = await requestSaaS(ProductCountQuery, 'getProductCount', { categoryPath: '' }, context); return countRes.data.productSearch?.page_info?.total_pages; } @@ -131,9 +103,7 @@ async function getProductCount(context) { */ async function getAllProductsByCategoryFamily(context, categoryFamilies) { if (categoryFamilies.length === 0) { - throw new Error( - "Tried to retrieve products by category family, but no category families are configured.", - ); + throw new Error('Tried to retrieve products by category family, but no category families are configured.'); } const slugs = await getCategorySlugsFromFamilies(context, categoryFamilies); @@ -142,9 +112,7 @@ async function getAllProductsByCategoryFamily(context, categoryFamilies) { const slugBatchSize = 50; for (let i = 0; i < slugs.length; i += slugBatchSize) { const batch = slugs.slice(i, i + slugBatchSize); - const results = await Promise.all( - batch.map((slug) => getProductsByCategory(slug, context)), - ); + const results = await Promise.all(batch.map((slug) => getProductsByCategory(slug, context))); collectProducts(productsBySku, results); } @@ -161,6 +129,7 @@ async function getAllProductsByCategoryFamily(context, categoryFamilies) { * @returns {Promise>} Deduplicated product list. */ async function getAllProductsByCategory(context, productCount) { + const { logger } = context; const productsBySku = new Map(); const categories = await getCategories(context); @@ -168,9 +137,7 @@ async function getAllProductsByCategory(context, productCount) { if (!levelGroup) continue; while (levelGroup.length) { const batch = levelGroup.splice(0, 50); - const results = await Promise.all( - batch.map((urlPath) => getProductsByCategory(urlPath, context)), - ); + const results = await Promise.all(batch.map((urlPath) => getProductsByCategory(urlPath, context))); collectProducts(productsBySku, results); if (productsBySku.size >= productCount) { // All products collected, break out of the outer loop @@ -180,9 +147,7 @@ async function getAllProductsByCategory(context, productCount) { } if (productsBySku.size !== productCount) { - console.warn( - `Expected ${productCount} products, but got ${productsBySku.size}.`, - ); + logger.warn(`Expected ${productCount} products, but got ${productsBySku.size}.`); } return [...productsBySku.values()]; @@ -197,34 +162,28 @@ async function getAllProductsByCategory(context, productCount) { * @returns {Promise>} */ async function getAllProducts(siteType, context, categoryFamilies) { + const { logger } = context; const productCount = await getProductCount(context); if (!productCount) { - throw new Error("Could not fetch product count from catalog."); + throw new Error('Could not fetch product count from catalog.'); } if (productCount <= MAX_PRODUCTS_PER_CATEGORY) { - console.info( + logger.info( `Catalog has less than ${MAX_PRODUCTS_PER_CATEGORY} products. Fetching all products from the default category.`, ); - return getProductsByCategory("", context); + return getProductsByCategory('', context); } if (siteType === SITE_TYPES.ACO) { - console.info( - `Fetching the first ${MAX_PRODUCTS_PER_CATEGORY} products from the catalog.`, - ); - const defaultProducts = await getProductsByCategory("", context); + logger.info(`Fetching the first ${MAX_PRODUCTS_PER_CATEGORY} products from the catalog.`); + const defaultProducts = await getProductsByCategory('', context); if (!hasFamilies(categoryFamilies)) { return defaultProducts; } - console.info( - "Category families are configured. Fetching additional products by category family.", - ); - const familyProducts = await getAllProductsByCategoryFamily( - context, - categoryFamilies, - ); + logger.info('Category families are configured. Fetching additional products by category family.'); + const familyProducts = await getAllProductsByCategoryFamily(context, categoryFamilies); const productsBySku = new Map(); collectProducts(productsBySku, [defaultProducts, familyProducts]); return [...productsBySku.values()]; @@ -244,49 +203,46 @@ async function getAllProducts(siteType, context, categoryFamilies) { async function main(params) { try { const cfg = getRuntimeConfig(params); - const logger = Core.Logger("main", { level: cfg.logLevel }); + const logger = Core.Logger('main', { level: cfg.logLevel }); const sharedContext = { ...cfg, logger }; + logger.info(`Fetching all products for locales ${cfg.locales.join(', ')}`); const results = await Promise.all( cfg.locales.map(async (locale) => { + logger.info(`Fetching all products for locale ${locale}`); const context = { ...sharedContext }; if (locale) { context.locale = locale; } const timings = new Timings(); - const stateFilePrefix = locale || "default"; + const stateFilePrefix = locale || 'default'; const siteConfig = await getConfig(context); const siteType = getSiteType(siteConfig); - const allProducts = await getAllProducts( - siteType, - context, - cfg.categoryFamilies, - ); + const allProducts = await getAllProducts(siteType, context, cfg.categoryFamilies); - timings.sample("getAllProducts"); + timings.sample('getAllProducts'); const filesLib = await Files.init(params.libInit || {}); - timings.sample("saveFile"); + timings.sample('saveFile'); const productsFileName = `${FILE_PREFIX}/${stateFilePrefix}-products.json`; + logger.debug(`Saving products to ${productsFileName}`); await filesLib.write(productsFileName, JSON.stringify(allProducts)); - console.debug( - `${allProducts.length} total products saved to ${productsFileName}`, - ); + logger.debug(`${allProducts.length} total products saved to ${productsFileName}`); return timings.measures; }), ); return { statusCode: 200, - body: { status: "completed", timings: results }, + body: { status: 'completed', timings: results }, }; } catch (error) { - const logger = Core.Logger("main", { level: "error" }); + const logger = Core.Logger('main', { level: 'error' }); return handleActionError(error, { logger, - actionName: "Fetch all products", + actionName: 'Fetch all products', }); } } diff --git a/actions/plp-renderer/index.js b/actions/plp-renderer/index.js new file mode 100644 index 00000000..25f1042c --- /dev/null +++ b/actions/plp-renderer/index.js @@ -0,0 +1,104 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +const { Core } = require('@adobe/aio-sdk'); +const { errorResponse, getConfig, getSiteType, requestSaaS, SITE_TYPES } = require('../utils'); +const { getCategoryDataFromFamilies } = require('../categories'); +const { generateCategoryHtml } = require('./render'); +const { getRuntimeConfig } = require('../lib/runtimeConfig'); +const { JobFailedError, ERROR_CODES } = require('../lib/errorHandler'); +const { PlpProductSearchQuery } = require('../queries'); + +/** + * One-off action to render a single category listing page by slug. + * + * @param {Object} params The parameters object + * @param {string} params.slug The category slug to render (e.g. "electronics/computers-tablets") + * @param {string} [params.locale] Optional locale override + * @param {string} params.CONFIG_NAME The config sheet to use + * @param {string} params.CONTENT_URL Edge Delivery URL of the store + * @param {string} params.STORE_URL Public facing URL of the store + * @param {string} params.ACO_CATEGORY_FAMILIES Comma-separated ACO category families + */ +async function main(params) { + const cfg = getRuntimeConfig(params); + const logger = Core.Logger('main', { level: cfg.logLevel }); + + try { + const { slug, locale } = params; + const context = { ...cfg, logger }; + if (locale) { + context.locale = locale; + } + const siteConfig = await getConfig(context); + const siteType = getSiteType(siteConfig); + + if (siteType === SITE_TYPES.ACO) { + if (!cfg.categoryFamilies?.length) { + throw new JobFailedError('Missing ACO_CATEGORY_FAMILIES configuration', ERROR_CODES.VALIDATION_ERROR, 400); + } + } else { + throw new JobFailedError('ACCS is not yet supported for PLP pre-rendering', ERROR_CODES.VALIDATION_ERROR, 400); + } + + if (!slug) { + throw new JobFailedError('Missing required parameter: slug must be provided', ERROR_CODES.VALIDATION_ERROR, 400); + } + + logger.info(`Rendering category slug: ${slug} for locale: ${locale || 'default'}`); + + // Fetch full category tree (needed for breadcrumb resolution) + logger.info(`Fetching category tree for families: ${cfg.categoryFamilies}`); + const categoryMap = await getCategoryDataFromFamilies(context, cfg.categoryFamilies); + logger.debug(`Category tree resolved with ${categoryMap.size} categories`); + + const categoryData = categoryMap.get(slug); + if (!categoryData) { + logger.info(`Slug "${slug}" not found. Available slugs: ${[...categoryMap.keys()].join(', ')}`); + throw new JobFailedError(`Category not found: ${slug}`, ERROR_CODES.VALIDATION_ERROR, 404); + } + logger.debug(`Found category: ${categoryData.name} (level: ${categoryData.level})`); + + // Fetch products for this category + logger.info(`Fetching products for category "${slug}" (pageSize: ${cfg.plpProductsPerPage})`); + const productsRes = await requestSaaS( + PlpProductSearchQuery, + 'plpProductSearch', + { + categoryPath: slug, + pageSize: cfg.plpProductsPerPage, + currentPage: 1, + }, + context, + ); + + const products = productsRes.data.productSearch.items.map((item) => item.productView); + logger.debug(`Retrieved ${products.length} products for category "${slug}"`); + + const categoryHtml = generateCategoryHtml(categoryData, products, categoryMap, context); + + const response = { + statusCode: 200, + body: categoryHtml, + }; + logger.info(`${response.statusCode}: category "${slug}" rendered successfully`); + return response; + } catch (error) { + logger.error(error); + if (error.statusCode) { + return errorResponse(error.statusCode, error.message, logger); + } + return errorResponse(500, 'server error', logger); + } +} + +exports.main = main; diff --git a/actions/render-all-categories/index.js b/actions/render-all-categories/index.js index 5d967312..baec6a50 100644 --- a/actions/render-all-categories/index.js +++ b/actions/render-all-categories/index.js @@ -42,8 +42,11 @@ async function main(params) { let activationResult; + logger.debug('Checking if PLP pre-rendering is already running'); const running = await stateMgr.get('plp-running'); + logger.debug('PLP pre-rendering is already running:', running?.value); if (running?.value === 'true') { + logger.warn('PLP pre-rendering is already running. Skipping this execution...'); activationResult = { state: 'skipped' }; try { @@ -56,6 +59,7 @@ async function main(params) { } try { + logger.debug('Starting PLP pre-rendering'); await stateMgr.put('plp-running', 'true', { ttl: 3600 }); activationResult = await poll(cfg, { stateLib: stateMgr, filesLib }, logger); @@ -73,6 +77,7 @@ async function main(params) { logger.warn('Failed to send activation result.', obsErr); } + logger.debug('PLP pre-rendering completed'); return activationResult; } catch (error) { logger = logger || Core.Logger('main', { level: 'error' }); diff --git a/actions/render-all-categories/poller.js b/actions/render-all-categories/poller.js index 362fdce0..1a4cc32e 100644 --- a/actions/render-all-categories/poller.js +++ b/actions/render-all-categories/poller.js @@ -48,10 +48,6 @@ function checkParams(params) { 'contentUrl', 'storeUrl', ]); - - if (!params.categoryFamilies?.length) { - throw new JobFailedError('Missing ACO_CATEGORY_FAMILIES configuration', ERROR_CODES.VALIDATION_ERROR, 400); - } } /** @@ -238,10 +234,17 @@ async function poll(params, aioLibs, logger) { // Discover all categories let categoryMap; if (siteType === SITE_TYPES.ACO) { + if (!categoryFamilies?.length) { + throw new JobFailedError( + 'Missing ACO_CATEGORY_FAMILIES configuration', + ERROR_CODES.VALIDATION_ERROR, + 400, + ); + } categoryMap = await getCategoryDataFromFamilies(context, categoryFamilies); } else { throw new JobFailedError( - 'ACCS is not yet support for PLP pre-rendering', + 'ACCS is not yet supported for PLP pre-rendering', ERROR_CODES.VALIDATION_ERROR, 400, ); @@ -279,8 +282,10 @@ async function poll(params, aioLibs, logger) { if (shouldPreviewAndPublish(category)) { toPublish.push(category); } else if (!category.renderedAt) { + logger.warn(`Category ${category.slug} failed to render`); counts.failed++; } else { + logger.debug(`Category ${category.slug} has not changed. Ignoring...`); counts.ignored++; state.categories[category.slug] = { lastRenderedAt: category.renderedAt, @@ -290,6 +295,7 @@ async function poll(params, aioLibs, logger) { } } + // Update lastRenderedAt for the categories to ignore to avoid re-rendering unnecessarily if (toIgnore.length) { await saveState(state, aioLibs, PLP_FILE_PREFIX, DATA_KEY); } @@ -303,9 +309,12 @@ async function poll(params, aioLibs, logger) { path, renderedAt, })); + // Preview and publish the categories + logger.debug(`Previewing and publishing ${categories.length} categories in batch ${batchNumber + 1}`); return adminApi .previewAndPublish(records, locale, batchNumber + 1) .then((publishedBatch) => + // Process the published batch and update the state processPublishedBatch(publishedBatch, state, counts, categories, aioLibs, { dataKey: DATA_KEY, keyField: 'slug', @@ -313,11 +322,13 @@ async function poll(params, aioLibs, logger) { }), ) .catch((error) => { + // Handle batch errors gracefully - don't fail the entire job if (error.code === ERROR_CODES.BATCH_ERROR) { logger.warn(`Batch ${batchNumber + 1} failed, continuing:`, { error: error.message, details: error.details, }); + // Update counts to reflect failed batch counts.failed += categories.length; return { failed: true, @@ -325,6 +336,7 @@ async function poll(params, aioLibs, logger) { error: error.message, }; } else { + // Re-throw global errors throw error; } }); @@ -338,6 +350,7 @@ async function poll(params, aioLibs, logger) { // Unpublish categories that are no longer in the tree const discoveredSlugs = new Set(categorySlugs); if (Object.keys(state.categories).some((slug) => !discoveredSlugs.has(slug))) { + logger.debug(`Unpublishing categories that are no longer in the tree`); await processRemovedCategories(discoveredSlugs, state, context, adminApi); timings.sample('unpublished-categories'); } else { diff --git a/app.config.yaml b/app.config.yaml index cece7ab9..a26f92d3 100644 --- a/app.config.yaml +++ b/app.config.yaml @@ -60,6 +60,15 @@ application: AEM_ADMIN_API_AUTH_TOKEN: "${AEM_ADMIN_API_AUTH_TOKEN}" annotations: final: true + plp-renderer: + function: "actions/plp-renderer/index.js" + web: "yes" + runtime: "nodejs:22" + annotations: + final: true + include: + - - "actions/plp-renderer/templates/*.hbs" + - "templates/" render-all-categories: function: "actions/render-all-categories/index.js" web: "no" @@ -74,34 +83,33 @@ application: AEM_ADMIN_API_AUTH_TOKEN: "${AEM_ADMIN_API_AUTH_TOKEN}" annotations: final: true -# triggers: -# productPollerTrigger: -# feed: "/whisk.system/alarms/interval" -# inputs: -# minutes: 5 -# productScraperTrigger: -# feed: "/whisk.system/alarms/interval" -# inputs: -# minutes: 60 -# markUpCleanUpTrigger: -# feed: "/whisk.system/alarms/interval" -# inputs: -# minutes: 60 -# rules: -# productPollerRule: -# trigger: "productPollerTrigger" -# action: "check-product-changes" -# productScraperRule: -# trigger: "productScraperTrigger" -# action: "fetch-all-products" -# markUpCleanUpRule: -# trigger: "markUpCleanUpTrigger" -# action: "mark-up-clean-up" -# renderAllCategoriesTrigger: -# feed: "/whisk.system/alarms/interval" -# inputs: -# minutes: 15 -# rules: -# renderAllCategoriesRule: -# trigger: "renderAllCategoriesTrigger" -# action: "render-all-categories" + # triggers: + # productPollerTrigger: + # feed: "/whisk.system/alarms/interval" + # inputs: + # minutes: 5 + # productScraperTrigger: + # feed: "/whisk.system/alarms/interval" + # inputs: + # minutes: 60 + # renderAllCategoriesTrigger: + # feed: "/whisk.system/alarms/interval" + # inputs: + # minutes: 15 + # markUpCleanUpTrigger: + # feed: "/whisk.system/alarms/interval" + # inputs: + # minutes: 60 + # rules: + # productPollerRule: + # trigger: "productPollerTrigger" + # action: "check-product-changes" + # productScraperRule: + # trigger: "productScraperTrigger" + # action: "fetch-all-products" + # renderAllCategoriesRule: + # trigger: "renderAllCategoriesTrigger" + # action: "render-all-categories" + # markUpCleanUpRule: + # trigger: "markUpCleanUpTrigger" + # action: "mark-up-clean-up" diff --git a/docs/CUSTOMIZE.md b/docs/CUSTOMIZE.md index cce31e9c..cb374855 100644 --- a/docs/CUSTOMIZE.md +++ b/docs/CUSTOMIZE.md @@ -1,8 +1,10 @@ # Rendering Logic & Customizations -## Structured data +## PDP (Product Detail Pages) -### GTIN & Product Codes +### Structured data + +#### GTIN & Product Codes GTIN [is strongly recommended](https://support.google.com/merchants/answer/6324461) in the structured data but not mandatory. @@ -29,4 +31,42 @@ You can customize this function to use your own logic logic to retrieve the GTIN ### Templates The main customization point to define markup structure is [the templates folder](/actions/pdp-renderer/templates) -Those files follow the [Handlebars](https://handlebarsjs.com/) syntax and the referenced variables can be defined in [render.js](/actions/pdp-renderer/render.js) \ No newline at end of file +Those files follow the [Handlebars](https://handlebarsjs.com/) syntax and the referenced variables can be defined in [render.js](/actions/pdp-renderer/render.js) + +## PLP (Product Listing Pages) + +### Structured data + +The PLP renderer generates [CollectionPage](https://schema.org/CollectionPage) JSON-LD with an `ItemList` of products and a `BreadcrumbList`. The logic is defined in [ldJson.js](/actions/plp-renderer/ldJson.js). + +### Templates + +PLP markup templates follow the same [Handlebars](https://handlebarsjs.com/) pattern as PDP. The templates are in [the templates folder](/actions/plp-renderer/templates) and the referenced variables can be defined in [render.js](/actions/plp-renderer/render.js). + +## E2E Testing + +End-to-end tests verify that the deployed `pdp-renderer` and `plp-renderer` actions return correctly structured HTML and JSON-LD. They run against your live Adobe I/O Runtime deployment. + +### Configuration + +Test inputs are defined in [`e2e/config.json`](/e2e/config.json): + +```json +{ + "pdpSku": "ADB177", + "plpSlug": "apparel" +} +``` + +- `pdpSku` -- the product SKU to use for PDP rendering tests +- `plpSlug` -- the category slug to use for PLP rendering tests + +Update these values to match products and categories available in your catalog. + +### Running the tests + +```bash +npm run e2e +``` + +The tests validate structural correctness and field-level checks on the rendered HTML and JSON-LD, without asserting on catalog-specific values like product names or image domains. \ No newline at end of file diff --git a/docs/POST-SETUP.md b/docs/POST-SETUP.md index 010ff766..66e34684 100644 --- a/docs/POST-SETUP.md +++ b/docs/POST-SETUP.md @@ -31,7 +31,9 @@ # Initial Rollout of Product Pages * After you completed the setup steps, you can deploy the AppBuilder project by [creating a release in GitHub](https://docs.github.com/en/repositories/releasing-projects-on-github/managing-releases-in-a-repository#creating-a-release) (recommended) or running the aio app deploy command with your production configuration. - * You can validate successful deployment of your action, by accessing "https://{namespace}.[adobeioruntime.net/api/v1/web/aem-commerce-ssg/pdp-renderer/products/{urlKey}/{sku](https://www.google.com/search?q=http://adobeioruntime.net/api/v1/web/aem-commerce-ssg/pdp-renderer/products/%7BurlKey%7D/%7Bsku%7D)". You should be able to see a rendered version of your product detail page. + * You can validate successful deployment of your actions by accessing: + * **PDP**: `https://{namespace}.adobeioruntime.net/api/v1/web/aem-commerce-ssg/pdp-renderer/products/{urlKey}/{sku}` -- you should see a rendered product detail page. + * **PLP**: `https://{namespace}.adobeioruntime.net/api/v1/web/aem-commerce-ssg/plp-renderer?slug={categorySlug}` -- you should see a rendered category listing page. * The rollout of PDPs might be required after the storefront is live and running, for example in order to refresh the pages to reflect changes that by design are not considered as a change in the Product model. See [Test, Monitoring and Ops](https://www.google.com/search?q=%23testing-monitoring-and-ops) for details. * The indexing process usually takes between 10-30 minutes, but in the first cold start it should be faster. You can check if the index was created by simply navigating to [https://store-base-url/published-product-index.json.](https://www.google.com/search?q=https://store-base-url/published-product-index.json.) Once it's ready, you can proceed with the next steps. diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index 3f2e84b3..5c4d8574 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -239,6 +239,20 @@ now, we can see at least one error and a whole batch that failed publish ops Issues with the rendering process can be found in the logs from above. +## Category renderer (PLPs) + +The `render-all-categories` action runs every 15 minutes (configurable in `app.config.yaml`). For Commerce Optimizer sites, it discovers all categories from the configured `ACO_CATEGORY_FAMILIES`, renders PLPs, and detects changes via hash comparison. Logic is defined in [poller.js](../actions/render-all-categories/poller.js). + +A `plp-running` mutex prevents concurrent executions, similar to the PDP poller. + +### Force re-rendering all PLPs + +Follow the same pattern as PDPs: reset the PLP state files in Markup Storage (`render-all-categories/{locale}.json`) and wait for the next cycle to reprocess all categories. + +### Operation + +Activations appear as `aem-commerce-ssg/render-all-categories` in `aio rt activation list`. Inspect logs and results using the same workflow as the product change detector above. + ## Product scraper from `aio rt activations list` you might notice the activations of the product scraper action (fetch-all-products) diff --git a/docs/USE-CASES.md b/docs/USE-CASES.md index 4950f2f4..1ce293c2 100644 --- a/docs/USE-CASES.md +++ b/docs/USE-CASES.md @@ -2,7 +2,8 @@ * how many skus Customer has in the catalog, per store? * how many variants (as above)? - * e2e latency requirements, from when the change is set to when it is reflected in a public pdp page + * e2e latency requirements, from when the change is set to when it is reflected in a public pdp/plp page + * how many categories and what depth of category tree? (relevant for PLP pre-rendering) * is the AppBuilder environment with Runtime enabled? * is helix5 enabled? * is staging site repoless or backed by a different repo? @@ -36,8 +37,8 @@ ## Advantages: - * Enhanced Product Pages: Improves product detail pages by embedding custom metadata and essential markup ahead-of-time, making them available already within the initial server response. - * Tailored Implementation: Customize the injected [metadata](https://github.com/adobe-rnd/aem-commerce-ssg/blob/main/actions/pdp-renderer/ldJson.js) and [markup](https://github.com/adobe-rnd/aem-commerce-ssg/blob/main/actions/pdp-renderer/templates) to better suit Customer's specific requirements. + * Enhanced Product & Category Pages: Improves product detail pages and category listing pages by embedding custom metadata and essential markup ahead-of-time, making them available already within the initial server response. + * Tailored Implementation: Customize the injected metadata and markup for both [PDPs](https://github.com/adobe-rnd/aem-commerce-ssg/blob/main/actions/pdp-renderer) and [PLPs](https://github.com/adobe-rnd/aem-commerce-ssg/blob/main/actions/plp-renderer) to better suit Customer's specific requirements. * Boosted SEO: Significantly improves search engine crawlability and indexability for better visibility, especially in organic traffic. * Rich Social Media Previews: Ensures product links generate engaging and informative previews when shared on social platforms. * Reliable Merchant Center Data: Provides accurate and readily available product information for Google Merchant Center. diff --git a/e2e/pdp-ssg.e2e.test.js b/e2e/pdp-ssg.e2e.test.js index 653eca47..702a5af6 100644 --- a/e2e/pdp-ssg.e2e.test.js +++ b/e2e/pdp-ssg.e2e.test.js @@ -1,5 +1,5 @@ /* -Copyright 2025 Adobe. All rights reserved. +Copyright 2026 Adobe. All rights reserved. This file is licensed to you under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 @@ -13,198 +13,183 @@ governing permissions and limitations under the License. const { Config } = require('@adobe/aio-sdk').Core; const fetch = require('node-fetch'); const cheerio = require('cheerio'); - -// get action url -const namespace = Config.get('runtime.namespace') -const hostname = Config.get('cna.hostname') || 'adobeioruntime.net' -const runtimePackage = 'aem-commerce-ssg' -const actionUrl = `https://${namespace}.${hostname}/api/v1/web/${runtimePackage}/pdp-renderer` - -test('simple product markup', async () => { - const res = await fetch(`${actionUrl}/products-ssg/bezier-tee/adb177?sku=ADB177`); - const content = await res.text(); - - // Parse markup and compare - const $ = cheerio.load(content); - - // Validate H1 - expect($('h1').text()).toEqual('Bezier tee'); - - // Validate price - expect($('.product-details > div > div:contains("Price")').next().text()).toEqual('$23.00'); - - // Validate images - expect($('.product-details > div > div:contains("Images")').next().find('img').map((_, e) => $(e).prop('outerHTML')).toArray()).toMatchInlineSnapshot(` -[ - "", - "", - "", -] -`); - - // Validate no options - expect($('.product-details > div > div:contains("Options")')).toHaveLength(0); - - // Validate description - expect($('.product-details > div > div:contains("Description")').next().html().trim()).toEqual('
This is an anodized aluminum push-action pen with a soft capacitive stylus. The stylus pen can be used for writing on paper and clicking on touch screen. The stylus helps protect your screen from smudges and increase sensitivity.Choose from different colored pens. Ink color: black.Adobe wordmark laser engraved near clip.

'); - - // Validate LD-JSON - const ldJson = JSON.parse($('script[type="application/ld+json"]').html()); - const expected = { - "@context": "http://schema.org", - "@type": "Product", - "sku": "ADB177", - "name": "Bezier tee", - "gtin": "", - "description": "Bezier tee", - "@id": "https://main--aem-boilerplate-commerce-staging--hlxsites.aem.live/products-ssg/bezier-tee/ADB177", - "offers": [ - { - "@type": "Offer", - "sku": "ADB177", - "url": "https://main--aem-boilerplate-commerce-staging--hlxsites.aem.live/products-ssg/bezier-tee/ADB177", - "availability": "https://schema.org/InStock", - "price": 23, - "priceCurrency": "USD", - "itemCondition": "https://schema.org/NewCondition" - } - ], - "image": "https://www.aemshop.net/media/catalog/product/adobestoredata/ADB177.jpg" - }; - expect(ldJson).toEqual(expected); -}); - -test('complex product markup', async () => { - const res = await fetch(`${actionUrl}/products-ssg/ssg-configurable-product/ssgconfig123?sku=SSGCONFIG123`); - const content = await res.text(); - - // Parse markup and compare - const $ = cheerio.load(content); - - // Validate H1 - expect($('h1').text()).toEqual('BYOM Configurable Product'); - - // Validate price - expect($('.product-details > div > div:contains("Price")').next().text()).toEqual('$40.00-$80.00'); - - // Validate images - expect($('.product-details > div > div:contains("Images")').next().find('img').map((_, e) => $(e).prop('outerHTML')).toArray()).toMatchInlineSnapshot(` -[ - "", -] -`); - - // Validate options - expect($('.product-details > div > div:contains("Options")')).toHaveLength(1); - const optionsHtml = $('.product-details > div > div:contains("Options")').next().html().trim(); - - expect(optionsHtml).toEqual(``); - - // Validate description - expect($('meta[name="description"]').attr('content')).toEqual('SSG Configurable Product'); - - // Validate LD-JSON - const ldJson = JSON.parse($('script[type="application/ld+json"]').html()); - const expected = { - "@context": "http://schema.org", - "@type": "ProductGroup", - "sku": "SSGCONFIG123", - "productGroupId": "SSGCONFIG123", - "name": "BYOM Configurable Product", - "gtin": "", - "variesBy": [ - "https://schema.org/color" - ], - "description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", - "@id": "https://main--aem-boilerplate-commerce-staging--hlxsites.aem.live/products-ssg/ssg-configurable-product/SSGCONFIG123", - "hasVariant": [ - { - "@type": "Product", - "sku": "SSGCONFIG123-blue", - "name": "BYOM Configurable Product-blue", - "gtin": "", - "image": "https://www.aemshop.net/media/catalog/product/a/d/adb402_1.jpg", - "offers": [ - { - "@type": "Offer", - "sku": "SSGCONFIG123-blue", - "url": "https://main--aem-boilerplate-commerce-staging--hlxsites.aem.live/products-ssg/ssg-configurable-product/SSGCONFIG123?optionsUIDs=Y29uZmlndXJhYmxlLzI3OS80NQ%3D%3D", - "availability": "https://schema.org/InStock", - "price": 60, - "priceCurrency": "USD", - "itemCondition": "https://schema.org/NewCondition" - } - ], - "color": "blue" - }, - { - "@type": "Product", - "sku": "SSGCONFIG123-green", - "name": "BYOM Configurable Product-green", - "gtin": "", - "image": "https://www.aemshop.net/media/catalog/product/a/d/adb412_1.jpg", - "offers": [ - { - "@type": "Offer", - "sku": "SSGCONFIG123-green", - "url": "https://main--aem-boilerplate-commerce-staging--hlxsites.aem.live/products-ssg/ssg-configurable-product/SSGCONFIG123?optionsUIDs=Y29uZmlndXJhYmxlLzI3OS80Mg%3D%3D", - "availability": "https://schema.org/InStock", - "price": 80, - "priceCurrency": "USD", - "itemCondition": "https://schema.org/NewCondition" - } - ], - "color": "green" - }, - { - "@type": "Product", - "sku": "SSGCONFIG123-red", - "name": "BYOM Configurable Product-red", - "gtin": "", - "image": "https://www.aemshop.net/media/catalog/product/a/d/adb187_1.jpg", - "offers": [ - { - "@type": "Offer", - "sku": "SSGCONFIG123-red", - "url": "https://main--aem-boilerplate-commerce-staging--hlxsites.aem.live/products-ssg/ssg-configurable-product/SSGCONFIG123?optionsUIDs=Y29uZmlndXJhYmxlLzI3OS8zOQ%3D%3D", - "availability": "https://schema.org/InStock", - "price": 40, - "priceCurrency": "USD", - "itemCondition": "https://schema.org/NewCondition" - } - ], - "color": "red" - } - ], - "image": "https://www.aemshop.net/media/catalog/product/a/d/adb124.jpg" - }; - expect(ldJson).toEqual(expected); +const config = require('./config.json'); + +const namespace = Config.get('runtime.namespace'); +const hostname = Config.get('cna.hostname') || 'adobeioruntime.net'; +const runtimePackage = 'aem-commerce-ssg'; +const actionUrl = `https://${namespace}.${hostname}/api/v1/web/${runtimePackage}/pdp-renderer`; + +const sku = config.pdpSku; + +function isValidUrl(str) { + try { + new URL(str); + return true; + } catch { + return false; + } +} + +function validateOffer(offer, expectedSku) { + expect(offer['@type']).toBe('Offer'); + expect(offer.sku).toBe(expectedSku); + expect(isValidUrl(offer.url)).toBe(true); + expect(['https://schema.org/InStock', 'https://schema.org/OutOfStock']).toContain(offer.availability); + expect(typeof offer.price).toBe('number'); + expect(typeof offer.priceCurrency).toBe('string'); + expect(offer.priceCurrency.length).toBeGreaterThan(0); + expect(offer.itemCondition).toBe('https://schema.org/NewCondition'); + + if (offer.priceSpecification) { + expect(offer.priceSpecification['@type']).toBe('UnitPriceSpecification'); + expect(typeof offer.priceSpecification.priceType).toBe('string'); + expect(typeof offer.priceSpecification.price).toBe('number'); + expect(typeof offer.priceSpecification.priceCurrency).toBe('string'); + } +} + +describe(`PDP e2e - SKU: ${sku}`, () => { + let $; + let ldJson; + + beforeAll(async () => { + const res = await fetch(`${actionUrl}?sku=${sku}`); + expect(res.status).toBe(200); + const content = await res.text(); + $ = cheerio.load(content); + ldJson = JSON.parse($('script[type="application/ld+json"]').html()); + }, 30000); + + test('h1 exists and is non-empty', () => { + const h1 = $('h1').text().trim(); + expect(h1.length).toBeGreaterThan(0); + }); + + test('price is present and matches currency pattern', () => { + const priceText = $('.product-details > div > div:contains("Price")').next().text().trim(); + expect(priceText.length).toBeGreaterThan(0); + expect(priceText).toMatch(/\$[\d,.]+(-\$[\d,.]+)?/); + }); + + test('at least one image with a valid URL', () => { + const images = $('.product-details > div > div:contains("Images")').next().find('img'); + expect(images.length).toBeGreaterThan(0); + images.each((_, el) => { + const src = $(el).attr('src'); + expect(isValidUrl(src)).toBe(true); + }); + }); + + test('meta description exists', () => { + const metaDesc = $('meta[name="description"]'); + expect(metaDesc).toHaveLength(1); + }); + + test('LD+JSON is valid and has correct @context', () => { + expect(ldJson).toBeDefined(); + expect(ldJson['@context']).toBe('http://schema.org'); + }); + + test('LD+JSON @type is Product or ProductGroup', () => { + expect(['Product', 'ProductGroup']).toContain(ldJson['@type']); + }); + + test('LD+JSON sku matches input', () => { + expect(ldJson.sku).toBe(sku); + }); + + test('LD+JSON name is a non-empty string', () => { + expect(typeof ldJson.name).toBe('string'); + expect(ldJson.name.length).toBeGreaterThan(0); + }); + + test('LD+JSON gtin is a string', () => { + expect(typeof ldJson.gtin).toBe('string'); + }); + + test('LD+JSON description is a string or null', () => { + if (ldJson.description !== null) { + expect(typeof ldJson.description).toBe('string'); + } + }); + + test('LD+JSON @id is a valid URL', () => { + expect(isValidUrl(ldJson['@id'])).toBe(true); + }); + + test('LD+JSON image is a valid URL or null', () => { + if (ldJson.image !== null) { + expect(isValidUrl(ldJson.image)).toBe(true); + } + }); + + test('LD+JSON offers array with valid entries (simple product only)', () => { + if (ldJson['@type'] !== 'Product') return; + expect(Array.isArray(ldJson.offers)).toBe(true); + expect(ldJson.offers.length).toBeGreaterThan(0); + ldJson.offers.forEach((offer) => validateOffer(offer, sku)); + }); + + test('options section matches product type', () => { + const optionsSection = $('.product-details > div > div:contains("Options")'); + if (ldJson['@type'] === 'ProductGroup') { + expect(optionsSection.length).toBeGreaterThanOrEqual(1); + } else { + expect(optionsSection).toHaveLength(0); + } + }); + + describe('ProductGroup-specific fields', () => { + test('productGroupId matches sku', () => { + if (ldJson?.['@type'] !== 'ProductGroup') return; + expect(ldJson.productGroupId).toBe(sku); + }); + + test('variesBy is an array of non-empty strings', () => { + if (ldJson?.['@type'] !== 'ProductGroup') return; + expect(Array.isArray(ldJson.variesBy)).toBe(true); + expect(ldJson.variesBy.length).toBeGreaterThan(0); + ldJson.variesBy.forEach((v) => { + expect(typeof v).toBe('string'); + expect(v.length).toBeGreaterThan(0); + }); + }); + + test('hasVariant is an array with valid variant entries', () => { + if (ldJson?.['@type'] !== 'ProductGroup') return; + expect(Array.isArray(ldJson.hasVariant)).toBe(true); + expect(ldJson.hasVariant.length).toBeGreaterThan(0); + + ldJson.hasVariant.forEach((variant) => { + expect(variant['@type']).toBe('Product'); + expect(typeof variant.sku).toBe('string'); + expect(variant.sku.length).toBeGreaterThan(0); + expect(typeof variant.name).toBe('string'); + expect(variant.name.length).toBeGreaterThan(0); + expect(typeof variant.gtin).toBe('string'); + + if (variant.image !== null && variant.image !== undefined) { + expect(isValidUrl(variant.image)).toBe(true); + } + + expect(Array.isArray(variant.offers)).toBe(true); + expect(variant.offers.length).toBeGreaterThan(0); + variant.offers.forEach((offer) => validateOffer(offer, variant.sku)); + }); + }); + + test('variants have at least one dynamic attribute from variesBy', () => { + if (ldJson?.['@type'] !== 'ProductGroup') return; + const axes = ldJson.variesBy.map((v) => { + const match = v.match(/schema\.org\/(.+)$/); + return match ? match[1] : v; + }); + + ldJson.hasVariant.forEach((variant) => { + const hasAtLeastOneAxis = axes.some((axis) => variant[axis] !== undefined); + expect(hasAtLeastOneAxis).toBe(true); + }); + }); + }); }); - -test('product by urlKey', async () => { - const res = await fetch(`${actionUrl}/bezier-tee?urlKey=bezier-tee`); - const content = await res.text(); - - // Parse markup and compare - const $ = cheerio.load(content); - - // Validate H1 - expect($('h1').text()).toEqual('Bezier tee'); -}) diff --git a/e2e/plp-ssg.e2e.test.js b/e2e/plp-ssg.e2e.test.js new file mode 100644 index 00000000..9a1fdd67 --- /dev/null +++ b/e2e/plp-ssg.e2e.test.js @@ -0,0 +1,198 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +const { Config } = require('@adobe/aio-sdk').Core; +const fetch = require('node-fetch'); +const cheerio = require('cheerio'); +const config = require('./config.json'); + +const namespace = Config.get('runtime.namespace'); +const hostname = Config.get('cna.hostname') || 'adobeioruntime.net'; +const runtimePackage = 'aem-commerce-ssg'; +const actionUrl = `https://${namespace}.${hostname}/api/v1/web/${runtimePackage}/plp-renderer`; + +const slug = config.plpSlug; + +function isValidUrl(str) { + try { + new URL(str); + return true; + } catch { + return false; + } +} + +function validateOffer(offer) { + expect(offer['@type']).toBe('Offer'); + expect(isValidUrl(offer.url)).toBe(true); + expect(['https://schema.org/InStock', 'https://schema.org/OutOfStock']).toContain(offer.availability); + expect(typeof offer.price).toBe('number'); + expect(typeof offer.priceCurrency).toBe('string'); + expect(offer.priceCurrency.length).toBeGreaterThan(0); + expect(offer.itemCondition).toBe('https://schema.org/NewCondition'); +} + +describe(`PLP e2e - slug: ${slug}`, () => { + let $; + let ldJson; + + beforeAll(async () => { + const res = await fetch(`${actionUrl}?slug=${slug}`); + expect(res.status).toBe(200); + const content = await res.text(); + $ = cheerio.load(content); + ldJson = JSON.parse($('script[type="application/ld+json"]').html()); + }, 30000); + + test('h1 exists and is non-empty', () => { + const h1 = $('h1').text().trim(); + expect(h1.length).toBeGreaterThan(0); + }); + + test('title is non-empty', () => { + const title = $('title').text().trim(); + expect(title.length).toBeGreaterThan(0); + }); + + test('meta category-slug matches input slug', () => { + const metaSlug = $('meta[name="category-slug"]').attr('content'); + expect(metaSlug).toBe(slug); + }); + + test('breadcrumb nav exists with at least one link', () => { + const breadcrumbLinks = $('nav.breadcrumb a'); + expect(breadcrumbLinks.length).toBeGreaterThan(0); + breadcrumbLinks.each((_, el) => { + const href = $(el).attr('href'); + expect(typeof href).toBe('string'); + expect(href.length).toBeGreaterThan(0); + }); + }); + + test('at least one product in the listing', () => { + const productItems = $('.product-listing li'); + expect(productItems.length).toBeGreaterThan(0); + }); + + test('each product has a link', () => { + const productItems = $('.product-listing li'); + productItems.each((_, el) => { + const link = $(el).find('a'); + expect(link.length).toBeGreaterThan(0); + const href = link.attr('href'); + expect(typeof href).toBe('string'); + expect(href.length).toBeGreaterThan(0); + }); + }); + + test('product images have valid URLs', () => { + const images = $('.product-listing li img'); + images.each((_, el) => { + const src = $(el).attr('src'); + expect(isValidUrl(src)).toBe(true); + }); + }); + + test('LD+JSON is valid with correct @context and @type', () => { + expect(ldJson).toBeDefined(); + expect(ldJson['@context']).toBe('https://schema.org'); + expect(ldJson['@type']).toBe('CollectionPage'); + }); + + test('LD+JSON name is a non-empty string', () => { + expect(typeof ldJson.name).toBe('string'); + expect(ldJson.name.length).toBeGreaterThan(0); + }); + + describe('LD+JSON breadcrumb', () => { + test('breadcrumb is a BreadcrumbList with entries', () => { + expect(ldJson.breadcrumb).toBeDefined(); + expect(ldJson.breadcrumb['@type']).toBe('BreadcrumbList'); + expect(Array.isArray(ldJson.breadcrumb.itemListElement)).toBe(true); + expect(ldJson.breadcrumb.itemListElement.length).toBeGreaterThan(0); + }); + + test('each breadcrumb entry has valid fields', () => { + ldJson.breadcrumb.itemListElement.forEach((entry) => { + expect(entry['@type']).toBe('ListItem'); + expect(typeof entry.position).toBe('number'); + expect(entry.position).toBeGreaterThan(0); + expect(typeof entry.name).toBe('string'); + expect(entry.name.length).toBeGreaterThan(0); + expect(isValidUrl(entry.item)).toBe(true); + }); + }); + }); + + describe('LD+JSON mainEntity (ItemList)', () => { + test('mainEntity is an ItemList', () => { + expect(ldJson.mainEntity).toBeDefined(); + expect(ldJson.mainEntity['@type']).toBe('ItemList'); + expect(typeof ldJson.mainEntity.name).toBe('string'); + expect(ldJson.mainEntity.name.length).toBeGreaterThan(0); + expect(typeof ldJson.mainEntity.numberOfItems).toBe('number'); + expect(ldJson.mainEntity.numberOfItems).toBeGreaterThan(0); + }); + + test('itemListElement has at least one entry', () => { + expect(Array.isArray(ldJson.mainEntity.itemListElement)).toBe(true); + expect(ldJson.mainEntity.itemListElement.length).toBeGreaterThan(0); + }); + + test('each product entry has required fields', () => { + ldJson.mainEntity.itemListElement.forEach((entry) => { + expect(entry['@type']).toBe('ListItem'); + expect(typeof entry.position).toBe('number'); + expect(entry.position).toBeGreaterThan(0); + + const product = entry.item; + expect(product['@type']).toBe('Product'); + expect(typeof product.name).toBe('string'); + expect(product.name.length).toBeGreaterThan(0); + expect(typeof product.sku).toBe('string'); + expect(product.sku.length).toBeGreaterThan(0); + expect(isValidUrl(product.url)).toBe(true); + }); + }); + + test('optional product fields have correct types when present', () => { + ldJson.mainEntity.itemListElement.forEach((entry) => { + const product = entry.item; + + if (product.gtin !== undefined) { + expect(typeof product.gtin).toBe('string'); + } + + if (product.brand !== undefined) { + expect(product.brand['@type']).toBe('Brand'); + expect(typeof product.brand.name).toBe('string'); + expect(product.brand.name.length).toBeGreaterThan(0); + } + + if (product.image !== undefined) { + expect(isValidUrl(product.image)).toBe(true); + } + + if (product.description !== undefined) { + expect(typeof product.description).toBe('string'); + expect(product.description.length).toBeGreaterThan(0); + } + + if (product.offers !== undefined) { + expect(Array.isArray(product.offers)).toBe(true); + expect(product.offers.length).toBeGreaterThan(0); + product.offers.forEach(validateOffer); + } + }); + }); + }); +}); diff --git a/package.json b/package.json index aa6b33a5..23994474 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ }, "scripts": { "test": "c8 node --experimental-vm-modules node_modules/jest/bin/jest.js --passWithNoTests ./test", - "e2e": "node --experimental-vm-modules node_modules/jest/bin/jest.js --collectCoverage=false --testRegex ./e2e", + "e2e": "node --experimental-vm-modules node_modules/jest/bin/jest.js --collectCoverage=false --testRegex 'e2e/.*\\.e2e\\.test\\.js$'", "format": "prettier --write \"actions/**/*.js\" \"test/**/*.js\"", "format:check": "prettier --check \"actions/**/*.js\" \"test/**/*.js\"", "lint": "eslint --ignore-pattern web-src --no-error-on-unmatched-pattern test src actions", From 39cc3a1e09c310a393bb43095f3c4dc23254090d Mon Sep 17 00:00:00 2001 From: rossbrandon Date: Tue, 17 Mar 2026 14:53:54 -0500 Subject: [PATCH 04/12] Add ACCS support to PLP pre-rendering; Improve head.hbs tags --- .prettierignore | 1 + actions/categories.js | 34 +++++++++++++++------ actions/fetch-all-products/index.js | 1 + actions/pdp-renderer/templates/head.hbs | 26 +++++++++++----- actions/plp-renderer/index.js | 23 +++++++------- actions/plp-renderer/render.js | 10 +++--- actions/plp-renderer/templates/head.hbs | 27 ++++++++++++++++ actions/plp-renderer/templates/plp-head.hbs | 12 -------- actions/render-all-categories/poller.js | 12 +++----- docs/CUSTOMIZE.md | 29 +++++++++++++----- e2e/plp-ssg.e2e.test.js | 23 ++++++++++++-- test/render-all-categories.test.js | 8 ++--- 12 files changed, 138 insertions(+), 68 deletions(-) create mode 100644 actions/plp-renderer/templates/head.hbs delete mode 100644 actions/plp-renderer/templates/plp-head.hbs diff --git a/.prettierignore b/.prettierignore index 2bf280b9..ce700fdb 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1,2 @@ **/*.yaml +**/*.hbs diff --git a/actions/categories.js b/actions/categories.js index ac414875..f27bde3f 100644 --- a/actions/categories.js +++ b/actions/categories.js @@ -39,7 +39,7 @@ function hasFamilies(families) { * Handles trees of arbitrary depth even when the API caps depth at * MAX_TREE_DEPTH per call — each iteration advances up to that many levels. * - * Shared by getCategorySlugsFromFamilies and getCategoryDataFromFamilies. + * Shared by getCategorySlugsFromFamilies and getCategoryMapFromFamilies. * * @param {Object} context - Request context (config, logger, headers, etc.). * @param {string[]} families - ACO category family identifiers. @@ -117,7 +117,7 @@ async function getCategorySlugsFromFamilies(context, families) { * @param {string[]} families - ACO category family identifiers. * @returns {Promise>} Map of category slug to category metadata. */ -async function getCategoryDataFromFamilies(context, families) { +async function getCategoryMapFromFamilies(context, families) { return fetchCategoryTree(context, families); } @@ -153,7 +153,23 @@ function buildBreadcrumbs(slug, categoryMap) { } /** - * Retrieves all ACCS categories grouped by level. + * Retrieves all categories as a Map of urlPath → category metadata. + * + * @param {Object} context - Request context (config, logger, headers, etc.). + * @returns {Promise>} Map of urlPath to category metadata. + */ +async function getCategoryMap(context) { + const categoriesRes = await requestSaaS(CategoriesQuery, 'getCategories', {}, context); + const categoryMap = new Map(); + for (const { name, level, urlPath } of categoriesRes.data.categories) { + if (!urlPath) continue; + categoryMap.set(urlPath, { slug: urlPath, name, level: parseInt(level) }); + } + return categoryMap; +} + +/** + * Retrieves all categories grouped by level. * * Returns a sparse array indexed by category level so callers can iterate * shallowest levels first (used for the early-exit optimization when @@ -163,19 +179,19 @@ function buildBreadcrumbs(slug, categoryMap) { * @returns {Promise} Sparse array where index N holds urlPath strings at level N. */ async function getCategories(context) { - const categoriesRes = await requestSaaS(CategoriesQuery, 'getCategories', {}, context); + const categoryMap = await getCategoryMap(context); const byLevel = []; - for (const { urlPath, level } of categoriesRes.data.categories) { - const idx = parseInt(level); - byLevel[idx] = byLevel[idx] || []; - byLevel[idx].push(urlPath); + for (const [, { slug, level }] of categoryMap) { + byLevel[level] = byLevel[level] || []; + byLevel[level].push(slug); } return byLevel; } module.exports = { getCategorySlugsFromFamilies, - getCategoryDataFromFamilies, + getCategoryMapFromFamilies, + getCategoryMap, getCategories, hasFamilies, buildBreadcrumbs, diff --git a/actions/fetch-all-products/index.js b/actions/fetch-all-products/index.js index 230d75f7..0e27fc42 100644 --- a/actions/fetch-all-products/index.js +++ b/actions/fetch-all-products/index.js @@ -220,6 +220,7 @@ async function main(params) { const siteConfig = await getConfig(context); const siteType = getSiteType(siteConfig); + logger.debug(`Detected site type: ${siteType}`); const allProducts = await getAllProducts(siteType, context, cfg.categoryFamilies); timings.sample('getAllProducts'); diff --git a/actions/pdp-renderer/templates/head.hbs b/actions/pdp-renderer/templates/head.hbs index 18f0c394..7b1b2a62 100644 --- a/actions/pdp-renderer/templates/head.hbs +++ b/actions/pdp-renderer/templates/head.hbs @@ -1,15 +1,25 @@ {{metaTitle}} - - {{#if metaDescription ~}}{{/if ~}} - {{#if metaKeyword ~}}{{/if ~}} - {{#if metaImage ~}}{{/if ~}} - {{#if externalId ~}}{{/if ~}} - {{#if sku ~}}{{/if ~}} - {{#if __typename ~}}{{/if ~}} +{{#if metaDescription}} + +{{/if}} +{{#if metaKeyword}} + +{{/if}} +{{#if metaImage}} + +{{/if}} +{{#if externalId}} + +{{/if}} +{{#if sku}} + +{{/if}} +{{#if __typename}} + +{{/if}} - diff --git a/actions/plp-renderer/index.js b/actions/plp-renderer/index.js index 25f1042c..8817d851 100644 --- a/actions/plp-renderer/index.js +++ b/actions/plp-renderer/index.js @@ -12,7 +12,7 @@ governing permissions and limitations under the License. const { Core } = require('@adobe/aio-sdk'); const { errorResponse, getConfig, getSiteType, requestSaaS, SITE_TYPES } = require('../utils'); -const { getCategoryDataFromFamilies } = require('../categories'); +const { getCategoryMapFromFamilies, getCategoryMap } = require('../categories'); const { generateCategoryHtml } = require('./render'); const { getRuntimeConfig } = require('../lib/runtimeConfig'); const { JobFailedError, ERROR_CODES } = require('../lib/errorHandler'); @@ -41,14 +41,7 @@ async function main(params) { } const siteConfig = await getConfig(context); const siteType = getSiteType(siteConfig); - - if (siteType === SITE_TYPES.ACO) { - if (!cfg.categoryFamilies?.length) { - throw new JobFailedError('Missing ACO_CATEGORY_FAMILIES configuration', ERROR_CODES.VALIDATION_ERROR, 400); - } - } else { - throw new JobFailedError('ACCS is not yet supported for PLP pre-rendering', ERROR_CODES.VALIDATION_ERROR, 400); - } + logger.debug(`Detected site type: ${siteType}`); if (!slug) { throw new JobFailedError('Missing required parameter: slug must be provided', ERROR_CODES.VALIDATION_ERROR, 400); @@ -56,9 +49,15 @@ async function main(params) { logger.info(`Rendering category slug: ${slug} for locale: ${locale || 'default'}`); - // Fetch full category tree (needed for breadcrumb resolution) - logger.info(`Fetching category tree for families: ${cfg.categoryFamilies}`); - const categoryMap = await getCategoryDataFromFamilies(context, cfg.categoryFamilies); + let categoryMap; + if (siteType === SITE_TYPES.ACO) { + if (!cfg.categoryFamilies?.length) { + throw new JobFailedError('Missing ACO_CATEGORY_FAMILIES configuration', ERROR_CODES.VALIDATION_ERROR, 400); + } + categoryMap = await getCategoryMapFromFamilies(context, cfg.categoryFamilies); + } else { + categoryMap = await getCategoryMap(context); + } logger.debug(`Category tree resolved with ${categoryMap.size} categories`); const categoryData = categoryMap.get(slug); diff --git a/actions/plp-renderer/render.js b/actions/plp-renderer/render.js index 90227ae5..ef4daa0c 100644 --- a/actions/plp-renderer/render.js +++ b/actions/plp-renderer/render.js @@ -21,7 +21,7 @@ const { buildBreadcrumbs } = require('../categories'); let compiledTemplate; function getCompiledTemplate() { if (!compiledTemplate) { - const [pageHbs, headHbs, productListingHbs] = ['page', 'plp-head', 'product-listing'].map((template) => + const [pageHbs, headHbs, productListingHbs] = ['page', 'head', 'product-listing'].map((template) => fs.readFileSync(path.join(__dirname, 'templates', `${template}.hbs`), 'utf8'), ); const handlebars = Handlebars.create(); @@ -49,17 +49,15 @@ function generateCategoryHtml(categoryData, products, categoryMap, context) { ? sanitize(categoryData.metaTags.description, 'all') : null; - const categoryImage = categoryData.images?.find( - (img) => img.roles?.includes('image') || img.customRoles?.includes('hero'), - ); + const categoryImage = categoryData.images?.find((img) => img.roles?.includes('BASE')) || categoryData.images?.[0]; const templateData = { categoryName: sanitize(categoryData.name, 'inline'), categoryDescription, - slug: categoryData.slug, + categoryUrl: getCategoryUrl(categoryData.slug, context), metaTitle: sanitize(categoryData.metaTags?.title || categoryData.name, 'no'), metaDescription: categoryData.metaTags?.description ? sanitize(categoryData.metaTags.description, 'no') : null, - metaKeywords: categoryData.metaTags?.keywords ? sanitize(categoryData.metaTags.keywords, 'no') : null, + metaKeywords: categoryData.metaTags?.keywords ? sanitize(categoryData.metaTags.keywords.join(', '), 'no') : null, metaImage: categoryImage?.url || null, breadcrumbs: breadcrumbs.map((crumb) => ({ name: sanitize(crumb.name, 'inline'), diff --git a/actions/plp-renderer/templates/head.hbs b/actions/plp-renderer/templates/head.hbs new file mode 100644 index 00000000..c3463ce9 --- /dev/null +++ b/actions/plp-renderer/templates/head.hbs @@ -0,0 +1,27 @@ + + + {{metaTitle}} +{{#if metaDescription}} + +{{/if}} +{{#if metaKeywords}} + +{{/if}} +{{#if metaImage}} + +{{/if}} + +{{#if metaTitle}} + +{{/if}} +{{#if metaDescription}} + +{{/if}} +{{#if categoryUrl}} + +{{/if}} +{{#if metaImage}} + +{{/if}} + + \ No newline at end of file diff --git a/actions/plp-renderer/templates/plp-head.hbs b/actions/plp-renderer/templates/plp-head.hbs deleted file mode 100644 index 39c8af0d..00000000 --- a/actions/plp-renderer/templates/plp-head.hbs +++ /dev/null @@ -1,12 +0,0 @@ - - - {{metaTitle}} - - {{#if metaDescription ~}}{{/if ~}} - {{#if metaKeywords ~}}{{/if ~}} - {{#if metaImage ~}}{{/if ~}} - - - - - diff --git a/actions/render-all-categories/poller.js b/actions/render-all-categories/poller.js index 1a4cc32e..48b2c209 100644 --- a/actions/render-all-categories/poller.js +++ b/actions/render-all-categories/poller.js @@ -33,7 +33,7 @@ const { validateRequiredParams, } = require('../renderUtils'); const { PlpProductSearchQuery } = require('../queries'); -const { getCategoryDataFromFamilies } = require('../categories'); +const { getCategoryMapFromFamilies, getCategoryMap } = require('../categories'); const { generateCategoryHtml } = require('../plp-renderer/render'); const { JobFailedError, ERROR_CODES } = require('../lib/errorHandler'); const DATA_KEY = 'categories'; @@ -227,6 +227,8 @@ async function poll(params, aioLibs, logger) { const context = { ...sharedContext, startTime: new Date() }; const siteConfig = await getConfig(context); const siteType = getSiteType(siteConfig); + logger.debug(`Detected site type: ${siteType}`); + if (locale) context.locale = locale; logger.info(`PLP polling for locale ${locale}`); @@ -241,13 +243,9 @@ async function poll(params, aioLibs, logger) { 400, ); } - categoryMap = await getCategoryDataFromFamilies(context, categoryFamilies); + categoryMap = await getCategoryMapFromFamilies(context, categoryFamilies); } else { - throw new JobFailedError( - 'ACCS is not yet supported for PLP pre-rendering', - ERROR_CODES.VALIDATION_ERROR, - 400, - ); + categoryMap = await getCategoryMap(context); } timings.sample('discover-categories'); diff --git a/docs/CUSTOMIZE.md b/docs/CUSTOMIZE.md index cb374855..b2290bc5 100644 --- a/docs/CUSTOMIZE.md +++ b/docs/CUSTOMIZE.md @@ -9,20 +9,23 @@ GTIN [is strongly recommended](https://support.google.com/merchants/answer/6324461) in the structured data but not mandatory. From [ldJson.js](/actions/pdp-renderer/ldJson.js#L73) + ```js /** * Extracts the GTIN (Global Trade Item Number) from a product's attributes. * Checks for GTIN, UPC, or EAN attributes as defined in the Catalog. - * + * * @param {Object} product - The product object containing attributes * @returns {string} The GTIN value if found, empty string otherwise */ function getGTIN(product) { - return product?.attributes?.find(attr => attr.name === 'gtin')?.value - || product?.attributes?.find(attr => attr.name === 'upc')?.value - || product?.attributes?.find(attr => attr.name === 'ean')?.value - || product?.attributes?.find(attr => attr.name === 'isbn')?.value - || ''; + return ( + product?.attributes?.find((attr) => attr.name === 'gtin')?.value || + product?.attributes?.find((attr) => attr.name === 'upc')?.value || + product?.attributes?.find((attr) => attr.name === 'ean')?.value || + product?.attributes?.find((attr) => attr.name === 'isbn')?.value || + '' + ); } ``` @@ -39,6 +42,18 @@ Those files follow the [Handlebars](https://handlebarsjs.com/) syntax and the re The PLP renderer generates [CollectionPage](https://schema.org/CollectionPage) JSON-LD with an `ItemList` of products and a `BreadcrumbList`. The logic is defined in [ldJson.js](/actions/plp-renderer/ldJson.js). +### Category Image Selection + +The PLP renderer selects a category image for the `og:image` meta tag and general display. By default it prefers an image with the `BASE` role, falling back to the first available image. + +From [render.js](/actions/plp-renderer/render.js): + +```js +const categoryImage = categoryData.images?.find((img) => img.roles?.includes('BASE')) || categoryData.images?.[0]; +``` + +You can customize this to match a different role (`SMALL`, `THUMBNAIL`, `SWATCH`) or a custom role defined in your catalog. + ### Templates PLP markup templates follow the same [Handlebars](https://handlebarsjs.com/) pattern as PDP. The templates are in [the templates folder](/actions/plp-renderer/templates) and the referenced variables can be defined in [render.js](/actions/plp-renderer/render.js). @@ -69,4 +84,4 @@ Update these values to match products and categories available in your catalog. npm run e2e ``` -The tests validate structural correctness and field-level checks on the rendered HTML and JSON-LD, without asserting on catalog-specific values like product names or image domains. \ No newline at end of file +The tests validate structural correctness and field-level checks on the rendered HTML and JSON-LD, without asserting on catalog-specific values like product names or image domains. diff --git a/e2e/plp-ssg.e2e.test.js b/e2e/plp-ssg.e2e.test.js index 9a1fdd67..a73bcae3 100644 --- a/e2e/plp-ssg.e2e.test.js +++ b/e2e/plp-ssg.e2e.test.js @@ -63,9 +63,26 @@ describe(`PLP e2e - slug: ${slug}`, () => { expect(title.length).toBeGreaterThan(0); }); - test('meta category-slug matches input slug', () => { - const metaSlug = $('meta[name="category-slug"]').attr('content'); - expect(metaSlug).toBe(slug); + test('og tags are present and non-empty', () => { + for (const property of ['og:type', 'og:title', 'og:url']) { + const content = $(`meta[property="${property}"]`).attr('content'); + expect(content).toBeTruthy(); + } + }); + + test('optional meta tags have non-empty content when present', () => { + for (const [attr, name] of [ + ['name', 'description'], + ['name', 'keywords'], + ['name', 'image'], + ['property', 'og:description'], + ['property', 'og:image'], + ]) { + const el = $(`meta[${attr}="${name}"]`); + if (el.length) { + expect(el.attr('content')).toBeTruthy(); + } + } }); test('breadcrumb nav exists with at least one link', () => { diff --git a/test/render-all-categories.test.js b/test/render-all-categories.test.js index feccb0e3..2bcb31f4 100644 --- a/test/render-all-categories.test.js +++ b/test/render-all-categories.test.js @@ -16,7 +16,7 @@ const { generatePlpLdJson } = require('../actions/plp-renderer/ldJson'); const { buildBreadcrumbs, getCategorySlugsFromFamilies, - getCategoryDataFromFamilies, + getCategoryMapFromFamilies, } = require('../actions/categories'); const { getCategoryUrl } = require('../actions/utils'); const Files = require('./__mocks__/files'); @@ -409,7 +409,7 @@ describe('getCategoryUrl', () => { }); }); -// ─── getCategoryDataFromFamilies / getCategorySlugsFromFamilies ───────────── +// ─── getCategoryMapFromFamilies / getCategorySlugsFromFamilies ───────────── describe('category tree fetching', () => { const server = useMockServer(); @@ -475,7 +475,7 @@ describe('category tree fetching', () => { expect(slugs).toHaveLength(2); }); - test('getCategoryDataFromFamilies returns Map with full metadata', async () => { + test('getCategoryMapFromFamilies returns Map with full metadata', async () => { server.use( http.post('https://commerce.com/graphql', async ({ request }) => { const body = await request.json(); @@ -510,7 +510,7 @@ describe('category tree fetching', () => { }), ); - const categoryMap = await getCategoryDataFromFamilies(mockContext, ['electronics']); + const categoryMap = await getCategoryMapFromFamilies(mockContext, ['electronics']); expect(categoryMap).toBeInstanceOf(Map); expect(categoryMap.has('electronics')).toBe(true); From 2564ece15fa80ca46cd5062d0a59b2a916555a3b Mon Sep 17 00:00:00 2001 From: rossbrandon Date: Tue, 17 Mar 2026 15:25:30 -0500 Subject: [PATCH 05/12] Fix markup matching in pdp-renderer test --- test/pdp-renderer.test.js | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/test/pdp-renderer.test.js b/test/pdp-renderer.test.js index 21db7c99..31fc1bae 100644 --- a/test/pdp-renderer.test.js +++ b/test/pdp-renderer.test.js @@ -158,9 +158,13 @@ describe('pdp-renderer', () => { Crown Summit Backpack - - - + + + + + + + @@ -593,9 +597,7 @@ describe('Meta Tags Template', () => { " - - @@ -617,9 +619,12 @@ describe('Meta Tags Template', () => { " - - - + + + + + + @@ -634,9 +639,8 @@ describe('Meta Tags Template', () => { " - - - + + From fd8e811510ef1a64255af5ee9b163b283ea55c32 Mon Sep 17 00:00:00 2001 From: rossbrandon Date: Wed, 18 Mar 2026 13:41:21 -0500 Subject: [PATCH 06/12] Ensure URLs are lowercased; fix issue with not correctly sanitizing product URLs in PDP and PLP rendering --- actions/plp-renderer/render.js | 6 +++--- actions/utils.js | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/actions/plp-renderer/render.js b/actions/plp-renderer/render.js index ef4daa0c..c60b6524 100644 --- a/actions/plp-renderer/render.js +++ b/actions/plp-renderer/render.js @@ -54,18 +54,18 @@ function generateCategoryHtml(categoryData, products, categoryMap, context) { const templateData = { categoryName: sanitize(categoryData.name, 'inline'), categoryDescription, - categoryUrl: getCategoryUrl(categoryData.slug, context), + categoryUrl: getCategoryUrl(categoryData.slug, context).toLowerCase(), metaTitle: sanitize(categoryData.metaTags?.title || categoryData.name, 'no'), metaDescription: categoryData.metaTags?.description ? sanitize(categoryData.metaTags.description, 'no') : null, metaKeywords: categoryData.metaTags?.keywords ? sanitize(categoryData.metaTags.keywords.join(', '), 'no') : null, metaImage: categoryImage?.url || null, breadcrumbs: breadcrumbs.map((crumb) => ({ name: sanitize(crumb.name, 'inline'), - url: getCategoryUrl(crumb.slug, context), + url: getCategoryUrl(crumb.slug, context).toLowerCase(), })), products: products.map((product) => ({ name: sanitize(product.name, 'inline'), - url: getProductUrl({ urlKey: product.urlKey, sku: product.sku }, context), + url: getProductUrl({ urlKey: product.urlKey, sku: 'test_sku' }, context).toLowerCase(), image: product.images?.find((img) => img.roles?.includes('image'))?.url || null, })), hasProducts: products.length > 0, diff --git a/actions/utils.js b/actions/utils.js index 9ab33f3b..672d17bc 100644 --- a/actions/utils.js +++ b/actions/utils.js @@ -507,7 +507,7 @@ function getProductUrl(product, context, addStore = true) { if (addStore) { path.unshift(storeUrl); - return path.join('/'); + return helixSharedStringLib.sanitizePath(path.join('/')); } return helixSharedStringLib.sanitizePath(`/${path.join('/')}`); From ade0da465b495455f6e5aa4f339c04fcc00a6341 Mon Sep 17 00:00:00 2001 From: rossbrandon Date: Wed, 18 Mar 2026 13:42:45 -0500 Subject: [PATCH 07/12] Cleanup --- actions/plp-renderer/render.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actions/plp-renderer/render.js b/actions/plp-renderer/render.js index c60b6524..a3e440b4 100644 --- a/actions/plp-renderer/render.js +++ b/actions/plp-renderer/render.js @@ -65,7 +65,7 @@ function generateCategoryHtml(categoryData, products, categoryMap, context) { })), products: products.map((product) => ({ name: sanitize(product.name, 'inline'), - url: getProductUrl({ urlKey: product.urlKey, sku: 'test_sku' }, context).toLowerCase(), + url: getProductUrl({ urlKey: product.urlKey, sku: product.sku }, context).toLowerCase(), image: product.images?.find((img) => img.roles?.includes('image'))?.url || null, })), hasProducts: products.length > 0, From e871538e4f25d4e13c7607a2786da75b7e7b3076 Mon Sep 17 00:00:00 2001 From: rossbrandon Date: Wed, 18 Mar 2026 14:00:29 -0500 Subject: [PATCH 08/12] Fix product URL construction --- actions/utils.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/actions/utils.js b/actions/utils.js index 672d17bc..3c7a5d2f 100644 --- a/actions/utils.js +++ b/actions/utils.js @@ -506,8 +506,7 @@ function getProductUrl(product, context, addStore = true) { .filter(Boolean); // Remove any empty segments if (addStore) { - path.unshift(storeUrl); - return helixSharedStringLib.sanitizePath(path.join('/')); + return `${storeUrl}${helixSharedStringLib.sanitizePath(`/${path.join('/')}`)}`; } return helixSharedStringLib.sanitizePath(`/${path.join('/')}`); From 779d128340cb05eac32b1910ad605a7e4b10ae0d Mon Sep 17 00:00:00 2001 From: rossbrandon Date: Thu, 19 Mar 2026 09:42:14 -0500 Subject: [PATCH 09/12] Fix PDP og:type meta tag in template --- actions/pdp-renderer/templates/head.hbs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actions/pdp-renderer/templates/head.hbs b/actions/pdp-renderer/templates/head.hbs index 7b1b2a62..050efc21 100644 --- a/actions/pdp-renderer/templates/head.hbs +++ b/actions/pdp-renderer/templates/head.hbs @@ -19,7 +19,7 @@ {{#if __typename}} {{/if}} - + From 7c3da2e24644ed0fa296183c098b846cd8a9fc20 Mon Sep 17 00:00:00 2001 From: rossbrandon Date: Thu, 19 Mar 2026 09:48:14 -0500 Subject: [PATCH 10/12] Fix tests --- test/pdp-renderer.test.js | 239 ++++++++++++++++++++------------------ 1 file changed, 123 insertions(+), 116 deletions(-) diff --git a/test/pdp-renderer.test.js b/test/pdp-renderer.test.js index 31fc1bae..90105389 100644 --- a/test/pdp-renderer.test.js +++ b/test/pdp-renderer.test.js @@ -17,26 +17,25 @@ const { useMockServer, handlers } = require('./mock-server.js'); jest.mock('@adobe/aio-sdk', () => ({ Core: { - Logger: jest.fn() - } -})) + Logger: jest.fn(), + }, +})); +const { Core } = require('@adobe/aio-sdk'); +const mockLoggerInstance = { info: jest.fn(), debug: jest.fn(), error: jest.fn() }; +Core.Logger.mockReturnValue(mockLoggerInstance); -const { Core } = require('@adobe/aio-sdk') -const mockLoggerInstance = { info: jest.fn(), debug: jest.fn(), error: jest.fn() } -Core.Logger.mockReturnValue(mockLoggerInstance) - -const action = require('./../actions/pdp-renderer/index.js') -const fs = require("fs"); -const path = require("path"); -const Handlebars = require("handlebars"); +const action = require('./../actions/pdp-renderer/index.js'); +const fs = require('fs'); +const path = require('path'); +const Handlebars = require('handlebars'); beforeEach(() => { - Core.Logger.mockClear() - mockLoggerInstance.info.mockReset() - mockLoggerInstance.debug.mockReset() - mockLoggerInstance.error.mockReset() -}) + Core.Logger.mockClear(); + mockLoggerInstance.info.mockReset(); + mockLoggerInstance.debug.mockReset(); + mockLoggerInstance.error.mockReset(); +}); const fakeParams = { __ow_headers: {}, @@ -51,16 +50,16 @@ describe('pdp-renderer', () => { describe('basic functionality', () => { test('main should be defined', () => { - expect(action.main).toBeInstanceOf(Function) - }) + expect(action.main).toBeInstanceOf(Function); + }); test('should set logger to use LOG_LEVEL param', async () => { - await action.main({ ...fakeParams, CONTENT_URL: 'https://content.com', LOG_LEVEL: 'fakeLevel' }) - expect(Core.Logger).toHaveBeenCalledWith(expect.any(String), { level: 'fakeLevel' }) - }) + await action.main({ ...fakeParams, CONTENT_URL: 'https://content.com', LOG_LEVEL: 'fakeLevel' }); + expect(Core.Logger).toHaveBeenCalledWith(expect.any(String), { level: 'fakeLevel' }); + }); test('should return an http response with error for invalid path', async () => { - const response = await action.main({ ...fakeParams, CONTENT_URL: 'https://content.com'}) + const response = await action.main({ ...fakeParams, CONTENT_URL: 'https://content.com' }); expect(response).toEqual({ error: { statusCode: 400, @@ -68,9 +67,9 @@ describe('pdp-renderer', () => { error: 'Missing required parameters: sku or urlKey must be provided', }, }, - }) - }) - }) + }); + }); + }); describe('product rendering', () => { test('render with product template', async () => { @@ -80,14 +79,14 @@ describe('pdp-renderer', () => { STORE_URL: 'https://store.com', CONTENT_URL: 'https://content.com', CONFIG_NAME: 'config', - PRODUCTS_TEMPLATE: "https://content.com/products/default", + PRODUCTS_TEMPLATE: 'https://content.com/products/default', PRODUCT_PAGE_URL_FORMAT: '/products/{urlKey}/{sku}', __ow_path: `/products/crown-summit-backpack/24-MB03`, }); expect(response.body).toBeDefined(); expect(typeof response.body).toBe('string'); - + const $ = cheerio.load(response.body); expect($('.product-recommendations')).toHaveLength(1); expect($('body > main > div')).toHaveLength(2); @@ -116,16 +115,18 @@ describe('pdp-renderer', () => { let configRequestUrl; const mockConfig = require('./mock-responses/mock-config.json'); - server.use(http.get('https://content.com/en/config.json', async (req) => { - configRequestUrl = req.request.url; - return HttpResponse.json(mockConfig); - })); + server.use( + http.get('https://content.com/en/config.json', async (req) => { + configRequestUrl = req.request.url; + return HttpResponse.json(mockConfig); + }), + ); const response = await action.main({ STORE_URL: 'https://store.com', CONTENT_URL: 'https://content.com', CONFIG_NAME: 'config', - PRODUCTS_TEMPLATE: "https://content.com/{locale}/products/default", + PRODUCTS_TEMPLATE: 'https://content.com/{locale}/products/default', PRODUCT_PAGE_URL_FORMAT: '/{locale}/products/{urlKey}/{sku}', __ow_path: `/en/products/crown-summit-backpack/24-MB03`, }); @@ -164,7 +165,7 @@ describe('pdp-renderer', () => { - + @@ -213,9 +214,9 @@ describe('pdp-renderer', () => {
-`) +`); }); - }) + }); describe('product lookup methods', () => { test('get product by sku', async () => { @@ -243,11 +244,11 @@ describe('pdp-renderer', () => { PRODUCT_PAGE_URL_FORMAT: '/{urlKey}', __ow_path: `/crown-summit-backpack`, }); - + const $ = cheerio.load(response.body); expect($('main .product-details h1').text()).toEqual('Crown Summit Backpack'); }); - }) + }); describe('localization', () => { test('render product with locale', async () => { @@ -255,10 +256,12 @@ describe('pdp-renderer', () => { let configRequestUrl; const mockConfig = require('./mock-responses/mock-config.json'); - server.use(http.get('https://content.com/en/config.json', async (req) => { - configRequestUrl = req.request.url; - return HttpResponse.json(mockConfig); - })); + server.use( + http.get('https://content.com/en/config.json', async (req) => { + configRequestUrl = req.request.url; + return HttpResponse.json(mockConfig); + }), + ); const response = await action.main({ STORE_URL: 'https://store.com', @@ -278,7 +281,7 @@ describe('pdp-renderer', () => { const ldJson = JSON.parse($('head > script[type="application/ld+json"]').html()); expect(ldJson.offers[0].url).toEqual('https://store.com/en/products/24-mb03'); }); - }) + }); describe('error handling', () => { test('return 400 if neither sku nor urlKey are provided', async () => { @@ -306,7 +309,7 @@ describe('pdp-renderer', () => { expect(response.error.statusCode).toEqual(404); }); - }) + }); describe('product content rendering', () => { beforeEach(() => { @@ -325,61 +328,59 @@ describe('pdp-renderer', () => { test('render images', async () => { const response = await getProductResponse(); - + const $ = cheerio.load(response.body); expect( - $('main .product-details h2:contains("Images")') - .parent() - .next() - .find('img') - .map((_, e) => $(e).prop('outerHTML')) - .toArray() + $('main .product-details h2:contains("Images")') + .parent() + .next() + .find('img') + .map((_, e) => $(e).prop('outerHTML')) + .toArray(), ).toEqual([ '', - '' + '', ]); }); test('render description', async () => { const response = await getProductResponse(); - + const $ = cheerio.load(response.body); const descContainer = $('main .product-details h2:contains("Description")').parent().next(); const descText = descContainer.find('p').text().trim(); expect(descText).toContain('The Crown Summit Backpack is equally at home'); - const bullets = descContainer.find('li').map((_, e) => $(e).text().trim()).toArray(); + const bullets = descContainer + .find('li') + .map((_, e) => $(e).text().trim()) + .toArray(); expect(bullets).toEqual([ 'Top handle.', 'Grommet holes.', 'Two-way zippers.', 'H 20" x W 14" x D 12".', - 'Weight: 2 lbs, 8 oz. Volume: 29 L.' + 'Weight: 2 lbs, 8 oz. Volume: 29 L.', ]); }); test('render price', async () => { const response = await getProductResponse(); - + const $ = cheerio.load(response.body); - expect( - $('main .product-details h2:contains("Price")') - .parent() - .next() - .text() - ).toBe('$38.00'); + expect($('main .product-details h2:contains("Price")').parent().next().text()).toBe('$38.00'); }); test('render title', async () => { const response = await getProductResponse(); - + const $ = cheerio.load(response.body); expect($('main .product-details h1').text()).toEqual('Crown Summit Backpack'); }); - }) + }); describe('product options', () => { test('render product without options', async () => { @@ -416,7 +417,7 @@ describe('pdp-renderer', () => { const optionsContainer = optionsHeader.parent().next(); expect(optionsContainer.find('li')).not.toHaveLength(0); }); - }) + }); describe('SEO and metadata', () => { test('render metadata', async () => { @@ -432,13 +433,17 @@ describe('pdp-renderer', () => { const $ = cheerio.load(response.body); expect($('head > meta')).toHaveLength(8); - expect($('head > meta[name="description"]').attr('content')).toMatchInlineSnapshot(`"The Crown Summit Backpack is equally at home in a gym locker, study cube or a pup tent, so be sure yours is packed with books, a bag lunch, water bottles, yoga block, laptop, or whatever else you want in hand. Rugged enough for day hikes and camping trips, it has two large zippered compartments and padded, adjustable shoulder straps.Top handle.Grommet holes.Two-way zippers.H 20" x W 14" x D 12".Weight: 2 lbs, 8 oz. Volume: 29 L."`); + expect($('head > meta[name="description"]').attr('content')).toMatchInlineSnapshot( + `"The Crown Summit Backpack is equally at home in a gym locker, study cube or a pup tent, so be sure yours is packed with books, a bag lunch, water bottles, yoga block, laptop, or whatever else you want in hand. Rugged enough for day hikes and camping trips, it has two large zippered compartments and padded, adjustable shoulder straps.Top handle.Grommet holes.Two-way zippers.H 20" x W 14" x D 12".Weight: 2 lbs, 8 oz. Volume: 29 L."`, + ); expect($('head > meta[name="keywords"]').attr('content')).toEqual('backpack, hiking, camping'); - expect($('head > meta[name="image"]').attr('content')).toEqual('http://www.aemshop.net/media/catalog/product/m/b/mb03-black-0.jpg'); + expect($('head > meta[name="image"]').attr('content')).toEqual( + 'http://www.aemshop.net/media/catalog/product/m/b/mb03-black-0.jpg', + ); expect($('head > meta[name="id"]').attr('content')).toEqual('7'); expect($('head > meta[name="sku"]').attr('content')).toEqual('24-MB03'); expect($('head > meta[name="__typename"]').attr('content')).toEqual('SimpleProductView'); - expect($('head > meta[property="og:type"]').attr('content')).toEqual('og:product'); + expect($('head > meta[property="og:type"]').attr('content')).toEqual('product'); }); test('render ld+json', async () => { @@ -455,29 +460,30 @@ describe('pdp-renderer', () => { const $ = cheerio.load(response.body); const ldJson = JSON.parse($('head > script[type="application/ld+json"]').html()); expect(ldJson).toEqual({ - "@context": "http://schema.org", - "@id": "https://store.com/products/crown-summit-backpack/24-mb03", - "@type": "Product", - "description": 'The Crown Summit Backpack is equally at home in a gym locker, study cube or a pup tent, so be sure yours is packed with books, a bag lunch, water bottles, yoga block, laptop, or whatever else you want in hand. Rugged enough for day hikes and camping trips, it has two large zippered compartments and padded, adjustable shoulder straps.Top handle.Grommet holes.Two-way zippers.H 20" x W 14" x D 12".Weight: 2 lbs, 8 oz. Volume: 29 L.', - "gtin": "", - "image": "http://www.aemshop.net/media/catalog/product/m/b/mb03-black-0.jpg", - "name": "Crown Summit Backpack", - "offers": [ + '@context': 'http://schema.org', + '@id': 'https://store.com/products/crown-summit-backpack/24-mb03', + '@type': 'Product', + description: + 'The Crown Summit Backpack is equally at home in a gym locker, study cube or a pup tent, so be sure yours is packed with books, a bag lunch, water bottles, yoga block, laptop, or whatever else you want in hand. Rugged enough for day hikes and camping trips, it has two large zippered compartments and padded, adjustable shoulder straps.Top handle.Grommet holes.Two-way zippers.H 20" x W 14" x D 12".Weight: 2 lbs, 8 oz. Volume: 29 L.', + gtin: '', + image: 'http://www.aemshop.net/media/catalog/product/m/b/mb03-black-0.jpg', + name: 'Crown Summit Backpack', + offers: [ { - "@type": "Offer", - "availability": "https://schema.org/InStock", - "itemCondition": "https://schema.org/NewCondition", - "price": 38, - "priceCurrency": "USD", - "sku": "24-MB03", - "url": "https://store.com/products/crown-summit-backpack/24-mb03", + '@type': 'Offer', + availability: 'https://schema.org/InStock', + itemCondition: 'https://schema.org/NewCondition', + price: 38, + priceCurrency: 'USD', + sku: '24-MB03', + url: 'https://store.com/products/crown-summit-backpack/24-mb03', }, ], - "sku": "24-MB03", + sku: '24-MB03', }); }); - }) -}) + }); +}); describe('generateProductHtml', () => { const { generateProductHtml } = require('../actions/pdp-renderer/render'); @@ -486,7 +492,7 @@ describe('generateProductHtml', () => { const defaultContext = { logger: { debug: jest.fn() }, storeUrl: 'https://store.com', - contentUrl: 'https://content.com', + contentUrl: 'https://content.com', configName: 'config', }; @@ -496,55 +502,53 @@ describe('generateProductHtml', () => { server.use( http.get('https://content.com/config.json', () => { return HttpResponse.json(mockConfig); - }) + }), ); }); describe('error handling', () => { test('throws 404 when neither sku nor urlKey provided', async () => { - await expect(generateProductHtml(null, null, defaultContext)) - .rejects - .toThrow('Either sku or urlKey must be provided'); + await expect(generateProductHtml(null, null, defaultContext)).rejects.toThrow( + 'Either sku or urlKey must be provided', + ); }); test('throws 404 when product not found', async () => { // Use both handlers for 404 responses server.use(handlers.return404()); server.use(handlers.returnLiveSearch404()); - + // Mock the config to be pre-loaded to avoid the HTTP request const contextWithConfig = { ...defaultContext, - config: mockConfig.data.reduce((acc, { key, value }) => ({ ...acc, [key]: value }), {}) + config: mockConfig.data.reduce((acc, { key, value }) => ({ ...acc, [key]: value }), {}), }; - - await expect(generateProductHtml('NON-EXISTENT', null, contextWithConfig)) - .rejects - .toThrow('Product not found'); - - await expect(generateProductHtml(null, 'non-existent-product', contextWithConfig)) - .rejects - .toThrow('Product not found'); + + await expect(generateProductHtml('NON-EXISTENT', null, contextWithConfig)).rejects.toThrow('Product not found'); + + await expect(generateProductHtml(null, 'non-existent-product', contextWithConfig)).rejects.toThrow( + 'Product not found', + ); }); }); describe('HTML generation', () => { test('generates HTML with product template', async () => { server.use(handlers.defaultProduct()); - + // Mock the template fetch const templateHtml = fs.readFileSync(path.join(__dirname, 'mock-responses', 'product-default.html'), 'utf8'); server.use( http.get('https://content.com/products/default.plain.html', () => { return HttpResponse.text(templateHtml); - }) + }), ); // Mock the config to be pre-loaded const contextWithConfig = { ...defaultContext, productsTemplate: 'https://content.com/products/default', - config: mockConfig.data.reduce((acc, { key, value }) => ({ ...acc, [key]: value }), {}) + config: mockConfig.data.reduce((acc, { key, value }) => ({ ...acc, [key]: value }), {}), }; const html = await generateProductHtml('24-MB03', null, contextWithConfig); @@ -559,7 +563,7 @@ describe('generateProductHtml', () => { // Mock the config to be pre-loaded const contextWithConfig = { ...defaultContext, - config: mockConfig.data.reduce((acc, { key, value }) => ({ ...acc, [key]: value }), {}) + config: mockConfig.data.reduce((acc, { key, value }) => ({ ...acc, [key]: value }), {}), }; const html = await generateProductHtml('24-MB03', null, contextWithConfig); @@ -574,7 +578,7 @@ describe('generateProductHtml', () => { // Mock the config to be pre-loaded const contextWithConfig = { ...defaultContext, - config: mockConfig.data.reduce((acc, { key, value }) => ({ ...acc, [key]: value }), {}) + config: mockConfig.data.reduce((acc, { key, value }) => ({ ...acc, [key]: value }), {}), }; const html = await generateProductHtml(null, 'crown-summit-backpack', contextWithConfig); @@ -587,7 +591,10 @@ describe('generateProductHtml', () => { describe('Meta Tags Template', () => { let headTemplate; beforeAll(() => { - const headTemplateFile = fs.readFileSync(path.join(__dirname, '..', 'actions', 'pdp-renderer', 'templates', `head.hbs`), 'utf8'); + const headTemplateFile = fs.readFileSync( + path.join(__dirname, '..', 'actions', 'pdp-renderer', 'templates', `head.hbs`), + 'utf8', + ); headTemplate = Handlebars.compile(headTemplateFile); }); @@ -597,7 +604,7 @@ describe('Meta Tags Template', () => { " - + @@ -607,12 +614,12 @@ describe('Meta Tags Template', () => { test('renders meta tags with all parameters provided', () => { const result = headTemplate({ - metaDescription: "Product Description", - metaKeyword: "foo, bar", - metaImage: "https://example.com/image.jpg", - lastModifiedAt: "2023-10-01", - sku: "12345", - externalId: "67890" + metaDescription: 'Product Description', + metaKeyword: 'foo, bar', + metaImage: 'https://example.com/image.jpg', + lastModifiedAt: '2023-10-01', + sku: '12345', + externalId: '67890', }); expect(result).toMatchInlineSnapshot(` @@ -624,7 +631,7 @@ describe('Meta Tags Template', () => { - + @@ -633,14 +640,14 @@ describe('Meta Tags Template', () => { }); test('renders only required meta tags when minimal parameters provided', () => { - const result = headTemplate({ sku: "12345" }); + const result = headTemplate({ sku: '12345' }); expect(result).toMatchInlineSnapshot(` " - + From b4f544cd640dbc2e7ccda02c66ef9e22171c322d Mon Sep 17 00:00:00 2001 From: rossbrandon Date: Thu, 19 Mar 2026 10:59:59 -0500 Subject: [PATCH 11/12] Improve error handling in plp-renderer/index action --- actions/plp-renderer/index.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/actions/plp-renderer/index.js b/actions/plp-renderer/index.js index 8817d851..94aae3bf 100644 --- a/actions/plp-renderer/index.js +++ b/actions/plp-renderer/index.js @@ -35,6 +35,9 @@ async function main(params) { try { const { slug, locale } = params; + if (!slug) { + throw new JobFailedError('Missing required parameter: slug must be provided', ERROR_CODES.VALIDATION_ERROR, 400); + } const context = { ...cfg, logger }; if (locale) { context.locale = locale; @@ -43,10 +46,6 @@ async function main(params) { const siteType = getSiteType(siteConfig); logger.debug(`Detected site type: ${siteType}`); - if (!slug) { - throw new JobFailedError('Missing required parameter: slug must be provided', ERROR_CODES.VALIDATION_ERROR, 400); - } - logger.info(`Rendering category slug: ${slug} for locale: ${locale || 'default'}`); let categoryMap; From 0e610401062c4c7de03f9fa16a46061aa0a2cf44 Mon Sep 17 00:00:00 2001 From: rossbrandon Date: Thu, 19 Mar 2026 11:18:22 -0500 Subject: [PATCH 12/12] Cleanup duplicate toLowerCase calls --- actions/check-product-changes/poller.js | 2 +- actions/plp-renderer/render.js | 6 +++--- actions/render-all-categories/poller.js | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/actions/check-product-changes/poller.js b/actions/check-product-changes/poller.js index b95d7ca0..4286f2c8 100644 --- a/actions/check-product-changes/poller.js +++ b/actions/check-product-changes/poller.js @@ -71,7 +71,7 @@ function enrichProductWithMetadata(product, state, context) { const { sku, urlKey, lastModifiedAt } = product; const lastRenderedDate = state.skus[sku]?.lastRenderedAt || new Date(0); const lastModifiedDate = new Date(lastModifiedAt); - const productUrl = getProductUrl({ urlKey, sku }, context, false).toLowerCase(); + const productUrl = getProductUrl({ urlKey, sku }, context, false); const currentHash = state.skus[sku]?.hash || null; return { diff --git a/actions/plp-renderer/render.js b/actions/plp-renderer/render.js index a3e440b4..ef4daa0c 100644 --- a/actions/plp-renderer/render.js +++ b/actions/plp-renderer/render.js @@ -54,18 +54,18 @@ function generateCategoryHtml(categoryData, products, categoryMap, context) { const templateData = { categoryName: sanitize(categoryData.name, 'inline'), categoryDescription, - categoryUrl: getCategoryUrl(categoryData.slug, context).toLowerCase(), + categoryUrl: getCategoryUrl(categoryData.slug, context), metaTitle: sanitize(categoryData.metaTags?.title || categoryData.name, 'no'), metaDescription: categoryData.metaTags?.description ? sanitize(categoryData.metaTags.description, 'no') : null, metaKeywords: categoryData.metaTags?.keywords ? sanitize(categoryData.metaTags.keywords.join(', '), 'no') : null, metaImage: categoryImage?.url || null, breadcrumbs: breadcrumbs.map((crumb) => ({ name: sanitize(crumb.name, 'inline'), - url: getCategoryUrl(crumb.slug, context).toLowerCase(), + url: getCategoryUrl(crumb.slug, context), })), products: products.map((product) => ({ name: sanitize(product.name, 'inline'), - url: getProductUrl({ urlKey: product.urlKey, sku: product.sku }, context).toLowerCase(), + url: getProductUrl({ urlKey: product.urlKey, sku: product.sku }, context), image: product.images?.find((img) => img.roles?.includes('image'))?.url || null, })), hasProducts: products.length > 0, diff --git a/actions/render-all-categories/poller.js b/actions/render-all-categories/poller.js index 48b2c209..43f7a1ac 100644 --- a/actions/render-all-categories/poller.js +++ b/actions/render-all-categories/poller.js @@ -65,7 +65,7 @@ async function renderCategory(categoryData, categoryMap, context) { const slug = categoryData.slug; const result = { slug, - path: getCategoryUrl(slug, context, false).toLowerCase(), + path: getCategoryUrl(slug, context, false), currentHash: context.state.categories[slug]?.hash || null, }; @@ -125,7 +125,7 @@ async function processRemovedCategories(discoveredSlugs, state, context, adminAp try { const records = removedSlugs.map((slug) => ({ slug, - path: getCategoryUrl(slug, context, false).toLowerCase(), + path: getCategoryUrl(slug, context, false), })); const batches = createBatches(records);