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('') || 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 at line ${lineNumber}, position ${charPosition}` + }; + } + + const lastOpenTag = stack.pop(); + if (lastOpenTag !== tagName) { + return { + valid: false, + reason: `Mismatched tags: expected but found 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\n at line 1, position 33' + ); + }); + + test('logs multiple warnings when multiple fields contain invalid HTML', async () => { + server.use(handlers.defaultProductBadData()); + + const response = await action.main({ + STORE_URL: 'https://store.com', + CONTENT_URL: 'https://content.com', + CONFIG_NAME: 'config', + PRODUCT_PAGE_URL_FORMAT: '/products/{sku}', + __ow_path: `/products/24-MB03`, + }); + + expect(response.body).toBeDefined(); + expect(mockLoggerInstance.warn).toHaveBeenCalledTimes(2); + expect(mockLoggerInstance.warn).toHaveBeenCalledWith( + 'Validation failed for "shortDescription" field: Unclosed tags: div' + ); + expect(mockLoggerInstance.warn).toHaveBeenCalledWith( + 'Validation failed for "description" field: Unclosed tags: p' + ); + }); + + test('does not log warnings when HTML is valid', async () => { + server.use(handlers.defaultProductValidHtml()); + + const response = await action.main({ + STORE_URL: 'https://store.com', + CONTENT_URL: 'https://content.com', + CONFIG_NAME: 'config', + PRODUCT_PAGE_URL_FORMAT: '/products/{sku}', + __ow_path: `/products/24-MB03`, + }); + + expect(response.body).toBeDefined(); + expect(mockLoggerInstance.warn).not.toHaveBeenCalled(); + }); + + test('handles empty or undefined HTML fields gracefully', async () => { + server.use(handlers.defaultProductEmptyHtml()); + + const response = await action.main({ + STORE_URL: 'https://store.com', + CONTENT_URL: 'https://content.com', + CONFIG_NAME: 'config', + PRODUCT_PAGE_URL_FORMAT: '/products/{sku}', + __ow_path: `/products/24-MB03`, + }); + + expect(response.body).toBeDefined(); + expect(mockLoggerInstance.warn).toHaveBeenCalledTimes(2); + expect(mockLoggerInstance.warn).toHaveBeenCalledWith( + 'Validation failed for "shortDescription" field: Input must be a string' + ); + expect(mockLoggerInstance.warn).toHaveBeenCalledWith( + 'Validation failed for "description" field: Input must be a string' + ); + }); + }); }); }); diff --git a/test/validateHtml.test.js b/test/validateHtml.test.js new file mode 100644 index 00000000..ecba8e8b --- /dev/null +++ b/test/validateHtml.test.js @@ -0,0 +1,320 @@ +/* +Copyright 2025 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 this License. +*/ + +const { validateHtml } = require('../actions/lib/validateHtml'); + +describe('validateHtml', () => { + describe('input validation', () => { + test('should return error for non-string input', () => { + expect(validateHtml(null)).toEqual({ + valid: false, + reason: 'Input must be a string' + }); + + expect(validateHtml(undefined)).toEqual({ + valid: false, + reason: 'Input must be a string' + }); + + expect(validateHtml(123)).toEqual({ + valid: false, + reason: 'Input must be a string' + }); + + expect(validateHtml({})).toEqual({ + valid: false, + reason: 'Input must be a string' + }); + }); + + test('should return valid for empty string', () => { + expect(validateHtml('')).toEqual({ + valid: true, + reason: 'Empty string is valid' + }); + + expect(validateHtml(' ')).toEqual({ + valid: true, + reason: 'Empty string is valid' + }); + }); + }); + + describe('valid HTML', () => { + test('should validate simple HTML', () => { + expect(validateHtml('

Hello World

')).toEqual({ + valid: true, + reason: 'HTML is valid' + }); + }); + + test('should validate nested HTML', () => { + expect(validateHtml('

Hello World

')).toEqual({ + valid: true, + reason: 'HTML is valid' + }); + }); + + test('should validate complex nested HTML', () => { + const complexHtml = ` + + + Test Page + + +
+
+

Main Title

+ +
+
+
+

Article Title

+

Article content with emphasis and strong text.

