feat: complete full-stack development integration

🎯 Major Achievement: Insertr is now a complete, production-ready CMS

## 🚀 Full-Stack Integration Complete
-  HTTP API Server: Complete REST API with SQLite database
-  Smart Client Integration: Environment-aware API client
-  Unified Development Workflow: Single command full-stack development
-  Professional Tooling: Enhanced build, status, and health checking

## 🔧 Development Experience
- Primary: `just dev` - Full-stack development (demo + API server)
- Alternative: `just demo-only` - Demo site only (special cases)
- Build: `just build` - Complete stack (library + CLI + server)
- Status: `just status` - Comprehensive project overview

## 📦 What's Included
- **insertr-server/**: Complete HTTP API server with SQLite database
- **Smart API Client**: Environment detection, helpful error messages
- **Enhanced Build Pipeline**: Builds library + CLI + server in one command
- **Integrated Tooling**: Status checking, health monitoring, clean workflows

## 🧹 Cleanup
- Removed legacy insertr-old code (no longer needed)
- Simplified workflow (full-stack by default)
- Updated all documentation to reflect complete CMS

## 🎉 Result
Insertr is now a complete, professional CMS with:
- Real content persistence via database
- Professional editing interface
- Build-time content injection
- Zero-configuration deployment
- Production-ready architecture

Ready for real-world use! 🚀
This commit is contained in:
2025-09-08 18:48:05 +02:00
parent 91cf377d77
commit 161c320304
31 changed files with 4344 additions and 2281 deletions

View File

@@ -1,175 +0,0 @@
/**
* Insertr Configuration System
* Extensible field type detection and form configuration
*/
class InsertrConfig {
constructor(customConfig = {}) {
// Default field type mappings
this.defaultFieldTypes = {
'H1': { type: 'text', label: 'Headline', maxLength: 60, placeholder: 'Enter headline...' },
'H2': { type: 'text', label: 'Subheading', maxLength: 80, placeholder: 'Enter subheading...' },
'H3': { type: 'text', label: 'Section Title', maxLength: 100, placeholder: 'Enter title...' },
'H4': { type: 'text', label: 'Title', maxLength: 100, placeholder: 'Enter title...' },
'H5': { type: 'text', label: 'Title', maxLength: 100, placeholder: 'Enter title...' },
'H6': { type: 'text', label: 'Title', maxLength: 100, placeholder: 'Enter title...' },
'P': { type: 'textarea', label: 'Paragraph', rows: 3, placeholder: 'Enter paragraph text...' },
'A': { type: 'link', label: 'Link', placeholder: 'Enter link text...' },
'SPAN': { type: 'text', label: 'Text', placeholder: 'Enter text...' },
'CITE': { type: 'text', label: 'Citation', placeholder: 'Enter citation...' },
'BUTTON': { type: 'text', label: 'Button Text', placeholder: 'Enter button text...' }
};
// CSS class-based enhancements
this.classEnhancements = {
'lead': {
label: 'Lead Paragraph',
rows: 4,
placeholder: 'Enter lead paragraph...'
},
'btn-primary': {
type: 'link',
label: 'Primary Button',
includeUrl: true,
placeholder: 'Enter button text...'
},
'btn-secondary': {
type: 'link',
label: 'Secondary Button',
includeUrl: true,
placeholder: 'Enter button text...'
},
'section-subtitle': {
label: 'Section Subtitle',
placeholder: 'Enter subtitle...'
}
};
// Content ID-based enhancements
this.contentIdRules = [
{
pattern: /cta/i,
config: { label: 'Call to Action' }
},
{
pattern: /quote/i,
config: {
type: 'textarea',
rows: 3,
label: 'Quote',
placeholder: 'Enter quote...'
}
}
];
// Validation limits
this.limits = {
maxContentLength: 10000,
maxHtmlTags: 20,
...customConfig.limits
};
// Markdown configuration
this.markdown = {
enabled: true,
label: 'Content (Markdown)',
rows: 8,
placeholder: 'Enter content in Markdown format...\n\nUse **bold**, *italic*, [links](url), and double line breaks for new paragraphs.',
...customConfig.markdown
};
// Merge custom configurations
this.fieldTypes = { ...this.defaultFieldTypes, ...customConfig.fieldTypes };
this.classEnhancements = { ...this.classEnhancements, ...customConfig.classEnhancements };
if (customConfig.contentIdRules) {
this.contentIdRules = [...this.contentIdRules, ...customConfig.contentIdRules];
}
}
/**
* Generate field configuration for an element
* @param {HTMLElement} element - The element to configure
* @returns {Object} Field configuration
*/
generateFieldConfig(element) {
// Check for explicit markdown type first
const fieldType = element.getAttribute('data-field-type');
if (fieldType === 'markdown') {
return { type: 'markdown', ...this.markdown };
}
// Start with tag-based configuration
const tagName = element.tagName;
let config = { ...this.fieldTypes[tagName] } || {
type: 'text',
label: 'Content',
placeholder: 'Enter content...'
};
// Apply class-based enhancements
for (const [className, enhancement] of Object.entries(this.classEnhancements)) {
if (element.classList.contains(className)) {
config = { ...config, ...enhancement };
}
}
// Apply content ID-based rules
const contentId = element.getAttribute('data-content-id');
if (contentId) {
for (const rule of this.contentIdRules) {
if (rule.pattern.test(contentId)) {
config = { ...config, ...rule.config };
}
}
}
return config;
}
/**
* Add or override field type mapping
* @param {string} tagName - HTML tag name
* @param {Object} config - Field configuration
*/
addFieldType(tagName, config) {
this.fieldTypes[tagName.toUpperCase()] = config;
}
/**
* Add class-based enhancement
* @param {string} className - CSS class name
* @param {Object} enhancement - Configuration enhancement
*/
addClassEnhancement(className, enhancement) {
this.classEnhancements[className] = enhancement;
}
/**
* Add content ID rule
* @param {RegExp|string} pattern - Pattern to match content IDs
* @param {Object} config - Configuration to apply
*/
addContentIdRule(pattern, config) {
const regexPattern = pattern instanceof RegExp ? pattern : new RegExp(pattern, 'i');
this.contentIdRules.push({ pattern: regexPattern, config });
}
/**
* Get validation limits
* @returns {Object} Validation limits
*/
getValidationLimits() {
return { ...this.limits };
}
}
// Export for module usage
if (typeof module !== 'undefined' && module.exports) {
module.exports = InsertrConfig;
}
// Global export for browser usage
if (typeof window !== 'undefined') {
window.InsertrConfig = InsertrConfig;
}

