Complete library cleanup and documentation overhaul

## Library Code Cleanup (~1,200+ lines removed)
- Remove legacy markdown system (markdown.js, previewer.js)
- Delete unused EditContext code from ui/editor.js (~400 lines)
- Remove version history UI components from form-renderer.js (~180 lines)
- Clean unused CSS styles from insertr.css (~120 lines)
- Update package.json dependencies (remove marked, turndown)

## Documentation Updates
- README.md: Update from markdown to HTML-first approach
- AGENTS.md: Add current architecture guidance and HTML-first principles
- TODO.md: Complete rewrite with realistic roadmap and current status
- demos/README.md: Update for development demo server usage

## System Reality Alignment
- All documentation now reflects current working system
- Removed aspirational features in favor of actual capabilities
- Clear separation between development and production workflows
- Accurate description of style-aware editor with HTML preservation

## Code Cleanup Benefits
- Simplified codebase focused on HTML-first approach
- Removed markdown conversion complexity
- Cleaner build process without unused dependencies
- Better alignment between frontend capabilities and documentation

Ready for Phase 3a server updates with clean foundation.
This commit is contained in:
2025-09-20 00:02:03 +02:00
parent 63939e2c68
commit bb5ea6f873
14 changed files with 343 additions and 1982 deletions

View File

