diff --git a/src/config.js b/src/config.js index 580afd3..527b920 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,8 @@ export async function resolveConfig(ctx, overrides = {}) { org, site, route, - siteOverrides: siteOverrides[siteKey], + siteKey, + matchedPatterns: paths, ...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/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/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..a48957f --- /dev/null +++ b/src/templates/html/HTMLTemplate.js @@ -0,0 +1,348 @@ +/* + * 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'; + +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} + */ + static metaProperty = (name, content) => ``; + + /** + * Create a price range string + * @param {number|undefined} min + * @param {number|undefined} max + * @returns {string} + */ + static priceRange = (min, max) => (min !== max ? ` (${min} - ${max})` : ''); + + /** @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} + +${HTMLTemplate.metaProperty('description', product.metaDescription)} +${HTMLTemplate.metaName('keywords', product.metaKeyword)}`; + } + + /** + * Create the Open Graph meta tags + * @returns {string} + */ + renderOpenGraphMetaTags() { + const { product, image } = this; + 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')}`; + } + + /** + * Create the Twitter meta tags + * @returns {string} + */ + renderTwitterMetaTags() { + const { product, image } = this; + 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')}`; + } + + /** + * Create the Commerce meta tags + * @returns {string} + */ + renderCommerceMetaTags() { + const { product } = this; + 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)}`; + } + + /** + * 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) => /* html */`\ +
+
${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}${HTMLTemplate.priceRange(prices.regular.minimumAmount, prices.regular.maximumAmount)}
+
Final: ${prices.final.amount} ${prices.final.currency}${HTMLTemplate.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) => /* html */`\ +
+
sku
+
${v.sku}
+
+
+
+ ${v.attributes?.map((attribute) => /* html */`\ +
+
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..745afbc --- /dev/null +++ b/src/templates/html/overrides/index.js @@ -0,0 +1,18 @@ +/* + * 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. + */ + +/** + * @type {Record} + */ +export default { + '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-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..c79ea0b --- /dev/null +++ b/src/templates/json/JSONTemplate.js @@ -0,0 +1,149 @@ +/* + * 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, matchedPatterns, confMap } = config; + const matchedPathConfig = confMap?.[matchedPatterns[0]]; + + const productPath = matchedPatterns[0] + .replace('{{urlkey}}', product.urlKey) + .replace('{{sku}}', encodeURIComponent(product.sku.toLowerCase())); + + const productUrl = `${host}${productPath}`; + + if (variant) { + const offerVariantURLTemplate = 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..1954ab1 --- /dev/null +++ b/src/templates/json/overrides/index.js @@ -0,0 +1,19 @@ +/* + * 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. + */ + +/** + * @type {Record} + */ +export default { + '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 new file mode 100644 index 0000000..96202f3 --- /dev/null +++ b/src/templates/json/overrides/thepixel--bul-eds.js @@ -0,0 +1,40 @@ +/* + * 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, matchedPatterns } = config; + + const productPath = matchedPatterns[0] + .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/overrides/wilson-ecommerce--wilson.js b/src/templates/json/overrides/wilson-ecommerce--wilson.js new file mode 100644 index 0000000..6c362ee --- /dev/null +++ b/src/templates/json/overrides/wilson-ecommerce--wilson.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'; + +export default class extends JSONTemplate { + // eslint-disable-next-line class-methods-use-this + 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 353de19..096f741 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,13 +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>; } @@ -55,10 +60,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 +163,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..d8b3193 100644 --- a/test/config.test.js +++ b/test/config.test.js @@ -35,9 +35,13 @@ 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', + siteKey: 'org--site', route: 'content', confMap: { '/us/p/{{urlkey}}/{{sku}}': { @@ -48,7 +52,6 @@ describe('config tests', () => { apiKey: 'bad', }, }, - siteOverrides: undefined, }); }); @@ -77,9 +80,13 @@ 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', + siteKey: 'org--site', route: 'content', confMap: { '/us/p/{{urlkey}}/{{sku}}': { @@ -98,7 +105,6 @@ describe('config tests', () => { }, }, }, - siteOverrides: undefined, }); }); @@ -119,9 +125,13 @@ describe('config tests', () => { apiKey: 'good', params: { sku: 'some-sku' }, headers: {}, + matchedPatterns: [ + '/us/p/*/{{sku}}', + ], pageType: 'product', org: 'org', site: 'site', + siteKey: 'org--site', route: 'content', confMap: { '/us/p/*/{{sku}}': { @@ -132,7 +142,6 @@ describe('config tests', () => { apiKey: 'bad', }, }, - siteOverrides: undefined, }); }); @@ -157,8 +166,12 @@ describe('config tests', () => { params: { sku: 'some-sku' }, pageType: 'product', headers: {}, + matchedPatterns: [ + '/us/p/{{sku}}', + ], org: 'org', site: 'site', + siteKey: 'org--site', route: 'content', confMap: { '/us/p/{{sku}}': { @@ -169,7 +182,6 @@ describe('config tests', () => { apiKey: 'bad1', }, }, - siteOverrides: undefined, }); }); 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/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 93% rename from test/template/html.test.js rename to test/templates/html/index.test.js index 2f74f5c..f9779e0 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) { @@ -37,9 +38,11 @@ describe('Render Product HTML', () => { variations = createDefaultVariations(); config = { host: 'https://example.com', - matchedPath: '/us/p/{{urlkey}}/{{sku}}', + matchedPatterns: ['/us/p/{{urlkey}}/{{sku}}'], + confMap: {}, }; - 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 +83,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 +96,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 +109,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; @@ -120,10 +123,12 @@ 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 = 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/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 b/test/utils/product.test.js deleted file mode 100644 index fd285c8..0000000 --- a/test/utils/product.test.js +++ /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 { 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 = { - 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'); - }); -});