View File

@@ -1,268 +0,0 @@
/**
* Insertr Content Manager Module
* Handles content operations, storage, and API interactions
*/
class InsertrContentManager {
constructor(options = {}) {
this.options = {
apiEndpoint: options.apiEndpoint || '/api/content',
storageKey: options.storageKey || 'insertr_content',
...options
};
this.contentCache = new Map();
this.loadContentFromStorage();
}
/**
* Load content from localStorage
*/
loadContentFromStorage() {
try {
const stored = localStorage.getItem(this.options.storageKey);
if (stored) {
const data = JSON.parse(stored);
this.contentCache = new Map(Object.entries(data));
}
} catch (error) {
console.warn('Failed to load content from storage:', error);
}
}
/**
* Save content to localStorage
*/
saveContentToStorage() {
try {
const data = Object.fromEntries(this.contentCache);
localStorage.setItem(this.options.storageKey, JSON.stringify(data));
} catch (error) {
console.warn('Failed to save content to storage:', error);
}
}
/**
* Get content for a specific element
* @param {string} contentId - Content identifier
* @returns {string|Object} Content data
*/
getContent(contentId) {
return this.contentCache.get(contentId);
}
/**
* Set content for an element
* @param {string} contentId - Content identifier
* @param {string|Object} content - Content data
*/
setContent(contentId, content) {
this.contentCache.set(contentId, content);
this.saveContentToStorage();
}
/**
* Apply content to DOM element
* @param {HTMLElement} element - Element to update
* @param {string|Object} content - Content to apply
*/
applyContentToElement(element, content) {
const config = element._insertrConfig;
if (config.type === 'markdown') {
// Handle markdown collection - content is a string
this.applyMarkdownContent(element, content);
} else if (config.type === 'link' && config.includeUrl && content.url !== undefined) {
// Update link text and URL
element.textContent = this.sanitizeForDisplay(content.text, 'text') || element.textContent;
if (content.url) {
element.href = this.sanitizeForDisplay(content.url, 'url');
}
} else if (content.text !== undefined) {
// Update text content
element.textContent = this.sanitizeForDisplay(content.text, 'text');
}
}
/**
* Apply markdown content to element
* @param {HTMLElement} element - Element to update
* @param {string} markdownText - Markdown content
*/
applyMarkdownContent(element, markdownText) {
// This method will be implemented by the main Insertr class
// which has access to the markdown processor
if (window.insertr && window.insertr.renderMarkdown) {
window.insertr.renderMarkdown(element, markdownText);
} else {
console.warn('Markdown processor not available');
element.textContent = markdownText;
}
}
/**
* Extract content from DOM element
* @param {HTMLElement} element - Element to extract from
* @returns {string|Object} Extracted content
*/
extractContentFromElement(element) {
const config = element._insertrConfig;
if (config.type === 'markdown') {
// For markdown collections, return cached content or extract text
const contentId = element.getAttribute('data-content-id');
const cached = this.contentCache.get(contentId);
if (cached) {
// Handle both old format (object) and new format (string)
if (typeof cached === 'string') {
return cached;
} else if (cached.text && typeof cached.text === 'string') {
return cached.text;
}
}
// Fallback: extract basic text content
const clone = element.cloneNode(true);
const editBtn = clone.querySelector('.insertr-edit-btn');
if (editBtn) editBtn.remove();
// Convert basic HTML structure to markdown
return this.basicHtmlToMarkdown(clone.innerHTML);
}
// Clone element to avoid modifying original
const clone = element.cloneNode(true);
// Remove edit button from clone
const editBtn = clone.querySelector('.insertr-edit-btn');
if (editBtn) editBtn.remove();
// Extract content based on element type
if (config.type === 'link' && config.includeUrl) {
return {
text: clone.textContent.trim(),
url: element.href || ''
};
}
return clone.textContent.trim();
}
/**
* Convert basic HTML to markdown (for initial content extraction)
* @param {string} html - HTML content
* @returns {string} Markdown content
*/
basicHtmlToMarkdown(html) {
let markdown = html;
// Basic paragraph conversion
markdown = markdown.replace(/<p[^>]*>(.*?)<\/p>/gis, (match, content) => {
return content.trim() + '\n\n';
});
markdown = markdown.replace(/<br\s*\/?>/gi, '\n');
// Remove HTML tags
markdown = markdown.replace(/<[^>]*>/g, '');
// Clean up entities
markdown = markdown.replace(/&nbsp;/g, ' ');
markdown = markdown.replace(/&amp;/g, '&');
markdown = markdown.replace(/&lt;/g, '<');
markdown = markdown.replace(/&gt;/g, '>');
// Clean whitespace
markdown = markdown
.split('\n')
.map(line => line.trim())
.join('\n')
.replace(/\n\n\n+/g, '\n\n')
.trim();
return markdown;
}
/**
* Save content to server (mock implementation)
* @param {string} contentId - Content identifier
* @param {string|Object} content - Content to save
* @returns {Promise} Save operation promise
*/
async saveToServer(contentId, content) {
// Mock API call - replace with real implementation
try {
console.log(`💾 Saving content for ${contentId}:`, content);
// Simulate network delay
await new Promise(resolve => setTimeout(resolve, 500));
// Store locally for now
this.setContent(contentId, content);
return { success: true, contentId, content };
} catch (error) {
console.error('Failed to save content:', error);
throw new Error('Save operation failed');
}
}
/**
* Basic sanitization for display
* @param {string} content - Content to sanitize
* @param {string} type - Content type
* @returns {string} Sanitized content
*/
sanitizeForDisplay(content, type) {
if (!content) return '';
switch (type) {
case 'text':
return this.escapeHtml(content);
case 'url':
if (content.startsWith('javascript:') || content.startsWith('data:')) {
return '';
}
return content;
default:
return this.escapeHtml(content);
}
}
/**
* Escape HTML characters
* @param {string} text - Text to escape
* @returns {string} Escaped text
*/
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Clear all cached content
*/
clearCache() {
this.contentCache.clear();
localStorage.removeItem(this.options.storageKey);
}
/**
* Get all cached content
* @returns {Object} All cached content
*/
getAllContent() {
return Object.fromEntries(this.contentCache);
}
}
// Export for module usage
if (typeof module !== 'undefined' && module.exports) {
module.exports = InsertrContentManager;
}
// Global export for browser usage
if (typeof window !== 'undefined') {
window.InsertrContentManager = InsertrContentManager;
}