@@ -155,8 +155,8 @@ export class InsertrEditor {
return 'link';
}
// ALL text elements use markdown for consistent editing experience
return 'markdown';
// ALL text elements use text content type
return 'text';
}
handleCancel(meta) {

View File

@@ -135,7 +135,7 @@ export class InsertrCore {
const tag = element.tagName.toLowerCase();
if (element.classList.contains('insertr-group')) {
return 'markdown';
return 'group';
}
switch (tag) {
@@ -146,9 +146,9 @@ export class InsertrCore {
case 'a': case 'button':
return 'link';
case 'div': case 'section':
return 'markdown';
return 'text';
case 'span':
return 'markdown'; // Match backend: spans support inline markdown
return 'text';
default:
return 'text';
}

View File

@@ -11,7 +11,7 @@
* - .insertr-gate: Minimal styling for user-defined gates
* - .insertr-auth-*: Authentication controls and buttons
* - .insertr-form-*: Modal forms and inputs
* - .insertr-version-*: Version history modal
* - .insertr-style-aware-*: Style-aware editor components
*/
/* =================================================================
@@ -546,133 +546,7 @@ body:not(.insertr-edit-mode) .insertr-editing-hover::after {
color: var(--insertr-text-primary);
}
/* =================================================================
VERSION HISTORY MODAL
================================================================= */
.insertr-version-modal {
padding: var(--insertr-spacing-lg);
margin: 0;
font-family: var(--insertr-font-family);
font-size: var(--insertr-font-size-base);
line-height: var(--insertr-line-height);
color: var(--insertr-text-primary);
background: var(--insertr-bg-primary);
}
.insertr-version-header {
margin: 0 0 var(--insertr-spacing-lg) 0;
padding: 0 0 var(--insertr-spacing-md) 0;
border-bottom: 1px solid var(--insertr-border-color);
}
.insertr-version-title {
margin: 0 0 var(--insertr-spacing-sm) 0;
padding: 0;
font-size: var(--insertr-font-size-lg);
font-weight: 600;
color: var(--insertr-text-primary);
}
.insertr-version-help {
margin: 0;
padding: 0;
font-size: var(--insertr-font-size-sm);
color: var(--insertr-text-muted);
}
.insertr-version-list {
margin: 0 0 var(--insertr-spacing-lg) 0;
padding: 0;
max-height: 400px;
overflow-y: auto;
}
.insertr-version-item {
padding: var(--insertr-spacing-sm);
margin: 0 0 var(--insertr-spacing-sm) 0;
border: 1px solid var(--insertr-border-color);
border-radius: var(--insertr-border-radius);
cursor: pointer;
transition: var(--insertr-transition);
background: var(--insertr-bg-primary);
color: var(--insertr-text-primary);
}
.insertr-version-item:hover {
background: var(--insertr-bg-secondary);
}
.insertr-version-item.selected {
border-color: var(--insertr-primary);
background: rgba(0, 123, 255, 0.1);
}
.insertr-version-date {
font-weight: 500;
color: var(--insertr-text-primary);
margin: 0;
padding: 0;
}
.insertr-version-preview {
margin: var(--insertr-spacing-xs) 0 0 0;
padding: 0;
font-size: var(--insertr-font-size-sm);
color: var(--insertr-text-secondary);
white-space: pre-wrap;
word-break: break-word;
}
.insertr-version-actions {
display: flex;
gap: var(--insertr-spacing-sm);
justify-content: flex-end;
margin: 0;
padding: var(--insertr-spacing-md) 0 0 0;
border-top: 1px solid var(--insertr-border-color);
}
.insertr-btn-restore,
.insertr-btn-close {
background: var(--insertr-primary);
color: var(--insertr-text-inverse);
border: none;
border-radius: var(--insertr-border-radius);
padding: var(--insertr-spacing-sm) var(--insertr-spacing-md);
margin: 0;
font-family: var(--insertr-font-family);
font-size: var(--insertr-font-size-base);
font-weight: 500;
cursor: pointer;
transition: var(--insertr-transition);
line-height: var(--insertr-line-height);
text-decoration: none;
display: inline-block;
text-align: center;
vertical-align: middle;
}
.insertr-btn-close {
background: var(--insertr-text-secondary);
color: var(--insertr-text-inverse);
}
.insertr-btn-close:hover {
background: var(--insertr-text-primary);
color: var(--insertr-text-inverse);
}
.insertr-btn-restore:hover {
background: var(--insertr-primary-hover);
color: var(--insertr-text-inverse);
}
.insertr-btn-restore:focus,
.insertr-btn-close:focus {
outline: 2px solid var(--insertr-primary);
outline-offset: 2px;
}
/* =================================================================
STATUS AND FEEDBACK MESSAGES

View File

@@ -2,13 +2,11 @@
* Editor - Handles all content types with style-aware approach
*/
import { StyleAwareEditor } from './style-aware-editor.js';
import { Previewer } from './previewer.js';
export class Editor {
constructor() {
this.currentOverlay = null;
this.currentStyleEditor = null;
this.previewer = new Previewer();
}
/**
@@ -40,8 +38,7 @@ export class Editor {
this.close();
},
onChange: (content) => {
// Optional: trigger live preview
this.handlePreviewChange(primaryElement, content);
// Optional: trigger change events if needed
}
});
@@ -125,237 +122,10 @@ export class Editor {
}
/**
* Handle preview changes from style-aware editor
* Handle content changes from style-aware editor
*/
handlePreviewChange(element, content) {
// Implement live preview if needed
// For now, we'll skip this to avoid complexity
}
/**
* Create editing form for any content type (legacy - keeping for compatibility)
*/
createForm(context, meta) {
const config = this.getFieldConfig(context);
const currentContent = context.extractContent();
const form = document.createElement('div');
form.className = 'insertr-edit-form';
// Build form HTML
let formHTML = `<div class="insertr-form-header">${config.label}</div>`;
// Markdown textarea (always present)
formHTML += this.createMarkdownField(config, currentContent);
// URL field (for links only)
if (config.includeUrl) {
formHTML += this.createUrlField(currentContent);
}
// Form actions
formHTML += `
<div class="insertr-form-actions">
<button type="button" class="insertr-btn-save">Save</button>
<button type="button" class="insertr-btn-cancel">Cancel</button>
<button type="button" class="insertr-btn-history" data-content-id="${meta.contentId}">View History</button>
</div>
`;
form.innerHTML = formHTML;
return form;
}
/**
* Get field configuration for any element type (markdown-first)
*/
getFieldConfig(context) {
const elementCount = context.elements.length;
const primaryElement = context.primaryElement;
const isLink = primaryElement.tagName.toLowerCase() === 'a';
// Multi-element groups
if (elementCount > 1) {
return {
type: 'markdown',
includeUrl: false,
label: `Group Content (${elementCount} elements)`,
rows: Math.max(8, elementCount * 2),
placeholder: 'Edit all content together using markdown...'
};
}
// Single elements - all get markdown by default
const tag = primaryElement.tagName.toLowerCase();
const baseConfig = {
type: 'markdown',
includeUrl: isLink,
placeholder: 'Enter content using markdown...'
};
// Customize by element type
switch (tag) {
case 'h1':
return { ...baseConfig, label: 'Main Headline', rows: 1, placeholder: 'Enter main headline...' };
case 'h2':
return { ...baseConfig, label: 'Subheading', rows: 1, placeholder: 'Enter subheading...' };
case 'h3': case 'h4': case 'h5': case 'h6':
return { ...baseConfig, label: 'Heading', rows: 2, placeholder: 'Enter heading (markdown supported)...' };
case 'p':
return { ...baseConfig, label: 'Content', rows: 4, placeholder: 'Enter content using markdown...' };
case 'span':
return { ...baseConfig, label: 'Text', rows: 2, placeholder: 'Enter text (markdown supported)...' };
case 'button':
return { ...baseConfig, label: 'Button Text', rows: 1, placeholder: 'Enter button text...' };
case 'a':
return { ...baseConfig, label: 'Link', rows: 2, placeholder: 'Enter link text (markdown supported)...' };
default:
return { ...baseConfig, label: 'Content', rows: 3, placeholder: 'Enter content using markdown...' };
}
}
/**
* Create markdown textarea field
*/
createMarkdownField(config, content) {
const textContent = typeof content === 'object' ? content.text || '' : content;
return `
<div class="insertr-form-group">
<textarea class="insertr-form-textarea insertr-markdown-editor" name="content"
rows="${config.rows}"
placeholder="${config.placeholder}">${this.escapeHtml(textContent)}</textarea>
<div class="insertr-form-help">
Supports Markdown formatting (bold, italic, links, etc.)
</div>
</div>
`;
}
/**
* Create URL field for links
*/
createUrlField(content) {
const url = typeof content === 'object' ? content.url || '' : '';
return `
<div class="insertr-form-group">
<label class="insertr-form-label">Link URL:</label>
<input type="url" class="insertr-form-input" name="url"
value="${this.escapeHtml(url)}"
placeholder="https://example.com">
</div>
`;
}
/**
* Setup event handlers
*/
setupEventHandlers(form, overlay, context, { onSave, onCancel }) {
const textarea = form.querySelector('textarea');
const urlInput = form.querySelector('input[name="url"]');
const saveBtn = form.querySelector('.insertr-btn-save');
const cancelBtn = form.querySelector('.insertr-btn-cancel');
const historyBtn = form.querySelector('.insertr-btn-history');
// Initialize previewer
this.previewer.setActiveContext(context);
// Setup live preview for content changes
if (textarea) {
textarea.addEventListener('input', () => {
const content = this.extractFormData(form);
this.previewer.schedulePreview(context, content);
});
}
// Setup live preview for URL changes (links only)
if (urlInput) {
urlInput.addEventListener('input', () => {
const content = this.extractFormData(form);
this.previewer.schedulePreview(context, content);
});
}
// Save handler
if (saveBtn) {
saveBtn.addEventListener('click', () => {
const content = this.extractFormData(form);
// Apply final content to elements
context.applyContent(content);
// Update stored original content to match current state
// This makes the saved content the new baseline for future edits
context.updateOriginalContent();
// Clear preview styling (won't restore content since original matches current)
this.previewer.clearPreview();
// Callback with the content
onSave(content);
this.close();
});
}
// Cancel handler
if (cancelBtn) {
cancelBtn.addEventListener('click', () => {
this.previewer.clearPreview();
onCancel();
this.close();
});
}
// History handler
if (historyBtn) {
historyBtn.addEventListener('click', () => {
const contentId = historyBtn.getAttribute('data-content-id');
console.log('Version history not implemented yet for:', contentId);
// TODO: Implement version history integration
});
}
// ESC key handler
const keyHandler = (e) => {
if (e.key === 'Escape') {
this.previewer.clearPreview();
onCancel();
this.close();
document.removeEventListener('keydown', keyHandler);
}
};
document.addEventListener('keydown', keyHandler);
// Click outside handler
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
this.previewer.clearPreview();
onCancel();
this.close();
}
});
}
/**
* Extract form data consistently
*/
extractFormData(form) {
const textarea = form.querySelector('textarea[name="content"]');
const urlInput = form.querySelector('input[name="url"]');
const content = textarea ? textarea.value : '';
if (urlInput) {
// Link content
return {
text: content,
url: urlInput.value
};
}
// Regular content
return content;
handleContentChange(element, content) {
// Optional: implement change events if needed
}
/**
@@ -445,10 +215,6 @@ export class Editor {
* Close current editor
*/
close() {
if (this.previewer) {
this.previewer.clearPreview();
}
if (this.currentStyleEditor) {
this.currentStyleEditor.destroy();
this.currentStyleEditor = null;
@@ -469,129 +235,4 @@ export class Editor {
div.textContent = text;
return div.innerHTML;
}
}
/**
* EditContext - Represents content elements for editing
*/
class EditContext {
constructor(elements, currentContent) {
this.elements = elements;
this.primaryElement = elements[0];
this.originalContent = null;
this.currentContent = currentContent;
}
/**
* Extract content from elements in markdown format
*/
extractContent() {
if (this.elements.length === 1) {
const element = this.elements[0];
// Handle links specially
if (element.tagName.toLowerCase() === 'a') {
return {
text: markdownConverter.htmlToMarkdown(element.innerHTML),
url: element.href
};
}
// Single element - convert to markdown
return markdownConverter.htmlToMarkdown(element.innerHTML);
} else {
// Multiple elements - use group extraction
return markdownConverter.extractGroupMarkdown(this.elements);
}
}
/**
* Apply content to elements from markdown/object
*/
applyContent(content) {
if (this.elements.length === 1) {
const element = this.elements[0];
// Handle links specially
if (element.tagName.toLowerCase() === 'a' && typeof content === 'object') {
element.innerHTML = markdownConverter.markdownToHtml(content.text || '');
if (content.url) {
element.href = content.url;
}
return;
}
// Single element - convert markdown to HTML
const html = markdownConverter.markdownToHtml(content);
element.innerHTML = html;
} else {
// Multiple elements - use group update
markdownConverter.updateGroupElements(this.elements, content);
}
}
/**
* Store original content for preview restoration
*/
storeOriginalContent() {
this.originalContent = this.elements.map(el => ({
innerHTML: el.innerHTML,
href: el.href // Store href for links
}));
}
/**
* Restore original content (for preview cancellation)
*/
restoreOriginalContent() {
if (this.originalContent) {
this.elements.forEach((el, index) => {
if (this.originalContent[index] !== undefined) {
el.innerHTML = this.originalContent[index].innerHTML;
if (this.originalContent[index].href) {
el.href = this.originalContent[index].href;
}
}
});
}
}
/**
* Update original content to match current element state (after save)
* This makes the current content the new baseline for future cancellations
*/
updateOriginalContent() {
this.originalContent = this.elements.map(el => ({
innerHTML: el.innerHTML,
href: el.href // Store href for links
}));
}
/**
* Apply preview styling to all elements
*/
applyPreviewStyling() {
this.elements.forEach(el => {
el.classList.add('insertr-preview-active');
});
// Also apply to containers if they're groups
if (this.primaryElement.classList.contains('insertr-group')) {
this.primaryElement.classList.add('insertr-preview-active');
}
}
/**
* Remove preview styling from all elements
*/
removePreviewStyling() {
this.elements.forEach(el => {
el.classList.remove('insertr-preview-active');
});
// Also remove from containers
if (this.primaryElement.classList.contains('insertr-group')) {
this.primaryElement.classList.remove('insertr-preview-active');
}
}
}

View File

@@ -1,5 +1,5 @@
/**
* InsertrFormRenderer - Form renderer using markdown-first approach
* InsertrFormRenderer - Form renderer for content editing
* Thin wrapper around the Editor system
*/
import { Editor } from './editor.js';
@@ -52,190 +52,5 @@ export class InsertrFormRenderer {
this.editor.close();
}
/**
* Show version history modal (placeholder for future implementation)
*/
async showVersionHistory(contentId, element, onRestore) {
try {
// Get version history from API
const apiClient = this.getApiClient();
if (!apiClient) {
console.warn('No API client configured for version history');
return;
}
const versions = await apiClient.getContentVersions(contentId);
// Create version history modal
const historyModal = this.createVersionHistoryModal(contentId, versions, onRestore);
document.body.appendChild(historyModal);
// Setup handlers
this.setupVersionHistoryHandlers(historyModal, contentId);
} catch (error) {
console.error('Failed to load version history:', error);
this.showVersionHistoryError('Failed to load version history. Please try again.');
}
}
/**
* Create version history modal (simplified placeholder)
*/
createVersionHistoryModal(contentId, versions, onRestore) {
const modal = document.createElement('div');
modal.className = 'insertr-version-modal';
let versionsHTML = '';
if (versions && versions.length > 0) {
versionsHTML = versions.map((version, index) => `
<div class="insertr-version-item" data-version-id="${version.version_id}">
<div class="insertr-version-meta">
<span class="insertr-version-label">${index === 0 ? 'Previous Version' : `Version ${versions.length - index}`}</span>
<span class="insertr-version-date">${this.formatDate(version.created_at)}</span>
${version.created_by ? `<span class="insertr-version-user">by ${version.created_by}</span>` : ''}
</div>
<div class="insertr-version-content">${this.escapeHtml(this.truncateContent(version.value, 100))}</div>
<div class="insertr-version-actions">
<button type="button" class="insertr-btn-restore" data-version-id="${version.version_id}">Restore</button>
<button type="button" class="insertr-btn-view-diff" data-version-id="${version.version_id}">View Full</button>
</div>
</div>
`).join('');
} else {
versionsHTML = '<div class="insertr-version-empty">No previous versions found</div>';
}
modal.innerHTML = `
<div class="insertr-version-backdrop">
<div class="insertr-version-content-modal">
<div class="insertr-version-header">
<h3>Version History</h3>
<button type="button" class="insertr-btn-close">&times;</button>
</div>
<div class="insertr-version-list">
${versionsHTML}
</div>
</div>
</div>
`;
return modal;
}
/**
* Setup version history modal handlers
*/
setupVersionHistoryHandlers(modal, contentId) {
const closeBtn = modal.querySelector('.insertr-btn-close');
const backdrop = modal.querySelector('.insertr-version-backdrop');
// Close handlers
if (closeBtn) {
closeBtn.addEventListener('click', () => modal.remove());
}
backdrop.addEventListener('click', (e) => {
if (e.target === backdrop) {
modal.remove();
}
});
// Restore handlers
const restoreButtons = modal.querySelectorAll('.insertr-btn-restore');
restoreButtons.forEach(btn => {
btn.addEventListener('click', async () => {
const versionId = btn.getAttribute('data-version-id');
if (await this.confirmRestore()) {
await this.restoreVersion(contentId, versionId);
modal.remove();
this.closeForm();
}
});
});
// View diff handlers
const viewButtons = modal.querySelectorAll('.insertr-btn-view-diff');
viewButtons.forEach(btn => {
btn.addEventListener('click', () => {
const versionId = btn.getAttribute('data-version-id');
this.showVersionDetails(versionId);
});
});
}
/**
* Helper methods for version history
*/
formatDate(dateString) {
const date = new Date(dateString);
const now = new Date();
const diff = now - date;
// Less than 24 hours ago
if (diff < 24 * 60 * 60 * 1000) {
const hours = Math.floor(diff / (60 * 60 * 1000));
if (hours < 1) {
const minutes = Math.floor(diff / (60 * 1000));
return `${minutes}m ago`;
}
return `${hours}h ago`;
}
// Less than 7 days ago
if (diff < 7 * 24 * 60 * 60 * 1000) {
const days = Math.floor(diff / (24 * 60 * 60 * 1000));
return `${days}d ago`;
}
// Older - show actual date
return date.toLocaleDateString();
}
truncateContent(content, maxLength) {
if (content.length <= maxLength) return content;
return content.substring(0, maxLength) + '...';
}
async confirmRestore() {
return confirm('Are you sure you want to restore this version? This will replace the current content.');
}
async restoreVersion(contentId, versionId) {
try {
const apiClient = this.getApiClient();
await apiClient.rollbackContent(contentId, versionId);
return true;
} catch (error) {
console.error('Failed to restore version:', error);
alert('Failed to restore version. Please try again.');
return false;
}
}
showVersionDetails(versionId) {
// TODO: Implement detailed version view with diff
alert(`Version details not implemented yet (Version ID: ${versionId})`);
}
showVersionHistoryError(message) {
alert(message);
}
// Helper to get API client
getApiClient() {
return this.apiClient || window.insertrAPIClient || null;
}
/**
* Escape HTML to prevent XSS
*/
escapeHtml(text) {
if (typeof text !== 'string') return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}

View File

@@ -1,157 +0,0 @@
/**
* Previewer - Handles live preview for all content types
*/
import { markdownConverter } from '../utils/markdown.js';
export class Previewer {
constructor() {
this.previewTimeout = null;
this.activeContext = null;
this.resizeObserver = null;
this.onHeightChange = null;
}
/**
* Set the active editing context for preview
*/
setActiveContext(context) {
this.clearPreview();
this.activeContext = context;
this.startResizeObserver();
}
/**
* Schedule a preview update with debouncing
*/
schedulePreview(context, content) {
// Clear existing timeout
if (this.previewTimeout) {
clearTimeout(this.previewTimeout);
}
// Schedule new preview with 500ms debounce
this.previewTimeout = setTimeout(() => {
this.updatePreview(context, content);
}, 500);
}
/**
* Update preview with new content
*/
updatePreview(context, content) {
// Store original content if first preview
if (!context.originalContent) {
context.storeOriginalContent();
}
// Apply preview content to elements
this.applyPreviewContent(context, content);
context.applyPreviewStyling();
}
/**
* Apply preview content to context elements
*/
applyPreviewContent(context, content) {
if (context.elements.length === 1) {
const element = context.elements[0];
// Handle links specially
if (element.tagName.toLowerCase() === 'a') {
if (typeof content === 'object') {
// Update link text (markdown to HTML)
if (content.text !== undefined) {
const html = markdownConverter.markdownToHtml(content.text);
element.innerHTML = html;
}
// Update link URL
if (content.url !== undefined && content.url.trim()) {
element.href = content.url;
}
} else if (content && content.trim()) {
// Just markdown content for link text
const html = markdownConverter.markdownToHtml(content);
element.innerHTML = html;
}
return;
}
// Regular single element
if (content && content.trim()) {
const html = markdownConverter.markdownToHtml(content);
element.innerHTML = html;
}
} else {
// Multiple elements - use group update
if (content && content.trim()) {
markdownConverter.updateGroupElements(context.elements, content);
}
}
}
/**
* Clear all preview state and restore original content
*/
clearPreview() {
if (this.activeContext) {
this.activeContext.restoreOriginalContent();
this.activeContext.removePreviewStyling();
this.activeContext = null;
}
if (this.previewTimeout) {
clearTimeout(this.previewTimeout);
this.previewTimeout = null;
}
this.stopResizeObserver();
}
/**
* Start observing element size changes for modal repositioning
*/
startResizeObserver() {
this.stopResizeObserver();
if (this.activeContext) {
this.resizeObserver = new ResizeObserver(() => {
// Handle height changes for modal repositioning
if (this.onHeightChange) {
this.onHeightChange(this.activeContext.primaryElement);
}
});
// Observe all elements in the context
this.activeContext.elements.forEach(el => {
this.resizeObserver.observe(el);
});
}
}
/**
* Stop observing element size changes
*/
stopResizeObserver() {
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
}
/**
* Set callback for height changes (for modal repositioning)
*/
setHeightChangeCallback(callback) {
this.onHeightChange = callback;
}
/**
* Get unique element ID for tracking
*/
getElementId(element) {
if (!element._insertrId) {
element._insertrId = 'insertr_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
}
return element._insertrId;
}
}

View File

@@ -1,288 +0,0 @@
/**
* Markdown conversion utilities using Marked and Turndown
*/
import { marked } from 'marked';
import TurndownService from 'turndown';
/**
* MarkdownConverter - Handles bidirectional HTML ↔ Markdown conversion
*/
export class MarkdownConverter {
constructor() {
this.initializeMarked();
this.initializeTurndown();
}
/**
* Configure marked for HTML output - MINIMAL MODE
* Only supports: **bold**, *italic*, and [links](url)
* Matches server-side goldmark configuration
*/
initializeMarked() {
marked.setOptions({
gfm: false, // Disable GFM to match server minimal mode
breaks: true, // Convert \n to <br> (matches server)
pedantic: false, // Don't be overly strict
sanitize: false, // Allow HTML (we control the input)
smartLists: false, // Disable lists (not supported on server)
smartypants: false // Don't convert quotes/dashes
});
// Override renderers to restrict to minimal feature set
marked.use({
renderer: {
// Disable headings - treat as plain text
heading(text, level) {
return text;
},
// Disable lists - treat as plain text
list(body, ordered, start) {
return body.replace(/<\/?li>/g, '');
},
listitem(text) {
return text + '\n';
},
// Disable code blocks - treat as plain text
code(code, language) {
return code;
},
blockquote(quote) {
return quote; // Disable blockquotes - treat as plain text
},
// Disable horizontal rules
hr() {
return '';
},
// Disable tables
table(header, body) {
return header + body;
},
tablecell(content, flags) {
return content;
},
tablerow(content) {
return content;
}
}
});
}
/**
* Configure turndown for markdown output - MINIMAL MODE
* Only supports: **bold**, *italic*, and [links](url)
* Matches server-side goldmark configuration
*/
initializeTurndown() {
this.turndown = new TurndownService({
// Minimal configuration - only basic formatting
headingStyle: 'atx', // # headers (but will be disabled)
hr: '---', // horizontal rule (but will be disabled)
bulletListMarker: '-', // bullet list (but will be disabled)
codeBlockStyle: 'fenced', // code blocks (but will be disabled)
fence: '```', // fence marker (but will be disabled)
emDelimiter: '*', // *italic* - matches server
strongDelimiter: '**', // **bold** - matches server
linkStyle: 'inlined', // [text](url) - matches server
linkReferenceStyle: 'full' // full reference links
});
// Add custom rules for better conversion
this.addTurndownRules();
}
/**
* Add custom turndown rules - MINIMAL MODE
* Only supports: **bold**, *italic*, and [links](url)
* Disables all other formatting to match server
*/
addTurndownRules() {
// Handle paragraph spacing properly - ensure double newlines between paragraphs
this.turndown.addRule('paragraph', {
filter: 'p',
replacement: function (content) {
if (!content.trim()) return '';
return content.trim() + '\n\n';
}
});
// Handle bold text in markdown - keep this (supported)
this.turndown.addRule('bold', {
filter: ['strong', 'b'],
replacement: function (content) {
if (!content.trim()) return '';
return '**' + content + '**';
}
});
// Handle italic text in markdown - keep this (supported)
this.turndown.addRule('italic', {
filter: ['em', 'i'],
replacement: function (content) {
if (!content.trim()) return '';
return '*' + content + '*';
}
});
// DISABLE unsupported features - convert to plain text
this.turndown.addRule('disableHeadings', {
filter: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
replacement: function (content) {
return content; // Just return text content, no # markup
}
});
this.turndown.addRule('disableLists', {
filter: ['ul', 'ol', 'li'],
replacement: function (content) {
return content; // Just return text content, no list markup
}
});
this.turndown.addRule('disableCode', {
filter: ['pre', 'code'],
replacement: function (content) {
return content; // Just return text content, no code markup
}
});
this.turndown.addRule('disableBlockquotes', {
filter: 'blockquote',
replacement: function (content) {
return content; // Just return text content, no > markup
}
});
this.turndown.addRule('disableHR', {
filter: 'hr',
replacement: function () {
return ''; // Remove horizontal rules entirely
}
});
}
/**
* Convert HTML to Markdown
* @param {string} html - HTML string to convert
* @returns {string} - Markdown string
*/
htmlToMarkdown(html) {
if (!html || html.trim() === '') {
return '';
}
try {
const markdown = this.turndown.turndown(html);
// Clean up and normalize newlines for proper paragraph separation
return markdown
.replace(/\n{3,}/g, '\n\n') // Replace 3+ newlines with 2
.replace(/^\n+|\n+$/g, '') // Remove leading/trailing newlines
.trim(); // Remove other whitespace
} catch (error) {
console.warn('HTML to Markdown conversion failed:', error);
// Fallback: extract text content
const tempDiv = document.createElement('div');
tempDiv.innerHTML = html;
return tempDiv.textContent || tempDiv.innerText || '';
}
}
/**
* Convert Markdown to HTML
* @param {string} markdown - Markdown string to convert
* @returns {string} - HTML string
*/
markdownToHtml(markdown) {
if (!markdown || markdown.trim() === '') {
return '';
}
try {
const html = marked(markdown);
return html;
} catch (error) {
console.warn('Markdown to HTML conversion failed:', error);
// Fallback: convert line breaks to paragraphs
return markdown
.split(/\n\s*\n/)
.filter(p => p.trim())
.map(p => `<p>${p.trim()}</p>`)
.join('');
}
}
/**
* Extract HTML content from a group of elements
* @param {HTMLElement[]} elements - Array of DOM elements
* @returns {string} - Combined HTML content
*/
extractGroupHTML(elements) {
const htmlParts = [];
elements.forEach(element => {
// Wrap inner content in paragraph tags to preserve structure
const html = element.innerHTML.trim();
if (html) {
// If element is already a paragraph, use its outer HTML
if (element.tagName.toLowerCase() === 'p') {
htmlParts.push(element.outerHTML);
} else {
// Wrap in paragraph tags
htmlParts.push(`<p>${html}</p>`);
}
}
});
return htmlParts.join('\n');
}
/**
* Convert HTML content from group elements to markdown
* @param {HTMLElement[]} elements - Array of DOM elements
* @returns {string} - Markdown representation
*/
extractGroupMarkdown(elements) {
const html = this.extractGroupHTML(elements);
const markdown = this.htmlToMarkdown(html);
return markdown;
}
/**
* Update group elements with markdown content
* @param {HTMLElement[]} elements - Array of DOM elements to update
* @param {string} markdown - Markdown content to render
*/
updateGroupElements(elements, markdown) {
const html = this.markdownToHtml(markdown);
// Split HTML into paragraphs
const tempDiv = document.createElement('div');
tempDiv.innerHTML = html;
const paragraphs = Array.from(tempDiv.querySelectorAll('p, div, h1, h2, h3, h4, h5, h6'));
// Handle case where we have more/fewer paragraphs than elements
const maxCount = Math.max(elements.length, paragraphs.length);
for (let i = 0; i < maxCount; i++) {
if (i < elements.length && i < paragraphs.length) {
// Update existing element with corresponding paragraph
elements[i].innerHTML = paragraphs[i].innerHTML;
} else if (i < elements.length) {
// More elements than paragraphs - clear extra elements
elements[i].innerHTML = '';
} else if (i < paragraphs.length) {
// More paragraphs than elements - create new element
const newElement = document.createElement('p');
newElement.innerHTML = paragraphs[i].innerHTML;
// Insert after the last existing element
const lastElement = elements[elements.length - 1];
lastElement.parentNode.insertBefore(newElement, lastElement.nextSibling);
elements.push(newElement); // Add to our elements array for future updates
}
}
}
}
// Export singleton instance
export const markdownConverter = new MarkdownConverter();