From cf3cfde7b33b36eb14d2ea19dda3da7e8f1f9235 Mon Sep 17 00:00:00 2001 From: Max Edell Date: Mon, 4 Nov 2024 15:23:44 -0800 Subject: [PATCH 1/3] feat: template overrides --- src/config.js | 3 +- src/content/adobe-commerce.js | 4 +- src/content/helix-commerce.js | 4 +- src/index.js | 4 + src/overrides/index.js | 31 -- src/templates/html.js | 337 ---------------- src/templates/html/HTMLTemplate.js | 367 ++++++++++++++++++ src/templates/html/index.js | 27 ++ src/templates/html/overrides/index.js | 20 + .../html/overrides/thepixel--bul-eds.js | 20 + src/templates/json-ld.js | 96 ----- src/templates/json/JSONTemplate.js | 148 +++++++ src/templates/json/index.js | 27 ++ src/templates/json/overrides/index.js | 20 + .../json/overrides/thepixel--bul-eds.js | 39 ++ src/types.d.ts | 19 +- src/utils/product.js | 30 -- test/config.test.js | 8 +- test/fixtures/context.js | 40 +- .../html/index.test.js} | 20 +- .../{product.test.js => product.test.js.bk} | 2 +- 21 files changed, 748 insertions(+), 518 deletions(-) delete mode 100644 src/overrides/index.js delete mode 100644 src/templates/html.js create mode 100644 src/templates/html/HTMLTemplate.js create mode 100644 src/templates/html/index.js create mode 100644 src/templates/html/overrides/index.js create mode 100644 src/templates/html/overrides/thepixel--bul-eds.js delete mode 100644 src/templates/json-ld.js create mode 100644 src/templates/json/JSONTemplate.js create mode 100644 src/templates/json/index.js create mode 100644 src/templates/json/overrides/index.js create mode 100644 src/templates/json/overrides/thepixel--bul-eds.js rename test/{template/html.test.js => templates/html/index.test.js} (94%) rename test/utils/{product.test.js => product.test.js.bk} (98%) diff --git a/src/config.js b/src/config.js index 580afd3..3d0eca0 100644 --- a/src/config.js +++ b/src/config.js @@ -11,7 +11,6 @@ */ 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. @@ -105,7 +104,7 @@ export async function resolveConfig(ctx, overrides = {}) { org, site, route, - siteOverrides: siteOverrides[siteKey], + siteKey, ...overrides, }; diff --git a/src/content/adobe-commerce.js b/src/content/adobe-commerce.js index cfbfd4a..6bb350e 100644 --- a/src/content/adobe-commerce.js +++ b/src/content/adobe-commerce.js @@ -14,7 +14,7 @@ import { errorResponse, errorWithResponse, ffetch } from '../utils/http.js'; import getProductQuery, { adapter as productAdapter } from './queries/cs-product.js'; import getVariantsQuery, { adapter as variantsAdapter } from './queries/cs-variants.js'; import getProductSKUQuery from './queries/core-product-sku.js'; -import HTML_TEMPLATE from '../templates/html.js'; +import htmlTemplateFromContext from '../templates/html/index.js'; /** * @param {string} sku @@ -173,7 +173,7 @@ export async function handle(ctx, config) { fetchProduct(sku.toUpperCase(), config), fetchVariants(sku.toUpperCase(), config), ]); - const html = HTML_TEMPLATE(config, product, variants); + const html = htmlTemplateFromContext(ctx, product, variants).render(); return new Response(html, { status: 200, headers: { diff --git a/src/content/helix-commerce.js b/src/content/helix-commerce.js index a194fe1..5798f54 100644 --- a/src/content/helix-commerce.js +++ b/src/content/helix-commerce.js @@ -12,7 +12,7 @@ import { errorResponse } from '../utils/http.js'; import { fetchProduct } from '../utils/r2.js'; -import HTML_TEMPLATE from '../templates/html.js'; +import htmlTemplateFromContext from '../templates/html/index.js'; /** * @param {Context} ctx @@ -28,7 +28,7 @@ export async function handle(ctx, config) { } const product = await fetchProduct(ctx, config, sku); - const html = HTML_TEMPLATE(config, product, product.variants); + const html = htmlTemplateFromContext(ctx, product, product.variants).render(); return new Response(html, { status: 200, headers: { diff --git a/src/index.js b/src/index.js index fc3d95c..005a505 100644 --- a/src/index.js +++ b/src/index.js @@ -35,6 +35,8 @@ export function makeContext(pctx, req, env) { /** @type {Context} */ // @ts-ignore const ctx = pctx; + // @ts-ignore + ctx.attributes = {}; ctx.env = env; ctx.url = new URL(req.url); ctx.log = console; @@ -58,6 +60,8 @@ export default { try { const overrides = Object.fromEntries(ctx.url.searchParams.entries()); const config = await resolveConfig(ctx, overrides); + ctx.config = config; + console.debug('resolved config: ', JSON.stringify(config)); if (!config) { return errorResponse(404, 'config not found'); diff --git a/src/overrides/index.js b/src/overrides/index.js deleted file mode 100644 index b99d037..0000000 --- a/src/overrides/index.js +++ /dev/null @@ -1,31 +0,0 @@ -/* - * 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/html.js b/src/templates/html.js deleted file mode 100644 index c719da5..0000000 --- a/src/templates/html.js +++ /dev/null @@ -1,337 +0,0 @@ -/* - * 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. - */ - -import { findProductImage } from '../utils/product.js'; -import JSON_LD_TEMPLATE from './json-ld.js'; - -/** - * Create a meta tag with a name attribute - * @param {string} name - * @param {string|boolean|number|undefined|null} [content] - * @returns {string} - */ -const metaName = (name, content) => (content ? `` : ''); - -/** - * Create a meta tag with a property attribute - * @param {string} name - * @param {string|boolean|number|undefined|null} [content] - * @returns {string} - */ -const metaProperty = (name, content) => ``; - -/** - * Create a price range string - * @param {number|undefined} min - * @param {number|undefined} max - * @returns {string} - */ -const priceRange = (min, max) => (min !== max ? ` (${min} - ${max})` : ''); - -/** - * Create the document meta tags - * @param {Product} product - * @returns {string} - */ -export const renderDocumentMetaTags = (product) => /* html */ ` - - ${product.metaTitle || product.name} - - ${metaProperty('description', product.metaDescription)} - ${metaName('keywords', product.metaKeyword)} -`; - -/** - * Create the Open Graph meta tags - * @param {Product} product - * @param {Image} image - * @returns {string} - */ -export const renderOpenGraphMetaTags = (product, image) => /* html */ ` - ${metaProperty('og:title', product.metaTitle || product.name)} - ${metaProperty('og:image', image?.url)} - ${metaProperty('og:image:secure_url', image?.url)} - ${metaProperty('og:type', 'product')} -`; - -/** - * Create the Twitter meta tags - * @param {Product} product - * @param {Image} image - * @returns {string} - */ -export const renderTwitterMetaTags = (product, image) => /* html */ ` - ${metaName('twitter:card', 'summary_large_image')} - ${metaName('twitter:title', product.name)} - ${metaName('twitter:image', image?.url)} - ${metaName('twitter:description', product.metaDescription)} - ${metaName('twitter:label1', 'Price')} - ${metaName('twitter:data1', product.prices.final.amount)} - ${metaName('twitter:label2', 'Availability')} - ${metaName('twitter:data2', product.inStock ? 'In stock' : 'Out of stock')} -`; - -/** - * Create the Commerce meta tags - * @param {Product} product - * @returns {string} - */ -export const renderCommerceMetaTags = (product) => /* html */ ` - ${metaName('sku', product.sku)} - ${metaName('urlKey', product.urlKey)} - ${metaName('externalId', product.externalId)} - ${metaName('addToCartAllowed', product.addToCartAllowed)} - ${metaName('inStock', product.inStock ? 'true' : 'false')} - ${metaProperty('product:availability', product.inStock ? 'In stock' : 'Out of stock')} - ${metaProperty('product:price.amount', product.prices.final.amount)} - ${metaProperty('product:price.currency', product.prices.final.currency)} -`; - -/** - * Create the Helix dependencies script tags - * @returns {string} - */ -export const renderHelixDependencies = () => /* html */ ` - - - -`; - -/** - * Create the JSON-LD script tag - * @param {Product} product - * @param {Variant[]} variants - * @returns {string} - */ -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, - { - documentMetaTags = renderDocumentMetaTags, - openGraphMetaTags = renderOpenGraphMetaTags, - twitterMetaTags = renderTwitterMetaTags, - commerceMetaTags = renderCommerceMetaTags, - helixDependencies = renderHelixDependencies, - JSONLD = renderJSONLD, - } = {}, -) => { - const image = findProductImage(product, variants); - - return /* html */ ` - - ${documentMetaTags(product)} - ${openGraphMetaTags(product, image)} - ${twitterMetaTags(product, image)} - ${commerceMetaTags(product)} - ${helixDependencies()} - ${JSONLD(config, product, variants)} - - `; -}; - -/** - * Create the product images - * @param {Image[]} images - * @returns {string} - */ -const renderProductImages = (images) => /* html */ ` -
-
- ${images.map((img) => /* html */ ` -
- - - - - ${img.label} - -
- `).join('\n')} -
-
-`; - -/** - * Create the product attributes - * @param {Attribute[]} attributes - * @returns {string} - */ -const renderProductAttributes = (attributes) => /* html */ ` -
- ${attributes.map((attr) => ` -
-
${attr.name}
-
${attr.label}
-
${attr.value}
-
- `).join('\n')} -
`; - -/** - * Create the product items - * @param {OptionValue[]} items - * @returns {string} - */ -const renderProductItems = (items) => items.map((item) => /* html */ ` -
-
option
-
${item.id}
-
${item.label}
-
${item.value ?? ''}
-
${item.selected ? 'selected' : ''}
-
${item.inStock ? 'inStock' : ''}
-
-`).join('\n'); - -/** - * Create the product options - * @param {ProductOption[]} options - * @returns {string} - */ -const renderProductOptions = (options) => /* html */ ` -
- ${options.map((opt) => /* html */ ` -
-
${opt.id}
-
${opt.label}
-
${opt.typename}
-
${opt.type ?? ''}
-
${opt.multiple ? 'multiple' : ''}
-
${opt.required === true ? 'required' : ''}
-
- ${renderProductItems(opt.items)} - `).join('\n')} -
-`; - -/** - * Create the variant images - * @param {Image[]} images - * @returns {string} - */ -const renderVariantImages = (images) => images.map((img) => /* html */ ` - - - - - ${img.label} - -`).join('\n'); - -/** - * Create the variant prices - * @param {Pick} prices - * @returns {string} - */ -const renderVariantPrices = (prices) => /* html */ ` -
Regular: ${prices.regular.amount} ${prices.regular.currency}${priceRange(prices.regular.minimumAmount, prices.regular.maximumAmount)}
-
Final: ${prices.final.amount} ${prices.final.currency}${priceRange(prices.final.minimumAmount, prices.final.maximumAmount)}
-`; - -/** - * Create the product variants - * @param {Variant[]} variants - * @returns {string} - */ -const renderProductVariants = (variants) => /* html */ ` -
- ${variants.map((v) => /* html */ ` -
-
${v.sku}
-
${v.name}
-
${v.description}
-
${v.inStock ? 'inStock' : ''}
- ${renderVariantPrices(v.prices)} -
${renderVariantImages(v.images)}
-
${v.selections.join(', ')}
-
- `).join('\n')} -
-`; - -/** - * Create the variant attributes - * @param {Variant[]} variants - * @returns {string} - */ -const renderProductVariantsAttributes = (variants) => /* html */ ` -
- ${variants?.map((v) => ` -
-
sku
-
${v.sku}
-
-
-
- ${v.attributes?.map((attribute) => ` -
-
attribute
-
${attribute.name}
-
${attribute.label}
-
${attribute.value}
-
- `).join('\n')} - `).join('\n')} -
-`; - -/** - * Create the HTML document - * @param {Product} product - * @param {Variant[]} variants - * @returns {string} - */ -export default (config, product, variants) => { - const { - name, - description, - attributes, - options, - images, - } = product; - - return /* html */` - - - ${renderHead(config, product, variants)} - -
-
-
-

