/** * InsertrCore - Core functionality for content management */ export class InsertrCore { constructor(options = {}) { this.options = { apiEndpoint: options.apiEndpoint || '/api/content', siteId: options.siteId || 'default', ...options }; } // Find all enhanced elements on the page with container expansion findEnhancedElements() { const directElements = document.querySelectorAll('.insertr'); const expandedElements = []; directElements.forEach(element => { if (this.isContainer(element) && !element.classList.contains('insertr-group')) { // Container element (.insertr) - expand to viable children const children = this.findViableChildren(element); expandedElements.push(...children); } else { // Regular element or group (.insertr-group) expandedElements.push(element); } }); return expandedElements; } // Check if element is a container that should expand to children isContainer(element) { const containerTags = new Set([ 'div', 'section', 'article', 'header', 'footer', 'main', 'aside', 'nav' ]); return containerTags.has(element.tagName.toLowerCase()); } // Find viable children for editing (elements with only text content) findViableChildren(containerElement) { const viable = []; for (const child of containerElement.children) { // Skip elements that already have .insertr class if (child.classList.contains('insertr')) { continue; } // Skip self-closing elements if (this.isSelfClosing(child)) { continue; } // Check if element has only text content (no nested HTML elements) if (this.hasOnlyTextContent(child)) { viable.push(child); } } return viable; } // Check if element is viable for editing (allows simple formatting) hasOnlyTextContent(element) { // Allow elements with simple formatting tags const allowedTags = new Set(['strong', 'b', 'em', 'i', 'a', 'span', 'code']); for (const child of element.children) { const tagName = child.tagName.toLowerCase(); // If child is not an allowed formatting tag, reject if (!allowedTags.has(tagName)) { return false; } // If formatting tag has nested complex elements, reject if (child.children.length > 0) { // Recursively check nested content isn't too complex for (const nestedChild of child.children) { const nestedTag = nestedChild.tagName.toLowerCase(); if (!allowedTags.has(nestedTag)) { return false; } } } } // Element has only text and/or simple formatting - this is viable return element.textContent.trim().length > 0; } // Check if element is self-closing isSelfClosing(element) { const selfClosingTags = new Set([ 'img', 'input', 'br', 'hr', 'meta', 'link', 'area', 'base', 'col', 'embed', 'source', 'track', 'wbr' ]); return selfClosingTags.has(element.tagName.toLowerCase()); } // Get element metadata getElementMetadata(element) { const existingId = element.getAttribute('data-content-id'); // Always provide both existing ID (if any) and element context // Backend will use existing ID if provided, or generate new one from context return { contentId: existingId, // null if new content, existing ID if updating contentType: element.getAttribute('data-content-type') || this.detectContentType(element), element: element, elementContext: this.extractElementContext(element) }; } // Extract element context for backend ID generation extractElementContext(element) { return { tag: element.tagName.toLowerCase(), classes: Array.from(element.classList), original_content: element.textContent.trim(), parent_context: this.getSemanticContext(element), purpose: this.getPurpose(element) }; } // Generate deterministic ID using same algorithm as CLI parser generateTempId(element) { return this.generateDeterministicId(element); } // Generate deterministic content ID (matches CLI parser algorithm) generateDeterministicId(element) { const context = this.getSemanticContext(element); const purpose = this.getPurpose(element); const contentHash = this.getContentHash(element); return this.createBaseId(context, purpose, contentHash); } // Get semantic context from parent elements (matches CLI algorithm) getSemanticContext(element) { let parent = element.parentElement; while (parent && parent.nodeType === Node.ELEMENT_NODE) { const classList = Array.from(parent.classList); // Check for common semantic section classes const semanticClasses = ['hero', 'services', 'nav', 'navbar', 'footer', 'about', 'contact', 'testimonial']; for (const semanticClass of semanticClasses) { if (classList.includes(semanticClass)) { return semanticClass; } } // Check for semantic HTML elements const tag = parent.tagName.toLowerCase(); if (['nav', 'header', 'footer', 'main', 'aside'].includes(tag)) { return tag; } parent = parent.parentElement; } return 'content'; } // Get purpose/role of the element (matches CLI algorithm) getPurpose(element) { const tag = element.tagName.toLowerCase(); const classList = Array.from(element.classList); // Check for specific CSS classes that indicate purpose for (const className of classList) { if (className.includes('title')) return 'title'; if (className.includes('headline')) return 'headline'; if (className.includes('description')) return 'description'; if (className.includes('subtitle')) return 'subtitle'; if (className.includes('cta')) return 'cta'; if (className.includes('button')) return 'button'; if (className.includes('logo')) return 'logo'; if (className.includes('lead')) return 'lead'; } // Infer purpose from HTML tag switch (tag) { case 'h1': return 'title'; case 'h2': return 'subtitle'; case 'h3': case 'h4': case 'h5': case 'h6': return 'heading'; case 'p': return 'text'; case 'a': return 'link'; case 'button': return 'button'; default: return 'content'; } } // Generate content hash (matches CLI algorithm) getContentHash(element) { const text = element.textContent.trim(); // Simple SHA-1 implementation for consistent hashing return this.sha1(text).substring(0, 6); } // Simple SHA-1 implementation (matches Go crypto/sha1) sha1(str) { // Convert string to UTF-8 bytes const utf8Bytes = new TextEncoder().encode(str); // SHA-1 implementation const h = [0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0]; const messageLength = utf8Bytes.length; // Pre-processing: adding padding bits const paddedMessage = new Uint8Array(Math.ceil((messageLength + 9) / 64) * 64); paddedMessage.set(utf8Bytes); paddedMessage[messageLength] = 0x80; // Append original length in bits as 64-bit big-endian integer const bitLength = messageLength * 8; const view = new DataView(paddedMessage.buffer); view.setUint32(paddedMessage.length - 4, bitLength, false); // big-endian // Process message in 512-bit chunks for (let chunk = 0; chunk < paddedMessage.length; chunk += 64) { const w = new Array(80); // Break chunk into sixteen 32-bit words for (let i = 0; i < 16; i++) { w[i] = view.getUint32(chunk + i * 4, false); // big-endian } // Extend the words for (let i = 16; i < 80; i++) { w[i] = this.leftRotate(w[i-3] ^ w[i-8] ^ w[i-14] ^ w[i-16], 1); } // Initialize hash value for this chunk let [a, b, c, d, e] = h; // Main loop for (let i = 0; i < 80; i++) { let f, k; if (i < 20) { f = (b & c) | ((~b) & d); k = 0x5A827999; } else if (i < 40) { f = b ^ c ^ d; k = 0x6ED9EBA1; } else if (i < 60) { f = (b & c) | (b & d) | (c & d); k = 0x8F1BBCDC; } else { f = b ^ c ^ d; k = 0xCA62C1D6; } const temp = (this.leftRotate(a, 5) + f + e + k + w[i]) >>> 0; e = d; d = c; c = this.leftRotate(b, 30); b = a; a = temp; } // Add this chunk's hash to result h[0] = (h[0] + a) >>> 0; h[1] = (h[1] + b) >>> 0; h[2] = (h[2] + c) >>> 0; h[3] = (h[3] + d) >>> 0; h[4] = (h[4] + e) >>> 0; } // Produce the final hash value as a 160-bit hex string return h.map(x => x.toString(16).padStart(8, '0')).join(''); } // Left rotate function for SHA-1 leftRotate(value, amount) { return ((value << amount) | (value >>> (32 - amount))) >>> 0; } // Create base ID from components (matches CLI algorithm) createBaseId(context, purpose, contentHash) { const parts = []; // Add context if meaningful if (context !== 'content') { parts.push(context); } // Add purpose parts.push(purpose); // Always add content hash for uniqueness parts.push(contentHash); let baseId = parts.join('-'); // Clean up the ID baseId = baseId.replace(/-+/g, '-'); baseId = baseId.replace(/^-+|-+$/g, ''); // Ensure it's not empty if (!baseId) { baseId = `content-${contentHash}`; } return baseId; } // Detect content type for elements without data-content-type detectContentType(element) { const tag = element.tagName.toLowerCase(); if (element.classList.contains('insertr-group')) { return 'markdown'; } switch (tag) { case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6': return 'text'; case 'p': return 'textarea'; case 'a': case 'button': return 'link'; case 'div': case 'section': return 'markdown'; default: return 'text'; } } // Get all elements with their metadata, including group elements getAllElements() { const directElements = document.querySelectorAll('.insertr, .insertr-group'); const processedElements = []; directElements.forEach(element => { if (element.classList.contains('insertr-group')) { // Group element - treat as single editable unit processedElements.push(element); } else if (this.isContainer(element)) { // Container element - expand to children const children = this.findViableChildren(element); processedElements.push(...children); } else { // Regular element processedElements.push(element); } }); return Array.from(processedElements).map(el => this.getElementMetadata(el)); } }