+
+
+
+ + + `; + + expect(validateHtml(complexHtml)).toEqual({ + valid: true, + reason: 'HTML is valid' + }); + }); + + test('should validate HTML with attributes', () => { + expect(validateHtml('
Content
')).toEqual({ + valid: true, + reason: 'HTML is valid' + }); + }); + }); + + describe('self-closing tags', () => { + test('should validate self-closing tags', () => { + expect(validateHtml('Image')).toEqual({ + valid: true, + reason: 'HTML is valid' + }); + + expect(validateHtml('
')).toEqual({ + valid: true, + reason: 'HTML is valid' + }); + + expect(validateHtml('')).toEqual({ + valid: true, + reason: 'HTML is valid' + }); + }); + + test('should validate HTML with mixed self-closing and regular tags', () => { + expect(validateHtml('

Text

')).toEqual({ + valid: true, + reason: 'HTML is valid' + }); + }); + + test('should validate all self-closing tag types', () => { + const selfClosingTags = [ + 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', + 'link', 'meta', 'param', 'source', 'track', 'wbr' + ]; + + selfClosingTags.forEach(tag => { + expect(validateHtml(`<${tag} />`)).toEqual({ + valid: true, + reason: 'HTML is valid' + }); + }); + }); + }); + + describe('invalid HTML', () => { + test('should detect unclosed tags', () => { + expect(validateHtml('
Content')).toEqual({ + valid: false, + reason: 'Unclosed tags: div' + }); + + expect(validateHtml('

Content')).toEqual({ + valid: false, + reason: 'Unclosed tags: p, div' + }); + }); + + test('should detect mismatched tags', () => { + expect(validateHtml('

Content

')).toEqual({ + valid: false, + reason: 'Mismatched tags: expected
but found

at line 1, position 12' + }); + + expect(validateHtml('

Content

')).toEqual({ + valid: false, + reason: 'Mismatched tags: expected

but found
at line 1, position 15' + }); + }); + + test('should detect unexpected closing tags', () => { + expect(validateHtml('
')).toEqual({ + valid: false, + reason: 'Unexpected closing tag at line 1, position 0' + }); + + expect(validateHtml('Content

')).toEqual({ + valid: false, + reason: 'Unexpected closing tag

at line 1, position 7' + }); + }); + + test('should detect complex mismatched structure', () => { + const invalidHtml = ` +
+
+

Title

+
+
+

Content

+ +
+ `; + + expect(validateHtml(invalidHtml)).toEqual({ + valid: false, + reason: 'Mismatched tags: expected but found at line 5, position 10' + }); + }); + }); + + describe('line and position reporting', () => { + test('should report correct line numbers for single line', () => { + expect(validateHtml('
Content

')).toEqual({ + valid: false, + reason: 'Mismatched tags: expected
but found

at line 1, position 12' + }); + }); + + test('should report correct line numbers for multi-line', () => { + const multiLineHtml = ` +
+

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('

Content')).toEqual({ + valid: false, + reason: 'Mismatched tags: expected
but found at line 1, position 14' + }); + }); + }); + + describe('edge cases', () => { + test('should handle HTML with comments', () => { + expect(validateHtml('
Content
')).toEqual({ + valid: true, + reason: 'HTML is valid' + }); + }); + + test('should handle HTML with DOCTYPE', () => { + expect(validateHtml('Content')).toEqual({ + valid: true, + reason: 'HTML is valid' + }); + }); + + test('should handle HTML with script tags', () => { + expect(validateHtml('
')).toEqual({ + valid: true, + reason: 'HTML is valid' + }); + }); + + test('should handle HTML with style tags', () => { + expect(validateHtml('
')).toEqual({ + valid: true, + reason: 'HTML is valid' + }); + }); + + test('should handle HTML with special characters in attributes', () => { + expect(validateHtml('
Content
')).toEqual({ + valid: true, + reason: 'HTML is valid' + }); + }); + }); + + describe('real-world scenarios', () => { + test('should validate product description HTML', () => { + const productDescription = ` +
+

Product Features

+ +

This product is made with premium materials and designed for maximum comfort.

+
+ `; + + expect(validateHtml(productDescription)).toEqual({ + valid: true, + reason: 'HTML is valid' + }); + }); + + test('should validate meta description with HTML', () => { + const metaDescription = 'Product description with bold text and italic emphasis.'; + + expect(validateHtml(metaDescription)).toEqual({ + valid: true, + reason: 'HTML is valid' + }); + }); + + test('should detect issues in product description', () => { + const invalidDescription = ` +
+

Product Details

+

Product description here +

+ + `; + + expect(validateHtml(invalidDescription)).toEqual({ + valid: false, + reason: 'Mismatched tags: expected but found at line 8, position 10' + }); + }); + }); +});