${name}

- ${description ? `

${description}

` : ''} - ${renderProductImages(images)} - ${renderProductAttributes(attributes)} - ${renderProductOptions(options)} - ${renderProductVariants(variants)} - ${renderProductVariantsAttributes(variants)} -
-
-
- - `; -}; diff --git a/src/templates/html/HTMLTemplate.js b/src/templates/html/HTMLTemplate.js new file mode 100644 index 0000000..66b8390 --- /dev/null +++ b/src/templates/html/HTMLTemplate.js @@ -0,0 +1,367 @@ +/* + * 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. + */ + +/* eslint-disable class-methods-use-this */ + +import { findProductImage } from '../../utils/product.js'; +import jsonTemplateFromContext from '../json/index.js'; + +/** + * Create a meta tag with a name attribute + * @param {string} name + * @param {string|boolean|number|undefined|null} [content] + * @returns {string} + */ +const metaName = (name, content) => (content ? `` : ''); + +/** + * Create a meta tag with a property attribute + * @param {string} name + * @param {string|boolean|number|undefined|null} [content] + * @returns {string} + */ +const metaProperty = (name, content) => ``; + +/** + * Create a price range string + * @param {number|undefined} min + * @param {number|undefined} max + * @returns {string} + */ +const priceRange = (min, max) => (min !== max ? ` (${min} - ${max})` : ''); + +export class HTMLTemplate { + /** @type {Context} */ + ctx = undefined; + + /** @type {Product} */ + product = undefined; + + /** @type {Variant[]} */ + variants = undefined; + + /** @type {Image} */ + image = undefined; + + /** + * @param {Context} ctx + * @param {Product} product + * @param {Variant[]} variants + */ + constructor(ctx, product, variants) { + this.ctx = ctx; + this.product = product; + this.variants = variants; + this.image = findProductImage(product, variants); + } + + /** + * Create the document meta tags + * @returns {string} + */ + renderDocumentMetaTags() { + const { product } = this; + return /* html */ ` + +${product.metaTitle || product.name} + +${metaProperty('description', product.metaDescription)} +${metaName('keywords', product.metaKeyword)} +`; + } + + /** + * Create the Open Graph meta tags + * @returns {string} + */ + renderOpenGraphMetaTags() { + const { product, image } = this; + return /* html */ ` +${metaProperty('og:title', product.metaTitle || product.name)} +${metaProperty('og:image', image?.url)} +${metaProperty('og:image:secure_url', image?.url)} +${metaProperty('og:type', 'product')} +`; + } + + /** + * Create the Twitter meta tags + * @returns {string} + */ + renderTwitterMetaTags() { + const { product, image } = this; + return /* html */ ` +${metaName('twitter:card', 'summary_large_image')} +${metaName('twitter:title', product.name)} +${metaName('twitter:image', image?.url)} +${metaName('twitter:description', product.metaDescription)} +${metaName('twitter:label1', 'Price')} +${metaName('twitter:data1', product.prices.final.amount)} +${metaName('twitter:label2', 'Availability')} +${metaName('twitter:data2', product.inStock ? 'In stock' : 'Out of stock')} +`; + } + + /** + * Create the Commerce meta tags + * @returns {string} + */ + renderCommerceMetaTags() { + const { product } = this; + return /* html */ ` +${metaName('sku', product.sku)} +${metaName('urlKey', product.urlKey)} +${metaName('externalId', product.externalId)} +${metaName('addToCartAllowed', product.addToCartAllowed)} +${metaName('inStock', product.inStock ? 'true' : 'false')} +${metaProperty('product:availability', product.inStock ? 'In stock' : 'Out of stock')} +${metaProperty('product:price.amount', product.prices.final.amount)} +${metaProperty('product:price.currency', product.prices.final.currency)} +`; + } + + /** + * Create the Helix dependencies script tags + * @returns {string} + */ + renderHelixDependencies() { + return /* html */ ` + + + +`; + } + + /** + * Create the JSON-LD script tag + * @returns {string} + */ + renderJSONLD() { + const jsonTemplate = jsonTemplateFromContext(this.ctx, this.product, this.variants); + return /* html */ ` + +`; + } + + /** + * Create the head tags + * @returns {string} + */ + renderHead() { + return /* html */ ` + + ${this.renderDocumentMetaTags()} + ${this.renderOpenGraphMetaTags()} + ${this.renderTwitterMetaTags()} + ${this.renderCommerceMetaTags()} + ${this.renderHelixDependencies()} + ${this.renderJSONLD()} + + `; + } + + /** + * Create the product images + * @param {Image[]} images + * @returns {string} + */ + renderProductImages(images) { + return /* html */ ` +
+
+ ${images.map((img) => /* html */ ` +
+ + + + + ${img.label} + +
+ `).join('\n')} +
+
+`; + } + + /** + * Create the product attributes + * @param {Attribute[]} attributes + * @returns {string} + */ + renderProductAttributes(attributes) { + return /* html */ ` +
+ ${attributes.map((attr) => ` +
+
${attr.name}
+
${attr.label}
+
${attr.value}
+
+ `).join('\n')} +
`; + } + + /** + * Create the product items + * @param {OptionValue[]} items + * @returns {string} + */ + renderProductItems(items) { + return items.map((item) => /* html */ ` +
+
option
+
${item.id}
+
${item.label}
+
${item.value ?? ''}
+
${item.selected ? 'selected' : ''}
+
${item.inStock ? 'inStock' : ''}
+
+`).join('\n'); + } + + /** + * Create the product options + * @param {ProductOption[]} options + * @returns {string} + */ + renderProductOptions(options) { + return /* html */ ` +
+ ${options.map((opt) => /* html */ ` +
+
${opt.id}
+
${opt.label}
+
${opt.typename}
+
${opt.type ?? ''}
+
${opt.multiple ? 'multiple' : ''}
+
${opt.required === true ? 'required' : ''}
+
+ ${this.renderProductItems(opt.items)} + `).join('\n')} +
+`; + } + + /** + * Create the variant images + * @param {Image[]} images + * @returns {string} + */ + renderVariantImages(images) { + return images.map((img) => /* html */ ` + + + + + ${img.label} + +`).join('\n'); + } + + /** + * Create the variant prices + * @param {Pick} prices + * @returns {string} + */ + renderVariantPrices(prices) { + return /* html */ ` +
Regular: ${prices.regular.amount} ${prices.regular.currency}${priceRange(prices.regular.minimumAmount, prices.regular.maximumAmount)}
+
Final: ${prices.final.amount} ${prices.final.currency}${priceRange(prices.final.minimumAmount, prices.final.maximumAmount)}
+`; + } + + /** + * Create the product variants + * @returns {string} + */ + renderProductVariants() { + return /* html */ ` +
+ ${this.variants.map((v) => /* html */ ` +
+
${v.sku}
+
${v.name}
+
${v.description}
+
${v.inStock ? 'inStock' : ''}
+ ${this.renderVariantPrices(v.prices)} +
${this.renderVariantImages(v.images)}
+
${v.selections.join(', ')}
+
+ `).join('\n')} +
+`; + } + + /** + * Create the variant attributes + * @returns {string} + */ + renderProductVariantsAttributes() { + return /* html */ ` +
+ ${this.variants?.map((v) => ` +
+
sku
+
${v.sku}
+
+
+
+ ${v.attributes?.map((attribute) => ` +
+
attribute
+
${attribute.name}
+
${attribute.label}
+
${attribute.value}
+
+ `).join('\n')} + `).join('\n')} +
+`; + } + + /** + * @returns {string} + */ + render() { + const { + name, + description, + attributes, + options, + images, + } = this.product; + + return /* html */` + + + ${this.renderHead()} + +
+
+
+

