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:
2025-09-19 19:33:56 +02:00
parent 968e64a57e
commit 67f9f242b5
6 changed files with 2032 additions and 3 deletions

View File

@@ -11,7 +11,8 @@ export default [
output: {
file: 'dist/insertr.js',
format: 'iife',
name: 'Insertr'
name: 'Insertr',
exports: 'default'
},
plugins: [
nodeResolve()
@@ -23,11 +24,12 @@ export default [
output: {
file: 'dist/insertr.min.js',
format: 'iife',
name: 'Insertr'
name: 'Insertr',
exports: 'default'
},
plugins: [
nodeResolve(),
terser()
]
}
];
];

View 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();

View 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;
}

View 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();

View 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;
}

View 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 }
};
}