- Replace separate POST/PUT endpoints with unified POST upsert - Add automatic content ID generation from element context when no ID provided - Implement version history preservation before content updates - Add element context support for backend ID generation - Update frontend to use single endpoint for all content operations - Enhanced demo site with latest database content including proper content IDs
368 lines
13 KiB
JavaScript
368 lines
13 KiB
JavaScript
/**
|
|
* 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));
|
|
}
|
|
} |