Refactor to modular architecture with extensible configuration system

- Split monolithic insertr.js (932 lines) into 6 focused modules
- Extract configuration system for extensible field types and validation
- Separate validation, form rendering, content management, and markdown processing
- Maintain same API surface while improving maintainability and testability
- Update demo pages to use modular system
- Remove legacy support for cleaner codebase
This commit is contained in:
2025-09-01 13:55:01 +02:00
parent e639c5e807
commit afd4879cef
9 changed files with 1385 additions and 755 deletions

View File

@@ -94,9 +94,17 @@ Perfect for:
### Basic Setup
```html
<!-- Include marked.js for markdown support -->
<!-- Include required dependencies -->
<script src="https://cdn.jsdelivr.net/npm/marked@16.2.1/lib/marked.umd.js"></script>
<!-- Include Insertr -->
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.5/dist/purify.min.js"></script>
<!-- Include Insertr modules -->
<link rel="stylesheet" href="insertr/insertr.css">
<script src="insertr/config.js"></script>
<script src="insertr/validation.js"></script>
<script src="insertr/form-renderer.js"></script>
<script src="insertr/content-manager.js"></script>
<script src="insertr/markdown-processor.js"></script>
<script src="insertr/insertr.js"></script>
```

View File

@@ -58,21 +58,21 @@
<div class="insertr" data-content-id="member1-info" data-field-type="markdown">
<h3>Sarah Chen</h3>
<p><strong>Founder & CEO</strong></p>
<p>Former **McKinsey consultant** with 15 years of experience in strategy and operations. MBA from Stanford.</p>
<p>Former <strong>McKinsey consultant</strong> with 15 years of experience in strategy and operations. MBA from Stanford.</p>
</div>
</div>
<div class="service-card">
<div class="insertr" data-content-id="member2-info" data-field-type="markdown">
<h3>Michael Rodriguez</h3>
<p><strong>Head of Operations</strong></p>
<p>20 years in manufacturing and supply chain optimization. Expert in **lean methodologies** and process improvement.</p>
<p>20 years in manufacturing and supply chain optimization. Expert in <strong>lean methodologies</strong> and process improvement.</p>
</div>
</div>
<div class="service-card">
<div class="insertr" data-content-id="member3-info" data-field-type="markdown">
<h3>Emma Thompson</h3>
<p><strong>Digital Strategy Lead</strong></p>
<p>Former tech startup founder turned consultant. Specializes in *digital transformation* and technology adoption.</p>
<p>Former tech startup founder turned consultant. Specializes in <em>digital transformation</em> and technology adoption.</p>
</div>
</div>
</div>
@@ -111,6 +111,11 @@
<!-- Insertr JavaScript Library -->
<script src="https://cdn.jsdelivr.net/npm/marked@16.2.1/lib/marked.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.5/dist/purify.min.js"></script>
<script src="insertr/config.js"></script>
<script src="insertr/validation.js"></script>
<script src="insertr/form-renderer.js"></script>
<script src="insertr/content-manager.js"></script>
<script src="insertr/markdown-processor.js"></script>
<script src="insertr/insertr.js"></script>
</body>
</html>

View File

@@ -87,6 +87,11 @@
<!-- Insertr JavaScript Library -->
<script src="https://cdn.jsdelivr.net/npm/marked@16.2.1/lib/marked.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.5/dist/purify.min.js"></script>
<script src="insertr/config.js"></script>
<script src="insertr/validation.js"></script>
<script src="insertr/form-renderer.js"></script>
<script src="insertr/content-manager.js"></script>
<script src="insertr/markdown-processor.js"></script>
<script src="insertr/insertr.js"></script>
</body>
</html>

175
demo-site/insertr/config.js Normal file
View File

@@ -0,0 +1,175 @@
/**
* 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

@@ -0,0 +1,268 @@
/**
* 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

@@ -0,0 +1,305 @@
/**
* 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;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,194 @@
/**
* 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

@@ -0,0 +1,194 @@
/**
* 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;
}