From d92f9382bdd086ff0838cd803ba99355c0426913 Mon Sep 17 00:00:00 2001 From: Dylan Depass Date: Fri, 1 Nov 2024 13:44:33 -0400 Subject: [PATCH 01/17] feat: fix offer urls and add variant overrides --- src/content/adobe-commerce.js | 4 +- src/content/handler.js | 22 ++++- src/content/queries/cs-variants.js | 9 +- src/templates/html.js | 13 +-- src/templates/json-ld.js | 37 +++++--- src/types.d.ts | 12 +++ src/utils/product.js | 57 ++++++++++++ test/catalog/handler.test.js | 2 +- test/catalog/lookup.test.js | 2 +- test/catalog/update.test.js | 2 +- test/config.test.js | 4 +- test/{utils => fixtures}/context.js | 0 test/{utils => fixtures}/kv.js | 0 test/fixtures/variant.js | 1 + test/template/html.test.js | 5 +- test/utils/product.test.js | 133 ++++++++++++++++++++++++++++ 16 files changed, 274 insertions(+), 29 deletions(-) rename test/{utils => fixtures}/context.js (100%) rename test/{utils => fixtures}/kv.js (100%) create mode 100644 test/utils/product.test.js diff --git a/src/content/adobe-commerce.js b/src/content/adobe-commerce.js index e64f537..cfbfd4a 100644 --- a/src/content/adobe-commerce.js +++ b/src/content/adobe-commerce.js @@ -96,7 +96,7 @@ async function fetchVariants(sku, config) { try { const json = await resp.json(); const { variants } = json?.data?.variants ?? {}; - return variantsAdapter(variants); + return variantsAdapter(config, variants); } catch (e) { console.error('failed to parse variants: ', e); throw errorWithResponse(500, 'failed to parse variants response'); @@ -173,7 +173,7 @@ export async function handle(ctx, config) { fetchProduct(sku.toUpperCase(), config), fetchVariants(sku.toUpperCase(), config), ]); - const html = HTML_TEMPLATE(product, variants); + const html = HTML_TEMPLATE(config, product, variants); return new Response(html, { status: 200, headers: { diff --git a/src/content/handler.js b/src/content/handler.js index 99be298..e2cc486 100644 --- a/src/content/handler.js +++ b/src/content/handler.js @@ -10,6 +10,7 @@ * governing permissions and limitations under the License. */ +import { matchConfigPath } from '../utils/product.js'; import { errorResponse } from '../utils/http.js'; import { handle as handleAdobeCommerce } from './adobe-commerce.js'; import { handle as handleHelixCommerce } from './helix-commerce.js'; @@ -30,9 +31,28 @@ export default async function contentHandler(ctx, config) { return errorResponse(400, 'invalid config for tenant site (missing pageType)'); } + const { pathname } = ctx.url; + const productPath = pathname.includes('/content/product') + ? pathname.split('/content/product')[1] + : null; + + if (productPath) { + const matchedPath = matchConfigPath(config, productPath); + const matchedPathConfig = config.confMap && config.confMap[matchedPath] + ? config.confMap[matchedPath] + : null; + if (matchedPathConfig) { + config.matchedPath = matchedPath; + config.matchedPathConfig = matchedPathConfig; + } else { + return errorResponse(400, 'No matching configuration for product path'); + } + } else { + return errorResponse(400, 'invalid product path'); + } + if (config.catalogSource === 'helix') { return handleHelixCommerce(ctx, config); } - return handleAdobeCommerce(ctx, config); } diff --git a/src/content/queries/cs-variants.js b/src/content/queries/cs-variants.js index d59a6b4..17755cb 100644 --- a/src/content/queries/cs-variants.js +++ b/src/content/queries/cs-variants.js @@ -16,7 +16,7 @@ import { gql } from '../../utils/product.js'; * @param {any} variants * @returns {Variant[]} */ -export const adapter = (variants) => variants.map(({ selections, product }) => { +export const adapter = (config, variants) => variants.map(({ selections, product }) => { const minPrice = product.priceRange?.minimum ?? product.price; const maxPrice = product.priceRange?.maximum ?? product.price; @@ -47,6 +47,13 @@ export const adapter = (variants) => variants.map(({ selections, product }) => { }, selections: selections ?? [], }; + + if (config.attributeOverrides?.variant) { + Object.entries(config.attributeOverrides.variant).forEach(([key, value]) => { + variant[key] = product.attributes?.find((attr) => attr.name === value)?.value; + }); + } + return variant; }); diff --git a/src/templates/html.js b/src/templates/html.js index 0e3106c..c719da5 100644 --- a/src/templates/html.js +++ b/src/templates/html.js @@ -43,6 +43,7 @@ const priceRange = (min, max) => (min !== max ? ` (${min} - ${max})` : ''); * @returns {string} */ export const renderDocumentMetaTags = (product) => /* html */ ` + ${product.metaTitle || product.name} ${metaProperty('description', product.metaDescription)} @@ -111,20 +112,22 @@ export const renderHelixDependencies = () => /* html */ ` * @param {Variant[]} variants * @returns {string} */ -export const renderJSONLD = (product, variants) => /* html */ ` +export const renderJSONLD = (config, product, variants) => /* html */ ` `; /** * Create the head tags + * @param {Config} config * @param {Product} product * @param {Variant[]} variants * @param {Object} [options] * @returns {string} */ export const renderHead = ( + config, product, variants, { @@ -145,7 +148,7 @@ export const renderHead = ( ${twitterMetaTags(product, image)} ${commerceMetaTags(product)} ${helixDependencies()} - ${JSONLD(product, variants)} + ${JSONLD(config, product, variants)} `; }; @@ -302,7 +305,7 @@ const renderProductVariantsAttributes = (variants) => /* html */ ` * @param {Variant[]} variants * @returns {string} */ -export default (product, variants) => { +export default (config, product, variants) => { const { name, description, @@ -314,7 +317,7 @@ export default (product, variants) => { return /* html */` - ${renderHead(product, variants)} + ${renderHead(config, product, variants)}
diff --git a/src/templates/json-ld.js b/src/templates/json-ld.js index f9b0250..dab7e4f 100644 --- a/src/templates/json-ld.js +++ b/src/templates/json-ld.js @@ -10,17 +10,16 @@ * governing permissions and limitations under the License. */ -import { findProductImage, pruneUndefined } from '../utils/product.js'; +import { constructProductUrl, findProductImage, pruneUndefined } from '../utils/product.js'; /** * @param {Product} product * @param {Variant[]} variants * @returns {string} */ -export default (product, variants) => { +export default (config, product, variants) => { const { sku, - url, name, metaDescription, images, @@ -31,12 +30,13 @@ export default (product, variants) => { prices, } = product; + const productUrl = constructProductUrl(config, product); const image = images?.[0]?.url ?? findProductImage(product, variants)?.url; const brandName = attributes?.find((attr) => attr.name === 'brand')?.value; return JSON.stringify(pruneUndefined({ '@context': 'http://schema.org', '@type': 'Product', - '@id': url, + '@id': productUrl, name, sku, description: metaDescription, @@ -46,22 +46,31 @@ export default (product, variants) => { prices ? ({ '@type': 'Offer', sku, - url, + url: productUrl, image, availability: inStock ? 'InStock' : 'OutOfStock', price: prices?.final?.amount, priceCurrency: prices?.final?.currency, }) : undefined, - ...variants.map((v) => ({ - '@type': 'Offer', - sku: v.sku, - url: v.url, - image: v.images?.[0]?.url ?? image, - availability: v.inStock ? 'InStock' : 'OutOfStock', - price: v.prices?.final?.amount, - priceCurrency: v.prices?.final?.currency, + ...variants.map((v) => { + const offerUrl = constructProductUrl(config, product, v); + + const offer = { + '@type': 'Offer', + sku: v.sku, + url: offerUrl, + image: v.images?.[0]?.url ?? image, + availability: v.inStock ? 'InStock' : 'OutOfStock', + price: v.prices?.final?.amount, + priceCurrency: v.prices?.final?.currency, + }; + + if (v.gtin) { + offer.gtin = v.gtin; + } - })).filter(Boolean), + return offer; + }).filter(Boolean), ], ...(brandName ? { diff --git a/src/types.d.ts b/src/types.d.ts index d251246..6856eb9 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -5,6 +5,12 @@ declare global { * { pathPattern => Config } */ export type ConfigMap = Record; + + export interface AttributeOverrides { + variant: { + [key: string]: string; + }; + } /** * Resolved config object @@ -27,6 +33,11 @@ declare global { confMap: ConfigMap; params: Record; headers: Record; + host: string; + offerPattern: string; + matchedPath: string; + matchedPathConfig: Config; + attributeOverrides: AttributeOverrides; } export interface Env { @@ -86,6 +97,7 @@ declare global { prices: Pick; selections: string[]; attributes: Attribute[]; + gtin?: string; } interface Image { diff --git a/src/utils/product.js b/src/utils/product.js index 4398abc..e1451bd 100644 --- a/src/utils/product.js +++ b/src/utils/product.js @@ -68,3 +68,60 @@ export function assertValidProduct(product) { throw new Error('Invalid product'); } } + +/** + * @param {Config} config + * @param {string} path + * @returns {string} matched path key + */ +export function matchConfigPath(config, path) { + // Filter out any keys that are not paths + const pathEntries = Object.entries(config.confMap).filter(([key]) => key !== 'base'); + + for (const [key] of pathEntries) { + // Replace `{{urlkey}}` and `{{sku}}` with regex patterns + const pattern = key + .replace('{{urlkey}}', '([^/]+)') + .replace('{{sku}}', '([^/]+)'); + + // Convert to regex and test against the path + const regex = new RegExp(`^${pattern}$`); + const match = path.match(regex); + + if (match) { + return key; + } + } + console.warn('No match found for path:', path); + return null; +} + +/** + * Constructs product url + * @param {Config} config + * @param {Product} product + * @param {Variant} [variant] + * @returns {string} + */ +export function constructProductUrl(config, product, variant) { + const { host, matchedPath } = config; + const productPath = matchedPath + .replace('{{urlkey}}', product.urlKey) + .replace('{{sku}}', encodeURIComponent(product.sku)); + + const productUrl = `${host}${productPath}`; + + if (variant) { + const offerPattern = config.matchedPathConfig?.offerPattern; + if (!offerPattern) { + return `${productUrl}/?optionsUIDs=${variant.selections.join(encodeURIComponent('=,'))}`; + } + + const variantPath = offerPattern + .replace('{{urlkey}}', product.urlKey) + .replace('{{sku}}', encodeURIComponent(variant.sku)); + return `${config.host}${variantPath}`; + } + + return productUrl; +} diff --git a/test/catalog/handler.test.js b/test/catalog/handler.test.js index 8aa0eb6..6ea4f97 100644 --- a/test/catalog/handler.test.js +++ b/test/catalog/handler.test.js @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import { strict as assert } from 'assert'; +import assert from 'node:assert'; import sinon from 'sinon'; import esmock from 'esmock'; diff --git a/test/catalog/lookup.test.js b/test/catalog/lookup.test.js index 43b2b54..cca203e 100644 --- a/test/catalog/lookup.test.js +++ b/test/catalog/lookup.test.js @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import { strict as assert } from 'assert'; +import assert from 'node:assert'; import sinon from 'sinon'; import esmock from 'esmock'; import { ResponseError } from '../../src/utils/http.js'; diff --git a/test/catalog/update.test.js b/test/catalog/update.test.js index dc231ff..0c74494 100644 --- a/test/catalog/update.test.js +++ b/test/catalog/update.test.js @@ -12,7 +12,7 @@ // @ts-nocheck -import { strict as assert } from 'assert'; +import assert from 'node:assert'; import sinon from 'sinon'; import esmock from 'esmock'; diff --git a/test/config.test.js b/test/config.test.js index 1d08623..98fa032 100644 --- a/test/config.test.js +++ b/test/config.test.js @@ -14,8 +14,8 @@ import assert from 'node:assert'; import { resolveConfig } from '../src/config.js'; -import { TEST_CONTEXT } from './utils/context.js'; -import { defaultTenantConfigs } from './utils/kv.js'; +import { TEST_CONTEXT } from './fixtures/context.js'; +import { defaultTenantConfigs } from './fixtures/kv.js'; describe('config tests', () => { it('should extract path params', async () => { diff --git a/test/utils/context.js b/test/fixtures/context.js similarity index 100% rename from test/utils/context.js rename to test/fixtures/context.js diff --git a/test/utils/kv.js b/test/fixtures/kv.js similarity index 100% rename from test/utils/kv.js rename to test/fixtures/kv.js diff --git a/test/fixtures/variant.js b/test/fixtures/variant.js index 4fcbe28..cf6df00 100644 --- a/test/fixtures/variant.js +++ b/test/fixtures/variant.js @@ -35,6 +35,7 @@ export function createProductVariationFixture(overrides = {}) { { name: 'criteria_5', label: 'Criteria 5', value: 'Canopy: 4.5" Round' }, { name: 'criteria_6', label: 'Criteria 6', value: 'Socket: E26 Keyless' }, { name: 'criteria_7', label: 'Criteria 7', value: 'Wattage: 40 T10' }, + { name: 'barcode', label: 'Barcode', value: '123456789012' }, { name: 'weight', label: 'Weight', value: 219 }, ], prices: { diff --git a/test/template/html.test.js b/test/template/html.test.js index fd95e9a..69b1569 100644 --- a/test/template/html.test.js +++ b/test/template/html.test.js @@ -33,7 +33,10 @@ describe('Render Product HTML', () => { before(() => { product = createProductFixture(); variations = createDefaultVariations(); - const html = htmlTemplate(product, variations); + const config = { + matchedPath: '/us/p/{{urlkey}}/{{sku}}', + }; + const html = htmlTemplate(config, product, variations); dom = new JSDOM(html); document = dom.window.document; }); diff --git a/test/utils/product.test.js b/test/utils/product.test.js new file mode 100644 index 0000000..76f684e --- /dev/null +++ b/test/utils/product.test.js @@ -0,0 +1,133 @@ +/* + * Copyright 2024 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. + */ + +// @ts-nocheck +/* eslint-disable max-len */ + +import assert from 'node:assert'; +import { constructProductUrl } from '../../src/utils/product.js'; + +describe('constructProductUrl', () => { + const configWithOfferPattern = { + host: 'https://www.example.com', + matchedPath: '/products/{{urlkey}}/{{sku}}', + matchedPathConfig: { + offerPattern: '/products/{{urlkey}}?selected_product={{sku}}', + }, + }; + + const configWithoutOfferPattern = { + host: 'https://www.example.com', + matchedPath: '/products/{{urlkey}}/{{sku}}', + }; + + const product1 = { + urlKey: 'utopia-small-pendant', + sku: 'KW5531', + }; + + const productWithSpecialCharacters = { + urlKey: 'summer-sun', + sku: 'KW 55/31', + }; + + const variant1 = { + sku: 'VAR 001', + selections: ['Y29uZmlndXJhYmxlLzE2NTEvODI3MQ=='], + }; + + const variantWithSpecialSelections = { + sku: 'VAR-002', + selections: ['Y29uZmlndXJhYmxlLzE2NTEvODI3MQ==', 'Y29uZmlndXJhYmxlLzI0NjEvMzYzNDE='], + }; + + it('should construct the correct product URL without variant', () => { + const url = constructProductUrl(configWithoutOfferPattern, product1); + const expectedUrl = 'https://www.example.com/products/utopia-small-pendant/KW5531'; + assert.strictEqual(url, expectedUrl, 'Product URL without variant does not match expected URL'); + }); + + // Test Case 2: Construct URL with variant and offerPattern + it('should construct the correct variant URL with offerPattern', () => { + const url = constructProductUrl(configWithOfferPattern, product1, variant1); + const expectedUrl = 'https://www.example.com/products/utopia-small-pendant?selected_product=VAR%20001'; + assert.strictEqual(url, expectedUrl, 'Variant URL with offerPattern does not match expected URL'); + }); + + it('should construct the correct variant URL without offerPattern', () => { + const url = constructProductUrl(configWithoutOfferPattern, product1, variant1); + const expectedUrl = 'https://www.example.com/products/utopia-small-pendant/KW5531/?optionsUIDs=Y29uZmlndXJhYmxlLzE2NTEvODI3MQ=='; + assert.strictEqual(url, expectedUrl, 'Variant URL without offerPattern does not match expected URL'); + }); + + // Test Case 4: Encode special characters in sku and urlKey + it('should correctly encode special characters in sku and urlKey', () => { + const url = constructProductUrl(configWithoutOfferPattern, productWithSpecialCharacters); + const expectedUrl = 'https://www.example.com/products/summer-sun/KW%2055%2F31'; + assert.strictEqual(url, expectedUrl, 'URL with special characters does not match expected URL'); + }); + + it('should correctly encode special characters in variant sku and selections', () => { + const url = constructProductUrl(configWithoutOfferPattern, productWithSpecialCharacters, variantWithSpecialSelections); + const expectedUrl = 'https://www.example.com/products/summer-sun/KW%2055%2F31/?optionsUIDs=Y29uZmlndXJhYmxlLzE2NTEvODI3MQ==%3D%2CY29uZmlndXJhYmxlLzI0NjEvMzYzNDE='; + assert.strictEqual(url, expectedUrl, 'Variant URL with special characters does not match expected URL'); + }); + + it('should handle variant with empty selections', () => { + const variantEmptySelections = { + sku: 'VAR-EMPTY', + selections: [], + }; + const url = constructProductUrl(configWithoutOfferPattern, product1, variantEmptySelections); + const expectedUrl = 'https://www.example.com/products/utopia-small-pendant/KW5531/?optionsUIDs='; + assert.strictEqual(url, expectedUrl, 'URL with empty variant selections does not match expected URL'); + }); + + it('should handle missing matchedPathConfig when variant is present', () => { + const url = constructProductUrl(configWithoutOfferPattern, product1, variant1); + const expectedUrl = 'https://www.example.com/products/utopia-small-pendant/KW5531/?optionsUIDs=Y29uZmlndXJhYmxlLzE2NTEvODI3MQ=='; + assert.strictEqual(url, expectedUrl, 'URL without offerPattern but with variant does not match expected URL'); + }); + + it('should construct the correct URL when variant is undefined', () => { + const url = constructProductUrl(configWithOfferPattern, product1); + const expectedUrl = 'https://www.example.com/products/utopia-small-pendant/KW5531'; + assert.strictEqual(url, expectedUrl, 'Product URL with undefined variant does not match expected URL'); + }); + + it('should correctly replace multiple placeholders in matchedPath', () => { + const configMultiplePlaceholders = { + host: 'https://www.example.com', + matchedPath: '/shop/{{urlkey}}/{{sku}}/details', + }; + const product = { + urlKey: 'modern-lamp', + sku: 'ML-2023', + }; + const url = constructProductUrl(configMultiplePlaceholders, product); + const expectedUrl = 'https://www.example.com/shop/modern-lamp/ML-2023/details'; + assert.strictEqual(url, expectedUrl, 'URL with multiple placeholders does not match expected URL'); + }); + + it('should handle empty offerPattern by falling back to optionsUIDs', () => { + const configEmptyOfferPattern = { + host: 'https://www.example.com', + matchedPath: '/products/{{urlkey}}-{{sku}}', + matchedPathConfig: { + offerPattern: '', + }, + }; + const url = constructProductUrl(configEmptyOfferPattern, product1, variant1); + const expectedUrl = 'https://www.example.com/products/utopia-small-pendant-KW5531/?optionsUIDs=Y29uZmlndXJhYmxlLzE2NTEvODI3MQ=='; + assert.strictEqual(url, expectedUrl, 'URL with empty offerPattern does not match expected URL'); + }); +}); From bd799b6dc8f390ac2159d71208e00cf646a68490 Mon Sep 17 00:00:00 2001 From: Dylan Depass Date: Fri, 1 Nov 2024 14:14:26 -0400 Subject: [PATCH 02/17] fix: add url tests for product json-ld --- test/template/html.test.js | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/test/template/html.test.js b/test/template/html.test.js index 69b1569..fbe994e 100644 --- a/test/template/html.test.js +++ b/test/template/html.test.js @@ -14,7 +14,8 @@ import assert from 'node:assert'; import { JSDOM } from 'jsdom'; -import { createDefaultVariations } from '../fixtures/variant.js'; +import { constructProductUrl } from '../../src/utils/product.js'; +import { createDefaultVariations, createProductVariationFixture } from '../fixtures/variant.js'; import { createProductFixture } from '../fixtures/product.js'; import htmlTemplate from '../../src/templates/html.js'; @@ -25,6 +26,7 @@ function priceRange(min, max) { } describe('Render Product HTML', () => { + let config; let dom; let document; let product; @@ -33,7 +35,8 @@ describe('Render Product HTML', () => { before(() => { product = createProductFixture(); variations = createDefaultVariations(); - const config = { + config = { + host: 'https://example.com', matchedPath: '/us/p/{{urlkey}}/{{sku}}', }; const html = htmlTemplate(config, product, variations); @@ -77,6 +80,7 @@ describe('Render Product HTML', () => { const jsonLd = JSON.parse(jsonLdScript.textContent); assert.strictEqual(jsonLd['@type'], 'Product', 'JSON-LD @type should be Product'); + assert.strictEqual(jsonLd['@id'], constructProductUrl(config, product), 'JSON-LD @id does not match product URL'); assert.strictEqual(jsonLd.name, product.name, 'JSON-LD name does not match product name'); assert.strictEqual(jsonLd.sku, product.sku, 'JSON-LD SKU does not match product SKU'); assert.strictEqual(jsonLd.description, product.metaDescription, 'JSON-LD description does not match product description'); @@ -89,6 +93,7 @@ describe('Render Product HTML', () => { const variant = index === 0 ? product : variations[index - 1]; assert.strictEqual(offer['@type'], 'Offer', `Offer type for variant ${variant.sku} should be Offer`); assert.strictEqual(offer.sku, variant.sku, `Offer SKU for variant ${variant.sku} does not match`); + assert.strictEqual(offer.url, constructProductUrl(config, product, index === 0 ? undefined : variant), 'JSON-LD @id does not match product URL'); assert.strictEqual(offer.price, variant.prices.final.amount, `Offer price for variant ${variant.sku} does not match`); assert.strictEqual(offer.priceCurrency, variant.prices.final.currency, `Offer priceCurrency for variant ${variant.sku} does not match`); assert.strictEqual(offer.availability, variant.inStock ? 'InStock' : 'OutOfStock', `Offer availability for variant ${variant.sku} does not match`); @@ -96,6 +101,24 @@ describe('Render Product HTML', () => { }); }); + it('should have the correct JSON-LD schema with attribute overrides', () => { + variations = [ + createProductVariationFixture({ gtin: '123' }), + createProductVariationFixture({ gtin: '456' }), + ]; + const html = htmlTemplate(config, product, variations); + dom = new JSDOM(html); + document = dom.window.document; + + const jsonLdScript = document.querySelector('script[type="application/ld+json"]'); + const jsonLd = JSON.parse(jsonLdScript.textContent); + + jsonLd.offers.forEach((offer, index) => { + const variant = index === 0 ? product : variations[index - 1]; + assert.strictEqual(offer.gtin, variant.gtin, `Offer gtin for variant ${variant.sku} does not match`); + }); + }); + it('should display the correct product name in

', () => { const h1 = document.querySelector('h1'); assert.strictEqual(h1.textContent, product.name, '

content does not match product name'); From 03b3d55cd1f861f179ea7bb37ca8f79260cf8397 Mon Sep 17 00:00:00 2001 From: Dylan Depass Date: Fri, 1 Nov 2024 14:15:20 -0400 Subject: [PATCH 03/17] fix: error label on offer url test --- test/template/html.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/template/html.test.js b/test/template/html.test.js index fbe994e..6a401fa 100644 --- a/test/template/html.test.js +++ b/test/template/html.test.js @@ -93,7 +93,7 @@ describe('Render Product HTML', () => { const variant = index === 0 ? product : variations[index - 1]; assert.strictEqual(offer['@type'], 'Offer', `Offer type for variant ${variant.sku} should be Offer`); assert.strictEqual(offer.sku, variant.sku, `Offer SKU for variant ${variant.sku} does not match`); - assert.strictEqual(offer.url, constructProductUrl(config, product, index === 0 ? undefined : variant), 'JSON-LD @id does not match product URL'); + assert.strictEqual(offer.url, constructProductUrl(config, product, index === 0 ? undefined : variant), 'JSON-LD offer URL does not match'); assert.strictEqual(offer.price, variant.prices.final.amount, `Offer price for variant ${variant.sku} does not match`); assert.strictEqual(offer.priceCurrency, variant.prices.final.currency, `Offer priceCurrency for variant ${variant.sku} does not match`); assert.strictEqual(offer.availability, variant.inStock ? 'InStock' : 'OutOfStock', `Offer availability for variant ${variant.sku} does not match`); From 654457faceb55e18d608fde0a4facff7a4b4e12d Mon Sep 17 00:00:00 2001 From: Dylan Depass Date: Fri, 1 Nov 2024 14:36:40 -0400 Subject: [PATCH 04/17] fix: add test for variant urls in json-ld --- test/template/html.test.js | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/test/template/html.test.js b/test/template/html.test.js index 6a401fa..cabe87e 100644 --- a/test/template/html.test.js +++ b/test/template/html.test.js @@ -32,7 +32,7 @@ describe('Render Product HTML', () => { let product; let variations; - before(() => { + beforeEach(() => { product = createProductFixture(); variations = createDefaultVariations(); config = { @@ -119,6 +119,23 @@ describe('Render Product HTML', () => { }); }); + it('should have the correct JSON-LD schema with custom offer pattern', () => { + config.matchedPathConfig = { + offerPattern: '/us/p/{{urlkey}}?selected_product={{sku}}', + }; + const html = htmlTemplate(config, product, variations); + dom = new JSDOM(html); + document = dom.window.document; + + const jsonLdScript = document.querySelector('script[type="application/ld+json"]'); + const jsonLd = JSON.parse(jsonLdScript.textContent); + + assert.strictEqual(jsonLd.offers[0].url, 'https://example.com/us/p/test-product-url-key/test-sku', 'JSON-LD offer URL does not match'); + assert.strictEqual(jsonLd.offers[1].url, 'https://example.com/us/p/test-product-url-key?selected_product=test-sku-1', 'JSON-LD offer URL does not match'); + assert.strictEqual(jsonLd.offers[2].url, 'https://example.com/us/p/test-product-url-key?selected_product=test-sku-2', 'JSON-LD offer URL does not match'); + assert.strictEqual(jsonLd.offers[3].url, 'https://example.com/us/p/test-product-url-key?selected_product=test-sku-3', 'JSON-LD offer URL does not match'); + }); + it('should display the correct product name in

', () => { const h1 = document.querySelector('h1'); assert.strictEqual(h1.textContent, product.name, '

content does not match product name'); From 6716e0b2eae586d4f3d8563c11948609b3f03ee5 Mon Sep 17 00:00:00 2001 From: dylandepass Date: Fri, 1 Nov 2024 15:59:31 -0400 Subject: [PATCH 05/17] Update src/utils/product.js Co-authored-by: Max Edell --- src/utils/product.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/product.js b/src/utils/product.js index e1451bd..c9cf7c6 100644 --- a/src/utils/product.js +++ b/src/utils/product.js @@ -114,7 +114,7 @@ export function constructProductUrl(config, product, variant) { if (variant) { const offerPattern = config.matchedPathConfig?.offerPattern; if (!offerPattern) { - return `${productUrl}/?optionsUIDs=${variant.selections.join(encodeURIComponent('=,'))}`; + return `${productUrl}/?optionsUIDs=${encodeURIComponent(variant.selections.join(','))}`; } const variantPath = offerPattern From c101f0067cf61c53f86a8735049dd0c4a66c7d02 Mon Sep 17 00:00:00 2001 From: Dylan Depass Date: Fri, 1 Nov 2024 16:01:36 -0400 Subject: [PATCH 06/17] fix: add externalId.. hack override for bulk for now --- src/content/queries/cs-variants.js | 2 ++ src/types.d.ts | 1 + src/utils/product.js | 7 ++++++- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/content/queries/cs-variants.js b/src/content/queries/cs-variants.js index 17755cb..db2ec92 100644 --- a/src/content/queries/cs-variants.js +++ b/src/content/queries/cs-variants.js @@ -29,6 +29,7 @@ export const adapter = (config, variants) => variants.map(({ selections, product inStock: product.inStock, images: product.images ?? [], attributes: product.attributes ?? [], + externalId: product.externalId, prices: { regular: { // TODO: determine whether to use min or max @@ -70,6 +71,7 @@ export default (sku) => gql` name sku inStock + externalId images { url label diff --git a/src/types.d.ts b/src/types.d.ts index 6856eb9..ec2d53b 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -97,6 +97,7 @@ declare global { prices: Pick; selections: string[]; attributes: Attribute[]; + externalId: string; gtin?: string; } diff --git a/src/utils/product.js b/src/utils/product.js index e1451bd..235c67e 100644 --- a/src/utils/product.js +++ b/src/utils/product.js @@ -107,11 +107,16 @@ export function constructProductUrl(config, product, variant) { const { host, matchedPath } = config; const productPath = matchedPath .replace('{{urlkey}}', product.urlKey) - .replace('{{sku}}', encodeURIComponent(product.sku)); + .replace('{{sku}}', encodeURIComponent(product.sku.toLowerCase())); const productUrl = `${host}${productPath}`; if (variant) { + if (config.host === 'https://www.bulk.com') { + const options = variant.selections.map((selection) => atob(selection)).join(',').replace(/configurable\//g, '').replace(/\//g, '-'); + return `${productUrl}?pid=${variant.externalId}&o=${btoa(options)}`; + } + const offerPattern = config.matchedPathConfig?.offerPattern; if (!offerPattern) { return `${productUrl}/?optionsUIDs=${variant.selections.join(encodeURIComponent('=,'))}`; From fcca71794fa64d000185416a328e250d94afdeee Mon Sep 17 00:00:00 2001 From: Dylan Depass Date: Fri, 1 Nov 2024 16:17:12 -0400 Subject: [PATCH 07/17] fix: use org--site --- src/utils/product.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/utils/product.js b/src/utils/product.js index e818713..ac6420d 100644 --- a/src/utils/product.js +++ b/src/utils/product.js @@ -112,7 +112,9 @@ export function constructProductUrl(config, product, variant) { const productUrl = `${host}${productPath}`; if (variant) { - if (config.host === 'https://www.bulk.com') { + // Temporarily hardcoded + const orgSite = `${config.org}--${config.site}`; + if (orgSite === 'thepixel--bul-eds') { const options = variant.selections.map((selection) => atob(selection)).join(',').replace(/configurable\//g, '').replace(/\//g, '-'); return `${productUrl}?pid=${variant.externalId}&o=${btoa(options)}`; } From e280e445e652acdcb7c10e86ccefe18570a62da0 Mon Sep 17 00:00:00 2001 From: Dylan Depass Date: Fri, 1 Nov 2024 16:51:11 -0400 Subject: [PATCH 08/17] fix: rename offerPattern to offerVariantURLTemplate --- src/types.d.ts | 2 +- src/utils/product.js | 6 +++--- test/template/html.test.js | 2 +- test/utils/product.test.js | 44 +++++++++++++++++++------------------- 4 files changed, 27 insertions(+), 27 deletions(-) diff --git a/src/types.d.ts b/src/types.d.ts index ec2d53b..d473b31 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -34,7 +34,7 @@ declare global { params: Record; headers: Record; host: string; - offerPattern: string; + offerVariantURLTemplate: string; matchedPath: string; matchedPathConfig: Config; attributeOverrides: AttributeOverrides; diff --git a/src/utils/product.js b/src/utils/product.js index ac6420d..b95d1f1 100644 --- a/src/utils/product.js +++ b/src/utils/product.js @@ -119,12 +119,12 @@ export function constructProductUrl(config, product, variant) { return `${productUrl}?pid=${variant.externalId}&o=${btoa(options)}`; } - const offerPattern = config.matchedPathConfig?.offerPattern; - if (!offerPattern) { + const offerVariantURLTemplate = config.matchedPathConfig?.offerVariantURLTemplate; + if (!offerVariantURLTemplate) { return `${productUrl}/?optionsUIDs=${encodeURIComponent(variant.selections.join(','))}`; } - const variantPath = offerPattern + const variantPath = offerVariantURLTemplate .replace('{{urlkey}}', product.urlKey) .replace('{{sku}}', encodeURIComponent(variant.sku)); return `${config.host}${variantPath}`; diff --git a/test/template/html.test.js b/test/template/html.test.js index cabe87e..2f74f5c 100644 --- a/test/template/html.test.js +++ b/test/template/html.test.js @@ -121,7 +121,7 @@ describe('Render Product HTML', () => { it('should have the correct JSON-LD schema with custom offer pattern', () => { config.matchedPathConfig = { - offerPattern: '/us/p/{{urlkey}}?selected_product={{sku}}', + offerVariantURLTemplate: '/us/p/{{urlkey}}?selected_product={{sku}}', }; const html = htmlTemplate(config, product, variations); dom = new JSDOM(html); diff --git a/test/utils/product.test.js b/test/utils/product.test.js index 76f684e..465bb8f 100644 --- a/test/utils/product.test.js +++ b/test/utils/product.test.js @@ -17,15 +17,15 @@ import assert from 'node:assert'; import { constructProductUrl } from '../../src/utils/product.js'; describe('constructProductUrl', () => { - const configWithOfferPattern = { + const configWithOfferVariantURLTemplate = { host: 'https://www.example.com', matchedPath: '/products/{{urlkey}}/{{sku}}', matchedPathConfig: { - offerPattern: '/products/{{urlkey}}?selected_product={{sku}}', + offerVariantURLTemplate: '/products/{{urlkey}}?selected_product={{sku}}', }, }; - const configWithoutOfferPattern = { + const configWithoutOfferVariantURLTemplate = { host: 'https://www.example.com', matchedPath: '/products/{{urlkey}}/{{sku}}', }; @@ -51,33 +51,33 @@ describe('constructProductUrl', () => { }; it('should construct the correct product URL without variant', () => { - const url = constructProductUrl(configWithoutOfferPattern, product1); + const url = constructProductUrl(configWithoutOfferVariantURLTemplate, product1); const expectedUrl = 'https://www.example.com/products/utopia-small-pendant/KW5531'; assert.strictEqual(url, expectedUrl, 'Product URL without variant does not match expected URL'); }); - // Test Case 2: Construct URL with variant and offerPattern - it('should construct the correct variant URL with offerPattern', () => { - const url = constructProductUrl(configWithOfferPattern, product1, variant1); + // Test Case 2: Construct URL with variant and offerVariantURLTemplate + it('should construct the correct variant URL with offerVariantURLTemplate', () => { + const url = constructProductUrl(configWithOfferVariantURLTemplate, product1, variant1); const expectedUrl = 'https://www.example.com/products/utopia-small-pendant?selected_product=VAR%20001'; - assert.strictEqual(url, expectedUrl, 'Variant URL with offerPattern does not match expected URL'); + assert.strictEqual(url, expectedUrl, 'Variant URL with offerVariantURLTemplate does not match expected URL'); }); - it('should construct the correct variant URL without offerPattern', () => { - const url = constructProductUrl(configWithoutOfferPattern, product1, variant1); + it('should construct the correct variant URL without offerVariantURLTemplate', () => { + const url = constructProductUrl(configWithoutOfferVariantURLTemplate, product1, variant1); const expectedUrl = 'https://www.example.com/products/utopia-small-pendant/KW5531/?optionsUIDs=Y29uZmlndXJhYmxlLzE2NTEvODI3MQ=='; - assert.strictEqual(url, expectedUrl, 'Variant URL without offerPattern does not match expected URL'); + assert.strictEqual(url, expectedUrl, 'Variant URL without offerVariantURLTemplate does not match expected URL'); }); // Test Case 4: Encode special characters in sku and urlKey it('should correctly encode special characters in sku and urlKey', () => { - const url = constructProductUrl(configWithoutOfferPattern, productWithSpecialCharacters); + const url = constructProductUrl(configWithoutOfferVariantURLTemplate, productWithSpecialCharacters); const expectedUrl = 'https://www.example.com/products/summer-sun/KW%2055%2F31'; assert.strictEqual(url, expectedUrl, 'URL with special characters does not match expected URL'); }); it('should correctly encode special characters in variant sku and selections', () => { - const url = constructProductUrl(configWithoutOfferPattern, productWithSpecialCharacters, variantWithSpecialSelections); + const url = constructProductUrl(configWithoutOfferVariantURLTemplate, productWithSpecialCharacters, variantWithSpecialSelections); const expectedUrl = 'https://www.example.com/products/summer-sun/KW%2055%2F31/?optionsUIDs=Y29uZmlndXJhYmxlLzE2NTEvODI3MQ==%3D%2CY29uZmlndXJhYmxlLzI0NjEvMzYzNDE='; assert.strictEqual(url, expectedUrl, 'Variant URL with special characters does not match expected URL'); }); @@ -87,19 +87,19 @@ describe('constructProductUrl', () => { sku: 'VAR-EMPTY', selections: [], }; - const url = constructProductUrl(configWithoutOfferPattern, product1, variantEmptySelections); + const url = constructProductUrl(configWithoutOfferVariantURLTemplate, product1, variantEmptySelections); const expectedUrl = 'https://www.example.com/products/utopia-small-pendant/KW5531/?optionsUIDs='; assert.strictEqual(url, expectedUrl, 'URL with empty variant selections does not match expected URL'); }); it('should handle missing matchedPathConfig when variant is present', () => { - const url = constructProductUrl(configWithoutOfferPattern, product1, variant1); + const url = constructProductUrl(configWithoutOfferVariantURLTemplate, product1, variant1); const expectedUrl = 'https://www.example.com/products/utopia-small-pendant/KW5531/?optionsUIDs=Y29uZmlndXJhYmxlLzE2NTEvODI3MQ=='; - assert.strictEqual(url, expectedUrl, 'URL without offerPattern but with variant does not match expected URL'); + assert.strictEqual(url, expectedUrl, 'URL without offerVariantURLTemplate but with variant does not match expected URL'); }); it('should construct the correct URL when variant is undefined', () => { - const url = constructProductUrl(configWithOfferPattern, product1); + const url = constructProductUrl(configWithOfferVariantURLTemplate, product1); const expectedUrl = 'https://www.example.com/products/utopia-small-pendant/KW5531'; assert.strictEqual(url, expectedUrl, 'Product URL with undefined variant does not match expected URL'); }); @@ -118,16 +118,16 @@ describe('constructProductUrl', () => { assert.strictEqual(url, expectedUrl, 'URL with multiple placeholders does not match expected URL'); }); - it('should handle empty offerPattern by falling back to optionsUIDs', () => { - const configEmptyOfferPattern = { + it('should handle empty offerVariantURLTemplate by falling back to optionsUIDs', () => { + const configEmptyOfferVariantURLTemplate = { host: 'https://www.example.com', matchedPath: '/products/{{urlkey}}-{{sku}}', matchedPathConfig: { - offerPattern: '', + offerVariantURLTemplate: '', }, }; - const url = constructProductUrl(configEmptyOfferPattern, product1, variant1); + const url = constructProductUrl(configEmptyOfferVariantURLTemplate, product1, variant1); const expectedUrl = 'https://www.example.com/products/utopia-small-pendant-KW5531/?optionsUIDs=Y29uZmlndXJhYmxlLzE2NTEvODI3MQ=='; - assert.strictEqual(url, expectedUrl, 'URL with empty offerPattern does not match expected URL'); + assert.strictEqual(url, expectedUrl, 'URL with empty offerVariantURLTemplate does not match expected URL'); }); }); From 91a48ff6e02a5fe8b4e3fb380a5de534b8f8ebbe Mon Sep 17 00:00:00 2001 From: Dylan Depass Date: Fri, 1 Nov 2024 16:57:09 -0400 Subject: [PATCH 09/17] fix: tests --- test/utils/product.test.js | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/test/utils/product.test.js b/test/utils/product.test.js index 465bb8f..5b532fc 100644 --- a/test/utils/product.test.js +++ b/test/utils/product.test.js @@ -52,7 +52,7 @@ describe('constructProductUrl', () => { it('should construct the correct product URL without variant', () => { const url = constructProductUrl(configWithoutOfferVariantURLTemplate, product1); - const expectedUrl = 'https://www.example.com/products/utopia-small-pendant/KW5531'; + const expectedUrl = 'https://www.example.com/products/utopia-small-pendant/kw5531'; assert.strictEqual(url, expectedUrl, 'Product URL without variant does not match expected URL'); }); @@ -65,20 +65,20 @@ describe('constructProductUrl', () => { it('should construct the correct variant URL without offerVariantURLTemplate', () => { const url = constructProductUrl(configWithoutOfferVariantURLTemplate, product1, variant1); - const expectedUrl = 'https://www.example.com/products/utopia-small-pendant/KW5531/?optionsUIDs=Y29uZmlndXJhYmxlLzE2NTEvODI3MQ=='; + const expectedUrl = 'https://www.example.com/products/utopia-small-pendant/kw5531/?optionsUIDs=Y29uZmlndXJhYmxlLzE2NTEvODI3MQ%3D%3D'; assert.strictEqual(url, expectedUrl, 'Variant URL without offerVariantURLTemplate does not match expected URL'); }); // Test Case 4: Encode special characters in sku and urlKey it('should correctly encode special characters in sku and urlKey', () => { const url = constructProductUrl(configWithoutOfferVariantURLTemplate, productWithSpecialCharacters); - const expectedUrl = 'https://www.example.com/products/summer-sun/KW%2055%2F31'; + const expectedUrl = 'https://www.example.com/products/summer-sun/kw%2055%2F31'; assert.strictEqual(url, expectedUrl, 'URL with special characters does not match expected URL'); }); it('should correctly encode special characters in variant sku and selections', () => { const url = constructProductUrl(configWithoutOfferVariantURLTemplate, productWithSpecialCharacters, variantWithSpecialSelections); - const expectedUrl = 'https://www.example.com/products/summer-sun/KW%2055%2F31/?optionsUIDs=Y29uZmlndXJhYmxlLzE2NTEvODI3MQ==%3D%2CY29uZmlndXJhYmxlLzI0NjEvMzYzNDE='; + const expectedUrl = 'https://www.example.com/products/summer-sun/kw%2055%2F31/?optionsUIDs=Y29uZmlndXJhYmxlLzE2NTEvODI3MQ%3D%3D%2CY29uZmlndXJhYmxlLzI0NjEvMzYzNDE%3D'; assert.strictEqual(url, expectedUrl, 'Variant URL with special characters does not match expected URL'); }); @@ -88,19 +88,19 @@ describe('constructProductUrl', () => { selections: [], }; const url = constructProductUrl(configWithoutOfferVariantURLTemplate, product1, variantEmptySelections); - const expectedUrl = 'https://www.example.com/products/utopia-small-pendant/KW5531/?optionsUIDs='; + const expectedUrl = 'https://www.example.com/products/utopia-small-pendant/kw5531/?optionsUIDs='; assert.strictEqual(url, expectedUrl, 'URL with empty variant selections does not match expected URL'); }); it('should handle missing matchedPathConfig when variant is present', () => { const url = constructProductUrl(configWithoutOfferVariantURLTemplate, product1, variant1); - const expectedUrl = 'https://www.example.com/products/utopia-small-pendant/KW5531/?optionsUIDs=Y29uZmlndXJhYmxlLzE2NTEvODI3MQ=='; + const expectedUrl = 'https://www.example.com/products/utopia-small-pendant/kw5531/?optionsUIDs=Y29uZmlndXJhYmxlLzE2NTEvODI3MQ%3D%3D'; assert.strictEqual(url, expectedUrl, 'URL without offerVariantURLTemplate but with variant does not match expected URL'); }); it('should construct the correct URL when variant is undefined', () => { const url = constructProductUrl(configWithOfferVariantURLTemplate, product1); - const expectedUrl = 'https://www.example.com/products/utopia-small-pendant/KW5531'; + const expectedUrl = 'https://www.example.com/products/utopia-small-pendant/kw5531'; assert.strictEqual(url, expectedUrl, 'Product URL with undefined variant does not match expected URL'); }); @@ -114,7 +114,7 @@ describe('constructProductUrl', () => { sku: 'ML-2023', }; const url = constructProductUrl(configMultiplePlaceholders, product); - const expectedUrl = 'https://www.example.com/shop/modern-lamp/ML-2023/details'; + const expectedUrl = 'https://www.example.com/shop/modern-lamp/ml-2023/details'; assert.strictEqual(url, expectedUrl, 'URL with multiple placeholders does not match expected URL'); }); @@ -127,7 +127,7 @@ describe('constructProductUrl', () => { }, }; const url = constructProductUrl(configEmptyOfferVariantURLTemplate, product1, variant1); - const expectedUrl = 'https://www.example.com/products/utopia-small-pendant-KW5531/?optionsUIDs=Y29uZmlndXJhYmxlLzE2NTEvODI3MQ=='; + const expectedUrl = 'https://www.example.com/products/utopia-small-pendant-kw5531/?optionsUIDs=Y29uZmlndXJhYmxlLzE2NTEvODI3MQ%3D%3D'; assert.strictEqual(url, expectedUrl, 'URL with empty offerVariantURLTemplate does not match expected URL'); }); }); From b4de3e384209f5c890e34dc7ffb4c9286fc0dc64 Mon Sep 17 00:00:00 2001 From: Dylan Depass Date: Sat, 2 Nov 2024 12:52:49 -0400 Subject: [PATCH 10/17] fix: add site overrides setup --- src/config.js | 3 +++ src/content/helix-commerce.js | 2 +- src/overrides/index.js | 31 +++++++++++++++++++++++++++++++ src/templates/json-ld.js | 6 ++++-- src/types.d.ts | 1 + 5 files changed, 40 insertions(+), 3 deletions(-) create mode 100644 src/overrides/index.js diff --git a/src/config.js b/src/config.js index b0f27d7..580afd3 100644 --- a/src/config.js +++ b/src/config.js @@ -11,6 +11,7 @@ */ import { errorWithResponse } from './utils/http.js'; +import { siteOverrides } from './overrides/index.js'; /** * This function finds ordered matches between a list of patterns and a given path. @@ -104,7 +105,9 @@ export async function resolveConfig(ctx, overrides = {}) { org, site, route, + siteOverrides: siteOverrides[siteKey], ...overrides, }; + return resolved; } diff --git a/src/content/helix-commerce.js b/src/content/helix-commerce.js index 8438a20..a194fe1 100644 --- a/src/content/helix-commerce.js +++ b/src/content/helix-commerce.js @@ -28,7 +28,7 @@ export async function handle(ctx, config) { } const product = await fetchProduct(ctx, config, sku); - const html = HTML_TEMPLATE(product, product.variants); + const html = HTML_TEMPLATE(config, product, product.variants); return new Response(html, { status: 200, headers: { diff --git a/src/overrides/index.js b/src/overrides/index.js new file mode 100644 index 0000000..b99d037 --- /dev/null +++ b/src/overrides/index.js @@ -0,0 +1,31 @@ +/* + * Copyright 2024 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. + */ + +export const siteOverrides = { + 'thepixel--bul-eds': { + constructProductUrl: (config, product, variant) => { + const { host, matchedPath } = config; + const productPath = matchedPath + .replace('{{urlkey}}', product.urlKey) + .replace('{{sku}}', encodeURIComponent(product.sku.toLowerCase())); + + const productUrl = `${host}${productPath}`; + + if (variant) { + const options = variant.selections.map((selection) => atob(selection)).join(',').replace(/configurable\//g, '').replace(/\//g, '-'); + return `${productUrl}?pid=${variant.externalId}&o=${btoa(options)}`; + } + + return productUrl; + }, + }, +}; diff --git a/src/templates/json-ld.js b/src/templates/json-ld.js index dab7e4f..a45a6cf 100644 --- a/src/templates/json-ld.js +++ b/src/templates/json-ld.js @@ -30,7 +30,8 @@ export default (config, product, variants) => { prices, } = product; - const productUrl = constructProductUrl(config, product); + const productUrl = config.siteOverrides?.constructProductUrl(config, product) + ?? constructProductUrl(config, product); const image = images?.[0]?.url ?? findProductImage(product, variants)?.url; const brandName = attributes?.find((attr) => attr.name === 'brand')?.value; return JSON.stringify(pruneUndefined({ @@ -53,7 +54,8 @@ export default (config, product, variants) => { priceCurrency: prices?.final?.currency, }) : undefined, ...variants.map((v) => { - const offerUrl = constructProductUrl(config, product, v); + const offerUrl = config.siteOverrides?.constructProductUrl(config, product, v) + ?? constructProductUrl(config, product, v); const offer = { '@type': 'Offer', diff --git a/src/types.d.ts b/src/types.d.ts index d473b31..353de19 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -38,6 +38,7 @@ declare global { matchedPath: string; matchedPathConfig: Config; attributeOverrides: AttributeOverrides; + siteOverrides: Record>; } export interface Env { From aa4b3a613318cccf4c9706924a44ecf549d83952 Mon Sep 17 00:00:00 2001 From: Dylan Depass Date: Sat, 2 Nov 2024 12:57:51 -0400 Subject: [PATCH 11/17] fix: resolve function once --- src/templates/json-ld.js | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/templates/json-ld.js b/src/templates/json-ld.js index a45a6cf..1cb326c 100644 --- a/src/templates/json-ld.js +++ b/src/templates/json-ld.js @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import { constructProductUrl, findProductImage, pruneUndefined } from '../utils/product.js'; +import { constructProductUrl as constructProductUrlBase, findProductImage, pruneUndefined } from '../utils/product.js'; /** * @param {Product} product @@ -30,8 +30,9 @@ export default (config, product, variants) => { prices, } = product; - const productUrl = config.siteOverrides?.constructProductUrl(config, product) - ?? constructProductUrl(config, product); + const constructProductUrl = config.siteOverrides?.constructProductUrl ?? constructProductUrlBase; + + const productUrl = constructProductUrl(config, product); const image = images?.[0]?.url ?? findProductImage(product, variants)?.url; const brandName = attributes?.find((attr) => attr.name === 'brand')?.value; return JSON.stringify(pruneUndefined({ @@ -54,9 +55,7 @@ export default (config, product, variants) => { priceCurrency: prices?.final?.currency, }) : undefined, ...variants.map((v) => { - const offerUrl = config.siteOverrides?.constructProductUrl(config, product, v) - ?? constructProductUrl(config, product, v); - + const offerUrl = constructProductUrl(config, product, v); const offer = { '@type': 'Offer', sku: v.sku, From 8dc4fabf8d1794d010438d7e638bc93841ac8cf0 Mon Sep 17 00:00:00 2001 From: Dylan Depass Date: Sat, 2 Nov 2024 16:05:34 -0400 Subject: [PATCH 12/17] fix: cleanup and tests --- src/utils/product.js | 11 ++-------- test/config.test.js | 4 ++++ test/utils/product.test.js | 42 +++++++++++++++++++++++++++++++++++++- 3 files changed, 47 insertions(+), 10 deletions(-) diff --git a/src/utils/product.js b/src/utils/product.js index b95d1f1..3b63699 100644 --- a/src/utils/product.js +++ b/src/utils/product.js @@ -81,8 +81,8 @@ export function matchConfigPath(config, path) { for (const [key] of pathEntries) { // Replace `{{urlkey}}` and `{{sku}}` with regex patterns const pattern = key - .replace('{{urlkey}}', '([^/]+)') - .replace('{{sku}}', '([^/]+)'); + .replace('{{urlkey}}', '([^]+)') + .replace('{{sku}}', '([^]+)'); // Convert to regex and test against the path const regex = new RegExp(`^${pattern}$`); @@ -112,13 +112,6 @@ export function constructProductUrl(config, product, variant) { const productUrl = `${host}${productPath}`; if (variant) { - // Temporarily hardcoded - const orgSite = `${config.org}--${config.site}`; - if (orgSite === 'thepixel--bul-eds') { - const options = variant.selections.map((selection) => atob(selection)).join(',').replace(/configurable\//g, '').replace(/\//g, '-'); - return `${productUrl}?pid=${variant.externalId}&o=${btoa(options)}`; - } - const offerVariantURLTemplate = config.matchedPathConfig?.offerVariantURLTemplate; if (!offerVariantURLTemplate) { return `${productUrl}/?optionsUIDs=${encodeURIComponent(variant.selections.join(','))}`; diff --git a/test/config.test.js b/test/config.test.js index 98fa032..724590f 100644 --- a/test/config.test.js +++ b/test/config.test.js @@ -48,6 +48,7 @@ describe('config tests', () => { apiKey: 'bad', }, }, + siteOverrides: undefined, }); }); @@ -97,6 +98,7 @@ describe('config tests', () => { }, }, }, + siteOverrides: undefined, }); }); @@ -130,6 +132,7 @@ describe('config tests', () => { apiKey: 'bad', }, }, + siteOverrides: undefined, }); }); @@ -166,6 +169,7 @@ describe('config tests', () => { apiKey: 'bad1', }, }, + siteOverrides: undefined, }); }); diff --git a/test/utils/product.test.js b/test/utils/product.test.js index 5b532fc..fd285c8 100644 --- a/test/utils/product.test.js +++ b/test/utils/product.test.js @@ -14,7 +14,47 @@ /* eslint-disable max-len */ import assert from 'node:assert'; -import { constructProductUrl } from '../../src/utils/product.js'; +import { constructProductUrl, matchConfigPath } from '../../src/utils/product.js'; + +describe('matchConfigPath', () => { + const mockConfig = { + confMap: { + base: 'some-base-value', + '/products/{{urlkey}}/{{sku}}': 'product-detail', + '/products/{{urlkey}}': 'product-detail', + }, + }; + + it('should match a product detail path with urlkey and sku', () => { + const path = '/products/some-product/123456'; + const result = matchConfigPath(mockConfig, path); + assert.strictEqual(result, '/products/{{urlkey}}/{{sku}}'); + }); + + it('should match a product detail path with urlkey', () => { + const path = '/products/some-product'; + const result = matchConfigPath(mockConfig, path); + assert.strictEqual(result, '/products/{{urlkey}}'); + }); + + it('should match a product detail path with urlkey with slash and sku', () => { + const path = '/products/some/product/123456'; + const result = matchConfigPath(mockConfig, path); + assert.strictEqual(result, '/products/{{urlkey}}/{{sku}}'); + }); + + it('should return null for unmatched paths', () => { + const path = '/non-existent-path'; + const result = matchConfigPath(mockConfig, path); + assert.strictEqual(result, null); + }); + + it('should handle paths with special characters in urlkey', () => { + const path = '/products/special-product-name-123/ABC123'; + const result = matchConfigPath(mockConfig, path); + assert.strictEqual(result, '/products/{{urlkey}}/{{sku}}'); + }); +}); describe('constructProductUrl', () => { const configWithOfferVariantURLTemplate = { From 6be4ac8fb66f392b73581c0a5977a2c8132e5111 Mon Sep 17 00:00:00 2001 From: Dylan Depass Date: Sun, 3 Nov 2024 11:28:15 -0500 Subject: [PATCH 13/17] fix: support uppercase configurable skus --- src/content/adobe-commerce.js | 4 ++++ src/types.d.ts | 1 + 2 files changed, 5 insertions(+) diff --git a/src/content/adobe-commerce.js b/src/content/adobe-commerce.js index cfbfd4a..92cbdfb 100644 --- a/src/content/adobe-commerce.js +++ b/src/content/adobe-commerce.js @@ -162,6 +162,10 @@ export async function handle(ctx, config) { return errorResponse(400, 'missing sku and coreEndpoint'); } + if (config.uppercaseSkus) { + sku = sku?.toUpperCase(); + } + if (!sku) { // lookup sku by urlkey with core // TODO: test if livesearch if enabled diff --git a/src/types.d.ts b/src/types.d.ts index 353de19..d295f3b 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -37,6 +37,7 @@ declare global { offerVariantURLTemplate: string; matchedPath: string; matchedPathConfig: Config; + uppercaseSkus: boolean; attributeOverrides: AttributeOverrides; siteOverrides: Record>; } From e98d8bbad649c2d068f2cf59ec0ff5c0ef0b3311 Mon Sep 17 00:00:00 2001 From: Dylan Depass Date: Sun, 3 Nov 2024 11:38:35 -0500 Subject: [PATCH 14/17] fix: remove --- src/content/adobe-commerce.js | 4 ---- src/types.d.ts | 1 - 2 files changed, 5 deletions(-) diff --git a/src/content/adobe-commerce.js b/src/content/adobe-commerce.js index 92cbdfb..cfbfd4a 100644 --- a/src/content/adobe-commerce.js +++ b/src/content/adobe-commerce.js @@ -162,10 +162,6 @@ export async function handle(ctx, config) { return errorResponse(400, 'missing sku and coreEndpoint'); } - if (config.uppercaseSkus) { - sku = sku?.toUpperCase(); - } - if (!sku) { // lookup sku by urlkey with core // TODO: test if livesearch if enabled diff --git a/src/types.d.ts b/src/types.d.ts index d295f3b..353de19 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -37,7 +37,6 @@ declare global { offerVariantURLTemplate: string; matchedPath: string; matchedPathConfig: Config; - uppercaseSkus: boolean; attributeOverrides: AttributeOverrides; siteOverrides: Record>; } From 85e056017240bf3d82e5a1adb808c7f8115cb28b Mon Sep 17 00:00:00 2001 From: Dylan Depass Date: Sun, 3 Nov 2024 16:51:29 -0500 Subject: [PATCH 15/17] fix: force https for images --- src/content/queries/cs-product.js | 3 +-- src/content/queries/cs-variants.js | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/content/queries/cs-product.js b/src/content/queries/cs-product.js index 1787f0e..6ff2f87 100644 --- a/src/content/queries/cs-product.js +++ b/src/content/queries/cs-product.js @@ -25,7 +25,6 @@ export const adapter = (productData) => { } else if (maxPrice == null) { maxPrice = minPrice; } - /** @type {Product} */ const product = { sku: productData.sku, @@ -40,7 +39,7 @@ export const adapter = (productData) => { addToCartAllowed: productData.addToCartAllowed, inStock: productData.inStock, externalId: productData.externalId, - images: productData.images ?? [], + images: productData.images?.filter((img) => img.url.replace('http://', 'https://')) ?? [], attributes: productData.attributes ?? [], options: (productData.options ?? []).map((option) => ({ id: option.id, diff --git a/src/content/queries/cs-variants.js b/src/content/queries/cs-variants.js index db2ec92..d6aa92e 100644 --- a/src/content/queries/cs-variants.js +++ b/src/content/queries/cs-variants.js @@ -27,7 +27,7 @@ export const adapter = (config, variants) => variants.map(({ selections, product description: product.description, url: product.url, inStock: product.inStock, - images: product.images ?? [], + images: product.images?.filter((img) => img.url.replace('http://', 'https://')) ?? [], attributes: product.attributes ?? [], externalId: product.externalId, prices: { From 710967c66d6bfe1de47c06ba2e8c00fbbe5e48df Mon Sep 17 00:00:00 2001 From: Dylan Depass Date: Sun, 3 Nov 2024 19:36:15 -0500 Subject: [PATCH 16/17] fix: forceImagesHTTPS function --- src/content/queries/cs-product.js | 3 ++- src/content/queries/cs-variants.js | 4 ++-- src/utils/http.js | 7 +++++++ 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/content/queries/cs-product.js b/src/content/queries/cs-product.js index 6ff2f87..e5d7cd1 100644 --- a/src/content/queries/cs-product.js +++ b/src/content/queries/cs-product.js @@ -10,6 +10,7 @@ * governing permissions and limitations under the License. */ +import { forceImagesHTTPS } from '../../utils/http.js'; import { gql } from '../../utils/product.js'; /** @@ -39,7 +40,7 @@ export const adapter = (productData) => { addToCartAllowed: productData.addToCartAllowed, inStock: productData.inStock, externalId: productData.externalId, - images: productData.images?.filter((img) => img.url.replace('http://', 'https://')) ?? [], + images: forceImagesHTTPS(productData.images) ?? [], attributes: productData.attributes ?? [], options: (productData.options ?? []).map((option) => ({ id: option.id, diff --git a/src/content/queries/cs-variants.js b/src/content/queries/cs-variants.js index d6aa92e..311b130 100644 --- a/src/content/queries/cs-variants.js +++ b/src/content/queries/cs-variants.js @@ -10,6 +10,7 @@ * governing permissions and limitations under the License. */ +import { forceImagesHTTPS } from '../../utils/http.js'; import { gql } from '../../utils/product.js'; /** @@ -27,7 +28,7 @@ export const adapter = (config, variants) => variants.map(({ selections, product description: product.description, url: product.url, inStock: product.inStock, - images: product.images?.filter((img) => img.url.replace('http://', 'https://')) ?? [], + images: forceImagesHTTPS(product.images) ?? [], attributes: product.attributes ?? [], externalId: product.externalId, prices: { @@ -48,7 +49,6 @@ export const adapter = (config, variants) => variants.map(({ selections, product }, selections: selections ?? [], }; - if (config.attributeOverrides?.variant) { Object.entries(config.attributeOverrides.variant).forEach(([key, value]) => { variant[key] = product.attributes?.find((attr) => attr.name === value)?.value; diff --git a/src/utils/http.js b/src/utils/http.js index 04dfaaa..89001c8 100644 --- a/src/utils/http.js +++ b/src/utils/http.js @@ -70,3 +70,10 @@ export function errorWithResponse(status, xError, body = '') { const error = new ResponseError(xError, response); return error; } + +export function forceImagesHTTPS(images) { + return images?.map((img) => ({ + ...img, + url: img.url.replace('http://', 'https://'), + })); +} From 1444e9483ae090a89c7b4c46d5ab94973f330cf5 Mon Sep 17 00:00:00 2001 From: Dylan Depass Date: Mon, 4 Nov 2024 11:58:22 -0500 Subject: [PATCH 17/17] fix: add tests --- test/content/handler.test.js | 124 ++++++++++++++++++++++++++++ test/utils/admin.test.js | 154 +++++++++++++++++++++++++++++++++++ 2 files changed, 278 insertions(+) create mode 100644 test/content/handler.test.js create mode 100644 test/utils/admin.test.js diff --git a/test/content/handler.test.js b/test/content/handler.test.js new file mode 100644 index 0000000..5391db2 --- /dev/null +++ b/test/content/handler.test.js @@ -0,0 +1,124 @@ +/* + * Copyright 2024 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. + */ + +// @ts-nocheck + +import assert from 'node:assert'; +import sinon from 'sinon'; +import esmock from 'esmock'; + +describe('contentHandler', () => { + let helixStub; + let adobeStub; + let contentHandler; + + beforeEach(async () => { + helixStub = sinon.stub().resolves(); + adobeStub = sinon.stub().resolves(); + contentHandler = await esmock('../../src/content/handler.js', { + '../../src/content/helix-commerce.js': { handle: helixStub }, + '../../src/content/adobe-commerce.js': { handle: adobeStub }, + }); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('returns 405 for non-GET methods', async () => { + const ctx = { + info: { method: 'POST' }, + url: { pathname: '/content/product/test' }, + }; + const config = { pageType: 'product' }; + + const response = await contentHandler(ctx, config); + assert.equal(response.status, 405); + }); + + it('returns 400 if pageType is missing', async () => { + const ctx = { + info: { method: 'GET' }, + url: { pathname: '/content/product/test' }, + }; + const config = {}; + + const response = await contentHandler(ctx, config); + assert.equal(response.status, 400); + }); + + it('returns 400 for invalid product path', async () => { + const ctx = { + info: { method: 'GET' }, + url: { pathname: '/invalid/path' }, + }; + const config = { pageType: 'product' }; + + const response = await contentHandler(ctx, config); + assert.equal(response.status, 400); + }); + + it('returns 400 if no matching configuration found', async () => { + const ctx = { + info: { method: 'GET' }, + url: { pathname: '/content/product/test' }, + }; + const config = { + pageType: 'product', + confMap: {}, + }; + + const response = await contentHandler(ctx, config); + assert.equal(response.status, 400); + }); + + it('calls handleHelixCommerce if catalogSource is helix', async () => { + const ctx = { + info: { method: 'GET' }, + url: { pathname: '/content/product/us/p/product-urlkey' }, + }; + const config = { + pageType: 'product', + catalogSource: 'helix', + confMap: { + '/us/p/{{urlkey}}': { some: 'config' }, + }, + }; + + await contentHandler(ctx, config); + assert(helixStub.calledOnce); + assert.deepStrictEqual(helixStub.firstCall.args[0], ctx); + assert.deepStrictEqual(helixStub.firstCall.args[1], config); + assert.equal(config.matchedPath, '/us/p/{{urlkey}}'); + assert.deepStrictEqual(config.matchedPathConfig, { some: 'config' }); + }); + + it('calls handleAdobeCommerce', async () => { + const ctx = { + info: { method: 'GET' }, + url: { pathname: '/content/product/us/p/product-urlkey' }, + }; + const config = { + pageType: 'product', + confMap: { + '/us/p/{{urlkey}}': { some: 'config' }, + }, + }; + + await contentHandler(ctx, config); + assert(adobeStub.calledOnce); + assert.deepStrictEqual(adobeStub.firstCall.args[0], ctx); + assert.deepStrictEqual(adobeStub.firstCall.args[1], config); + assert.equal(config.matchedPath, '/us/p/{{urlkey}}'); + assert.deepStrictEqual(config.matchedPathConfig, { some: 'config' }); + }); +}); diff --git a/test/utils/admin.test.js b/test/utils/admin.test.js new file mode 100644 index 0000000..2087969 --- /dev/null +++ b/test/utils/admin.test.js @@ -0,0 +1,154 @@ +/* + * Copyright 2024 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. + */ + +// @ts-nocheck + +import { strict as assert } from 'node:assert'; +import sinon from 'sinon'; +import { createAdminUrl, callAdmin, ADMIN_ORIGIN } from '../../src/utils/admin.js'; + +describe('admin utils', () => { + let fetchStub; + + beforeEach(() => { + // Setup fetch stub before each test + // eslint-disable-next-line no-multi-assign + global.fetch = fetchStub = sinon.stub(); + }); + + afterEach(() => { + // Restore all stubs after each test + sinon.restore(); + }); + + describe('createAdminUrl', () => { + it('creates basic admin URL with required parameters', () => { + const config = { + org: 'adobe', + site: 'blog', + ref: 'main', + }; + const url = createAdminUrl(config, 'preview'); + + assert.equal(url.toString(), `${ADMIN_ORIGIN}/preview/adobe/blog/main`); + }); + + it('creates URL with custom path', () => { + const config = { + org: 'adobe', + site: 'blog', + ref: 'main', + }; + const url = createAdminUrl(config, 'preview', '/content/index'); + + assert.equal(url.toString(), `${ADMIN_ORIGIN}/preview/adobe/blog/main/content/index`); + }); + + it('handles admin version parameter', () => { + const config = { + org: 'adobe', + site: 'blog', + ref: 'main', + adminVersion: '1.2.3', + }; + const url = createAdminUrl(config, 'preview'); + + assert.equal(url.searchParams.get('hlx-admin-version'), '1.2.3'); + }); + + it('handles custom search parameters', () => { + const config = { + org: 'adobe', + site: 'blog', + ref: 'main', + }; + const searchParams = new URLSearchParams(); + searchParams.append('foo', 'bar'); + + const url = createAdminUrl(config, 'preview', '', searchParams); + + assert.equal(url.searchParams.get('foo'), 'bar'); + }); + + it('creates URL without org/site/ref when not all are provided', () => { + const config = { + org: 'adobe', + // site missing + ref: 'main', + }; + const url = createAdminUrl(config, 'preview'); + + assert.equal(url.toString(), `${ADMIN_ORIGIN}/preview`); + }); + }); + + describe('callAdmin', () => { + it('makes GET request by default', async () => { + const config = { + org: 'adobe', + site: 'blog', + ref: 'main', + }; + + fetchStub.resolves(new Response()); + + await callAdmin(config, 'preview'); + + assert(fetchStub.calledOnce); + const [url, opts] = fetchStub.firstCall.args; + assert.equal(url.toString(), `${ADMIN_ORIGIN}/preview/adobe/blog/main`); + assert.equal(opts.method, 'get'); + assert.equal(opts.headers, undefined); + assert.equal(opts.body, undefined); + }); + + it('makes POST request with JSON body', async () => { + const config = { + org: 'adobe', + site: 'blog', + ref: 'main', + }; + + const body = { hello: 'world' }; + fetchStub.resolves(new Response()); + + await callAdmin(config, 'preview', '', { + method: 'post', + body, + }); + + assert(fetchStub.calledOnce); + const [_, opts] = fetchStub.firstCall.args; + assert.equal(opts.method, 'post'); + assert.deepEqual(opts.headers, { 'Content-Type': 'application/json' }); + assert.equal(opts.body, JSON.stringify(body)); + }); + + it('includes search parameters in request', async () => { + const config = { + org: 'adobe', + site: 'blog', + ref: 'main', + }; + + const searchParams = new URLSearchParams(); + searchParams.append('test', 'value'); + fetchStub.resolves(new Response()); + + await callAdmin(config, 'preview', '', { searchParams }); + + assert(fetchStub.calledOnce); + const [url] = fetchStub.firstCall.args; + assert.equal(url.searchParams.get('test'), 'value'); + }); + }); +});