View File

@@ -1,305 +0,0 @@
/**
* Insertr Form Renderer Module
* Handles form creation and UI interactions
*/
class InsertrFormRenderer {
constructor(validation) {
this.validation = validation;
}
/**
* Create edit form for a content element
* @param {string} contentId - Content identifier
* @param {Object} config - Field configuration
* @param {string|Object} currentContent - Current content value
* @returns {HTMLElement} Form element
*/
createEditForm(contentId, config, currentContent) {
const form = document.createElement('div');
form.className = 'insertr-edit-form';
let formHTML = `<div class="insertr-form-header">${config.label}</div>`;
if (config.type === 'markdown') {
// Markdown collection editing
formHTML += `
<div class="insertr-form-group">
<textarea class="insertr-form-textarea insertr-markdown-editor" name="content"
rows="${config.rows || 8}"
placeholder="${config.placeholder}">${this.validation.escapeHtml(currentContent)}</textarea>
<div class="insertr-form-help">
Live preview will appear here when you start typing
</div>
<div class="insertr-markdown-preview" style="display: none;">
<div class="insertr-preview-label">Preview:</div>
<div class="insertr-preview-content"></div>
</div>
</div>
`;
} else if (config.type === 'link' && config.includeUrl) {
// Link with URL field
const linkText = typeof currentContent === 'object' ? currentContent.text || '' : currentContent;
const linkUrl = typeof currentContent === 'object' ? currentContent.url || '' : '';
formHTML += `
<div class="insertr-form-group">
<label>Link Text:</label>
<input type="text" class="insertr-form-input" name="text"
value="${this.validation.escapeHtml(linkText)}"
placeholder="${config.placeholder}"
maxlength="${config.maxLength || 200}">
</div>
<div class="insertr-form-group">
<label>Link URL:</label>
<input type="url" class="insertr-form-input" name="url"
value="${this.validation.escapeHtml(linkUrl)}"
placeholder="https://example.com">
</div>
`;
} else if (config.type === 'textarea') {
// Textarea for longer content
const content = typeof currentContent === 'object' ? currentContent.text || '' : currentContent;
formHTML += `
<div class="insertr-form-group">
<textarea class="insertr-form-textarea" name="content"
rows="${config.rows || 3}"
placeholder="${config.placeholder}"
maxlength="${config.maxLength || 1000}">${this.validation.escapeHtml(content)}</textarea>
</div>
`;
} else {
// Regular text input
const content = typeof currentContent === 'object' ? currentContent.text || '' : currentContent;
formHTML += `
<div class="insertr-form-group">
<input type="text" class="insertr-form-input" name="content"
value="${this.validation.escapeHtml(content)}"
placeholder="${config.placeholder}"
maxlength="${config.maxLength || 200}">
</div>
`;
}
// Form buttons
formHTML += `
<div class="insertr-form-actions">
<button type="button" class="insertr-btn-save">Save</button>
<button type="button" class="insertr-btn-cancel">Cancel</button>
</div>
`;
form.innerHTML = formHTML;
// Setup form validation
this.setupFormValidation(form, config);
return form;
}
/**
* Setup real-time validation for form inputs
* @param {HTMLElement} form - Form element
* @param {Object} config - Field configuration
*/
setupFormValidation(form, config) {
const inputs = form.querySelectorAll('input, textarea');
inputs.forEach(input => {
// Real-time validation on input
input.addEventListener('input', () => {
this.validateFormField(input, config);
});
// Also validate on blur for better UX
input.addEventListener('blur', () => {
this.validateFormField(input, config);
});
});
// Setup markdown preview if applicable
if (config.type === 'markdown') {
this.setupMarkdownPreview(form);
}
}
/**
* Validate individual form field
* @param {HTMLElement} input - Input element to validate
* @param {Object} config - Field configuration
*/
validateFormField(input, config) {
const value = input.value.trim();
let fieldType = config.type;
// Determine validation type based on input
if (input.type === 'url' || input.name === 'url') {
fieldType = 'link';
}
const validation = this.validation.validateInput(value, fieldType);
// Visual feedback
input.classList.toggle('error', !validation.valid);
input.classList.toggle('valid', validation.valid && value.length > 0);
// Show/hide validation message
if (!validation.valid) {
this.validation.showValidationMessage(input, validation.message, true);
} else {
this.validation.showValidationMessage(input, '', false);
}
return validation.valid;
}
/**
* Setup live markdown preview
* @param {HTMLElement} form - Form containing markdown textarea
*/
setupMarkdownPreview(form) {
const textarea = form.querySelector('.insertr-markdown-editor');
const preview = form.querySelector('.insertr-markdown-preview');
const previewContent = form.querySelector('.insertr-preview-content');
if (!textarea || !preview || !previewContent) return;
let previewTimeout;
textarea.addEventListener('input', () => {
const content = textarea.value.trim();
if (content) {
preview.style.display = 'block';
// Debounced preview update
clearTimeout(previewTimeout);
previewTimeout = setTimeout(() => {
this.updateMarkdownPreview(previewContent, content);
}, 300);
} else {
preview.style.display = 'none';
}
});
}
/**
* Update markdown preview content
* @param {HTMLElement} previewElement - Preview container
* @param {string} markdown - Markdown content
*/
updateMarkdownPreview(previewElement, markdown) {
// This method will be called by the main Insertr class
// which has access to the markdown processor
if (window.insertr && window.insertr.updateMarkdownPreview) {
window.insertr.updateMarkdownPreview(previewElement, markdown);
} else {
previewElement.innerHTML = '<p><em>Preview unavailable</em></p>';
}
}
/**
* Position edit form relative to element
* @param {HTMLElement} element - Element being edited
* @param {HTMLElement} overlay - Form overlay
*/
positionEditForm(element, overlay) {
const rect = element.getBoundingClientRect();
const form = overlay.querySelector('.insertr-edit-form');
// Calculate optimal width (responsive)
const viewportWidth = window.innerWidth;
let formWidth;
if (viewportWidth < 768) {
formWidth = Math.min(viewportWidth - 40, 350);
} else {
formWidth = Math.min(Math.max(rect.width, 300), 500);
}
form.style.width = `${formWidth}px`;
// Position below element with some spacing
const top = rect.bottom + window.scrollY + 10;
const left = Math.max(20, rect.left + window.scrollX);
overlay.style.position = 'absolute';
overlay.style.top = `${top}px`;
overlay.style.left = `${left}px`;
overlay.style.zIndex = '10000';
}
/**
* Show edit form
* @param {HTMLElement} element - Element being edited
* @param {HTMLElement} form - Form to show
*/
showEditForm(element, form) {
// Create overlay
const overlay = document.createElement('div');
overlay.className = 'insertr-form-overlay';
overlay.appendChild(form);
// Position and show
document.body.appendChild(overlay);
this.positionEditForm(element, overlay);
// Focus first input
const firstInput = form.querySelector('input, textarea');
if (firstInput) {
setTimeout(() => firstInput.focus(), 100);
}
// Handle clicking outside to close
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
this.hideEditForm(overlay);
}
});
return overlay;
}
/**
* Hide edit form
* @param {HTMLElement} overlay - Form overlay to hide
*/
hideEditForm(overlay) {
if (overlay && overlay.parentNode) {
overlay.remove();
}
}
/**
* Extract form data
* @param {HTMLElement} form - Form to extract data from
* @param {Object} config - Field configuration
* @returns {Object|string} Extracted form data
*/
extractFormData(form, config) {
if (config.type === 'markdown') {
const textarea = form.querySelector('[name="content"]');
return textarea ? textarea.value.trim() : '';
} else if (config.type === 'link' && config.includeUrl) {
const textInput = form.querySelector('[name="text"]');
const urlInput = form.querySelector('[name="url"]');
return {
text: textInput ? textInput.value.trim() : '',
url: urlInput ? urlInput.value.trim() : ''
};
} else {
const input = form.querySelector('[name="content"]');
return input ? input.value.trim() : '';
}
}
}
// Export for module usage
if (typeof module !== 'undefined' && module.exports) {
module.exports = InsertrFormRenderer;
}
// Global export for browser usage
if (typeof window !== 'undefined') {
window.InsertrFormRenderer = InsertrFormRenderer;
}

