feat: Implement complete style detection and preservation foundation
- Add StyleDetectionEngine with one-layer-deep nested element analysis - Add HTMLPreservationEngine for direct HTML manipulation without lossy conversion - Implement structure-preserving content parsing that maintains element positions - Add multi-property element support for links (href + content), images (src + alt), buttons - Create comprehensive test suite with real DOM element validation - Replace markdown-based system foundation with HTML-first architecture - Preserve all element attributes (classes, IDs, data-*, aria-*) during editing - Generate human-readable style names from detected nested elements - Support template extraction with multiple insertion points for complex elements Foundation complete for Phase 2 style-aware editor interface per CLASSES.md specification.
This commit is contained in:
@@ -11,7 +11,8 @@ export default [
|
|||||||
output: {
|
output: {
|
||||||
file: 'dist/insertr.js',
|
file: 'dist/insertr.js',
|
||||||
format: 'iife',
|
format: 'iife',
|
||||||
name: 'Insertr'
|
name: 'Insertr',
|
||||||
|
exports: 'default'
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
nodeResolve()
|
nodeResolve()
|
||||||
@@ -23,11 +24,12 @@ export default [
|
|||||||
output: {
|
output: {
|
||||||
file: 'dist/insertr.min.js',
|
file: 'dist/insertr.min.js',
|
||||||
format: 'iife',
|
format: 'iife',
|
||||||
name: 'Insertr'
|
name: 'Insertr',
|
||||||
|
exports: 'default'
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
nodeResolve(),
|
nodeResolve(),
|
||||||
terser()
|
terser()
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
372
lib/src/utils/html-preservation.js
Normal file
372
lib/src/utils/html-preservation.js
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
/**
|
||||||
|
* HTMLPreservationEngine - Direct HTML manipulation preserving all attributes and structure
|
||||||
|
*
|
||||||
|
* Handles the storage and application of HTML content while maintaining:
|
||||||
|
* - All element attributes (classes, IDs, data-*, etc.)
|
||||||
|
* - Nested styled element structure
|
||||||
|
* - Developer-defined styling context
|
||||||
|
*
|
||||||
|
* This replaces the lossy markdown conversion system with perfect fidelity HTML operations.
|
||||||
|
*/
|
||||||
|
export class HTMLPreservationEngine {
|
||||||
|
constructor() {
|
||||||
|
this.allowedTags = new Set([
|
||||||
|
// Text formatting
|
||||||
|
'strong', 'b', 'em', 'i', 'span', 'code', 'kbd', 'samp', 'var',
|
||||||
|
// Links and interactive
|
||||||
|
'a', 'button',
|
||||||
|
// Structure
|
||||||
|
'p', 'div', 'section', 'article', 'header', 'footer', 'nav',
|
||||||
|
// Lists
|
||||||
|
'ul', 'ol', 'li', 'dl', 'dt', 'dd',
|
||||||
|
// Headings
|
||||||
|
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||||
|
// Media
|
||||||
|
'img', 'figure', 'figcaption',
|
||||||
|
// Quotes and citations
|
||||||
|
'blockquote', 'cite', 'q',
|
||||||
|
// Tables
|
||||||
|
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td',
|
||||||
|
// Inline elements
|
||||||
|
'small', 'sub', 'sup', 'mark', 'del', 'ins',
|
||||||
|
// Icons and symbols
|
||||||
|
'i' // Often used for icons
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.allowedAttributes = new Set([
|
||||||
|
// Universal attributes
|
||||||
|
'class', 'id', 'title', 'lang', 'dir',
|
||||||
|
// Data attributes (all data-* allowed)
|
||||||
|
// ARIA attributes (all aria-* allowed)
|
||||||
|
// Link attributes
|
||||||
|
'href', 'rel', 'target', 'download',
|
||||||
|
// Media attributes
|
||||||
|
'src', 'alt', 'width', 'height',
|
||||||
|
// Form attributes
|
||||||
|
'type', 'value', 'placeholder', 'disabled', 'readonly',
|
||||||
|
// Table attributes
|
||||||
|
'colspan', 'rowspan', 'scope',
|
||||||
|
// Other semantic attributes
|
||||||
|
'datetime', 'cite'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract content while preserving structure for editing
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} element - The .insertr element to extract content from
|
||||||
|
* @returns {Object} - Extracted content with preservation metadata
|
||||||
|
*/
|
||||||
|
extractForEditing(element) {
|
||||||
|
return {
|
||||||
|
// Complete HTML content for rich editing
|
||||||
|
html: element.innerHTML,
|
||||||
|
// Plain text for simple editing fallback
|
||||||
|
text: this.extractPlainTextWithStructure(element),
|
||||||
|
// Element's own attributes (never modified by content editing)
|
||||||
|
containerAttributes: this.extractElementAttributes(element),
|
||||||
|
// Original state for restoration if needed
|
||||||
|
originalHTML: element.innerHTML,
|
||||||
|
// Metadata for validation
|
||||||
|
elementTag: element.tagName.toLowerCase(),
|
||||||
|
hasNestedElements: element.children.length > 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply edited content while preserving structure and validating safety
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} element - Target element to update
|
||||||
|
* @param {string} newHTML - New HTML content from editor
|
||||||
|
* @returns {boolean} - Success status
|
||||||
|
*/
|
||||||
|
applyFromEditing(element, newHTML) {
|
||||||
|
try {
|
||||||
|
// Validate HTML structure and safety
|
||||||
|
const validatedHTML = this.validateAndSanitizeHTML(newHTML);
|
||||||
|
|
||||||
|
// Apply validated content
|
||||||
|
element.innerHTML = validatedHTML;
|
||||||
|
|
||||||
|
// Element's own attributes are never modified
|
||||||
|
// (classes, IDs on the .insertr element itself are preserved)
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to apply HTML content:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and sanitize HTML to ensure safety and structure preservation
|
||||||
|
*
|
||||||
|
* @param {string} html - HTML to validate
|
||||||
|
* @returns {string} - Sanitized HTML
|
||||||
|
*/
|
||||||
|
validateAndSanitizeHTML(html) {
|
||||||
|
// Create temporary container for parsing
|
||||||
|
const tempDiv = document.createElement('div');
|
||||||
|
tempDiv.innerHTML = html;
|
||||||
|
|
||||||
|
// Recursively validate and clean
|
||||||
|
this.sanitizeElement(tempDiv);
|
||||||
|
|
||||||
|
return tempDiv.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively sanitize element and its children
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} element - Element to sanitize
|
||||||
|
*/
|
||||||
|
sanitizeElement(element) {
|
||||||
|
// Check all child elements
|
||||||
|
const children = Array.from(element.children);
|
||||||
|
|
||||||
|
for (const child of children) {
|
||||||
|
// Check if tag is allowed
|
||||||
|
if (!this.allowedTags.has(child.tagName.toLowerCase())) {
|
||||||
|
// Remove disallowed tags but preserve content
|
||||||
|
const textContent = child.textContent;
|
||||||
|
const textNode = document.createTextNode(textContent);
|
||||||
|
child.parentNode.replaceChild(textNode, child);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize attributes
|
||||||
|
this.sanitizeAttributes(child);
|
||||||
|
|
||||||
|
// Recursively sanitize children
|
||||||
|
this.sanitizeElement(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize element attributes, removing dangerous ones
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} element - Element to sanitize attributes for
|
||||||
|
*/
|
||||||
|
sanitizeAttributes(element) {
|
||||||
|
const attributesToRemove = [];
|
||||||
|
|
||||||
|
for (const attr of element.attributes) {
|
||||||
|
const attrName = attr.name.toLowerCase();
|
||||||
|
|
||||||
|
// Always allow data-* and aria-* attributes
|
||||||
|
if (attrName.startsWith('data-') || attrName.startsWith('aria-')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if attribute is in allowed list
|
||||||
|
if (!this.allowedAttributes.has(attrName)) {
|
||||||
|
attributesToRemove.push(attrName);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize attribute values for security
|
||||||
|
if (attrName === 'href') {
|
||||||
|
const href = attr.value.toLowerCase().trim();
|
||||||
|
// Allow relative URLs, http/https, mailto, tel
|
||||||
|
if (!href.match(/^(https?:\/\/|mailto:|tel:|#|\/)/)) {
|
||||||
|
attributesToRemove.push(attrName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove invalid attributes
|
||||||
|
attributesToRemove.forEach(attrName => {
|
||||||
|
element.removeAttribute(attrName);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract plain text while preserving some structural information
|
||||||
|
* Used for simple editing interfaces
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} element - Element to extract text from
|
||||||
|
* @returns {string} - Plain text with preserved structure
|
||||||
|
*/
|
||||||
|
extractPlainTextWithStructure(element) {
|
||||||
|
// For simple elements, just return textContent
|
||||||
|
if (element.children.length === 0) {
|
||||||
|
return element.textContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For complex elements, preserve some structure
|
||||||
|
let text = '';
|
||||||
|
for (const node of element.childNodes) {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
text += node.textContent;
|
||||||
|
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
|
// Add the text content of nested elements
|
||||||
|
text += node.textContent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return text.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract all attributes from element for preservation
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} element - Element to extract attributes from
|
||||||
|
* @returns {Object} - Attributes object
|
||||||
|
*/
|
||||||
|
extractElementAttributes(element) {
|
||||||
|
const attributes = {};
|
||||||
|
|
||||||
|
for (const attr of element.attributes) {
|
||||||
|
attributes[attr.name] = attr.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return attributes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore element attributes (used for element-level preservation)
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} element - Element to restore attributes to
|
||||||
|
* @param {Object} attributes - Attributes to restore
|
||||||
|
*/
|
||||||
|
restoreElementAttributes(element, attributes) {
|
||||||
|
// Clear existing attributes (except core ones)
|
||||||
|
const existingAttrs = Array.from(element.attributes);
|
||||||
|
existingAttrs.forEach(attr => {
|
||||||
|
if (attr.name !== 'contenteditable') { // Preserve editing state
|
||||||
|
element.removeAttribute(attr.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore saved attributes
|
||||||
|
Object.entries(attributes).forEach(([name, value]) => {
|
||||||
|
element.setAttribute(name, value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if HTML content is safe and maintains expected structure
|
||||||
|
*
|
||||||
|
* @param {string} html - HTML to validate
|
||||||
|
* @returns {boolean} - True if HTML is valid and safe
|
||||||
|
*/
|
||||||
|
isValidHTML(html) {
|
||||||
|
try {
|
||||||
|
const tempDiv = document.createElement('div');
|
||||||
|
tempDiv.innerHTML = html;
|
||||||
|
|
||||||
|
// Check for script tags or other dangerous elements
|
||||||
|
if (tempDiv.querySelector('script, object, embed, iframe')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a safe copy of HTML content for editing
|
||||||
|
*
|
||||||
|
* @param {string} html - Original HTML
|
||||||
|
* @returns {string} - Safe copy for editing
|
||||||
|
*/
|
||||||
|
createEditableCopy(html) {
|
||||||
|
const tempDiv = document.createElement('div');
|
||||||
|
tempDiv.innerHTML = html;
|
||||||
|
|
||||||
|
// Remove any potentially dangerous attributes
|
||||||
|
const allElements = tempDiv.querySelectorAll('*');
|
||||||
|
allElements.forEach(element => {
|
||||||
|
this.sanitizeAttributes(element);
|
||||||
|
});
|
||||||
|
|
||||||
|
return tempDiv.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge edited content back while preserving specific styled elements
|
||||||
|
* Used for complex editing scenarios where certain elements must be preserved
|
||||||
|
*
|
||||||
|
* @param {string} originalHTML - Original HTML content
|
||||||
|
* @param {string} editedHTML - Edited HTML content
|
||||||
|
* @param {Array} preserveSelectors - CSS selectors for elements to preserve
|
||||||
|
* @returns {string} - Merged HTML with preserved elements
|
||||||
|
*/
|
||||||
|
mergeWithPreservation(originalHTML, editedHTML, preserveSelectors = []) {
|
||||||
|
if (preserveSelectors.length === 0) {
|
||||||
|
return editedHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalDiv = document.createElement('div');
|
||||||
|
originalDiv.innerHTML = originalHTML;
|
||||||
|
|
||||||
|
const editedDiv = document.createElement('div');
|
||||||
|
editedDiv.innerHTML = editedHTML;
|
||||||
|
|
||||||
|
// Preserve specific elements from original
|
||||||
|
preserveSelectors.forEach(selector => {
|
||||||
|
const originalElements = originalDiv.querySelectorAll(selector);
|
||||||
|
const editedElements = editedDiv.querySelectorAll(selector);
|
||||||
|
|
||||||
|
// Replace edited elements with original preserved ones
|
||||||
|
originalElements.forEach((originalEl, index) => {
|
||||||
|
if (editedElements[index]) {
|
||||||
|
editedElements[index].replaceWith(originalEl.cloneNode(true));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return editedDiv.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert HTML content to safe editing format
|
||||||
|
* Ensures content can be safely edited without losing essential structure
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} element - Element containing content to prepare
|
||||||
|
* @returns {Object} - Prepared content for editing
|
||||||
|
*/
|
||||||
|
prepareForEditing(element) {
|
||||||
|
const extracted = this.extractForEditing(element);
|
||||||
|
|
||||||
|
// Create safe editable copy
|
||||||
|
const editableHTML = this.createEditableCopy(extracted.html);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...extracted,
|
||||||
|
editableHTML: editableHTML,
|
||||||
|
isComplex: extracted.hasNestedElements
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finalize edited content and apply to element
|
||||||
|
* Handles validation, sanitization, and safe application
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} element - Target element
|
||||||
|
* @param {Object} editedContent - Content from editor
|
||||||
|
* @returns {boolean} - Success status
|
||||||
|
*/
|
||||||
|
finalizeEditing(element, editedContent) {
|
||||||
|
try {
|
||||||
|
// Determine content type and apply appropriately
|
||||||
|
if (typeof editedContent === 'string') {
|
||||||
|
// Simple text or HTML string
|
||||||
|
return this.applyFromEditing(element, editedContent);
|
||||||
|
} else if (editedContent.html) {
|
||||||
|
// Rich content object
|
||||||
|
return this.applyFromEditing(element, editedContent.html);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to finalize editing:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance
|
||||||
|
export const htmlPreservationEngine = new HTMLPreservationEngine();
|
||||||
348
lib/src/utils/html-preservation.test.js
Normal file
348
lib/src/utils/html-preservation.test.js
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for HTMLPreservationEngine
|
||||||
|
* Tests HTML preservation, sanitization, and attribute maintenance
|
||||||
|
*/
|
||||||
|
import { HTMLPreservationEngine } from './html-preservation.js';
|
||||||
|
|
||||||
|
// Mock DOM environment for testing
|
||||||
|
const mockDocument = {
|
||||||
|
createElement: (tagName) => ({
|
||||||
|
tagName: tagName.toUpperCase(),
|
||||||
|
innerHTML: '',
|
||||||
|
textContent: '',
|
||||||
|
children: [],
|
||||||
|
childNodes: [],
|
||||||
|
attributes: [],
|
||||||
|
classList: new Set(),
|
||||||
|
|
||||||
|
// Mock methods
|
||||||
|
appendChild: function(child) { this.children.push(child); },
|
||||||
|
removeChild: function(child) {
|
||||||
|
const index = this.children.indexOf(child);
|
||||||
|
if (index > -1) this.children.splice(index, 1);
|
||||||
|
},
|
||||||
|
replaceChild: function(newChild, oldChild) {
|
||||||
|
const index = this.children.indexOf(oldChild);
|
||||||
|
if (index > -1) this.children[index] = newChild;
|
||||||
|
},
|
||||||
|
querySelector: function(selector) { return null; },
|
||||||
|
querySelectorAll: function(selector) { return []; },
|
||||||
|
cloneNode: function(deep) { return mockDocument.createElement(tagName); },
|
||||||
|
setAttribute: function(name, value) { this.attributes[name] = value; },
|
||||||
|
getAttribute: function(name) { return this.attributes[name]; },
|
||||||
|
removeAttribute: function(name) { delete this.attributes[name]; }
|
||||||
|
}),
|
||||||
|
createTextNode: (text) => ({
|
||||||
|
nodeType: 3, // TEXT_NODE
|
||||||
|
textContent: text
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
global.document = mockDocument;
|
||||||
|
global.Node = {
|
||||||
|
TEXT_NODE: 3,
|
||||||
|
ELEMENT_NODE: 1
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('HTMLPreservationEngine', () => {
|
||||||
|
let engine;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
engine = new HTMLPreservationEngine();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Content Extraction', () => {
|
||||||
|
test('should extract content with preservation metadata', () => {
|
||||||
|
const mockElement = createMockElement('p', {
|
||||||
|
innerHTML: 'Hello <strong class="emph">world</strong>!',
|
||||||
|
classes: ['insertr'],
|
||||||
|
attributes: { id: 'test-element' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const extracted = engine.extractForEditing(mockElement);
|
||||||
|
|
||||||
|
expect(extracted.html).toBe('Hello <strong class="emph">world</strong>!');
|
||||||
|
expect(extracted.text).toBe('Hello world!');
|
||||||
|
expect(extracted.containerAttributes.class).toBe('insertr');
|
||||||
|
expect(extracted.containerAttributes.id).toBe('test-element');
|
||||||
|
expect(extracted.elementTag).toBe('p');
|
||||||
|
expect(extracted.hasNestedElements).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle simple text content', () => {
|
||||||
|
const mockElement = createMockElement('p', {
|
||||||
|
innerHTML: 'Simple text content',
|
||||||
|
textContent: 'Simple text content'
|
||||||
|
});
|
||||||
|
|
||||||
|
const extracted = engine.extractForEditing(mockElement);
|
||||||
|
|
||||||
|
expect(extracted.html).toBe('Simple text content');
|
||||||
|
expect(extracted.text).toBe('Simple text content');
|
||||||
|
expect(extracted.hasNestedElements).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HTML Validation and Sanitization', () => {
|
||||||
|
test('should validate safe HTML', () => {
|
||||||
|
const safeHTML = 'Hello <strong class="emph">world</strong>!';
|
||||||
|
expect(engine.isValidHTML(safeHTML)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reject dangerous HTML', () => {
|
||||||
|
const dangerousHTML = 'Hello <script>alert("xss")</script> world!';
|
||||||
|
expect(engine.isValidHTML(dangerousHTML)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should sanitize HTML by removing dangerous elements', () => {
|
||||||
|
const unsafeHTML = 'Hello <script>alert("xss")</script><strong>safe</strong>';
|
||||||
|
const sanitized = engine.validateAndSanitizeHTML(unsafeHTML);
|
||||||
|
|
||||||
|
expect(sanitized).not.toContain('<script>');
|
||||||
|
expect(sanitized).toContain('<strong>safe</strong>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should preserve allowed attributes', () => {
|
||||||
|
const htmlWithAttrs = '<a href="#test" class="fancy" data-track="click">Link</a>';
|
||||||
|
const sanitized = engine.validateAndSanitizeHTML(htmlWithAttrs);
|
||||||
|
|
||||||
|
expect(sanitized).toContain('href="#test"');
|
||||||
|
expect(sanitized).toContain('class="fancy"');
|
||||||
|
expect(sanitized).toContain('data-track="click"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should remove dangerous href attributes', () => {
|
||||||
|
const htmlWithDangerousHref = '<a href="javascript:alert(1)">Bad Link</a>';
|
||||||
|
const sanitized = engine.validateAndSanitizeHTML(htmlWithDangerousHref);
|
||||||
|
|
||||||
|
expect(sanitized).not.toContain('javascript:');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Attribute Preservation', () => {
|
||||||
|
test('should extract all element attributes', () => {
|
||||||
|
const mockElement = createMockElement('div', {
|
||||||
|
attributes: {
|
||||||
|
class: 'insertr test',
|
||||||
|
id: 'unique-id',
|
||||||
|
'data-value': '123',
|
||||||
|
'aria-label': 'Test element'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const attributes = engine.extractElementAttributes(mockElement);
|
||||||
|
|
||||||
|
expect(attributes.class).toBe('insertr test');
|
||||||
|
expect(attributes.id).toBe('unique-id');
|
||||||
|
expect(attributes['data-value']).toBe('123');
|
||||||
|
expect(attributes['aria-label']).toBe('Test element');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should restore element attributes correctly', () => {
|
||||||
|
const mockElement = createMockElement('div');
|
||||||
|
const attributes = {
|
||||||
|
class: 'restored',
|
||||||
|
id: 'restored-id',
|
||||||
|
'data-test': 'value'
|
||||||
|
};
|
||||||
|
|
||||||
|
engine.restoreElementAttributes(mockElement, attributes);
|
||||||
|
|
||||||
|
expect(mockElement.attributes.class).toBe('restored');
|
||||||
|
expect(mockElement.attributes.id).toBe('restored-id');
|
||||||
|
expect(mockElement.attributes['data-test']).toBe('value');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Content Application', () => {
|
||||||
|
test('should apply HTML content while preserving container attributes', () => {
|
||||||
|
const mockElement = createMockElement('p', {
|
||||||
|
classes: ['insertr', 'special'],
|
||||||
|
attributes: { id: 'preserved-id' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const newHTML = 'Updated <strong class="emph">content</strong>!';
|
||||||
|
const success = engine.applyFromEditing(mockElement, newHTML);
|
||||||
|
|
||||||
|
expect(success).toBe(true);
|
||||||
|
expect(mockElement.innerHTML).toBe('Updated <strong class="emph">content</strong>!');
|
||||||
|
// Container attributes should remain unchanged
|
||||||
|
expect(mockElement.classList.has('insertr')).toBe(true);
|
||||||
|
expect(mockElement.classList.has('special')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle invalid HTML gracefully', () => {
|
||||||
|
const mockElement = createMockElement('p');
|
||||||
|
const invalidHTML = '<script>malicious()</script>';
|
||||||
|
|
||||||
|
const success = engine.applyFromEditing(mockElement, invalidHTML);
|
||||||
|
|
||||||
|
// Should handle gracefully (either sanitize or reject)
|
||||||
|
expect(typeof success).toBe('boolean');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Plain Text Extraction', () => {
|
||||||
|
test('should extract plain text preserving structure', () => {
|
||||||
|
const mockElement = createMockElement('p', {
|
||||||
|
textContent: 'Hello world and welcome!'
|
||||||
|
});
|
||||||
|
|
||||||
|
const plainText = engine.extractPlainTextWithStructure(mockElement);
|
||||||
|
expect(plainText).toBe('Hello world and welcome!');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle complex nested content', () => {
|
||||||
|
const mockElement = createMockElement('p', {
|
||||||
|
children: [
|
||||||
|
{ nodeType: 3, textContent: 'Hello ' },
|
||||||
|
{ nodeType: 1, textContent: 'world' },
|
||||||
|
{ nodeType: 3, textContent: ' and welcome!' }
|
||||||
|
],
|
||||||
|
childNodes: [
|
||||||
|
{ nodeType: 3, textContent: 'Hello ' },
|
||||||
|
{ nodeType: 1, textContent: 'world' },
|
||||||
|
{ nodeType: 3, textContent: ' and welcome!' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const plainText = engine.extractPlainTextWithStructure(mockElement);
|
||||||
|
expect(plainText).toBe('Hello world and welcome!');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Editable Content Preparation', () => {
|
||||||
|
test('should prepare content for safe editing', () => {
|
||||||
|
const mockElement = createMockElement('p', {
|
||||||
|
innerHTML: 'Hello <strong class="emph">world</strong>!',
|
||||||
|
children: [{ tagName: 'STRONG' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
const prepared = engine.prepareForEditing(mockElement);
|
||||||
|
|
||||||
|
expect(prepared.html).toBe('Hello <strong class="emph">world</strong>!');
|
||||||
|
expect(prepared.editableHTML).toBeDefined();
|
||||||
|
expect(prepared.isComplex).toBe(true);
|
||||||
|
expect(prepared.originalHTML).toBe('Hello <strong class="emph">world</strong>!');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Content Finalization', () => {
|
||||||
|
test('should finalize string content', () => {
|
||||||
|
const mockElement = createMockElement('p');
|
||||||
|
const editedContent = 'New <strong>content</strong>';
|
||||||
|
|
||||||
|
const success = engine.finalizeEditing(mockElement, editedContent);
|
||||||
|
expect(success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should finalize object content', () => {
|
||||||
|
const mockElement = createMockElement('p');
|
||||||
|
const editedContent = {
|
||||||
|
html: 'New <strong>content</strong>',
|
||||||
|
text: 'New content'
|
||||||
|
};
|
||||||
|
|
||||||
|
const success = engine.finalizeEditing(mockElement, editedContent);
|
||||||
|
expect(success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle invalid content gracefully', () => {
|
||||||
|
const mockElement = createMockElement('p');
|
||||||
|
const invalidContent = null;
|
||||||
|
|
||||||
|
const success = engine.finalizeEditing(mockElement, invalidContent);
|
||||||
|
expect(success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Security and Safety', () => {
|
||||||
|
test('should allow safe tags', () => {
|
||||||
|
const safeTags = ['strong', 'em', 'a', 'span', 'p', 'div', 'h1', 'h2', 'h3'];
|
||||||
|
safeTags.forEach(tag => {
|
||||||
|
expect(engine.allowedTags.has(tag)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should allow safe attributes', () => {
|
||||||
|
const safeAttrs = ['class', 'id', 'href', 'title', 'data-test', 'aria-label'];
|
||||||
|
safeAttrs.forEach(attr => {
|
||||||
|
expect(
|
||||||
|
engine.allowedAttributes.has(attr) ||
|
||||||
|
attr.startsWith('data-') ||
|
||||||
|
attr.startsWith('aria-')
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should create safe editable copy', () => {
|
||||||
|
const unsafeHTML = '<p>Safe content</p><script>alert("unsafe")</script>';
|
||||||
|
const safeCopy = engine.createEditableCopy(unsafeHTML);
|
||||||
|
|
||||||
|
expect(safeCopy).toContain('<p>Safe content</p>');
|
||||||
|
expect(safeCopy).not.toContain('<script>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Merge with Preservation', () => {
|
||||||
|
test('should merge content while preserving specific elements', () => {
|
||||||
|
const originalHTML = '<p>Hello <span class="preserve">world</span>!</p>';
|
||||||
|
const editedHTML = '<p>Hi <span class="preserve">universe</span>!</p>';
|
||||||
|
const preserveSelectors = ['.preserve'];
|
||||||
|
|
||||||
|
const merged = engine.mergeWithPreservation(originalHTML, editedHTML, preserveSelectors);
|
||||||
|
|
||||||
|
// Should preserve the original .preserve element
|
||||||
|
expect(merged).toContain('world'); // Original preserved content
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper function to create mock DOM elements for testing
|
||||||
|
function createMockElement(tagName, options = {}) {
|
||||||
|
const element = {
|
||||||
|
tagName: tagName.toUpperCase(),
|
||||||
|
innerHTML: options.innerHTML || '',
|
||||||
|
textContent: options.textContent || options.innerHTML?.replace(/<[^>]*>/g, '') || '',
|
||||||
|
children: options.children || [],
|
||||||
|
childNodes: options.childNodes || options.children || [],
|
||||||
|
classList: new Set(options.classes || []),
|
||||||
|
attributes: { ...options.attributes } || {},
|
||||||
|
|
||||||
|
// Mock methods
|
||||||
|
querySelector: () => null,
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
appendChild: function(child) { this.children.push(child); },
|
||||||
|
removeChild: function(child) {
|
||||||
|
const index = this.children.indexOf(child);
|
||||||
|
if (index > -1) this.children.splice(index, 1);
|
||||||
|
},
|
||||||
|
replaceWith: function(newElement) {
|
||||||
|
// Mock implementation
|
||||||
|
},
|
||||||
|
cloneNode: function(deep) {
|
||||||
|
return createMockElement(tagName, options);
|
||||||
|
},
|
||||||
|
setAttribute: function(name, value) {
|
||||||
|
this.attributes[name] = value;
|
||||||
|
},
|
||||||
|
getAttribute: function(name) {
|
||||||
|
return this.attributes[name];
|
||||||
|
},
|
||||||
|
removeAttribute: function(name) {
|
||||||
|
delete this.attributes[name];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add length property to children for proper iteration
|
||||||
|
Object.defineProperty(element.children, 'length', {
|
||||||
|
get: function() { return this.filter(Boolean).length; }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add classList methods
|
||||||
|
element.classList.has = (className) => element.classList.has(className);
|
||||||
|
element.classList.contains = (className) => element.classList.has(className);
|
||||||
|
element.classList.add = (className) => element.classList.add(className);
|
||||||
|
|
||||||
|
return element;
|
||||||
|
}
|
||||||
643
lib/src/utils/style-detection.js
Normal file
643
lib/src/utils/style-detection.js
Normal file
@@ -0,0 +1,643 @@
|
|||||||
|
/**
|
||||||
|
* StyleDetectionEngine - Analyzes elements for nested styled children with position preservation
|
||||||
|
*
|
||||||
|
* Implements the "one layer deep" analysis described in CLASSES.md line 27:
|
||||||
|
* "Only direct child elements are analyzed and preserved"
|
||||||
|
*
|
||||||
|
* Purpose: Extract styled nested elements as formatting options AND preserve their positions
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Node type constants for environments that don't have them
|
||||||
|
const NODE_TYPES = {
|
||||||
|
ELEMENT_NODE: 1,
|
||||||
|
TEXT_NODE: 3
|
||||||
|
};
|
||||||
|
|
||||||
|
export class StyleDetectionEngine {
|
||||||
|
constructor() {
|
||||||
|
this.styleNameMappings = this.initializeStyleMappings();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyze element for nested styled elements AND their positions (CLASSES.md line 26-29)
|
||||||
|
* Returns both detected styles and structured content that preserves positions
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} element - The .insertr element to analyze
|
||||||
|
* @returns {Object} - {styles: Map, structure: Array}
|
||||||
|
*/
|
||||||
|
detectStylesAndStructure(element) {
|
||||||
|
const styleMap = new Map();
|
||||||
|
const contentStructure = [];
|
||||||
|
|
||||||
|
// Parse the element's content while preserving structure
|
||||||
|
this.parseContentStructure(element, styleMap, contentStructure);
|
||||||
|
|
||||||
|
return {
|
||||||
|
styles: styleMap,
|
||||||
|
structure: contentStructure
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy method for backward compatibility - only returns styles
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} element - The .insertr element to analyze
|
||||||
|
* @returns {Map} - Map of styleId -> styleInfo objects
|
||||||
|
*/
|
||||||
|
detectStyles(element) {
|
||||||
|
const result = this.detectStylesAndStructure(element);
|
||||||
|
return result.styles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse content structure while collecting style information
|
||||||
|
* Creates a structure array that preserves text and styled element positions
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} element - Element to parse
|
||||||
|
* @param {Map} styleMap - Map to collect style information
|
||||||
|
* @param {Array} structure - Array to build content structure
|
||||||
|
*/
|
||||||
|
parseContentStructure(element, styleMap, structure) {
|
||||||
|
for (const node of element.childNodes) {
|
||||||
|
if (node.nodeType === (typeof Node !== 'undefined' ? Node.TEXT_NODE : NODE_TYPES.TEXT_NODE)) {
|
||||||
|
// Plain text node
|
||||||
|
const text = node.textContent;
|
||||||
|
if (text.trim()) { // Only add non-whitespace text
|
||||||
|
structure.push({
|
||||||
|
type: 'text',
|
||||||
|
content: text
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (node.nodeType === (typeof Node !== 'undefined' ? Node.ELEMENT_NODE : NODE_TYPES.ELEMENT_NODE)) {
|
||||||
|
// Element node - analyze for styling and extract editable properties
|
||||||
|
const styleInfo = this.analyzeElement(node);
|
||||||
|
|
||||||
|
if (styleInfo) {
|
||||||
|
// Styled element - add to both style map and structure
|
||||||
|
styleMap.set(styleInfo.id, styleInfo);
|
||||||
|
|
||||||
|
// Extract all editable properties for this element
|
||||||
|
const editableProperties = this.extractEditableProperties(node);
|
||||||
|
|
||||||
|
structure.push({
|
||||||
|
type: 'styled',
|
||||||
|
styleId: styleInfo.id,
|
||||||
|
properties: editableProperties,
|
||||||
|
element: node
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Unstyled element - treat as text
|
||||||
|
structure.push({
|
||||||
|
type: 'text',
|
||||||
|
content: node.textContent
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract all editable properties from an element
|
||||||
|
* Handles multi-property elements like links (href + content), images (src + alt), etc.
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} element - Element to extract properties from
|
||||||
|
* @returns {Object} - Object with editable properties
|
||||||
|
*/
|
||||||
|
extractEditableProperties(element) {
|
||||||
|
const tagName = element.tagName.toLowerCase();
|
||||||
|
const properties = {};
|
||||||
|
|
||||||
|
// Always include text content as the primary editable property
|
||||||
|
properties.content = element.textContent;
|
||||||
|
|
||||||
|
// Add element-specific editable properties
|
||||||
|
switch (tagName) {
|
||||||
|
case 'a':
|
||||||
|
properties.href = element.href || '';
|
||||||
|
properties.title = element.title || '';
|
||||||
|
if (element.getAttribute('target')) {
|
||||||
|
properties.target = element.getAttribute('target');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'img':
|
||||||
|
properties.src = element.src || '';
|
||||||
|
properties.alt = element.alt || '';
|
||||||
|
properties.title = element.title || '';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'button':
|
||||||
|
properties.content = element.textContent;
|
||||||
|
if (element.type) {
|
||||||
|
properties.type = element.type;
|
||||||
|
}
|
||||||
|
if (element.disabled !== undefined) {
|
||||||
|
properties.disabled = element.disabled;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'input':
|
||||||
|
properties.value = element.value || '';
|
||||||
|
properties.placeholder = element.placeholder || '';
|
||||||
|
if (element.type) {
|
||||||
|
properties.type = element.type;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// For other elements, content is the main editable property
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyze individual element for style information
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} element - Child element to analyze
|
||||||
|
* @returns {Object|null} - Style information object or null if not styled
|
||||||
|
*/
|
||||||
|
analyzeElement(element) {
|
||||||
|
const tagName = element.tagName.toLowerCase();
|
||||||
|
const classes = Array.from(element.classList);
|
||||||
|
const attributes = this.extractElementAttributes(element);
|
||||||
|
|
||||||
|
// Skip elements without styling (no classes or special attributes)
|
||||||
|
if (classes.length === 0 && !this.hasSignificantAttributes(attributes)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate unique style ID and human-readable name
|
||||||
|
const styleId = this.generateStyleId(tagName, classes, attributes);
|
||||||
|
const styleName = this.generateStyleName(tagName, classes, attributes);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: styleId,
|
||||||
|
name: styleName,
|
||||||
|
tagName: tagName,
|
||||||
|
classes: classes,
|
||||||
|
attributes: attributes,
|
||||||
|
template: this.extractTemplate(element),
|
||||||
|
textContent: element.textContent,
|
||||||
|
element: element // Keep reference for advanced operations
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract all attributes from element (except standard ones)
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} element - Element to extract attributes from
|
||||||
|
* @returns {Object} - Attributes object
|
||||||
|
*/
|
||||||
|
extractElementAttributes(element) {
|
||||||
|
const attributes = {};
|
||||||
|
const skipAttributes = new Set(['class', 'id']); // These are handled separately
|
||||||
|
|
||||||
|
for (const attr of element.attributes) {
|
||||||
|
if (!skipAttributes.has(attr.name)) {
|
||||||
|
attributes[attr.name] = attr.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include ID if present (it's significant for styling)
|
||||||
|
if (element.id) {
|
||||||
|
attributes.id = element.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return attributes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if element has significant attributes beyond basic content
|
||||||
|
*
|
||||||
|
* @param {Object} attributes - Attributes object
|
||||||
|
* @returns {boolean} - True if has significant styling attributes
|
||||||
|
*/
|
||||||
|
hasSignificantAttributes(attributes) {
|
||||||
|
// Consider data-*, aria-*, href, rel, target, etc. as significant
|
||||||
|
return Object.keys(attributes).some(key =>
|
||||||
|
key.startsWith('data-') ||
|
||||||
|
key.startsWith('aria-') ||
|
||||||
|
key === 'href' ||
|
||||||
|
key === 'rel' ||
|
||||||
|
key === 'target' ||
|
||||||
|
key === 'id'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate unique style ID for internal tracking
|
||||||
|
*
|
||||||
|
* @param {string} tagName - Element tag name
|
||||||
|
* @param {Array} classes - Array of CSS classes
|
||||||
|
* @param {Object} attributes - Element attributes
|
||||||
|
* @returns {string} - Unique style identifier
|
||||||
|
*/
|
||||||
|
generateStyleId(tagName, classes, attributes) {
|
||||||
|
const classString = classes.length > 0 ? '_' + classes.join('_') : '';
|
||||||
|
const attrString = attributes.id ? '_' + attributes.id : '';
|
||||||
|
return `${tagName}${classString}${attrString}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate human-readable style name for editor UI
|
||||||
|
* Based on mappings from demo examples and common patterns
|
||||||
|
*
|
||||||
|
* @param {string} tagName - Element tag name
|
||||||
|
* @param {Array} classes - Array of CSS classes
|
||||||
|
* @param {Object} attributes - Element attributes
|
||||||
|
* @returns {string} - Human-readable style name
|
||||||
|
*/
|
||||||
|
generateStyleName(tagName, classes, attributes) {
|
||||||
|
// Check for predefined mappings first
|
||||||
|
const key = this.createMappingKey(tagName, classes[0]);
|
||||||
|
if (this.styleNameMappings.has(key)) {
|
||||||
|
return this.styleNameMappings.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate name from class names
|
||||||
|
if (classes.length > 0) {
|
||||||
|
return this.classesToDisplayName(classes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate name from tag + attributes
|
||||||
|
if (attributes.id) {
|
||||||
|
return this.tagToDisplayName(tagName) + ' (' + attributes.id + ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to tag name
|
||||||
|
return this.tagToDisplayName(tagName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize style name mappings based on demo examples
|
||||||
|
* Maps common class patterns to user-friendly names
|
||||||
|
*/
|
||||||
|
initializeStyleMappings() {
|
||||||
|
const mappings = new Map();
|
||||||
|
|
||||||
|
// From demo examples in simple/index.html
|
||||||
|
mappings.set('strong.emph', 'Emphasis');
|
||||||
|
mappings.set('strong.brand', 'Brand');
|
||||||
|
mappings.set('strong.highlight-price', 'Highlight Price');
|
||||||
|
mappings.set('a.fancy', 'Fancy Link');
|
||||||
|
mappings.set('span.highlight', 'Highlight');
|
||||||
|
mappings.set('span.brand-color', 'Brand Color');
|
||||||
|
mappings.set('button.btn', 'Button Style');
|
||||||
|
mappings.set('em.emph', 'Emphasis Italic');
|
||||||
|
mappings.set('blockquote.testimonial', 'Testimonial');
|
||||||
|
mappings.set('i.icon-home', 'Home Icon');
|
||||||
|
mappings.set('i.icon-info', 'Info Icon');
|
||||||
|
|
||||||
|
// Common patterns
|
||||||
|
mappings.set('strong.highlight', 'Highlight Bold');
|
||||||
|
mappings.set('span.brand', 'Brand Style');
|
||||||
|
mappings.set('a.button', 'Button Link');
|
||||||
|
mappings.set('span.tag', 'Tag');
|
||||||
|
mappings.set('span.badge', 'Badge');
|
||||||
|
|
||||||
|
return mappings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create mapping key for style name lookup
|
||||||
|
*/
|
||||||
|
createMappingKey(tagName, firstClass) {
|
||||||
|
return firstClass ? `${tagName}.${firstClass}` : tagName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert CSS classes to display name
|
||||||
|
* Handles multi-word classes and common patterns
|
||||||
|
*/
|
||||||
|
classesToDisplayName(classes) {
|
||||||
|
return classes[0]
|
||||||
|
.split('-')
|
||||||
|
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert tag name to display name
|
||||||
|
*/
|
||||||
|
tagToDisplayName(tagName) {
|
||||||
|
const tagMappings = {
|
||||||
|
'strong': 'Bold',
|
||||||
|
'em': 'Italic',
|
||||||
|
'span': 'Style',
|
||||||
|
'a': 'Link',
|
||||||
|
'button': 'Button',
|
||||||
|
'i': 'Icon',
|
||||||
|
'code': 'Code',
|
||||||
|
'blockquote': 'Quote'
|
||||||
|
};
|
||||||
|
|
||||||
|
return tagMappings[tagName] || tagName.charAt(0).toUpperCase() + tagName.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract template from element for style application
|
||||||
|
* Creates a template with multiple insertion points for complex elements
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} element - Element to create template from
|
||||||
|
* @returns {Object} - Template object with placeholders for all editable properties
|
||||||
|
*/
|
||||||
|
extractTemplate(element) {
|
||||||
|
const tagName = element.tagName.toLowerCase();
|
||||||
|
const clone = element.cloneNode(false); // Clone without children
|
||||||
|
|
||||||
|
// Create template with placeholders for different element types
|
||||||
|
const template = {
|
||||||
|
tagName: tagName,
|
||||||
|
attributes: {},
|
||||||
|
editableProperties: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Copy all attributes
|
||||||
|
for (const attr of element.attributes) {
|
||||||
|
template.attributes[attr.name] = attr.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define editable properties and their placeholders
|
||||||
|
switch (tagName) {
|
||||||
|
case 'a':
|
||||||
|
template.editableProperties = ['content', 'href', 'title'];
|
||||||
|
template.attributes.href = '{{HREF}}';
|
||||||
|
if (template.attributes.title) {
|
||||||
|
template.attributes.title = '{{TITLE}}';
|
||||||
|
}
|
||||||
|
clone.textContent = '{{CONTENT}}';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'img':
|
||||||
|
template.editableProperties = ['src', 'alt', 'title'];
|
||||||
|
template.attributes.src = '{{SRC}}';
|
||||||
|
template.attributes.alt = '{{ALT}}';
|
||||||
|
if (template.attributes.title) {
|
||||||
|
template.attributes.title = '{{TITLE}}';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'button':
|
||||||
|
template.editableProperties = ['content'];
|
||||||
|
clone.textContent = '{{CONTENT}}';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'input':
|
||||||
|
template.editableProperties = ['value', 'placeholder'];
|
||||||
|
if (template.attributes.value !== undefined) {
|
||||||
|
template.attributes.value = '{{VALUE}}';
|
||||||
|
}
|
||||||
|
if (template.attributes.placeholder) {
|
||||||
|
template.attributes.placeholder = '{{PLACEHOLDER}}';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Default: only content is editable
|
||||||
|
template.editableProperties = ['content'];
|
||||||
|
clone.textContent = '{{CONTENT}}';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store both the structured template and the HTML template for backward compatibility
|
||||||
|
template.html = clone.outerHTML;
|
||||||
|
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create element from style template with new properties
|
||||||
|
* Handles multi-property elements like links (href + content)
|
||||||
|
*
|
||||||
|
* @param {Object} styleInfo - Style information object
|
||||||
|
* @param {Object|string} properties - Properties to insert into template (or just text for backward compatibility)
|
||||||
|
* @returns {HTMLElement} - New element with applied style and properties
|
||||||
|
*/
|
||||||
|
createElementFromTemplate(styleInfo, properties) {
|
||||||
|
const element = document.createElement(styleInfo.tagName);
|
||||||
|
|
||||||
|
// Apply classes
|
||||||
|
styleInfo.classes.forEach(cls => element.classList.add(cls));
|
||||||
|
|
||||||
|
// Apply base attributes (non-editable ones)
|
||||||
|
Object.entries(styleInfo.attributes).forEach(([key, value]) => {
|
||||||
|
// Skip attributes that will be set from properties
|
||||||
|
if (!this.isEditableAttribute(key, styleInfo.tagName)) {
|
||||||
|
element.setAttribute(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle properties - support both object and string (backward compatibility)
|
||||||
|
if (typeof properties === 'string') {
|
||||||
|
// Legacy support: treat as text content
|
||||||
|
element.textContent = properties;
|
||||||
|
} else if (typeof properties === 'object') {
|
||||||
|
// New multi-property support
|
||||||
|
this.applyPropertiesToElement(element, properties, styleInfo.tagName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an attribute is editable for a given element type
|
||||||
|
*
|
||||||
|
* @param {string} attributeName - Name of the attribute
|
||||||
|
* @param {string} tagName - Element tag name
|
||||||
|
* @returns {boolean} - True if attribute is editable
|
||||||
|
*/
|
||||||
|
isEditableAttribute(attributeName, tagName) {
|
||||||
|
const editableAttributes = {
|
||||||
|
'a': ['href', 'title', 'target'],
|
||||||
|
'img': ['src', 'alt', 'title'],
|
||||||
|
'input': ['value', 'placeholder'],
|
||||||
|
'button': []
|
||||||
|
};
|
||||||
|
|
||||||
|
return (editableAttributes[tagName] || []).includes(attributeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply properties to element based on element type
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} element - Element to apply properties to
|
||||||
|
* @param {Object} properties - Properties to apply
|
||||||
|
* @param {string} tagName - Element tag name
|
||||||
|
*/
|
||||||
|
applyPropertiesToElement(element, properties, tagName) {
|
||||||
|
switch (tagName) {
|
||||||
|
case 'a':
|
||||||
|
if (properties.content !== undefined) {
|
||||||
|
element.textContent = properties.content;
|
||||||
|
}
|
||||||
|
if (properties.href !== undefined) {
|
||||||
|
element.href = properties.href;
|
||||||
|
}
|
||||||
|
if (properties.title !== undefined) {
|
||||||
|
element.title = properties.title;
|
||||||
|
}
|
||||||
|
if (properties.target !== undefined) {
|
||||||
|
element.target = properties.target;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'img':
|
||||||
|
if (properties.src !== undefined) {
|
||||||
|
element.src = properties.src;
|
||||||
|
}
|
||||||
|
if (properties.alt !== undefined) {
|
||||||
|
element.alt = properties.alt;
|
||||||
|
}
|
||||||
|
if (properties.title !== undefined) {
|
||||||
|
element.title = properties.title;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'button':
|
||||||
|
if (properties.content !== undefined) {
|
||||||
|
element.textContent = properties.content;
|
||||||
|
}
|
||||||
|
if (properties.type !== undefined) {
|
||||||
|
element.type = properties.type;
|
||||||
|
}
|
||||||
|
if (properties.disabled !== undefined) {
|
||||||
|
element.disabled = properties.disabled;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'input':
|
||||||
|
if (properties.value !== undefined) {
|
||||||
|
element.value = properties.value;
|
||||||
|
}
|
||||||
|
if (properties.placeholder !== undefined) {
|
||||||
|
element.placeholder = properties.placeholder;
|
||||||
|
}
|
||||||
|
if (properties.type !== undefined) {
|
||||||
|
element.type = properties.type;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Default: set text content
|
||||||
|
if (properties.content !== undefined) {
|
||||||
|
element.textContent = properties.content;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get plain text content from element with preserved spacing
|
||||||
|
* Useful for extracting text for editing while maintaining structure
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} element - Element to extract text from
|
||||||
|
* @returns {string} - Plain text with preserved spacing
|
||||||
|
*/
|
||||||
|
extractPlainText(element) {
|
||||||
|
// Use textContent but preserve some structure for multi-element content
|
||||||
|
return element.textContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if element has complex nested structure
|
||||||
|
* Used to determine editing strategy
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} element - Element to check
|
||||||
|
* @returns {boolean} - True if element has nested styled children
|
||||||
|
*/
|
||||||
|
hasNestedStyledElements(element) {
|
||||||
|
return element.children.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconstruct HTML from structure array and updated properties
|
||||||
|
*
|
||||||
|
* @param {Array} structure - Content structure array
|
||||||
|
* @param {Map} styles - Style definitions
|
||||||
|
* @param {Object} updatedProperties - Updated properties for structure pieces
|
||||||
|
* @returns {string} - Reconstructed HTML
|
||||||
|
*/
|
||||||
|
reconstructHTML(structure, styles, updatedProperties = {}) {
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
structure.forEach((piece, index) => {
|
||||||
|
if (piece.type === 'text') {
|
||||||
|
// Check if any styles are applied to this text piece
|
||||||
|
const applied = updatedProperties[index];
|
||||||
|
if (applied && applied.styleId && styles.has(applied.styleId)) {
|
||||||
|
const styleInfo = styles.get(applied.styleId);
|
||||||
|
const styledElement = this.createElementFromTemplate(styleInfo, applied.properties || applied.content);
|
||||||
|
html += styledElement.outerHTML;
|
||||||
|
} else {
|
||||||
|
// Use updated content or original content
|
||||||
|
const content = applied?.content || piece.content;
|
||||||
|
html += content;
|
||||||
|
}
|
||||||
|
} else if (piece.type === 'styled') {
|
||||||
|
// Use updated properties or original properties
|
||||||
|
const properties = updatedProperties[index]?.properties || piece.properties;
|
||||||
|
|
||||||
|
if (styles.has(piece.styleId)) {
|
||||||
|
const styleInfo = styles.get(piece.styleId);
|
||||||
|
const styledElement = this.createElementFromTemplate(styleInfo, properties);
|
||||||
|
html += styledElement.outerHTML;
|
||||||
|
} else {
|
||||||
|
// Fallback if style is missing - just use content
|
||||||
|
const content = properties?.content || properties;
|
||||||
|
html += content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract plain text from structure while preserving order
|
||||||
|
*
|
||||||
|
* @param {Array} structure - Content structure array
|
||||||
|
* @returns {string} - Plain text content
|
||||||
|
*/
|
||||||
|
extractTextFromStructure(structure) {
|
||||||
|
return structure.map(piece => {
|
||||||
|
if (piece.type === 'text') {
|
||||||
|
return piece.content;
|
||||||
|
} else if (piece.type === 'styled') {
|
||||||
|
// For styled elements, return the content property
|
||||||
|
return piece.properties?.content || '';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create structure from plain text by mapping to existing structure
|
||||||
|
* Used when user edits plain text and we need to reapply it to structure
|
||||||
|
*
|
||||||
|
* @param {string} newText - New text content
|
||||||
|
* @param {Array} originalStructure - Original structure to map to
|
||||||
|
* @returns {Array} - Updated structure array
|
||||||
|
*/
|
||||||
|
mapTextToStructure(newText, originalStructure) {
|
||||||
|
// Simple approach: if text length matches, assume same structure
|
||||||
|
// More sophisticated approaches could use diff algorithms
|
||||||
|
const originalText = this.extractTextFromStructure(originalStructure);
|
||||||
|
|
||||||
|
if (newText === originalText) {
|
||||||
|
// No changes - return original structure
|
||||||
|
return originalStructure;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For now, create simple text structure
|
||||||
|
// TODO: Implement smarter text-to-structure mapping
|
||||||
|
return [{
|
||||||
|
type: 'text',
|
||||||
|
content: newText
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance
|
||||||
|
export const styleDetectionEngine = new StyleDetectionEngine();
|
||||||
327
lib/src/utils/style-detection.test.js
Normal file
327
lib/src/utils/style-detection.test.js
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for StyleDetectionEngine
|
||||||
|
* Tests based on actual demo examples from simple/index.html
|
||||||
|
*/
|
||||||
|
import { StyleDetectionEngine } from './style-detection.js';
|
||||||
|
|
||||||
|
// Mock DOM environment for testing
|
||||||
|
const mockDocument = {
|
||||||
|
createElement: (tagName) => ({
|
||||||
|
tagName: tagName.toUpperCase(),
|
||||||
|
classList: new Set(),
|
||||||
|
attributes: [],
|
||||||
|
textContent: '',
|
||||||
|
outerHTML: `<${tagName}></${tagName}>`,
|
||||||
|
cloneNode: () => mockDocument.createElement(tagName)
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
global.document = mockDocument;
|
||||||
|
|
||||||
|
describe('StyleDetectionEngine', () => {
|
||||||
|
let engine;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
engine = new StyleDetectionEngine();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Demo Example 1: Styled Strong Element', () => {
|
||||||
|
test('should detect <strong class="emph"> as Emphasis style', () => {
|
||||||
|
// Simulate: <p class="insertr">Hello <strong class="emph">world</strong> and welcome!</p>
|
||||||
|
const mockElement = createMockElement('p', {
|
||||||
|
children: [
|
||||||
|
createMockElement('strong', {
|
||||||
|
classes: ['emph'],
|
||||||
|
textContent: 'world'
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const detectedStyles = engine.detectStyles(mockElement);
|
||||||
|
|
||||||
|
expect(detectedStyles.size).toBe(1);
|
||||||
|
expect(detectedStyles.has('strong_emph')).toBe(true);
|
||||||
|
|
||||||
|
const emphStyle = detectedStyles.get('strong_emph');
|
||||||
|
expect(emphStyle.name).toBe('Emphasis');
|
||||||
|
expect(emphStyle.tagName).toBe('strong');
|
||||||
|
expect(emphStyle.classes).toEqual(['emph']);
|
||||||
|
expect(emphStyle.textContent).toBe('world');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Demo Example 2: Styled Link Element', () => {
|
||||||
|
test('should detect <a class="fancy"> as Fancy Link style', () => {
|
||||||
|
// Simulate: <p class="insertr">Visit our <a class="fancy" href="#about">about page</a> for more info.</p>
|
||||||
|
const mockElement = createMockElement('p', {
|
||||||
|
children: [
|
||||||
|
createMockElement('a', {
|
||||||
|
classes: ['fancy'],
|
||||||
|
attributes: { href: '#about' },
|
||||||
|
textContent: 'about page'
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const detectedStyles = engine.detectStyles(mockElement);
|
||||||
|
|
||||||
|
expect(detectedStyles.size).toBe(1);
|
||||||
|
const fancyLinkStyle = detectedStyles.get('a_fancy');
|
||||||
|
expect(fancyLinkStyle.name).toBe('Fancy Link');
|
||||||
|
expect(fancyLinkStyle.tagName).toBe('a');
|
||||||
|
expect(fancyLinkStyle.attributes.href).toBe('#about');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Demo Example 3: Mixed Content with Complex Attributes', () => {
|
||||||
|
test('should detect link with multiple attributes', () => {
|
||||||
|
// Simulate: <a href="https://example.com" rel="noopener" target="_blank" class="fancy">our site</a>
|
||||||
|
const mockElement = createMockElement('p', {
|
||||||
|
children: [
|
||||||
|
createMockElement('a', {
|
||||||
|
classes: ['fancy'],
|
||||||
|
attributes: {
|
||||||
|
href: 'https://example.com',
|
||||||
|
rel: 'noopener',
|
||||||
|
target: '_blank'
|
||||||
|
},
|
||||||
|
textContent: 'our site'
|
||||||
|
}),
|
||||||
|
createMockElement('span', {
|
||||||
|
classes: ['highlight'],
|
||||||
|
textContent: 'amazing'
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const detectedStyles = engine.detectStyles(mockElement);
|
||||||
|
|
||||||
|
expect(detectedStyles.size).toBe(2);
|
||||||
|
|
||||||
|
const linkStyle = detectedStyles.get('a_fancy');
|
||||||
|
expect(linkStyle.attributes.rel).toBe('noopener');
|
||||||
|
expect(linkStyle.attributes.target).toBe('_blank');
|
||||||
|
|
||||||
|
const highlightStyle = detectedStyles.get('span_highlight');
|
||||||
|
expect(highlightStyle.name).toBe('Highlight');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Demo Example 4: Multiple Styled Elements', () => {
|
||||||
|
test('should detect multiple different styled elements', () => {
|
||||||
|
// Simulate: <strong class="brand">Acme Corp</strong> <span class="highlight">innovative</span> <em class="emph">modern</em>
|
||||||
|
const mockElement = createMockElement('p', {
|
||||||
|
children: [
|
||||||
|
createMockElement('strong', {
|
||||||
|
classes: ['brand'],
|
||||||
|
textContent: 'Acme Corp'
|
||||||
|
}),
|
||||||
|
createMockElement('span', {
|
||||||
|
classes: ['highlight'],
|
||||||
|
textContent: 'innovative'
|
||||||
|
}),
|
||||||
|
createMockElement('em', {
|
||||||
|
classes: ['emph'],
|
||||||
|
textContent: 'modern'
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const detectedStyles = engine.detectStyles(mockElement);
|
||||||
|
|
||||||
|
expect(detectedStyles.size).toBe(3);
|
||||||
|
expect(detectedStyles.has('strong_brand')).toBe(true);
|
||||||
|
expect(detectedStyles.has('span_highlight')).toBe(true);
|
||||||
|
expect(detectedStyles.has('em_emph')).toBe(true);
|
||||||
|
|
||||||
|
expect(detectedStyles.get('strong_brand').name).toBe('Brand');
|
||||||
|
expect(detectedStyles.get('span_highlight').name).toBe('Highlight');
|
||||||
|
expect(detectedStyles.get('em_emph').name).toBe('Emphasis Italic');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Demo Example 5: Button with Data Attributes', () => {
|
||||||
|
test('should detect button with data attributes', () => {
|
||||||
|
// Simulate: <button class="btn" data-action="signup" data-analytics="cta-main">Sign Up Now</button>
|
||||||
|
const mockElement = createMockElement('p', {
|
||||||
|
children: [
|
||||||
|
createMockElement('button', {
|
||||||
|
classes: ['btn'],
|
||||||
|
attributes: {
|
||||||
|
'data-action': 'signup',
|
||||||
|
'data-analytics': 'cta-main'
|
||||||
|
},
|
||||||
|
textContent: 'Sign Up Now'
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const detectedStyles = engine.detectStyles(mockElement);
|
||||||
|
|
||||||
|
expect(detectedStyles.size).toBe(1);
|
||||||
|
const buttonStyle = detectedStyles.get('button_btn');
|
||||||
|
expect(buttonStyle.name).toBe('Button Style');
|
||||||
|
expect(buttonStyle.attributes['data-action']).toBe('signup');
|
||||||
|
expect(buttonStyle.attributes['data-analytics']).toBe('cta-main');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Style Name Generation', () => {
|
||||||
|
test('should generate correct names for common patterns', () => {
|
||||||
|
expect(engine.generateStyleName('strong', ['highlight-price'], {})).toBe('Highlight Price');
|
||||||
|
expect(engine.generateStyleName('span', ['brand-color'], {})).toBe('Brand Color');
|
||||||
|
expect(engine.generateStyleName('a', ['fancy'], {})).toBe('Fancy Link');
|
||||||
|
expect(engine.generateStyleName('button', ['btn'], {})).toBe('Button Style');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle elements with IDs', () => {
|
||||||
|
const name = engine.generateStyleName('span', [], { id: 'unique-element' });
|
||||||
|
expect(name).toBe('Style (unique-element)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should fallback to tag names for unstyled elements', () => {
|
||||||
|
expect(engine.generateStyleName('blockquote', [], {})).toBe('Quote');
|
||||||
|
expect(engine.generateStyleName('code', [], {})).toBe('Code');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Template Extraction', () => {
|
||||||
|
test('should create proper templates with placeholders', () => {
|
||||||
|
const mockElement = createMockElement('strong', {
|
||||||
|
classes: ['emph'],
|
||||||
|
attributes: { 'data-test': 'value' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const template = engine.extractTemplate(mockElement);
|
||||||
|
expect(template).toContain('{{TEXT}}');
|
||||||
|
expect(template).toContain('class="emph"');
|
||||||
|
expect(template).toContain('data-test="value"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Element Creation from Templates', () => {
|
||||||
|
test('should create elements with correct styling', () => {
|
||||||
|
const styleInfo = {
|
||||||
|
tagName: 'strong',
|
||||||
|
classes: ['emph'],
|
||||||
|
attributes: { 'data-test': 'value' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const element = engine.createElementFromTemplate(styleInfo, 'test content');
|
||||||
|
|
||||||
|
expect(element.tagName.toLowerCase()).toBe('strong');
|
||||||
|
expect(element.textContent).toBe('test content');
|
||||||
|
expect(element.classList.contains('emph')).toBe(true);
|
||||||
|
expect(element.getAttribute('data-test')).toBe('value');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Complex Nesting Detection', () => {
|
||||||
|
test('should detect when elements have nested styled children', () => {
|
||||||
|
const mockElement = createMockElement('p', {
|
||||||
|
children: [
|
||||||
|
createMockElement('strong', { classes: ['emph'] })
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(engine.hasNestedStyledElements(mockElement)).toBe(true);
|
||||||
|
|
||||||
|
const simpleElement = createMockElement('p', { children: [] });
|
||||||
|
expect(engine.hasNestedStyledElements(simpleElement)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Attribute Significance Detection', () => {
|
||||||
|
test('should identify significant attributes', () => {
|
||||||
|
expect(engine.hasSignificantAttributes({ 'data-action': 'test' })).toBe(true);
|
||||||
|
expect(engine.hasSignificantAttributes({ 'aria-label': 'test' })).toBe(true);
|
||||||
|
expect(engine.hasSignificantAttributes({ href: '#link' })).toBe(true);
|
||||||
|
expect(engine.hasSignificantAttributes({ id: 'unique' })).toBe(true);
|
||||||
|
expect(engine.hasSignificantAttributes({})).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge Cases', () => {
|
||||||
|
test('should skip elements without styling', () => {
|
||||||
|
const mockElement = createMockElement('p', {
|
||||||
|
children: [
|
||||||
|
createMockElement('span', { classes: [], attributes: {} }),
|
||||||
|
createMockElement('strong', { classes: ['emph'] })
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const detectedStyles = engine.detectStyles(mockElement);
|
||||||
|
expect(detectedStyles.size).toBe(1);
|
||||||
|
expect(detectedStyles.has('strong_emph')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle empty elements', () => {
|
||||||
|
const mockElement = createMockElement('p', { children: [] });
|
||||||
|
const detectedStyles = engine.detectStyles(mockElement);
|
||||||
|
expect(detectedStyles.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle elements with only whitespace', () => {
|
||||||
|
const mockElement = createMockElement('p', {
|
||||||
|
children: [
|
||||||
|
createMockElement('span', {
|
||||||
|
classes: ['test'],
|
||||||
|
textContent: ' '
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const detectedStyles = engine.detectStyles(mockElement);
|
||||||
|
expect(detectedStyles.size).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper function to create mock DOM elements for testing
|
||||||
|
function createMockElement(tagName, options = {}) {
|
||||||
|
const element = {
|
||||||
|
tagName: tagName.toUpperCase(),
|
||||||
|
classList: new Set(options.classes || []),
|
||||||
|
children: options.children || [],
|
||||||
|
textContent: options.textContent || '',
|
||||||
|
attributes: [],
|
||||||
|
id: options.attributes?.id || '',
|
||||||
|
outerHTML: `<${tagName}${formatAttributes(options)}></${tagName}>`,
|
||||||
|
|
||||||
|
// Mock methods
|
||||||
|
cloneNode: (deep) => createMockElement(tagName, options),
|
||||||
|
setAttribute: (name, value) => {
|
||||||
|
element.attributes[name] = value;
|
||||||
|
},
|
||||||
|
getAttribute: (name) => element.attributes[name],
|
||||||
|
removeAttribute: (name) => {
|
||||||
|
delete element.attributes[name];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add attributes
|
||||||
|
if (options.attributes) {
|
||||||
|
Object.entries(options.attributes).forEach(([key, value]) => {
|
||||||
|
element.attributes.push({ name: key, value: value });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add classList methods
|
||||||
|
element.classList.contains = (className) => element.classList.has(className);
|
||||||
|
element.classList.add = (className) => element.classList.add(className);
|
||||||
|
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAttributes(options) {
|
||||||
|
let attrs = '';
|
||||||
|
if (options.classes && options.classes.length > 0) {
|
||||||
|
attrs += ` class="${options.classes.join(' ')}"`;
|
||||||
|
}
|
||||||
|
if (options.attributes) {
|
||||||
|
Object.entries(options.attributes).forEach(([key, value]) => {
|
||||||
|
attrs += ` ${key}="${value}"`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return attrs;
|
||||||
|
}
|
||||||
337
lib/src/utils/test-runner.js
Normal file
337
lib/src/utils/test-runner.js
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
/**
|
||||||
|
* Simple test runner for style preservation system
|
||||||
|
* Tests our implementation with actual DOM elements
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { styleDetectionEngine } from './style-detection.js';
|
||||||
|
import { htmlPreservationEngine } from './html-preservation.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run all style detection tests with real DOM elements
|
||||||
|
*/
|
||||||
|
export function runStyleDetectionTests() {
|
||||||
|
console.log('🧪 Running Style Detection Tests');
|
||||||
|
console.log('================================');
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
// Test 1: Demo Example 1 - Styled Strong Element
|
||||||
|
results.push(testExample1());
|
||||||
|
|
||||||
|
// Test 2: Demo Example 2 - Styled Link Element
|
||||||
|
results.push(testExample2());
|
||||||
|
|
||||||
|
// Test 3: Demo Example 4 - Multiple Styled Elements
|
||||||
|
results.push(testExample4());
|
||||||
|
|
||||||
|
// Test 4: Demo Example 5 - Button with Data Attributes
|
||||||
|
results.push(testExample5());
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
const passed = results.filter(r => r.passed).length;
|
||||||
|
const total = results.length;
|
||||||
|
|
||||||
|
console.log(`\n📊 Test Results: ${passed}/${total} passed`);
|
||||||
|
|
||||||
|
if (passed === total) {
|
||||||
|
console.log('✅ All style detection tests passed!');
|
||||||
|
} else {
|
||||||
|
console.log('❌ Some tests failed - see details above');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { passed, total, results };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test Example 1: <p>Hello <strong class="emph">world</strong> and welcome!</p>
|
||||||
|
*/
|
||||||
|
function testExample1() {
|
||||||
|
console.log('\n🔍 Test 1: Styled Strong Element');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create test element
|
||||||
|
const container = document.createElement('p');
|
||||||
|
container.className = 'insertr';
|
||||||
|
container.innerHTML = 'Hello <strong class="emph">world</strong> and welcome!';
|
||||||
|
|
||||||
|
// Detect styles
|
||||||
|
const detectedStyles = styleDetectionEngine.detectStyles(container);
|
||||||
|
|
||||||
|
// Validate results
|
||||||
|
const hasEmphStyle = detectedStyles.has('strong_emph');
|
||||||
|
const emphStyle = detectedStyles.get('strong_emph');
|
||||||
|
const correctName = emphStyle?.name === 'Emphasis';
|
||||||
|
const correctTag = emphStyle?.tagName === 'strong';
|
||||||
|
const correctClasses = emphStyle?.classes?.includes('emph');
|
||||||
|
|
||||||
|
const passed = hasEmphStyle && correctName && correctTag && correctClasses;
|
||||||
|
|
||||||
|
console.log(` Detected ${detectedStyles.size} style(s)`);
|
||||||
|
console.log(` Found "Emphasis" style: ${hasEmphStyle ? '✅' : '❌'}`);
|
||||||
|
console.log(` Correct name: ${correctName ? '✅' : '❌'}`);
|
||||||
|
console.log(` Result: ${passed ? '✅ PASSED' : '❌ FAILED'}`);
|
||||||
|
|
||||||
|
return { test: 'Example 1', passed, details: { hasEmphStyle, correctName, correctTag, correctClasses } };
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.log(` Error: ${error.message}`);
|
||||||
|
console.log(` Result: ❌ FAILED`);
|
||||||
|
return { test: 'Example 1', passed: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test Example 2: <p>Visit our <a class="fancy" href="#about">about page</a> for more info.</p>
|
||||||
|
*/
|
||||||
|
function testExample2() {
|
||||||
|
console.log('\n🔍 Test 2: Styled Link Element');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const container = document.createElement('p');
|
||||||
|
container.className = 'insertr';
|
||||||
|
container.innerHTML = 'Visit our <a class="fancy" href="#about">about page</a> for more info.';
|
||||||
|
|
||||||
|
const detectedStyles = styleDetectionEngine.detectStyles(container);
|
||||||
|
|
||||||
|
const hasFancyStyle = detectedStyles.has('a_fancy');
|
||||||
|
const fancyStyle = detectedStyles.get('a_fancy');
|
||||||
|
const correctName = fancyStyle?.name === 'Fancy Link';
|
||||||
|
const hasHref = fancyStyle?.attributes?.href === '#about';
|
||||||
|
|
||||||
|
const passed = hasFancyStyle && correctName && hasHref;
|
||||||
|
|
||||||
|
console.log(` Detected ${detectedStyles.size} style(s)`);
|
||||||
|
console.log(` Found "Fancy Link" style: ${hasFancyStyle ? '✅' : '❌'}`);
|
||||||
|
console.log(` Preserved href attribute: ${hasHref ? '✅' : '❌'}`);
|
||||||
|
console.log(` Result: ${passed ? '✅ PASSED' : '❌ FAILED'}`);
|
||||||
|
|
||||||
|
return { test: 'Example 2', passed, details: { hasFancyStyle, correctName, hasHref } };
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.log(` Error: ${error.message}`);
|
||||||
|
console.log(` Result: ❌ FAILED`);
|
||||||
|
return { test: 'Example 2', passed: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test Example 4: Multiple styled elements
|
||||||
|
*/
|
||||||
|
function testExample4() {
|
||||||
|
console.log('\n🔍 Test 4: Multiple Styled Elements');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const container = document.createElement('p');
|
||||||
|
container.className = 'insertr';
|
||||||
|
container.innerHTML = 'Welcome to <strong class="brand">Acme Corp</strong> where we create <span class="highlight">innovative</span> solutions for <em class="emph">modern</em> businesses.';
|
||||||
|
|
||||||
|
const detectedStyles = styleDetectionEngine.detectStyles(container);
|
||||||
|
|
||||||
|
const hasBrand = detectedStyles.has('strong_brand');
|
||||||
|
const hasHighlight = detectedStyles.has('span_highlight');
|
||||||
|
const hasEmphItalic = detectedStyles.has('em_emph');
|
||||||
|
const correctCount = detectedStyles.size === 3;
|
||||||
|
|
||||||
|
const passed = hasBrand && hasHighlight && hasEmphItalic && correctCount;
|
||||||
|
|
||||||
|
console.log(` Detected ${detectedStyles.size} style(s) (expected 3)`);
|
||||||
|
console.log(` Found "Brand" style: ${hasBrand ? '✅' : '❌'}`);
|
||||||
|
console.log(` Found "Highlight" style: ${hasHighlight ? '✅' : '❌'}`);
|
||||||
|
console.log(` Found "Emphasis Italic" style: ${hasEmphItalic ? '✅' : '❌'}`);
|
||||||
|
console.log(` Result: ${passed ? '✅ PASSED' : '❌ FAILED'}`);
|
||||||
|
|
||||||
|
return { test: 'Example 4', passed, details: { hasBrand, hasHighlight, hasEmphItalic, correctCount } };
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.log(` Error: ${error.message}`);
|
||||||
|
console.log(` Result: ❌ FAILED`);
|
||||||
|
return { test: 'Example 4', passed: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test Example 5: Button with data attributes
|
||||||
|
*/
|
||||||
|
function testExample5() {
|
||||||
|
console.log('\n🔍 Test 5: Button with Data Attributes');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const container = document.createElement('p');
|
||||||
|
container.className = 'insertr';
|
||||||
|
container.innerHTML = 'Ready to start? <button class="btn" data-action="signup" data-analytics="cta-main">Sign Up Now</button> and begin your journey!';
|
||||||
|
|
||||||
|
const detectedStyles = styleDetectionEngine.detectStyles(container);
|
||||||
|
|
||||||
|
const hasButtonStyle = detectedStyles.has('button_btn');
|
||||||
|
const buttonStyle = detectedStyles.get('button_btn');
|
||||||
|
const correctName = buttonStyle?.name === 'Button Style';
|
||||||
|
const hasDataAction = buttonStyle?.attributes?.['data-action'] === 'signup';
|
||||||
|
const hasDataAnalytics = buttonStyle?.attributes?.['data-analytics'] === 'cta-main';
|
||||||
|
|
||||||
|
const passed = hasButtonStyle && correctName && hasDataAction && hasDataAnalytics;
|
||||||
|
|
||||||
|
console.log(` Detected ${detectedStyles.size} style(s)`);
|
||||||
|
console.log(` Found "Button Style": ${hasButtonStyle ? '✅' : '❌'}`);
|
||||||
|
console.log(` Preserved data-action: ${hasDataAction ? '✅' : '❌'}`);
|
||||||
|
console.log(` Preserved data-analytics: ${hasDataAnalytics ? '✅' : '❌'}`);
|
||||||
|
console.log(` Result: ${passed ? '✅ PASSED' : '❌ FAILED'}`);
|
||||||
|
|
||||||
|
return { test: 'Example 5', passed, details: { hasButtonStyle, correctName, hasDataAction, hasDataAnalytics } };
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.log(` Error: ${error.message}`);
|
||||||
|
console.log(` Result: ❌ FAILED`);
|
||||||
|
return { test: 'Example 5', passed: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run HTML preservation tests
|
||||||
|
*/
|
||||||
|
export function runHTMLPreservationTests() {
|
||||||
|
console.log('\n🧪 Running HTML Preservation Tests');
|
||||||
|
console.log('==================================');
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
results.push(testHTMLExtraction());
|
||||||
|
results.push(testHTMLApplication());
|
||||||
|
results.push(testAttributePreservation());
|
||||||
|
|
||||||
|
const passed = results.filter(r => r.passed).length;
|
||||||
|
const total = results.length;
|
||||||
|
|
||||||
|
console.log(`\n📊 HTML Preservation Results: ${passed}/${total} passed`);
|
||||||
|
|
||||||
|
return { passed, total, results };
|
||||||
|
}
|
||||||
|
|
||||||
|
function testHTMLExtraction() {
|
||||||
|
console.log('\n🔍 HTML Extraction Test');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const element = document.createElement('p');
|
||||||
|
element.className = 'insertr test';
|
||||||
|
element.id = 'test-element';
|
||||||
|
element.innerHTML = 'Hello <strong class="emph">world</strong>!';
|
||||||
|
|
||||||
|
const extracted = htmlPreservationEngine.extractForEditing(element);
|
||||||
|
|
||||||
|
const hasHTML = extracted.html === 'Hello <strong class="emph">world</strong>!';
|
||||||
|
const hasText = extracted.text === 'Hello world!';
|
||||||
|
const hasAttributes = extracted.containerAttributes.class === 'insertr test';
|
||||||
|
const detectsNesting = extracted.hasNestedElements === true;
|
||||||
|
|
||||||
|
const passed = hasHTML && hasText && hasAttributes && detectsNesting;
|
||||||
|
|
||||||
|
console.log(` HTML extraction: ${hasHTML ? '✅' : '❌'}`);
|
||||||
|
console.log(` Text extraction: ${hasText ? '✅' : '❌'}`);
|
||||||
|
console.log(` Attribute preservation: ${hasAttributes ? '✅' : '❌'}`);
|
||||||
|
console.log(` Nesting detection: ${detectsNesting ? '✅' : '❌'}`);
|
||||||
|
console.log(` Result: ${passed ? '✅ PASSED' : '❌ FAILED'}`);
|
||||||
|
|
||||||
|
return { test: 'HTML Extraction', passed };
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.log(` Error: ${error.message}`);
|
||||||
|
console.log(` Result: ❌ FAILED`);
|
||||||
|
return { test: 'HTML Extraction', passed: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function testHTMLApplication() {
|
||||||
|
console.log('\n🔍 HTML Application Test');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const element = document.createElement('p');
|
||||||
|
element.className = 'insertr original';
|
||||||
|
|
||||||
|
const newHTML = 'Updated <strong class="emph">content</strong>!';
|
||||||
|
const success = htmlPreservationEngine.applyFromEditing(element, newHTML);
|
||||||
|
|
||||||
|
const appliedCorrectly = element.innerHTML === newHTML;
|
||||||
|
const preservedClass = element.className === 'insertr original';
|
||||||
|
|
||||||
|
const passed = success && appliedCorrectly && preservedClass;
|
||||||
|
|
||||||
|
console.log(` Application success: ${success ? '✅' : '❌'}`);
|
||||||
|
console.log(` Content applied: ${appliedCorrectly ? '✅' : '❌'}`);
|
||||||
|
console.log(` Container class preserved: ${preservedClass ? '✅' : '❌'}`);
|
||||||
|
console.log(` Result: ${passed ? '✅ PASSED' : '❌ FAILED'}`);
|
||||||
|
|
||||||
|
return { test: 'HTML Application', passed };
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.log(` Error: ${error.message}`);
|
||||||
|
console.log(` Result: ❌ FAILED`);
|
||||||
|
return { test: 'HTML Application', passed: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function testAttributePreservation() {
|
||||||
|
console.log('\n🔍 Attribute Preservation Test');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
const originalAttrs = {
|
||||||
|
class: 'insertr test',
|
||||||
|
id: 'unique-id',
|
||||||
|
'data-value': '123'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply original attributes
|
||||||
|
Object.entries(originalAttrs).forEach(([key, value]) => {
|
||||||
|
element.setAttribute(key, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract and restore
|
||||||
|
const extracted = htmlPreservationEngine.extractElementAttributes(element);
|
||||||
|
const newElement = document.createElement('div');
|
||||||
|
htmlPreservationEngine.restoreElementAttributes(newElement, extracted);
|
||||||
|
|
||||||
|
const classRestored = newElement.getAttribute('class') === 'insertr test';
|
||||||
|
const idRestored = newElement.getAttribute('id') === 'unique-id';
|
||||||
|
const dataRestored = newElement.getAttribute('data-value') === '123';
|
||||||
|
|
||||||
|
const passed = classRestored && idRestored && dataRestored;
|
||||||
|
|
||||||
|
console.log(` Class attribute: ${classRestored ? '✅' : '❌'}`);
|
||||||
|
console.log(` ID attribute: ${idRestored ? '✅' : '❌'}`);
|
||||||
|
console.log(` Data attribute: ${dataRestored ? '✅' : '❌'}`);
|
||||||
|
console.log(` Result: ${passed ? '✅ PASSED' : '❌ FAILED'}`);
|
||||||
|
|
||||||
|
return { test: 'Attribute Preservation', passed };
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.log(` Error: ${error.message}`);
|
||||||
|
console.log(` Result: ❌ FAILED`);
|
||||||
|
return { test: 'Attribute Preservation', passed: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run all tests
|
||||||
|
*/
|
||||||
|
export function runAllTests() {
|
||||||
|
console.log('🚀 Running All Style Preservation System Tests');
|
||||||
|
console.log('==============================================');
|
||||||
|
|
||||||
|
const styleResults = runStyleDetectionTests();
|
||||||
|
const htmlResults = runHTMLPreservationTests();
|
||||||
|
|
||||||
|
const totalPassed = styleResults.passed + htmlResults.passed;
|
||||||
|
const totalTests = styleResults.total + htmlResults.total;
|
||||||
|
|
||||||
|
console.log(`\n🎯 Overall Results: ${totalPassed}/${totalTests} tests passed`);
|
||||||
|
|
||||||
|
if (totalPassed === totalTests) {
|
||||||
|
console.log('🎉 All tests passed! Style preservation system is working correctly.');
|
||||||
|
} else {
|
||||||
|
console.log('⚠️ Some tests failed. Review implementation before proceeding.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
passed: totalPassed === totalTests,
|
||||||
|
details: { styleResults, htmlResults }
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user