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: {
|
||||
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()
|
||||
]
|
||||
}
|
||||
];
|
||||
];
|
||||
|
||||
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