View File

@@ -1,268 +0,0 @@
/* Insertr Core Styles */
/* Hide edit indicators by default (customer view) */
.insertr-edit-btn {
display: none;
}
/* Show edit controls when authenticated and edit mode is on */
.insertr-authenticated.insertr-edit-mode .insertr {
position: relative;
border: 2px dashed transparent;
transition: all 0.3s ease;
}
.insertr-authenticated.insertr-edit-mode .insertr:hover {
border-color: #3b82f6;
background-color: rgba(59, 130, 246, 0.05);
}
/* Edit button styling */
.insertr-authenticated.insertr-edit-mode .insertr-edit-btn {
display: block;
position: absolute;
top: 8px;
right: 8px;
width: 32px;
height: 32px;
background: #3b82f6;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
z-index: 10;
transition: all 0.2s;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
}
.insertr-edit-btn:hover {
background: #2563eb;
transform: scale(1.05);
}
/* Edit overlay container */
.insertr-edit-overlay {
position: absolute;
z-index: 1000;
}
/* Edit form container */
.insertr-edit-form {
background: white;
border: 2px solid #3b82f6;
border-radius: 8px;
padding: 1rem;
box-shadow: 0 8px 25px rgba(0,0,0,0.15);
width: 100%;
box-sizing: border-box;
}
/* Form header */
.insertr-form-header {
font-weight: 600;
color: #1f2937;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid #e5e7eb;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Form controls */
.insertr-form-group {
margin-bottom: 1rem;
}
.insertr-form-group:last-child {
margin-bottom: 0;
}
.insertr-form-label {
display: block;
font-weight: 600;
color: #374151;
margin-bottom: 0.5rem;
font-size: 0.875rem;
}
.insertr-form-input,
.insertr-form-textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 6px;
font-family: inherit;
font-size: 1rem;
transition: border-color 0.2s, box-shadow 0.2s;
}
.insertr-form-input:focus,
.insertr-form-textarea:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.insertr-form-textarea {
min-height: 120px;
resize: vertical;
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
}
.insertr-markdown-editor {
min-height: 200px;
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
font-size: 0.9rem;
line-height: 1.5;
background-color: #f8fafc;
}
/* Form actions */
.insertr-form-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #e5e7eb;
}
.insertr-btn-save {
background: #10b981;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 6px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.insertr-btn-save:hover {
background: #059669;
}
.insertr-btn-cancel {
background: #6b7280;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 6px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.insertr-btn-cancel:hover {
background: #4b5563;
}
/* Content type indicators */
.insertr[data-content-type="rich"]::before {
content: "📝";
position: absolute;
top: -8px;
left: -8px;
background: #8b5cf6;
color: white;
width: 24px;
height: 24px;
border-radius: 50%;
display: none;
align-items: center;
justify-content: center;
font-size: 12px;
z-index: 5;
}
.insertr-authenticated.insertr-edit-mode .insertr[data-content-type="rich"]:hover::before {
display: flex;
}
/* Loading and success states */
.insertr-saving {
opacity: 0.7;
pointer-events: none;
}
.insertr-save-success {
border-color: #10b981 !important;
background-color: rgba(16, 185, 129, 0.05) !important;
}
.insertr-save-success::after {
content: "✓ Saved";
position: absolute;
top: -8px;
right: -8px;
background: #10b981;
color: white;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
z-index: 15;
animation: fadeInOut 2s ease-in-out;
}
@keyframes fadeInOut {
0%, 100% { opacity: 0; transform: translateY(10px); }
20%, 80% { opacity: 1; transform: translateY(0); }
}
/* Authentication status indicator */
.insertr-auth-status {
position: fixed;
bottom: 20px;
right: 20px;
background: #1f2937;
color: white;
padding: 0.75rem 1rem;
border-radius: 8px;
font-size: 0.875rem;
z-index: 1000;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
transition: all 0.3s;
}
.insertr-auth-status.authenticated {
background: #10b981;
}
.insertr-auth-status.edit-mode {
background: #3b82f6;
}
/* Validation messages */
.insertr-validation-message {
margin-top: 0.5rem;
padding: 0.5rem;
border-radius: 4px;
font-size: 0.875rem;
animation: slideIn 0.3s ease-out;
}
.insertr-validation-message.error {
background-color: #fef2f2;
border: 1px solid #fecaca;
color: #dc2626;
}
.insertr-validation-message.success {
background-color: #f0fdf4;
border: 1px solid #bbf7d0;
color: #16a34a;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -1,409 +0,0 @@
/**
* Insertr - Element-Level Edit-in-place CMS Library
* Modular architecture with configuration system
*/
class Insertr {
constructor(options = {}) {
this.options = {
apiEndpoint: options.apiEndpoint || '/api/content',
authEndpoint: options.authEndpoint || '/api/auth',
autoInit: options.autoInit !== false,
...options
};
// Core state
this.state = {
isAuthenticated: false,
editMode: false,
currentUser: null,
activeEditor: null
};
this.editableElements = new Map();
this.statusIndicator = null;
// Initialize modules
this.config = new InsertrConfig(options.config);
this.validation = new InsertrValidation(this.config);
this.formRenderer = new InsertrFormRenderer(this.validation);
this.contentManager = new InsertrContentManager(options);
this.markdownProcessor = new InsertrMarkdownProcessor();
if (this.options.autoInit) {
this.init();
}
}
/**
* Initialize the CMS system
*/
async init() {
console.log('🚀 Insertr initializing with modular architecture...');
// Scan for editable elements
this.scanForEditableElements();
// Setup authentication controls
this.setupAuthenticationControls();
// Create status indicator
this.createStatusIndicator();
// Apply initial state
this.updateBodyClasses();
console.log(`📝 Found ${this.editableElements.size} editable elements`);
}
/**
* Scan for editable elements and set them up
*/
scanForEditableElements() {
const elements = document.querySelectorAll('.insertr');
elements.forEach(element => {
const contentId = element.getAttribute('data-content-id');
if (!contentId) {
console.warn('Insertr element missing data-content-id:', element);
return;
}
this.editableElements.set(contentId, element);
this.setupEditableElement(element, contentId);
});
}
/**
* Setup individual editable element
* @param {HTMLElement} element - Element to setup
* @param {string} contentId - Content identifier
*/
setupEditableElement(element, contentId) {
// Generate field configuration
const fieldConfig = this.config.generateFieldConfig(element);
element._insertrConfig = fieldConfig;
// Add edit button
this.addEditButton(element, contentId);
// Load saved content if available
const savedContent = this.contentManager.getContent(contentId);
if (savedContent) {
this.contentManager.applyContentToElement(element, savedContent);
}
}
/**
* Add edit button to element
* @param {HTMLElement} element - Element to add button to
* @param {string} contentId - Content identifier
*/
addEditButton(element, contentId) {
// Create edit button
const editBtn = document.createElement('button');
editBtn.className = 'insertr-edit-btn';
editBtn.innerHTML = '✏️';
editBtn.title = `Edit ${element._insertrConfig.label}`;
editBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
this.startEditing(contentId);
});
// Position relative for button placement
if (getComputedStyle(element).position === 'static') {
element.style.position = 'relative';
}
element.appendChild(editBtn);
}
/**
* Start editing an element
* @param {string} contentId - Content identifier
*/
startEditing(contentId) {
const element = this.editableElements.get(contentId);
if (!element || !this.state.editMode) return;
// Close any active editor
if (this.state.activeEditor && this.state.activeEditor !== contentId) {
this.cancelEditing(this.state.activeEditor);
}
const config = element._insertrConfig;
const currentContent = this.contentManager.extractContentFromElement(element);
// Create and show edit form
const form = this.formRenderer.createEditForm(contentId, config, currentContent);
const overlay = this.formRenderer.showEditForm(element, form);
// Setup form event handlers
this.setupFormHandlers(overlay, contentId);
this.state.activeEditor = contentId;
}
/**
* Setup form event handlers
* @param {HTMLElement} overlay - Form overlay
* @param {string} contentId - Content identifier
*/
setupFormHandlers(overlay, contentId) {
const saveBtn = overlay.querySelector('.insertr-btn-save');
const cancelBtn = overlay.querySelector('.insertr-btn-cancel');
if (saveBtn) {
saveBtn.addEventListener('click', () => {
this.saveElementContent(contentId, overlay);
});
}
if (cancelBtn) {
cancelBtn.addEventListener('click', () => {
this.cancelEditing(contentId);
});
}
// Handle Enter to save, Escape to cancel
overlay.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.saveElementContent(contentId, overlay);
} else if (e.key === 'Escape') {
e.preventDefault();
this.cancelEditing(contentId);
}
});
}
/**
* Save element content
* @param {string} contentId - Content identifier
* @param {HTMLElement} overlay - Form overlay
*/
async saveElementContent(contentId, overlay) {
const element = this.editableElements.get(contentId);
const form = overlay.querySelector('.insertr-edit-form');
const config = element._insertrConfig;
if (!element || !form) return;
// Extract form data
const formData = this.formRenderer.extractFormData(form, config);
// Validate the data
const validation = this.validateFormData(formData, config);
if (!validation.valid) {
alert(validation.message);
return;
}
try {
// Show saving state
element.classList.add('insertr-saving');
// Save to server (mock for now)
await this.contentManager.saveToServer(contentId, formData);
// Apply content to element
this.contentManager.applyContentToElement(element, formData);
// Close form
this.formRenderer.hideEditForm(overlay);
this.state.activeEditor = null;
// Show success feedback
element.classList.add('insertr-save-success');
setTimeout(() => {
element.classList.remove('insertr-save-success');
}, 2000);
} catch (error) {
console.error('Failed to save content:', error);
alert('Failed to save content. Please try again.');
} finally {
element.classList.remove('insertr-saving');
}
}
/**
* Validate form data before saving
* @param {string|Object} data - Form data to validate
* @param {Object} config - Field configuration
* @returns {Object} Validation result
*/
validateFormData(data, config) {
if (config.type === 'link' && config.includeUrl) {
// Validate link data
const textValidation = this.validation.validateInput(data.text, 'text');
if (!textValidation.valid) return textValidation;
const urlValidation = this.validation.validateInput(data.url, 'link');
if (!urlValidation.valid) return urlValidation;
return { valid: true };
} else {
// Validate single content
return this.validation.validateInput(data, config.type);
}
}
/**
* Cancel editing
* @param {string} contentId - Content identifier
*/
cancelEditing(contentId) {
const overlay = document.querySelector('.insertr-form-overlay');
if (overlay) {
this.formRenderer.hideEditForm(overlay);
}
if (this.state.activeEditor === contentId) {
this.state.activeEditor = null;
}
}
/**
* Update markdown preview (called by form renderer)
* @param {HTMLElement} previewElement - Preview container
* @param {string} markdown - Markdown content
*/
updateMarkdownPreview(previewElement, markdown) {
if (this.markdownProcessor.isReady()) {
const html = this.markdownProcessor.createPreview(markdown);
previewElement.innerHTML = html;
} else {
previewElement.innerHTML = '<p><em>Markdown processor not available</em></p>';
}
}
/**
* Render markdown content (called by content manager)
* @param {HTMLElement} element - Element to update
* @param {string} markdownText - Markdown content
*/
renderMarkdown(element, markdownText) {
if (this.markdownProcessor.isReady()) {
this.markdownProcessor.applyToElement(element, markdownText);
} else {
console.warn('Markdown processor not available');
element.textContent = markdownText;
}
}
// Authentication and UI methods (simplified)
/**
* Setup authentication controls
*/
setupAuthenticationControls() {
const authToggle = document.getElementById('auth-toggle');
const editToggle = document.getElementById('edit-mode-toggle');
if (authToggle) {
authToggle.addEventListener('click', () => this.toggleAuthentication());
}
if (editToggle) {
editToggle.addEventListener('click', () => this.toggleEditMode());
}
}
/**
* Toggle authentication state
*/
toggleAuthentication() {
this.state.isAuthenticated = !this.state.isAuthenticated;
this.state.currentUser = this.state.isAuthenticated ? { name: 'Demo User' } : null;
if (!this.state.isAuthenticated) {
this.state.editMode = false;
}
this.updateBodyClasses();
this.updateStatusIndicator();
const authBtn = document.getElementById('auth-toggle');
if (authBtn) {
authBtn.textContent = this.state.isAuthenticated ? 'Logout' : 'Login as Client';
}
}
/**
* Toggle edit mode
*/
toggleEditMode() {
if (!this.state.isAuthenticated) return;
this.state.editMode = !this.state.editMode;
if (!this.state.editMode && this.state.activeEditor) {
this.cancelEditing(this.state.activeEditor);
}
this.updateBodyClasses();
this.updateStatusIndicator();
const editBtn = document.getElementById('edit-mode-toggle');
if (editBtn) {
editBtn.textContent = `Edit Mode: ${this.state.editMode ? 'On' : 'Off'}`;
}
}
/**
* Update body CSS classes based on state
*/
updateBodyClasses() {
document.body.classList.toggle('insertr-authenticated', this.state.isAuthenticated);
document.body.classList.toggle('insertr-edit-mode', this.state.editMode);
const editToggle = document.getElementById('edit-mode-toggle');
if (editToggle) {
editToggle.style.display = this.state.isAuthenticated ? 'inline-block' : 'none';
}
}
/**
* Create status indicator
*/
createStatusIndicator() {
// Implementation similar to original, simplified for brevity
this.updateStatusIndicator();
}
/**
* Update status indicator
*/
updateStatusIndicator() {
// Implementation similar to original, simplified for brevity
console.log(`Status: Auth=${this.state.isAuthenticated}, Edit=${this.state.editMode}`);
}
/**
* Get configuration instance (for external customization)
* @returns {InsertrConfig} Configuration instance
*/
getConfig() {
return this.config;
}
/**
* Get content manager instance
* @returns {InsertrContentManager} Content manager instance
*/
getContentManager() {
return this.contentManager;
}
}
// Export for module usage
if (typeof module !== 'undefined' && module.exports) {
module.exports = Insertr;
}
// Auto-initialize when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
window.insertr = new Insertr();
});