${name}

+ ${description ? `

${description}

` : ''} + ${this.renderProductImages(images)} + ${this.renderProductAttributes(attributes)} + ${this.renderProductOptions(options)} + ${this.renderProductVariants()} + ${this.renderProductVariantsAttributes()} +
+
+
+ + `; + } +} diff --git a/src/templates/html/index.js b/src/templates/html/index.js new file mode 100644 index 0000000..e715420 --- /dev/null +++ b/src/templates/html/index.js @@ -0,0 +1,27 @@ +/* + * 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. + */ + +import { HTMLTemplate } from './HTMLTemplate.js'; +import OVERRIDES from './overrides/index.js'; + +/** + * @param {Context} ctx + * @param {Product} product + * @param {Variant[]} variants + */ +export default function fromContext(ctx, product, variants) { + if (!ctx.attributes.htmlTemplate) { + const Cls = OVERRIDES[ctx.config.siteKey] ?? HTMLTemplate; + ctx.attributes.htmlTemplate = new Cls(ctx, product, variants); + } + return ctx.attributes.htmlTemplate; +} diff --git a/src/templates/html/overrides/index.js b/src/templates/html/overrides/index.js new file mode 100644 index 0000000..82f89e2 --- /dev/null +++ b/src/templates/html/overrides/index.js @@ -0,0 +1,20 @@ +/* + * 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. + */ + +import thePixelBulEds from './thepixel--bul-eds.js'; + +/** + * @type {Record} + */ +export default { + 'thepixel--bul-eds': thePixelBulEds, +}; diff --git a/src/templates/html/overrides/thepixel--bul-eds.js b/src/templates/html/overrides/thepixel--bul-eds.js new file mode 100644 index 0000000..1180320 --- /dev/null +++ b/src/templates/html/overrides/thepixel--bul-eds.js @@ -0,0 +1,20 @@ +/* + * 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. + */ + +import { HTMLTemplate } from '../HTMLTemplate.js'; + +export default class extends HTMLTemplate { + // eslint-disable-next-line class-methods-use-this + renderHelixDependencies() { + return undefined; + } +} diff --git a/src/templates/json-ld.js b/src/templates/json-ld.js deleted file mode 100644 index 1cb326c..0000000 --- a/src/templates/json-ld.js +++ /dev/null @@ -1,96 +0,0 @@ -/* - * 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. - */ - -import { constructProductUrl as constructProductUrlBase, findProductImage, pruneUndefined } from '../utils/product.js'; - -/** - * @param {Product} product - * @param {Variant[]} variants - * @returns {string} - */ -export default (config, product, variants) => { - const { - sku, - name, - metaDescription, - images, - reviewCount, - ratingValue, - attributes, - inStock, - prices, - } = 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({ - '@context': 'http://schema.org', - '@type': 'Product', - '@id': productUrl, - name, - sku, - description: metaDescription, - image, - productID: sku, - offers: [ - prices ? ({ - '@type': 'Offer', - sku, - url: productUrl, - image, - availability: inStock ? 'InStock' : 'OutOfStock', - price: prices?.final?.amount, - priceCurrency: prices?.final?.currency, - }) : undefined, - ...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; - } - - return offer; - }).filter(Boolean), - ], - ...(brandName - ? { - brand: { - '@type': 'Brand', - name: brandName, - }, - } - : {}), - ...(typeof reviewCount === 'number' - && typeof ratingValue === 'number' - && reviewCount > 0 - ? { - aggregateRating: { - '@type': 'AggregateRating', - ratingValue, - reviewCount, - }, - } - : {}), - })); -}; diff --git a/src/templates/json/JSONTemplate.js b/src/templates/json/JSONTemplate.js new file mode 100644 index 0000000..9b9ea8d --- /dev/null +++ b/src/templates/json/JSONTemplate.js @@ -0,0 +1,148 @@ +/* + * 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. + */ + +import { findProductImage, pruneUndefined } from '../../utils/product.js'; + +export class JSONTemplate { + /** @type {Context} */ + ctx = undefined; + + /** @type {Product} */ + product = undefined; + + /** @type {Variant[]} */ + variants = undefined; + + /** + * @param {Context} ctx + * @param {Product} product + * @param {Variant[]} variants + */ + constructor(ctx, product, variants) { + this.ctx = ctx; + this.product = product; + this.variants = variants; + } + + /** + * @param {Variant} [variant] + * @returns + */ + constructProductURL(variant) { + const { + product, + ctx: { config }, + } = this; + const { host, matchedPath } = config; + + const productPath = matchedPath + .replace('{{urlkey}}', product.urlKey) + .replace('{{sku}}', encodeURIComponent(product.sku.toLowerCase())); + + const productUrl = `${host}${productPath}`; + + if (variant) { + const offerVariantURLTemplate = config.matchedPathConfig?.offerVariantURLTemplate; + if (!offerVariantURLTemplate) { + return `${productUrl}/?optionsUIDs=${encodeURIComponent(variant.selections.join(','))}`; + } + + const variantPath = offerVariantURLTemplate + .replace('{{urlkey}}', product.urlKey) + .replace('{{sku}}', encodeURIComponent(variant.sku)); + return `${config.host}${variantPath}`; + } + + return productUrl; + } + + renderBrand() { + const { attributes } = this.product; + const brandName = attributes?.find((attr) => attr.name === 'brand')?.value; + if (!brandName) { + return undefined; + } + return { + brand: { + '@type': 'Brand', + name: brandName, + }, + }; + } + + render() { + const { + sku, + name, + metaDescription, + images, + reviewCount, + ratingValue, + inStock, + prices, + } = this.product; + + const productUrl = this.constructProductURL(); + const image = images?.[0]?.url ?? findProductImage(this.product, this.variants)?.url; + return JSON.stringify(pruneUndefined({ + '@context': 'http://schema.org', + '@type': 'Product', + '@id': productUrl, + name, + sku, + description: metaDescription, + image, + productID: sku, + offers: [ + prices ? ({ + '@type': 'Offer', + sku, + url: productUrl, + image, + availability: inStock ? 'InStock' : 'OutOfStock', + price: prices?.final?.amount, + priceCurrency: prices?.final?.currency, + }) : undefined, + ...this.variants.map((v) => { + const offerUrl = this.constructProductURL(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; + } + + return offer; + }).filter(Boolean), + ], + ...(this.renderBrand() ?? {}), + ...(typeof reviewCount === 'number' + && typeof ratingValue === 'number' + && reviewCount > 0 + ? { + aggregateRating: { + '@type': 'AggregateRating', + ratingValue, + reviewCount, + }, + } + : {}), + })); + } +} diff --git a/src/templates/json/index.js b/src/templates/json/index.js new file mode 100644 index 0000000..d99beea --- /dev/null +++ b/src/templates/json/index.js @@ -0,0 +1,27 @@ +/* + * 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. + */ + +import { JSONTemplate } from './JSONTemplate.js'; +import OVERRIDES from './overrides/index.js'; + +/** + * @param {Context} ctx + * @param {Product} product + * @param {Variant[]} variants + */ +export default function fromContext(ctx, product, variants) { + if (!ctx.attributes.jsonTemplate) { + const Cls = OVERRIDES[ctx.config.siteKey] ?? JSONTemplate; + ctx.attributes.jsonTemplate = new Cls(ctx, product, variants); + } + return ctx.attributes.jsonTemplate; +} diff --git a/src/templates/json/overrides/index.js b/src/templates/json/overrides/index.js new file mode 100644 index 0000000..fc9a5e3 --- /dev/null +++ b/src/templates/json/overrides/index.js @@ -0,0 +1,20 @@ +/* + * 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. + */ + +import thePixelBulEds from './thepixel--bul-eds.js'; + +/** + * @type {Record} + */ +export default { + 'thepixel--bul-eds': thePixelBulEds, +}; diff --git a/src/templates/json/overrides/thepixel--bul-eds.js b/src/templates/json/overrides/thepixel--bul-eds.js new file mode 100644 index 0000000..decb49d --- /dev/null +++ b/src/templates/json/overrides/thepixel--bul-eds.js @@ -0,0 +1,39 @@ +/* + * 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. + */ + +import { JSONTemplate } from '../JSONTemplate.js'; + +export default class extends JSONTemplate { + /** + * @param {Variant} [variant] + * @returns {string} + */ + constructProductURL(variant) { + const { + product, + ctx: { config }, + } = this; + 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/types.d.ts b/src/types.d.ts index 353de19..87e2828 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -1,11 +1,13 @@ import type { ExecutionContext, KVNamespace } from "@cloudflare/workers-types/experimental"; +import type { HTMLTemplate } from "./templates/html/HTMLTemplate.js"; +import { JSONTemplate } from "./templates/json/JSONTemplate.js"; declare global { /** * { pathPattern => Config } */ export type ConfigMap = Record; - + export interface AttributeOverrides { variant: { [key: string]: string; @@ -18,6 +20,7 @@ declare global { export interface Config { org: string; site: string; + siteKey: string; route: string; pageType: 'product' | string; origin?: string; @@ -30,6 +33,7 @@ declare global { catalogSource: string catalogEndpoint?: string; sku?: string; + confMap: ConfigMap; params: Record; headers: Record; @@ -55,10 +59,16 @@ declare global { url: URL; env: Env; log: Console; + config: Config; info: { method: string; headers: Record; } + attributes: { + htmlTemplate?: HTMLTemplate; + jsonTemplate?: JSONTemplate; + [key: string]: any; + } } export interface Product { @@ -152,6 +162,13 @@ declare global { label: string; value: string; } + + // === util types === + + export type PickStartsWith = { + [K in keyof T as K extends `${S}${infer R}` ? K : never]: T[K] + } + } export { }; \ No newline at end of file diff --git a/src/utils/product.js b/src/utils/product.js index 3b63699..e970a0f 100644 --- a/src/utils/product.js +++ b/src/utils/product.js @@ -95,33 +95,3 @@ export function matchConfigPath(config, path) { 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.toLowerCase())); - - const productUrl = `${host}${productPath}`; - - if (variant) { - const offerVariantURLTemplate = config.matchedPathConfig?.offerVariantURLTemplate; - if (!offerVariantURLTemplate) { - return `${productUrl}/?optionsUIDs=${encodeURIComponent(variant.selections.join(','))}`; - } - - const variantPath = offerVariantURLTemplate - .replace('{{urlkey}}', product.urlKey) - .replace('{{sku}}', encodeURIComponent(variant.sku)); - return `${config.host}${variantPath}`; - } - - return productUrl; -} diff --git a/test/config.test.js b/test/config.test.js index 724590f..f8653bb 100644 --- a/test/config.test.js +++ b/test/config.test.js @@ -38,6 +38,7 @@ describe('config tests', () => { pageType: 'product', org: 'org', site: 'site', + siteKey: 'org--site', route: 'content', confMap: { '/us/p/{{urlkey}}/{{sku}}': { @@ -48,7 +49,6 @@ describe('config tests', () => { apiKey: 'bad', }, }, - siteOverrides: undefined, }); }); @@ -80,6 +80,7 @@ describe('config tests', () => { pageType: 'product', org: 'org', site: 'site', + siteKey: 'org--site', route: 'content', confMap: { '/us/p/{{urlkey}}/{{sku}}': { @@ -98,7 +99,6 @@ describe('config tests', () => { }, }, }, - siteOverrides: undefined, }); }); @@ -122,6 +122,7 @@ describe('config tests', () => { pageType: 'product', org: 'org', site: 'site', + siteKey: 'org--site', route: 'content', confMap: { '/us/p/*/{{sku}}': { @@ -132,7 +133,6 @@ describe('config tests', () => { apiKey: 'bad', }, }, - siteOverrides: undefined, }); }); @@ -159,6 +159,7 @@ describe('config tests', () => { headers: {}, org: 'org', site: 'site', + siteKey: 'org--site', route: 'content', confMap: { '/us/p/{{sku}}': { @@ -169,7 +170,6 @@ describe('config tests', () => { apiKey: 'bad1', }, }, - siteOverrides: undefined, }); }); diff --git a/test/fixtures/context.js b/test/fixtures/context.js index 1485dfc..966b799 100644 --- a/test/fixtures/context.js +++ b/test/fixtures/context.js @@ -10,16 +10,50 @@ * governing permissions and limitations under the License. */ -export const TEST_CONTEXT = (path, configMap, baseUrl = 'https://www.example.com/org/site/content') => ({ +/** + * @param {Partial} [overrides = {}] + * @param {{ + * path?: string; + * configMap?: Record; + * baseUrl?: string; + * }} opts + * @returns {Context} + */ +export const DEFAULT_CONTEXT = ( + overrides = {}, + { + path = '', + configMap = {}, + baseUrl = 'https://www.example.com/org/site/content', + } = {}, +) => ({ + url: new URL(`${baseUrl}${path}`), + log: console, + ...overrides, + attributes: { + ...(overrides.attributes ?? {}), + }, env: { CONFIGS: { + // @ts-ignore get: async (id) => configMap[id], }, + ...(overrides.env ?? {}), }, - log: console, - url: new URL(`${baseUrl}${path}`), info: { method: 'GET', headers: {}, + ...(overrides.info ?? {}), }, }); + +/** + * @param {string} path + * @param {Record} configMap + * @param {string} baseUrl + * @returns {Context} + */ +export const TEST_CONTEXT = (path, configMap, baseUrl) => DEFAULT_CONTEXT( + {}, + { path, configMap, baseUrl }, +); diff --git a/test/template/html.test.js b/test/templates/html/index.test.js similarity index 94% rename from test/template/html.test.js rename to test/templates/html/index.test.js index 2f74f5c..9282adb 100644 --- a/test/template/html.test.js +++ b/test/templates/html/index.test.js @@ -14,10 +14,11 @@ import assert from 'node:assert'; import { JSDOM } from 'jsdom'; -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'; +// import { constructProductUrl } from '../../../src/utils/product.js'; +import { DEFAULT_CONTEXT } from '../../fixtures/context.js'; +import { createDefaultVariations, createProductVariationFixture } from '../../fixtures/variant.js'; +import { createProductFixture } from '../../fixtures/product.js'; +import htmlTemplateFromContext from '../../../src/templates/html/index.js'; // Helper function to format price range function priceRange(min, max) { @@ -39,7 +40,8 @@ describe('Render Product HTML', () => { host: 'https://example.com', matchedPath: '/us/p/{{urlkey}}/{{sku}}', }; - const html = htmlTemplate(config, product, variations); + // @ts-ignore + const html = htmlTemplateFromContext(DEFAULT_CONTEXT({ config }), product, variations).render(); dom = new JSDOM(html); document = dom.window.document; }); @@ -80,7 +82,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['@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'); @@ -93,7 +95,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 offer URL does not match'); + // 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`); @@ -106,7 +108,7 @@ describe('Render Product HTML', () => { createProductVariationFixture({ gtin: '123' }), createProductVariationFixture({ gtin: '456' }), ]; - const html = htmlTemplate(config, product, variations); + const html = htmlTemplateFromContext(DEFAULT_CONTEXT({ config }), product, variations).render(); dom = new JSDOM(html); document = dom.window.document; @@ -123,7 +125,7 @@ describe('Render Product HTML', () => { config.matchedPathConfig = { offerVariantURLTemplate: '/us/p/{{urlkey}}?selected_product={{sku}}', }; - const html = htmlTemplate(config, product, variations); + const html = htmlTemplateFromContext(DEFAULT_CONTEXT({ config }), product, variations).render(); dom = new JSDOM(html); document = dom.window.document; diff --git a/test/utils/product.test.js b/test/utils/product.test.js.bk similarity index 98% rename from test/utils/product.test.js rename to test/utils/product.test.js.bk index fd285c8..130772b 100644 --- a/test/utils/product.test.js +++ b/test/utils/product.test.js.bk @@ -14,7 +14,7 @@ /* eslint-disable max-len */ import assert from 'node:assert'; -import { constructProductUrl, matchConfigPath } from '../../src/utils/product.js'; +import { matchConfigPath } from '../../src/utils/product.js'; describe('matchConfigPath', () => { const mockConfig = { From 21632787bd3de8bc353037972fac31cc1b06ffa8 Mon Sep 17 00:00:00 2001 From: Max Edell Date: Mon, 4 Nov 2024 16:12:17 -0800 Subject: [PATCH 2/3] fix: cleanup, rm dupe logic --- src/config.js | 1 + src/content/handler.js | 22 +- src/templates/html/HTMLTemplate.js | 235 ++++++++---------- src/templates/html/overrides/index.js | 4 +- .../overrides/wilson-ecommerce--wilson.js | 29 +++ src/templates/json/JSONTemplate.js | 7 +- src/templates/json/overrides/index.js | 5 +- .../json/overrides/thepixel--bul-eds.js | 5 +- .../overrides/wilson-ecommerce--wilson.js} | 15 +- src/types.d.ts | 5 +- test/templates/html/index.test.js | 9 +- 11 files changed, 169 insertions(+), 168 deletions(-) create mode 100644 src/templates/html/overrides/wilson-ecommerce--wilson.js rename src/templates/{html/overrides/thepixel--bul-eds.js => json/overrides/wilson-ecommerce--wilson.js} (64%) diff --git a/src/config.js b/src/config.js index 3d0eca0..527b920 100644 --- a/src/config.js +++ b/src/config.js @@ -105,6 +105,7 @@ export async function resolveConfig(ctx, overrides = {}) { site, route, siteKey, + matchedPatterns: paths, ...overrides, }; diff --git a/src/content/handler.js b/src/content/handler.js index e2cc486..d49f417 100644 --- a/src/content/handler.js +++ b/src/content/handler.js @@ -10,7 +10,6 @@ * 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,26 +29,7 @@ export default async function contentHandler(ctx, config) { if (!config.pageType) { 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'); - } + console.log('config: ', JSON.stringify(config, null, 2)); if (config.catalogSource === 'helix') { return handleHelixCommerce(ctx, config); diff --git a/src/templates/html/HTMLTemplate.js b/src/templates/html/HTMLTemplate.js index 66b8390..a48957f 100644 --- a/src/templates/html/HTMLTemplate.js +++ b/src/templates/html/HTMLTemplate.js @@ -15,31 +15,31 @@ import { findProductImage } from '../../utils/product.js'; import jsonTemplateFromContext from '../json/index.js'; -/** - * Create a meta tag with a name attribute - * @param {string} name - * @param {string|boolean|number|undefined|null} [content] - * @returns {string} - */ -const metaName = (name, content) => (content ? `` : ''); +export class HTMLTemplate { + /** + * Create a meta tag with a name attribute + * @param {string} name + * @param {string|boolean|number|undefined|null} [content] + * @returns {string} + */ + static metaName = (name, content) => (content ? `` : ''); -/** - * Create a meta tag with a property attribute - * @param {string} name - * @param {string|boolean|number|undefined|null} [content] - * @returns {string} - */ -const metaProperty = (name, content) => ``; + /** + * Create a meta tag with a property attribute + * @param {string} name + * @param {string|boolean|number|undefined|null} [content] + * @returns {string} + */ + static metaProperty = (name, content) => ``; -/** - * Create a price range string - * @param {number|undefined} min - * @param {number|undefined} max - * @returns {string} - */ -const priceRange = (min, max) => (min !== max ? ` (${min} - ${max})` : ''); + /** + * Create a price range string + * @param {number|undefined} min + * @param {number|undefined} max + * @returns {string} + */ + static priceRange = (min, max) => (min !== max ? ` (${min} - ${max})` : ''); -export class HTMLTemplate { /** @type {Context} */ ctx = undefined; @@ -70,13 +70,12 @@ export class HTMLTemplate { */ renderDocumentMetaTags() { const { product } = this; - return /* html */ ` + return /* html */ `\ ${product.metaTitle || product.name} -${metaProperty('description', product.metaDescription)} -${metaName('keywords', product.metaKeyword)} -`; +${HTMLTemplate.metaProperty('description', product.metaDescription)} +${HTMLTemplate.metaName('keywords', product.metaKeyword)}`; } /** @@ -85,12 +84,11 @@ ${metaName('keywords', product.metaKeyword)} */ renderOpenGraphMetaTags() { const { product, image } = this; - return /* html */ ` -${metaProperty('og:title', product.metaTitle || product.name)} -${metaProperty('og:image', image?.url)} -${metaProperty('og:image:secure_url', image?.url)} -${metaProperty('og:type', 'product')} -`; + return /* html */`\ +${HTMLTemplate.metaProperty('og:title', product.metaTitle || product.name)} +${HTMLTemplate.metaProperty('og:image', image?.url)} +${HTMLTemplate.metaProperty('og:image:secure_url', image?.url)} +${HTMLTemplate.metaProperty('og:type', 'product')}`; } /** @@ -99,16 +97,15 @@ ${metaProperty('og:type', 'product')} */ renderTwitterMetaTags() { const { product, image } = this; - return /* html */ ` -${metaName('twitter:card', 'summary_large_image')} -${metaName('twitter:title', product.name)} -${metaName('twitter:image', image?.url)} -${metaName('twitter:description', product.metaDescription)} -${metaName('twitter:label1', 'Price')} -${metaName('twitter:data1', product.prices.final.amount)} -${metaName('twitter:label2', 'Availability')} -${metaName('twitter:data2', product.inStock ? 'In stock' : 'Out of stock')} -`; + return /* html */ `\ +${HTMLTemplate.metaName('twitter:card', 'summary_large_image')} +${HTMLTemplate.metaName('twitter:title', product.name)} +${HTMLTemplate.metaName('twitter:image', image?.url)} +${HTMLTemplate.metaName('twitter:description', product.metaDescription)} +${HTMLTemplate.metaName('twitter:label1', 'Price')} +${HTMLTemplate.metaName('twitter:data1', product.prices.final.amount)} +${HTMLTemplate.metaName('twitter:label2', 'Availability')} +${HTMLTemplate.metaName('twitter:data2', product.inStock ? 'In stock' : 'Out of stock')}`; } /** @@ -117,16 +114,15 @@ ${metaName('twitter:data2', product.inStock ? 'In stock' : 'Out of stock')} */ renderCommerceMetaTags() { const { product } = this; - return /* html */ ` -${metaName('sku', product.sku)} -${metaName('urlKey', product.urlKey)} -${metaName('externalId', product.externalId)} -${metaName('addToCartAllowed', product.addToCartAllowed)} -${metaName('inStock', product.inStock ? 'true' : 'false')} -${metaProperty('product:availability', product.inStock ? 'In stock' : 'Out of stock')} -${metaProperty('product:price.amount', product.prices.final.amount)} -${metaProperty('product:price.currency', product.prices.final.currency)} -`; + return /* html */ `\ +${HTMLTemplate.metaName('sku', product.sku)} +${HTMLTemplate.metaName('urlKey', product.urlKey)} +${HTMLTemplate.metaName('externalId', product.externalId)} +${HTMLTemplate.metaName('addToCartAllowed', product.addToCartAllowed)} +${HTMLTemplate.metaName('inStock', product.inStock ? 'true' : 'false')} +${HTMLTemplate.metaProperty('product:availability', product.inStock ? 'In stock' : 'Out of stock')} +${HTMLTemplate.metaProperty('product:price.amount', product.prices.final.amount)} +${HTMLTemplate.metaProperty('product:price.currency', product.prices.final.currency)}`; } /** @@ -134,11 +130,10 @@ ${metaProperty('product:price.currency', product.prices.final.currency)} * @returns {string} */ renderHelixDependencies() { - return /* html */ ` + return /* html */ `\ - -`; +`; } /** @@ -147,11 +142,10 @@ ${metaProperty('product:price.currency', product.prices.final.currency)} */ renderJSONLD() { const jsonTemplate = jsonTemplateFromContext(this.ctx, this.product, this.variants); - return /* html */ ` + return /* html */ `\ -`; +`; } /** @@ -159,16 +153,15 @@ ${metaProperty('product:price.currency', product.prices.final.currency)} * @returns {string} */ renderHead() { - return /* html */ ` - - ${this.renderDocumentMetaTags()} - ${this.renderOpenGraphMetaTags()} - ${this.renderTwitterMetaTags()} - ${this.renderCommerceMetaTags()} - ${this.renderHelixDependencies()} - ${this.renderJSONLD()} - - `; + return /* html */ `\ + + ${this.renderDocumentMetaTags()} + ${this.renderOpenGraphMetaTags()} + ${this.renderTwitterMetaTags()} + ${this.renderCommerceMetaTags()} + ${this.renderHelixDependencies()} + ${this.renderJSONLD()} +`; } /** @@ -177,10 +170,10 @@ ${metaProperty('product:price.currency', product.prices.final.currency)} * @returns {string} */ renderProductImages(images) { - return /* html */ ` + return /* html */ `\
- ${images.map((img) => /* html */ ` + ${images.map((img) => /* html */ `\
@@ -188,11 +181,9 @@ ${metaProperty('product:price.currency', product.prices.final.currency)} ${img.label} -
- `).join('\n')} +
`).join('\n')}
- -`; + `; } /** @@ -201,15 +192,14 @@ ${metaProperty('product:price.currency', product.prices.final.currency)} * @returns {string} */ renderProductAttributes(attributes) { - return /* html */ ` + return /* html */ `\
- ${attributes.map((attr) => ` + ${attributes.map((attr) => /* html */`\
${attr.name}
${attr.label}
${attr.value}
-
- `).join('\n')} +
`).join('\n')} `; } @@ -219,7 +209,7 @@ ${metaProperty('product:price.currency', product.prices.final.currency)} * @returns {string} */ renderProductItems(items) { - return items.map((item) => /* html */ ` + return items.map((item) => /* html */`\
option
${item.id}
@@ -227,8 +217,7 @@ ${metaProperty('product:price.currency', product.prices.final.currency)}
${item.value ?? ''}
${item.selected ? 'selected' : ''}
${item.inStock ? 'inStock' : ''}
-
-`).join('\n'); + `).join('\n'); } /** @@ -237,9 +226,9 @@ ${metaProperty('product:price.currency', product.prices.final.currency)} * @returns {string} */ renderProductOptions(options) { - return /* html */ ` + return /* html */ `\
- ${options.map((opt) => /* html */ ` + ${options.map((opt) => /* html */ `\
${opt.id}
${opt.label}
@@ -248,10 +237,8 @@ ${metaProperty('product:price.currency', product.prices.final.currency)}
${opt.multiple ? 'multiple' : ''}
${opt.required === true ? 'required' : ''}
- ${this.renderProductItems(opt.items)} - `).join('\n')} -
-`; + ${this.renderProductItems(opt.items)}`).join('\n')} + `; } /** @@ -260,14 +247,13 @@ ${metaProperty('product:price.currency', product.prices.final.currency)} * @returns {string} */ renderVariantImages(images) { - return images.map((img) => /* html */ ` + return images.map((img) => /* html */ `\ ${img.label} - -`).join('\n'); + `).join('\n'); } /** @@ -276,10 +262,9 @@ ${metaProperty('product:price.currency', product.prices.final.currency)} * @returns {string} */ renderVariantPrices(prices) { - return /* html */ ` -
Regular: ${prices.regular.amount} ${prices.regular.currency}${priceRange(prices.regular.minimumAmount, prices.regular.maximumAmount)}
-
Final: ${prices.final.amount} ${prices.final.currency}${priceRange(prices.final.minimumAmount, prices.final.maximumAmount)}
-`; + return /* html */ `\ +
Regular: ${prices.regular.amount} ${prices.regular.currency}${HTMLTemplate.priceRange(prices.regular.minimumAmount, prices.regular.maximumAmount)}
+
Final: ${prices.final.amount} ${prices.final.currency}${HTMLTemplate.priceRange(prices.final.minimumAmount, prices.final.maximumAmount)}
`; } /** @@ -287,9 +272,9 @@ ${metaProperty('product:price.currency', product.prices.final.currency)} * @returns {string} */ renderProductVariants() { - return /* html */ ` + return /* html */ `\
- ${this.variants.map((v) => /* html */ ` + ${this.variants.map((v) => /* html */`\
${v.sku}
${v.name}
@@ -298,10 +283,8 @@ ${metaProperty('product:price.currency', product.prices.final.currency)} ${this.renderVariantPrices(v.prices)}
${this.renderVariantImages(v.images)}
${v.selections.join(', ')}
-
- `).join('\n')} -
-`; + `).join('\n')} + `; } /** @@ -309,26 +292,24 @@ ${metaProperty('product:price.currency', product.prices.final.currency)} * @returns {string} */ renderProductVariantsAttributes() { - return /* html */ ` + return /* html */ `\
- ${this.variants?.map((v) => ` + ${this.variants?.map((v) => /* html */`\
sku
${v.sku}
- ${v.attributes?.map((attribute) => ` + ${v.attributes?.map((attribute) => /* html */`\
attribute
${attribute.name}
${attribute.label}
${attribute.value}
-
- `).join('\n')} +
`).join('\n')}\ `).join('\n')} - -`; + `; } /** @@ -343,25 +324,25 @@ ${metaProperty('product:price.currency', product.prices.final.currency)} images, } = this.product; - return /* html */` - - - ${this.renderHead()} - -
-
-
-

${name}

- ${description ? `

