Article Title
+Article content with emphasis and strong text.
+diff --git a/actions/lib/validateHtml.js b/actions/lib/validateHtml.js new file mode 100644 index 00000000..a4c475cc --- /dev/null +++ b/actions/lib/validateHtml.js @@ -0,0 +1,77 @@ +/** + * Validates HTML syntax by checking for balanced opening and closing tags + * @param {string} html - The HTML string to validate + * @returns {Object} - { valid: boolean, reason: string } + */ +function validateHtml(html) { + if (typeof html !== 'string') { + return { valid: false, reason: 'Input must be a string' }; + } + + if (html.trim() === '') { + return { valid: true, reason: 'Empty string is valid' }; + } + + const stack = []; + const selfClosingTags = new Set([ + 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', + 'link', 'meta', 'param', 'source', 'track', 'wbr' + ]); + + // Regular expression to match HTML tags + const tagRegex = /<\/?([a-zA-Z][a-zA-Z0-9]*)\b[^>]*>/g; + let match; + let lineNumber = 1; + let charPosition = 0; + + while ((match = tagRegex.exec(html)) !== null) { + const fullTag = match[0]; + const tagName = match[1].toLowerCase(); + const isClosingTag = fullTag.startsWith(''); + const isSelfClosing = fullTag.endsWith('/>') || selfClosingTags.has(tagName); + + // Calculate position for error reporting + const beforeMatch = html.substring(0, match.index); + lineNumber = beforeMatch.split('\n').length; + charPosition = match.index - beforeMatch.lastIndexOf('\n') - 1; + + if (isSelfClosing) { + // Self-closing tags don't need to be balanced + continue; + } + + if (isClosingTag) { + // Check if we have a matching opening tag + if (stack.length === 0) { + return { + valid: false, + reason: `Unexpected closing tag ${tagName}> at line ${lineNumber}, position ${charPosition}` + }; + } + + const lastOpenTag = stack.pop(); + if (lastOpenTag !== tagName) { + return { + valid: false, + reason: `Mismatched tags: expected ${lastOpenTag}> but found ${tagName}> at line ${lineNumber}, position ${charPosition}` + }; + } + } else { + // Opening tag - push onto stack + stack.push(tagName); + } + } + + // Check if any opening tags weren't closed + if (stack.length > 0) { + const unclosedTags = stack.reverse().join(', '); + return { + valid: false, + reason: `Unclosed tags: ${unclosedTags}` + }; + } + + return { valid: true, reason: 'HTML is valid' }; +} + +module.exports = { validateHtml } diff --git a/actions/pdp-renderer/render.js b/actions/pdp-renderer/render.js index 596dac3d..bbcc2620 100644 --- a/actions/pdp-renderer/render.js +++ b/actions/pdp-renderer/render.js @@ -5,10 +5,11 @@ const { findDescription, prepareBaseTemplate, getPrimaryImage, generatePriceStri const { generateLdJson } = require('./ldJson'); const { requestSaaS, getProductUrl } = require('../utils'); const { ProductQuery, ProductByUrlKeyQuery } = require('../queries'); +const { validateHtml } = require('../lib/validateHtml'); const productTemplateCache = {}; -function toTemplateProductData(baseProduct) { +function toTemplateProductData(baseProduct, context) { const templateProductData = { ...baseProduct }; const primaryImage = getPrimaryImage(baseProduct)?.url; @@ -20,6 +21,17 @@ function toTemplateProductData(baseProduct) { templateProductData.primaryImage = primaryImage; templateProductData.metaTitle = baseProduct.metaTitle || baseProduct.name || 'Product Details'; + const fieldValidations = { + shortDescription: validateHtml(templateProductData.shortDescription), + description: validateHtml(templateProductData.description) + } + + Object.entries(fieldValidations).forEach(([field, validation]) => { + if (!validation.valid) { + context.logger.warn(`Validation failed for "${field}" field: ${validation.reason}`); + } + }) + return templateProductData; } @@ -65,7 +77,7 @@ async function generateProductHtml(sku, urlKey, context) { } // Assign meta tag data for template - const templateProductData = toTemplateProductData(baseProduct); + const templateProductData = toTemplateProductData(baseProduct, context); // Generate LD-JSON const ldJson = await generateLdJson(baseProduct, context); diff --git a/actions/queries.js b/actions/queries.js index bb994a51..7a8f73e8 100644 --- a/actions/queries.js +++ b/actions/queries.js @@ -161,7 +161,7 @@ const CategoriesQuery = ` name level urlPath - } + } } `; @@ -194,7 +194,7 @@ const ProductsQuery = ` items { productView { urlKey - sku + sku } } page_info { @@ -214,4 +214,4 @@ module.exports = { CategoriesQuery, ProductCountQuery, ProductsQuery -}; \ No newline at end of file +}; diff --git a/test/mock-responses/mock-product.json b/test/mock-responses/mock-product.json index 2b148dab..288773b7 100644 --- a/test/mock-responses/mock-product.json +++ b/test/mock-responses/mock-product.json @@ -7,7 +7,7 @@ "sku": "24-MB03", "name": "Crown Summit Backpack", "url": "http://www.aemshop.net/crown-summit-backpack.html", - "description": "
The Crown Summit Backpack is equally at home in a gym locker, study cube or a pup tent, so be sure yours is packed with books, a bag lunch, water bottles, yoga block, laptop, or whatever else you want in hand. Rugged enough for day hikes and camping trips, it has two large zippered compartments and padded, adjustable shoulder straps.
\r\nThe Crown Summit Backpack is equally at home in a gym locker, study cube or a pup tent, so be sure yours is packed with books, a bag lunch, water bottles, yoga block, laptop, or whatever else you want in hand. Rugged enough for day hikes and camping trips, it has two large zippered compartments and padded, adjustable shoulder straps.
\r\nMismatched tags
Unclosed paragraph' + }; + return HttpResponse.json({ + data: { + products: [badProduct] + } + }); + }), + defaultProductValidHtml: (matcher) => graphql.query('ProductQuery', (req) => { + matcher?.(req); + // Create a product with valid HTML in all fields + const validProduct = { + ...mockProduct.data.products[0], + metaDescription: '
Valid paragraph
', + shortDescription: '
',
'
'
@@ -254,9 +255,9 @@ describe('pdp-renderer', () => {
test('render description', async () => {
const response = await getProductResponse();
-
+
const $ = cheerio.load(response.body);
-
+
expect($('body > main > div.product-details > div > div:contains("Description")').next().html().trim()).toMatchInlineSnapshot(`
"The Crown Summit Backpack is equally at home in a gym locker, study cube or a pup tent, so be sure yours is packed with books, a bag lunch, water bottles, yoga block, laptop, or whatever else you want in hand. Rugged enough for day hikes and camping trips, it has two large zippered compartments and padded, adjustable shoulder straps.
Hello World
')).toEqual({ + valid: true, + reason: 'HTML is valid' + }); + }); + + test('should validate nested HTML', () => { + expect(validateHtml('Hello World
Article content with emphasis and strong text.
+
')).toEqual({
+ valid: true,
+ reason: 'HTML is valid'
+ });
+
+ expect(validateHtml('
Text
Content')).toEqual({ + valid: false, + reason: 'Unclosed tags: p, div' + }); + }); + + test('should detect mismatched tags', () => { + expect(validateHtml('
Content
Content
+ +First paragraph
+Second paragraph
+Unclosed paragraph + `; + + expect(validateHtml(multiLineHtml)).toEqual({ + valid: false, + reason: 'Unclosed tags: p' + }); + }); + + test('should report correct position within line', () => { + expect(validateHtml('
This product is made with premium materials and designed for maximum comfort.
+Product description here +