View File

@@ -1,194 +0,0 @@
/**
* Insertr Markdown Processor Module
* Handles markdown parsing and rendering with sanitization
*/
class InsertrMarkdownProcessor {
constructor() {
this.marked = null;
this.markedParser = null;
this.DOMPurify = null;
this.initialize();
}
/**
* Initialize markdown processor
*/
initialize() {
// Check if marked is available
if (typeof marked === 'undefined' && typeof window.marked === 'undefined') {
console.warn('Marked.js not loaded! Markdown collections will not work.');
return false;
}
// Get the marked object
this.marked = window.marked || marked;
this.markedParser = this.marked.marked || this.marked.parse || this.marked;
if (typeof this.markedParser !== 'function') {
console.warn('Cannot find marked parse function');
return false;
}
// Configure marked for basic use
if (this.marked.use) {
this.marked.use({
breaks: true,
gfm: true
});
}
// Initialize DOMPurify if available
if (typeof DOMPurify !== 'undefined' || typeof window.DOMPurify !== 'undefined') {
this.DOMPurify = window.DOMPurify || DOMPurify;
}
console.log('✅ Markdown processor initialized');
return true;
}
/**
* Check if markdown processor is ready
* @returns {boolean} Ready status
*/
isReady() {
return this.markedParser !== null;
}
/**
* Render markdown to HTML
* @param {string} markdownText - Markdown content
* @returns {string} Rendered HTML
*/
renderToHtml(markdownText) {
if (!this.markedParser) {
console.error('Markdown parser not available');
return markdownText;
}
if (typeof markdownText !== 'string') {
console.error('Expected markdown string, got:', typeof markdownText, markdownText);
return 'Markdown content error';
}
try {
// Convert markdown to HTML
const html = this.markedParser(markdownText);
if (typeof html !== 'string') {
console.error('Markdown parser returned non-string:', typeof html, html);
return 'Markdown parsing error';
}
// Sanitize HTML if DOMPurify is available
if (this.DOMPurify) {
return this.DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['p', 'strong', 'em', 'a', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'br'],
ALLOWED_ATTR: ['href', 'class'],
ALLOWED_SCHEMES: ['http', 'https', 'mailto']
});
}
return html;
} catch (error) {
console.error('Markdown rendering failed:', error);
return markdownText;
}
}
/**
* Apply markdown content to DOM element
* @param {HTMLElement} element - Element to update
* @param {string} markdownText - Markdown content
*/
applyToElement(element, markdownText) {
const html = this.renderToHtml(markdownText);
element.innerHTML = html;
}
/**
* Create markdown preview
* @param {string} markdownText - Markdown content
* @returns {string} HTML preview
*/
createPreview(markdownText) {
return this.renderToHtml(markdownText);
}
/**
* Validate markdown content
* @param {string} markdownText - Markdown to validate
* @returns {Object} Validation result
*/
validateMarkdown(markdownText) {
try {
const html = this.renderToHtml(markdownText);
return {
valid: true,
html,
warnings: this.getMarkdownWarnings(markdownText)
};
} catch (error) {
return {
valid: false,
message: 'Invalid markdown format',
error: error.message
};
}
}
/**
* Get warnings for markdown content
* @param {string} markdownText - Markdown to check
* @returns {Array} Array of warnings
*/
getMarkdownWarnings(markdownText) {
const warnings = [];
// Check for potentially problematic patterns
if (markdownText.includes('<script')) {
warnings.push('Script tags detected in markdown');
}
if (markdownText.includes('javascript:')) {
warnings.push('JavaScript URLs detected in markdown');
}
// Check for excessive nesting
const headerCount = (markdownText.match(/^#+\s/gm) || []).length;
if (headerCount > 10) {
warnings.push('Many headers detected - consider simplifying structure');
}
return warnings;
}
/**
* Strip markdown formatting to plain text
* @param {string} markdownText - Markdown content
* @returns {string} Plain text
*/
toPlainText(markdownText) {
return markdownText
.replace(/#{1,6}\s+/g, '') // Remove headers
.replace(/\*\*(.*?)\*\*/g, '$1') // Remove bold
.replace(/\*(.*?)\*/g, '$1') // Remove italic
.replace(/\[(.*?)\]\(.*?\)/g, '$1') // Remove links, keep text
.replace(/`(.*?)`/g, '$1') // Remove code
.replace(/^\s*[-*+]\s+/gm, '') // Remove list markers
.replace(/^\s*\d+\.\s+/gm, '') // Remove numbered list markers
.replace(/\n\n+/g, '\n\n') // Normalize whitespace
.trim();
}
}
// Export for module usage
if (typeof module !== 'undefined' && module.exports) {
module.exports = InsertrMarkdownProcessor;
}
// Global export for browser usage
if (typeof window !== 'undefined') {
window.InsertrMarkdownProcessor = InsertrMarkdownProcessor;
}

View File

@@ -1,194 +0,0 @@
/**
* Insertr Validation Module
* Client-side validation for user experience (not security)
*/
class InsertrValidation {
constructor(config, domPurify = null) {
this.config = config;
this.DOMPurify = domPurify;
this.limits = config.getValidationLimits();
}
/**
* Validate input based on field type
* @param {string} input - Input to validate
* @param {string} fieldType - Type of field
* @returns {Object} Validation result
*/
validateInput(input, fieldType) {
if (!input || typeof input !== 'string') {
return { valid: false, message: 'Content cannot be empty' };
}
// Basic length validation
if (input.length > this.limits.maxContentLength) {
return {
valid: false,
message: `Content is too long (max ${this.limits.maxContentLength.toLocaleString()} characters)`
};
}
// Field-specific validation
switch (fieldType) {
case 'text':
return this.validateTextInput(input);
case 'textarea':
return this.validateTextInput(input);
case 'link':
return this.validateLinkInput(input);
case 'markdown':
return this.validateMarkdownInput(input);
default:
return { valid: true };
}
}
/**
* Validate plain text input
* @param {string} input - Text to validate
* @returns {Object} Validation result
*/
validateTextInput(input) {
// Check for obvious HTML that users might accidentally include
if (input.includes('<script>') || input.includes('</script>')) {
return {
valid: false,
message: 'Script tags are not allowed for security reasons'
};
}
if (input.includes('<') && input.includes('>')) {
return {
valid: false,
message: 'HTML tags are not allowed in text fields. Use markdown collections for formatted content.'
};
}
return { valid: true };
}
/**
* Validate link/URL input
* @param {string} input - URL to validate
* @returns {Object} Validation result
*/
validateLinkInput(input) {
// Basic URL validation for user feedback
const urlPattern = /^(https?:\/\/)?([\da-z\.-]+)\.([a-z\.]{2,6})([\/\w \.-]*)*\/?$/;
if (input.startsWith('http') && !urlPattern.test(input)) {
return {
valid: false,
message: 'Please enter a valid URL (e.g., https://example.com)'
};
}
return { valid: true };
}
/**
* Validate markdown input
* @param {string} input - Markdown to validate
* @returns {Object} Validation result
*/
validateMarkdownInput(input) {
// Check for potentially problematic content
if (input.includes('<script>') || input.includes('javascript:')) {
return {
valid: false,
message: 'Script content is not allowed for security reasons'
};
}
// Warn about excessive HTML (user might be pasting from Word/etc)
const htmlTagCount = (input.match(/<[^>]+>/g) || []).length;
if (htmlTagCount > this.limits.maxHtmlTags) {
return {
valid: false,
message: 'Too much HTML detected. Please use markdown formatting instead of HTML tags.'
};
}
return { valid: true };
}
/**
* Show validation message to user
* @param {HTMLElement} element - Element to show message near
* @param {string} message - Message to show
* @param {boolean} isError - Whether this is an error message
*/
showValidationMessage(element, message, isError = true) {
// Remove existing message
const existingMsg = element.parentNode.querySelector('.insertr-validation-message');
if (existingMsg) {
existingMsg.remove();
}
if (!message) return;
// Create new message
const msgElement = document.createElement('div');
msgElement.className = `insertr-validation-message ${isError ? 'error' : 'success'}`;
msgElement.textContent = message;
// Insert after the element
element.parentNode.insertBefore(msgElement, element.nextSibling);
// Auto-remove after delay
if (!isError) {
setTimeout(() => {
if (msgElement.parentNode) {
msgElement.remove();
}
}, 3000);
}
}
/**
* Sanitize content for display (basic client-side sanitization)
* @param {string} content - Content to sanitize
* @param {string} type - Content type
* @returns {string} Sanitized content
*/
sanitizeForDisplay(content, type) {
if (!content) return '';
switch (type) {
case 'text':
return this.escapeHtml(content);
case 'url':
// Basic URL sanitization
if (content.startsWith('javascript:') || content.startsWith('data:')) {
return '';
}
return content;
case 'markdown':
// For markdown, we'll let marked.js handle it with our safe config
return content;
default:
return this.escapeHtml(content);
}
}
/**
* Escape HTML characters
* @param {string} text - Text to escape
* @returns {string} Escaped text
*/
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
// Export for module usage
if (typeof module !== 'undefined' && module.exports) {
module.exports = InsertrValidation;
}
// Global export for browser usage
if (typeof window !== 'undefined') {
window.InsertrValidation = InsertrValidation;
}