${description}

` : ''} - ${this.renderProductImages(images)} - ${this.renderProductAttributes(attributes)} - ${this.renderProductOptions(options)} - ${this.renderProductVariants()} - ${this.renderProductVariantsAttributes()} -
-
-
- - `; + return /* html */`\ + + + ${this.renderHead()} + +
+
+
+

${name}

+ ${description ? `

${description}

` : ''} + ${this.renderProductImages(images)} + ${this.renderProductAttributes(attributes)} + ${this.renderProductOptions(options)} + ${this.renderProductVariants()} + ${this.renderProductVariantsAttributes()} +
+
+
+ +`; } } diff --git a/src/templates/html/overrides/index.js b/src/templates/html/overrides/index.js index 82f89e2..745afbc 100644 --- a/src/templates/html/overrides/index.js +++ b/src/templates/html/overrides/index.js @@ -10,11 +10,9 @@ * governing permissions and limitations under the License. */ -import thePixelBulEds from './thepixel--bul-eds.js'; - /** * @type {Record} */ export default { - 'thepixel--bul-eds': thePixelBulEds, + 'wilson-ecommerce--wilson': (await import('./wilson-ecommerce--wilson.js')).default, }; diff --git a/src/templates/html/overrides/wilson-ecommerce--wilson.js b/src/templates/html/overrides/wilson-ecommerce--wilson.js new file mode 100644 index 0000000..e8cae78 --- /dev/null +++ b/src/templates/html/overrides/wilson-ecommerce--wilson.js @@ -0,0 +1,29 @@ +/* + * 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. + */ + +import { HTMLTemplate } from '../HTMLTemplate.js'; + +export default class extends HTMLTemplate { + /** + * Create the document meta tags + * @returns {string} + */ + renderDocumentMetaTags() { + const { product } = this; + return /* html */ `\ + +${product.metaTitle || product.name} | Wilson Sporting Goods + +${HTMLTemplate.metaProperty('description', product.metaDescription)} +${HTMLTemplate.metaName('keywords', product.metaKeyword)}`; + } +} diff --git a/src/templates/json/JSONTemplate.js b/src/templates/json/JSONTemplate.js index 9b9ea8d..d60af53 100644 --- a/src/templates/json/JSONTemplate.js +++ b/src/templates/json/JSONTemplate.js @@ -42,16 +42,17 @@ export class JSONTemplate { product, ctx: { config }, } = this; - const { host, matchedPath } = config; + const { host, matchedPatterns, confMap } = config; + const matchedPathConfig = confMap[matchedPatterns[0]]; - const productPath = matchedPath + const productPath = matchedPatterns[0] .replace('{{urlkey}}', product.urlKey) .replace('{{sku}}', encodeURIComponent(product.sku.toLowerCase())); const productUrl = `${host}${productPath}`; if (variant) { - const offerVariantURLTemplate = config.matchedPathConfig?.offerVariantURLTemplate; + const offerVariantURLTemplate = matchedPathConfig?.offerVariantURLTemplate; if (!offerVariantURLTemplate) { return `${productUrl}/?optionsUIDs=${encodeURIComponent(variant.selections.join(','))}`; } diff --git a/src/templates/json/overrides/index.js b/src/templates/json/overrides/index.js index fc9a5e3..1954ab1 100644 --- a/src/templates/json/overrides/index.js +++ b/src/templates/json/overrides/index.js @@ -10,11 +10,10 @@ * governing permissions and limitations under the License. */ -import thePixelBulEds from './thepixel--bul-eds.js'; - /** * @type {Record} */ export default { - 'thepixel--bul-eds': thePixelBulEds, + 'thepixel--bul-eds': (await import('./thepixel--bul-eds.js')).default, + 'wilson-ecommerce--wilson': (await import('./wilson-ecommerce--wilson.js')).default, }; diff --git a/src/templates/json/overrides/thepixel--bul-eds.js b/src/templates/json/overrides/thepixel--bul-eds.js index decb49d..96202f3 100644 --- a/src/templates/json/overrides/thepixel--bul-eds.js +++ b/src/templates/json/overrides/thepixel--bul-eds.js @@ -22,8 +22,9 @@ export default class extends JSONTemplate { product, ctx: { config }, } = this; - const { host, matchedPath } = config; - const productPath = matchedPath + const { host, matchedPatterns } = config; + + const productPath = matchedPatterns[0] .replace('{{urlkey}}', product.urlKey) .replace('{{sku}}', encodeURIComponent(product.sku.toLowerCase())); diff --git a/src/templates/html/overrides/thepixel--bul-eds.js b/src/templates/json/overrides/wilson-ecommerce--wilson.js similarity index 64% rename from src/templates/html/overrides/thepixel--bul-eds.js rename to src/templates/json/overrides/wilson-ecommerce--wilson.js index 1180320..6c362ee 100644 --- a/src/templates/html/overrides/thepixel--bul-eds.js +++ b/src/templates/json/overrides/wilson-ecommerce--wilson.js @@ -10,11 +10,18 @@ * governing permissions and limitations under the License. */ -import { HTMLTemplate } from '../HTMLTemplate.js'; +import { JSONTemplate } from '../JSONTemplate.js'; -export default class extends HTMLTemplate { +export default class extends JSONTemplate { // eslint-disable-next-line class-methods-use-this - renderHelixDependencies() { - return undefined; + renderBrand() { + return { + brand: { + '@type': 'Organization', + name: 'Amersports', + url: 'https://www.wilson.com/en-us/', + image: 'https://www.wilson.com/en-us/static/version1730708006/frontend/Magento/blank/default/images/logo.svg', + }, + }; } } diff --git a/src/types.d.ts b/src/types.d.ts index 87e2828..096f741 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -33,14 +33,15 @@ declare global { catalogSource: string catalogEndpoint?: string; sku?: string; + matchedPatterns: string[]; confMap: ConfigMap; params: Record; headers: Record; host: string; offerVariantURLTemplate: string; - matchedPath: string; - matchedPathConfig: Config; + // matchedPath: string; + // matchedPathConfig: Config; attributeOverrides: AttributeOverrides; siteOverrides: Record>; } diff --git a/test/templates/html/index.test.js b/test/templates/html/index.test.js index 9282adb..f9779e0 100644 --- a/test/templates/html/index.test.js +++ b/test/templates/html/index.test.js @@ -38,7 +38,8 @@ describe('Render Product HTML', () => { variations = createDefaultVariations(); config = { host: 'https://example.com', - matchedPath: '/us/p/{{urlkey}}/{{sku}}', + matchedPatterns: ['/us/p/{{urlkey}}/{{sku}}'], + confMap: {}, }; // @ts-ignore const html = htmlTemplateFromContext(DEFAULT_CONTEXT({ config }), product, variations).render(); @@ -122,8 +123,10 @@ describe('Render Product HTML', () => { }); it('should have the correct JSON-LD schema with custom offer pattern', () => { - config.matchedPathConfig = { - offerVariantURLTemplate: '/us/p/{{urlkey}}?selected_product={{sku}}', + config.confMap = { + '/us/p/{{urlkey}}/{{sku}}': { + offerVariantURLTemplate: '/us/p/{{urlkey}}?selected_product={{sku}}', + }, }; const html = htmlTemplateFromContext(DEFAULT_CONTEXT({ config }), product, variations).render(); dom = new JSDOM(html); From e3d2852034cb9b4a615090003cfb5e022c0a3c8b Mon Sep 17 00:00:00 2001 From: Max Edell Date: Mon, 4 Nov 2024 21:03:30 -0800 Subject: [PATCH 3/3] chore: fix tests --- src/templates/json/JSONTemplate.js | 2 +- test/config.test.js | 12 ++ test/content/handler.test.js | 29 ---- test/templates/json/JSONTemplate.test.js | 151 ++++++++++++++++++++ test/utils/product.test.js.bk | 173 ----------------------- 5 files changed, 164 insertions(+), 203 deletions(-) create mode 100644 test/templates/json/JSONTemplate.test.js delete mode 100644 test/utils/product.test.js.bk diff --git a/src/templates/json/JSONTemplate.js b/src/templates/json/JSONTemplate.js index d60af53..c79ea0b 100644 --- a/src/templates/json/JSONTemplate.js +++ b/src/templates/json/JSONTemplate.js @@ -43,7 +43,7 @@ export class JSONTemplate { ctx: { config }, } = this; const { host, matchedPatterns, confMap } = config; - const matchedPathConfig = confMap[matchedPatterns[0]]; + const matchedPathConfig = confMap?.[matchedPatterns[0]]; const productPath = matchedPatterns[0] .replace('{{urlkey}}', product.urlKey) diff --git a/test/config.test.js b/test/config.test.js index f8653bb..d8b3193 100644 --- a/test/config.test.js +++ b/test/config.test.js @@ -35,6 +35,9 @@ describe('config tests', () => { apiKey: 'good', params: { urlkey: 'my-url-key', sku: 'some-sku' }, headers: {}, + matchedPatterns: [ + '/us/p/{{urlkey}}/{{sku}}', + ], pageType: 'product', org: 'org', site: 'site', @@ -77,6 +80,9 @@ describe('config tests', () => { apiKey: 'good', params: { urlkey: 'my-url-key', sku: 'some-sku' }, headers: { foo: '2', baz: '1', bar: '2' }, + matchedPatterns: [ + '/us/p/{{urlkey}}/{{sku}}', + ], pageType: 'product', org: 'org', site: 'site', @@ -119,6 +125,9 @@ describe('config tests', () => { apiKey: 'good', params: { sku: 'some-sku' }, headers: {}, + matchedPatterns: [ + '/us/p/*/{{sku}}', + ], pageType: 'product', org: 'org', site: 'site', @@ -157,6 +166,9 @@ describe('config tests', () => { params: { sku: 'some-sku' }, pageType: 'product', headers: {}, + matchedPatterns: [ + '/us/p/{{sku}}', + ], org: 'org', site: 'site', siteKey: 'org--site', diff --git a/test/content/handler.test.js b/test/content/handler.test.js index 5391db2..fc60bd7 100644 --- a/test/content/handler.test.js +++ b/test/content/handler.test.js @@ -56,31 +56,6 @@ describe('contentHandler', () => { 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' }, @@ -98,8 +73,6 @@ describe('contentHandler', () => { 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 () => { @@ -118,7 +91,5 @@ describe('contentHandler', () => { 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/templates/json/JSONTemplate.test.js b/test/templates/json/JSONTemplate.test.js new file mode 100644 index 0000000..5f0c877 --- /dev/null +++ b/test/templates/json/JSONTemplate.test.js @@ -0,0 +1,151 @@ +/* + * 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. + */ + +import assert from 'node:assert'; +import { DEFAULT_CONTEXT } from '../../fixtures/context.js'; +import { JSONTemplate } from '../../../src/templates/json/JSONTemplate.js'; + +describe('JSONTemplate', () => { + describe('#constructProductURL()', () => { + const configWithOfferVariantURLTemplate = { + host: 'https://www.example.com', + matchedPatterns: ['/products/{{urlkey}}/{{sku}}'], + configMap: { + '/products/{{urlkey}}/{{sku}}': { + offerVariantURLTemplate: '/products/{{urlkey}}?selected_product={{sku}}', + }, + }, + }; + + const configWithoutOfferVariantURLTemplate = { + host: 'https://www.example.com', + matchedPatterns: ['/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 template = new JSONTemplate(DEFAULT_CONTEXT({ + // @ts-ignore + config: configWithoutOfferVariantURLTemplate, + // @ts-ignore + }), product1, []); + const url = template.constructProductURL(); + const expectedUrl = 'https://www.example.com/products/utopia-small-pendant/kw5531'; + assert.strictEqual(url, expectedUrl, 'Product URL without variant does not match expected URL'); + }); + + it('should construct the correct variant URL without offerVariantURLTemplate', () => { + const template = new JSONTemplate(DEFAULT_CONTEXT({ + // @ts-ignore + config: configWithoutOfferVariantURLTemplate, + // @ts-ignore + }), product1, [variant1]); + + // @ts-ignore + const url = template.constructProductURL(variant1); + 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 template = new JSONTemplate(DEFAULT_CONTEXT({ + // @ts-ignore + config: configWithoutOfferVariantURLTemplate, + // @ts-ignore + }), productWithSpecialCharacters, []); + + const url = template.constructProductURL(); + 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 template = new JSONTemplate(DEFAULT_CONTEXT({ + // @ts-ignore + config: configWithoutOfferVariantURLTemplate, + // @ts-ignore + }), productWithSpecialCharacters, [variantWithSpecialSelections]); + + // @ts-ignore + const url = template.constructProductURL(variantWithSpecialSelections); + 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'); + }); + + it('should handle variant with empty selections', () => { + const variantEmptySelections = { + sku: 'VAR-EMPTY', + selections: [], + }; + + const template = new JSONTemplate(DEFAULT_CONTEXT({ + // @ts-ignore + config: configWithoutOfferVariantURLTemplate, + // @ts-ignore + }), product1, [variantEmptySelections]); + // @ts-ignore + const url = template.constructProductURL(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 construct the correct URL when variant is undefined', () => { + const template = new JSONTemplate(DEFAULT_CONTEXT({ + // @ts-ignore + config: configWithOfferVariantURLTemplate, + // @ts-ignore + }), product1, []); + const url = template.constructProductURL(); + 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', + matchedPatterns: ['/shop/{{urlkey}}/{{sku}}/details'], + }; + const product = { + urlKey: 'modern-lamp', + sku: 'ML-2023', + }; + const template = new JSONTemplate(DEFAULT_CONTEXT({ + // @ts-ignore + config: configMultiplePlaceholders, + // @ts-ignore + }), product, []); + const url = template.constructProductURL(); + 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'); + }); + }); +}); diff --git a/test/utils/product.test.js.bk b/test/utils/product.test.js.bk deleted file mode 100644 index 130772b..0000000 --- a/test/utils/product.test.js.bk +++ /dev/null @@ -1,173 +0,0 @@ -/* - * 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 { 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 = { - host: 'https://www.example.com', - matchedPath: '/products/{{urlkey}}/{{sku}}', - matchedPathConfig: { - offerVariantURLTemplate: '/products/{{urlkey}}?selected_product={{sku}}', - }, - }; - - const configWithoutOfferVariantURLTemplate = { - 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(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 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 offerVariantURLTemplate does not match expected URL'); - }); - - 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%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'; - 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%3D%2CY29uZmlndXJhYmxlLzI0NjEvMzYzNDE%3D'; - 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(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(configWithoutOfferVariantURLTemplate, product1, variant1); - 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'; - 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 offerVariantURLTemplate by falling back to optionsUIDs', () => { - const configEmptyOfferVariantURLTemplate = { - host: 'https://www.example.com', - matchedPath: '/products/{{urlkey}}-{{sku}}', - matchedPathConfig: { - offerVariantURLTemplate: '', - }, - }; - const url = constructProductUrl(configEmptyOfferVariantURLTemplate, product1, variant1); - 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'); - }); -});