quikdown 1.0.3 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2317 @@
1
+ /**
2
+ * Quikdown Editor - Drop-in Markdown Parser
3
+ * @version 1.0.5
4
+ * @license BSD-2-Clause
5
+ * @copyright DeftIO 2025
6
+ */
7
+ /**
8
+ * quikdown - A minimal markdown parser optimized for chat/LLM output
9
+ * Supports tables, code blocks, lists, and common formatting
10
+ * @param {string} markdown - The markdown source text
11
+ * @param {Object} options - Optional configuration object
12
+ * @param {Function} options.fence_plugin - Custom renderer for fenced code blocks
13
+ * (content, fence_string) => html string
14
+ * @param {boolean} options.inline_styles - If true, uses inline styles instead of classes
15
+ * @param {boolean} options.bidirectional - If true, adds data-qd attributes for source tracking
16
+ * @param {boolean} options.lazy_linefeeds - If true, single newlines become <br> tags
17
+ * @returns {string} - The rendered HTML
18
+ */
19
+
20
+ // Version will be injected at build time
21
+ const quikdownVersion = '1.0.5';
22
+
23
+ // Constants for reuse
24
+ const CLASS_PREFIX = 'quikdown-';
25
+ const PLACEHOLDER_CB = '§CB';
26
+ const PLACEHOLDER_IC = '§IC';
27
+
28
+ // Escape map at module level
29
+ const ESC_MAP = {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'};
30
+
31
+ // Single source of truth for all style definitions - optimized
32
+ const QUIKDOWN_STYLES = {
33
+ h1: 'font-size:2em;font-weight:600;margin:.67em 0;text-align:left',
34
+ h2: 'font-size:1.5em;font-weight:600;margin:.83em 0',
35
+ h3: 'font-size:1.25em;font-weight:600;margin:1em 0',
36
+ h4: 'font-size:1em;font-weight:600;margin:1.33em 0',
37
+ h5: 'font-size:.875em;font-weight:600;margin:1.67em 0',
38
+ h6: 'font-size:.85em;font-weight:600;margin:2em 0',
39
+ pre: 'background:#f4f4f4;padding:10px;border-radius:4px;overflow-x:auto;margin:1em 0',
40
+ code: 'background:#f0f0f0;padding:2px 4px;border-radius:3px;font-family:monospace',
41
+ blockquote: 'border-left:4px solid #ddd;margin-left:0;padding-left:1em',
42
+ table: 'border-collapse:collapse;width:100%;margin:1em 0',
43
+ th: 'border:1px solid #ddd;padding:8px;background-color:#f2f2f2;font-weight:bold;text-align:left',
44
+ td: 'border:1px solid #ddd;padding:8px;text-align:left',
45
+ hr: 'border:none;border-top:1px solid #ddd;margin:1em 0',
46
+ img: 'max-width:100%;height:auto',
47
+ a: 'color:#06c;text-decoration:underline',
48
+ strong: 'font-weight:bold',
49
+ em: 'font-style:italic',
50
+ del: 'text-decoration:line-through',
51
+ ul: 'margin:.5em 0;padding-left:2em',
52
+ ol: 'margin:.5em 0;padding-left:2em',
53
+ li: 'margin:.25em 0',
54
+ // Task list specific styles
55
+ 'task-item': 'list-style:none',
56
+ 'task-checkbox': 'margin-right:.5em'
57
+ };
58
+
59
+ // Factory function to create getAttr for a given context
60
+ function createGetAttr(inline_styles, styles) {
61
+ return function(tag, additionalStyle = '') {
62
+ if (inline_styles) {
63
+ let style = styles[tag];
64
+ if (!style && !additionalStyle) return '';
65
+
66
+ // Remove default text-align if we're adding a different alignment
67
+ if (additionalStyle && additionalStyle.includes('text-align') && style && style.includes('text-align')) {
68
+ style = style.replace(/text-align:[^;]+;?/, '').trim();
69
+ if (style && !style.endsWith(';')) style += ';';
70
+ }
71
+
72
+ /* istanbul ignore next - defensive: additionalStyle without style doesn't occur with current tags */
73
+ const fullStyle = additionalStyle ? (style ? `${style}${additionalStyle}` : additionalStyle) : style;
74
+ return ` style="${fullStyle}"`;
75
+ } else {
76
+ const classAttr = ` class="${CLASS_PREFIX}${tag}"`;
77
+ // Apply inline styles for alignment even when using CSS classes
78
+ if (additionalStyle) {
79
+ return `${classAttr} style="${additionalStyle}"`;
80
+ }
81
+ return classAttr;
82
+ }
83
+ };
84
+ }
85
+
86
+ function quikdown(markdown, options = {}) {
87
+ if (!markdown || typeof markdown !== 'string') {
88
+ return '';
89
+ }
90
+
91
+ const { fence_plugin, inline_styles = false, bidirectional = false, lazy_linefeeds = false } = options;
92
+ const styles = QUIKDOWN_STYLES; // Use module-level styles
93
+ const getAttr = createGetAttr(inline_styles, styles); // Create getAttr once
94
+
95
+ // Escape HTML entities to prevent XSS
96
+ function escapeHtml(text) {
97
+ return text.replace(/[&<>"']/g, m => ESC_MAP[m]);
98
+ }
99
+
100
+ // Helper to add data-qd attributes for bidirectional support
101
+ const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';
102
+
103
+ // Sanitize URLs to prevent XSS attacks
104
+ function sanitizeUrl(url, allowUnsafe = false) {
105
+ /* istanbul ignore next - defensive programming, regex ensures url is never empty */
106
+ if (!url) return '';
107
+
108
+ // If unsafe URLs are explicitly allowed, return as-is
109
+ if (allowUnsafe) return url;
110
+
111
+ const trimmedUrl = url.trim();
112
+ const lowerUrl = trimmedUrl.toLowerCase();
113
+
114
+ // Block dangerous protocols
115
+ const dangerousProtocols = ['javascript:', 'vbscript:', 'data:'];
116
+
117
+ for (const protocol of dangerousProtocols) {
118
+ if (lowerUrl.startsWith(protocol)) {
119
+ // Exception: Allow data:image/* for images
120
+ if (protocol === 'data:' && lowerUrl.startsWith('data:image/')) {
121
+ return trimmedUrl;
122
+ }
123
+ // Return safe empty link for dangerous protocols
124
+ return '#';
125
+ }
126
+ }
127
+
128
+ return trimmedUrl;
129
+ }
130
+
131
+ // Process the markdown in phases
132
+ let html = markdown;
133
+
134
+ // Phase 1: Extract and protect code blocks and inline code
135
+ const codeBlocks = [];
136
+ const inlineCodes = [];
137
+
138
+ // Extract fenced code blocks first (supports both ``` and ~~~)
139
+ // Match paired fences - ``` with ``` and ~~~ with ~~~
140
+ // Fence must be at start of line
141
+ html = html.replace(/^(```|~~~)([^\n]*)\n([\s\S]*?)^\1$/gm, (match, fence, lang, code) => {
142
+ const placeholder = `${PLACEHOLDER_CB}${codeBlocks.length}§`;
143
+
144
+ // Trim the language specification
145
+ const langTrimmed = lang ? lang.trim() : '';
146
+
147
+ // If custom fence plugin is provided, use it
148
+ if (fence_plugin && typeof fence_plugin === 'function') {
149
+ codeBlocks.push({
150
+ lang: langTrimmed,
151
+ code: code.trimEnd(),
152
+ custom: true,
153
+ fence: fence
154
+ });
155
+ } else {
156
+ codeBlocks.push({
157
+ lang: langTrimmed,
158
+ code: escapeHtml(code.trimEnd()),
159
+ custom: false,
160
+ fence: fence
161
+ });
162
+ }
163
+ return placeholder;
164
+ });
165
+
166
+ // Extract inline code
167
+ html = html.replace(/`([^`]+)`/g, (match, code) => {
168
+ const placeholder = `${PLACEHOLDER_IC}${inlineCodes.length}§`;
169
+ inlineCodes.push(escapeHtml(code));
170
+ return placeholder;
171
+ });
172
+
173
+ // Now escape HTML in the rest of the content
174
+ html = escapeHtml(html);
175
+
176
+ // Phase 2: Process block elements
177
+
178
+ // Process tables
179
+ html = processTable(html, getAttr);
180
+
181
+ // Process headings (supports optional trailing #'s)
182
+ html = html.replace(/^(#{1,6})\s+(.+?)\s*#*$/gm, (match, hashes, content) => {
183
+ const level = hashes.length;
184
+ return `<h${level}${getAttr('h' + level)}${dataQd(hashes)}>${content}</h${level}>`;
185
+ });
186
+
187
+ // Process blockquotes (must handle escaped > since we already escaped HTML)
188
+ html = html.replace(/^&gt;\s+(.+)$/gm, `<blockquote${getAttr('blockquote')}>$1</blockquote>`);
189
+ // Merge consecutive blockquotes
190
+ html = html.replace(/<\/blockquote>\n<blockquote>/g, '\n');
191
+
192
+ // Process horizontal rules
193
+ html = html.replace(/^---+$/gm, `<hr${getAttr('hr')}>`);
194
+
195
+ // Process lists
196
+ html = processLists(html, getAttr, inline_styles, bidirectional);
197
+
198
+ // Phase 3: Process inline elements
199
+
200
+ // Images (must come before links, with URL sanitization)
201
+ html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
202
+ const sanitizedSrc = sanitizeUrl(src, options.allow_unsafe_urls);
203
+ const altAttr = bidirectional && alt ? ` data-qd-alt="${escapeHtml(alt)}"` : '';
204
+ const srcAttr = bidirectional ? ` data-qd-src="${escapeHtml(src)}"` : '';
205
+ return `<img${getAttr('img')} src="${sanitizedSrc}" alt="${alt}"${altAttr}${srcAttr}${dataQd('!')}>`;
206
+ });
207
+
208
+ // Links (with URL sanitization)
209
+ html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, href) => {
210
+ // Sanitize URL to prevent XSS
211
+ const sanitizedHref = sanitizeUrl(href, options.allow_unsafe_urls);
212
+ const isExternal = /^https?:\/\//i.test(sanitizedHref);
213
+ const rel = isExternal ? ' rel="noopener noreferrer"' : '';
214
+ const textAttr = bidirectional ? ` data-qd-text="${escapeHtml(text)}"` : '';
215
+ return `<a${getAttr('a')} href="${sanitizedHref}"${rel}${textAttr}${dataQd('[')}>${text}</a>`;
216
+ });
217
+
218
+ // Autolinks - convert bare URLs to clickable links
219
+ html = html.replace(/(^|\s)(https?:\/\/[^\s<]+)/g, (match, prefix, url) => {
220
+ const sanitizedUrl = sanitizeUrl(url, options.allow_unsafe_urls);
221
+ return `${prefix}<a${getAttr('a')} href="${sanitizedUrl}" rel="noopener noreferrer">${url}</a>`;
222
+ });
223
+
224
+ // Process inline formatting (bold, italic, strikethrough)
225
+ const inlinePatterns = [
226
+ [/\*\*(.+?)\*\*/g, 'strong', '**'],
227
+ [/__(.+?)__/g, 'strong', '__'],
228
+ [/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, 'em', '*'],
229
+ [/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, 'em', '_'],
230
+ [/~~(.+?)~~/g, 'del', '~~']
231
+ ];
232
+
233
+ inlinePatterns.forEach(([pattern, tag, marker]) => {
234
+ html = html.replace(pattern, `<${tag}${getAttr(tag)}${dataQd(marker)}>$1</${tag}>`);
235
+ });
236
+
237
+ // Line breaks
238
+ if (lazy_linefeeds) {
239
+ // Lazy linefeeds: single newline becomes <br> (except between paragraphs and after/before block elements)
240
+ const blocks = [];
241
+ let bi = 0;
242
+
243
+ // Protect tables and lists
244
+ html = html.replace(/<(table|[uo]l)[^>]*>[\s\S]*?<\/\1>/g, m => {
245
+ blocks[bi] = m;
246
+ return `§B${bi++}§`;
247
+ });
248
+
249
+ // Handle paragraphs and block elements
250
+ html = html.replace(/\n\n+/g, '§P§')
251
+ // After block elements
252
+ .replace(/(<\/(?:h[1-6]|blockquote|pre)>)\n/g, '$1§N§')
253
+ .replace(/(<(?:h[1-6]|blockquote|pre|hr)[^>]*>)\n/g, '$1§N§')
254
+ // Before block elements
255
+ .replace(/\n(<(?:h[1-6]|blockquote|pre|hr)[^>]*>)/g, '§N§$1')
256
+ .replace(/\n(§B\d+§)/g, '§N§$1')
257
+ .replace(/(§B\d+§)\n/g, '$1§N§')
258
+ // Convert remaining newlines
259
+ .replace(/\n/g, `<br${getAttr('br')}>`)
260
+ // Restore
261
+ .replace(/§N§/g, '\n')
262
+ .replace(/§P§/g, '</p><p>');
263
+
264
+ // Restore protected blocks
265
+ blocks.forEach((b, i) => html = html.replace(`§B${i}§`, b));
266
+
267
+ html = '<p>' + html + '</p>';
268
+ } else {
269
+ // Standard: two spaces at end of line for line breaks
270
+ html = html.replace(/ $/gm, `<br${getAttr('br')}>`);
271
+
272
+ // Paragraphs (double newlines)
273
+ html = html.replace(/\n\n+/g, '</p><p>');
274
+ html = '<p>' + html + '</p>';
275
+ }
276
+
277
+ // Clean up empty paragraphs and unwrap block elements
278
+ const cleanupPatterns = [
279
+ [/<p><\/p>/g, ''],
280
+ [/<p>(<h[1-6][^>]*>)/g, '$1'],
281
+ [/(<\/h[1-6]>)<\/p>/g, '$1'],
282
+ [/<p>(<blockquote[^>]*>)/g, '$1'],
283
+ [/(<\/blockquote>)<\/p>/g, '$1'],
284
+ [/<p>(<ul[^>]*>|<ol[^>]*>)/g, '$1'],
285
+ [/(<\/ul>|<\/ol>)<\/p>/g, '$1'],
286
+ [/<p>(<hr[^>]*>)<\/p>/g, '$1'],
287
+ [/<p>(<table[^>]*>)/g, '$1'],
288
+ [/(<\/table>)<\/p>/g, '$1'],
289
+ [/<p>(<pre[^>]*>)/g, '$1'],
290
+ [/(<\/pre>)<\/p>/g, '$1'],
291
+ [new RegExp(`<p>(${PLACEHOLDER_CB}\\d+§)<\/p>`, 'g'), '$1']
292
+ ];
293
+
294
+ cleanupPatterns.forEach(([pattern, replacement]) => {
295
+ html = html.replace(pattern, replacement);
296
+ });
297
+
298
+ // Phase 4: Restore code blocks and inline code
299
+
300
+ // Restore code blocks
301
+ codeBlocks.forEach((block, i) => {
302
+ let replacement;
303
+
304
+ if (block.custom && fence_plugin) {
305
+ // Use custom fence plugin
306
+ replacement = fence_plugin(block.code, block.lang);
307
+ // If plugin returns undefined, fall back to default rendering
308
+ if (replacement === undefined) {
309
+ const langClass = !inline_styles && block.lang ? ` class="language-${block.lang}"` : '';
310
+ const codeAttr = inline_styles ? getAttr('code') : langClass;
311
+ const langAttr = bidirectional && block.lang ? ` data-qd-lang="${escapeHtml(block.lang)}"` : '';
312
+ const fenceAttr = bidirectional ? ` data-qd-fence="${escapeHtml(block.fence)}"` : '';
313
+ replacement = `<pre${getAttr('pre')}${fenceAttr}${langAttr}><code${codeAttr}>${escapeHtml(block.code)}</code></pre>`;
314
+ }
315
+ } else {
316
+ // Default rendering
317
+ const langClass = !inline_styles && block.lang ? ` class="language-${block.lang}"` : '';
318
+ const codeAttr = inline_styles ? getAttr('code') : langClass;
319
+ const langAttr = bidirectional && block.lang ? ` data-qd-lang="${escapeHtml(block.lang)}"` : '';
320
+ const fenceAttr = bidirectional ? ` data-qd-fence="${escapeHtml(block.fence)}"` : '';
321
+ replacement = `<pre${getAttr('pre')}${fenceAttr}${langAttr}><code${codeAttr}>${block.code}</code></pre>`;
322
+ }
323
+
324
+ const placeholder = `${PLACEHOLDER_CB}${i}§`;
325
+ html = html.replace(placeholder, replacement);
326
+ });
327
+
328
+ // Restore inline code
329
+ inlineCodes.forEach((code, i) => {
330
+ const placeholder = `${PLACEHOLDER_IC}${i}§`;
331
+ html = html.replace(placeholder, `<code${getAttr('code')}${dataQd('`')}>${code}</code>`);
332
+ });
333
+
334
+ return html.trim();
335
+ }
336
+
337
+ /**
338
+ * Process inline markdown formatting
339
+ */
340
+ function processInlineMarkdown(text, getAttr) {
341
+
342
+ // Process inline formatting patterns
343
+ const patterns = [
344
+ [/\*\*(.+?)\*\*/g, 'strong'],
345
+ [/__(.+?)__/g, 'strong'],
346
+ [/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, 'em'],
347
+ [/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, 'em'],
348
+ [/~~(.+?)~~/g, 'del'],
349
+ [/`([^`]+)`/g, 'code']
350
+ ];
351
+
352
+ patterns.forEach(([pattern, tag]) => {
353
+ text = text.replace(pattern, `<${tag}${getAttr(tag)}>$1</${tag}>`);
354
+ });
355
+
356
+ return text;
357
+ }
358
+
359
+ /**
360
+ * Process markdown tables
361
+ */
362
+ function processTable(text, getAttr) {
363
+ const lines = text.split('\n');
364
+ const result = [];
365
+ let inTable = false;
366
+ let tableLines = [];
367
+
368
+ for (let i = 0; i < lines.length; i++) {
369
+ const line = lines[i].trim();
370
+
371
+ // Check if this line looks like a table row (with or without trailing |)
372
+ if (line.includes('|') && (line.startsWith('|') || /[^\\|]/.test(line))) {
373
+ if (!inTable) {
374
+ inTable = true;
375
+ tableLines = [];
376
+ }
377
+ tableLines.push(line);
378
+ } else {
379
+ // Not a table line
380
+ if (inTable) {
381
+ // Process the accumulated table
382
+ const tableHtml = buildTable(tableLines, getAttr);
383
+ if (tableHtml) {
384
+ result.push(tableHtml);
385
+ } else {
386
+ // Not a valid table, restore original lines
387
+ result.push(...tableLines);
388
+ }
389
+ inTable = false;
390
+ tableLines = [];
391
+ }
392
+ result.push(lines[i]);
393
+ }
394
+ }
395
+
396
+ // Handle table at end of text
397
+ if (inTable && tableLines.length > 0) {
398
+ const tableHtml = buildTable(tableLines, getAttr);
399
+ if (tableHtml) {
400
+ result.push(tableHtml);
401
+ } else {
402
+ result.push(...tableLines);
403
+ }
404
+ }
405
+
406
+ return result.join('\n');
407
+ }
408
+
409
+ /**
410
+ * Build an HTML table from markdown table lines
411
+ */
412
+ function buildTable(lines, getAttr) {
413
+
414
+ if (lines.length < 2) return null;
415
+
416
+ // Check for separator line (second line should be the separator)
417
+ let separatorIndex = -1;
418
+ for (let i = 1; i < lines.length; i++) {
419
+ // Support separator with or without leading/trailing pipes
420
+ if (/^\|?[\s\-:|]+\|?$/.test(lines[i]) && lines[i].includes('-')) {
421
+ separatorIndex = i;
422
+ break;
423
+ }
424
+ }
425
+
426
+ if (separatorIndex === -1) return null;
427
+
428
+ const headerLines = lines.slice(0, separatorIndex);
429
+ const bodyLines = lines.slice(separatorIndex + 1);
430
+
431
+ // Parse alignment from separator
432
+ const separator = lines[separatorIndex];
433
+ // Handle pipes at start/end or not
434
+ const separatorCells = separator.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
435
+ const alignments = separatorCells.map(cell => {
436
+ const trimmed = cell.trim();
437
+ if (trimmed.startsWith(':') && trimmed.endsWith(':')) return 'center';
438
+ if (trimmed.endsWith(':')) return 'right';
439
+ return 'left';
440
+ });
441
+
442
+ let html = `<table${getAttr('table')}>\n`;
443
+
444
+ // Build header
445
+ // Note: headerLines will always have length > 0 since separatorIndex starts from 1
446
+ html += `<thead${getAttr('thead')}>\n`;
447
+ headerLines.forEach(line => {
448
+ html += `<tr${getAttr('tr')}>\n`;
449
+ // Handle pipes at start/end or not
450
+ const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
451
+ cells.forEach((cell, i) => {
452
+ const alignStyle = alignments[i] && alignments[i] !== 'left' ? `text-align:${alignments[i]}` : '';
453
+ const processedCell = processInlineMarkdown(cell.trim(), getAttr);
454
+ html += `<th${getAttr('th', alignStyle)}>${processedCell}</th>\n`;
455
+ });
456
+ html += '</tr>\n';
457
+ });
458
+ html += '</thead>\n';
459
+
460
+ // Build body
461
+ if (bodyLines.length > 0) {
462
+ html += `<tbody${getAttr('tbody')}>\n`;
463
+ bodyLines.forEach(line => {
464
+ html += `<tr${getAttr('tr')}>\n`;
465
+ // Handle pipes at start/end or not
466
+ const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
467
+ cells.forEach((cell, i) => {
468
+ const alignStyle = alignments[i] && alignments[i] !== 'left' ? `text-align:${alignments[i]}` : '';
469
+ const processedCell = processInlineMarkdown(cell.trim(), getAttr);
470
+ html += `<td${getAttr('td', alignStyle)}>${processedCell}</td>\n`;
471
+ });
472
+ html += '</tr>\n';
473
+ });
474
+ html += '</tbody>\n';
475
+ }
476
+
477
+ html += '</table>';
478
+ return html;
479
+ }
480
+
481
+ /**
482
+ * Process markdown lists (ordered and unordered)
483
+ */
484
+ function processLists(text, getAttr, inline_styles, bidirectional) {
485
+
486
+ const lines = text.split('\n');
487
+ const result = [];
488
+ let listStack = []; // Track nested lists
489
+
490
+ // Helper to escape HTML for data-qd attributes
491
+ const escapeHtml = (text) => text.replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[m]);
492
+ const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';
493
+
494
+ for (let i = 0; i < lines.length; i++) {
495
+ const line = lines[i];
496
+ const match = line.match(/^(\s*)([*\-+]|\d+\.)\s+(.+)$/);
497
+
498
+ if (match) {
499
+ const [, indent, marker, content] = match;
500
+ const level = Math.floor(indent.length / 2);
501
+ const isOrdered = /^\d+\./.test(marker);
502
+ const listType = isOrdered ? 'ol' : 'ul';
503
+
504
+ // Check for task list items
505
+ let listItemContent = content;
506
+ let taskListClass = '';
507
+ const taskMatch = content.match(/^\[([x ])\]\s+(.*)$/i);
508
+ if (taskMatch && !isOrdered) {
509
+ const [, checked, taskContent] = taskMatch;
510
+ const isChecked = checked.toLowerCase() === 'x';
511
+ const checkboxAttr = inline_styles
512
+ ? ' style="margin-right:.5em"'
513
+ : ` class="${CLASS_PREFIX}task-checkbox"`;
514
+ listItemContent = `<input type="checkbox"${checkboxAttr}${isChecked ? ' checked' : ''} disabled> ${taskContent}`;
515
+ taskListClass = inline_styles ? ' style="list-style:none"' : ` class="${CLASS_PREFIX}task-item"`;
516
+ }
517
+
518
+ // Close deeper levels
519
+ while (listStack.length > level + 1) {
520
+ const list = listStack.pop();
521
+ result.push(`</${list.type}>`);
522
+ }
523
+
524
+ // Open new level if needed
525
+ if (listStack.length === level) {
526
+ // Need to open a new list
527
+ listStack.push({ type: listType, level });
528
+ result.push(`<${listType}${getAttr(listType)}>`);
529
+ } else if (listStack.length === level + 1) {
530
+ // Check if we need to switch list type
531
+ const currentList = listStack[listStack.length - 1];
532
+ if (currentList.type !== listType) {
533
+ result.push(`</${currentList.type}>`);
534
+ listStack.pop();
535
+ listStack.push({ type: listType, level });
536
+ result.push(`<${listType}${getAttr(listType)}>`);
537
+ }
538
+ }
539
+
540
+ const liAttr = taskListClass || getAttr('li');
541
+ result.push(`<li${liAttr}${dataQd(marker)}>${listItemContent}</li>`);
542
+ } else {
543
+ // Not a list item, close all lists
544
+ while (listStack.length > 0) {
545
+ const list = listStack.pop();
546
+ result.push(`</${list.type}>`);
547
+ }
548
+ result.push(line);
549
+ }
550
+ }
551
+
552
+ // Close any remaining lists
553
+ while (listStack.length > 0) {
554
+ const list = listStack.pop();
555
+ result.push(`</${list.type}>`);
556
+ }
557
+
558
+ return result.join('\n');
559
+ }
560
+
561
+ /**
562
+ * Emit CSS styles for quikdown elements
563
+ * @param {string} prefix - Optional class prefix (default: 'quikdown-')
564
+ * @param {string} theme - Optional theme: 'light' (default) or 'dark'
565
+ * @returns {string} CSS string with quikdown styles
566
+ */
567
+ quikdown.emitStyles = function(prefix = 'quikdown-', theme = 'light') {
568
+ const styles = QUIKDOWN_STYLES;
569
+
570
+ // Define theme color overrides
571
+ const themeOverrides = {
572
+ dark: {
573
+ '#f4f4f4': '#2a2a2a', // pre background
574
+ '#f0f0f0': '#2a2a2a', // code background
575
+ '#f2f2f2': '#2a2a2a', // th background
576
+ '#ddd': '#3a3a3a', // borders
577
+ '#06c': '#6db3f2', // links
578
+ _textColor: '#e0e0e0'
579
+ },
580
+ light: {
581
+ _textColor: '#333' // Explicit text color for light theme
582
+ }
583
+ };
584
+
585
+ let css = '';
586
+ for (const [tag, style] of Object.entries(styles)) {
587
+ let themedStyle = style;
588
+
589
+ // Apply theme overrides if dark theme
590
+ if (theme === 'dark' && themeOverrides.dark) {
591
+ // Replace colors
592
+ for (const [oldColor, newColor] of Object.entries(themeOverrides.dark)) {
593
+ if (!oldColor.startsWith('_')) {
594
+ themedStyle = themedStyle.replace(new RegExp(oldColor, 'g'), newColor);
595
+ }
596
+ }
597
+
598
+ // Add text color for certain elements in dark theme
599
+ const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote'];
600
+ if (needsTextColor.includes(tag)) {
601
+ themedStyle += `;color:${themeOverrides.dark._textColor}`;
602
+ }
603
+ } else if (theme === 'light' && themeOverrides.light) {
604
+ // Add explicit text color for light theme elements too
605
+ const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote'];
606
+ if (needsTextColor.includes(tag)) {
607
+ themedStyle += `;color:${themeOverrides.light._textColor}`;
608
+ }
609
+ }
610
+
611
+ css += `.${prefix}${tag} { ${themedStyle} }\n`;
612
+ }
613
+
614
+ return css;
615
+ };
616
+
617
+ /**
618
+ * Configure quikdown with options and return a function
619
+ * @param {Object} options - Configuration options
620
+ * @returns {Function} Configured quikdown function
621
+ */
622
+ quikdown.configure = function(options) {
623
+ return function(markdown) {
624
+ return quikdown(markdown, options);
625
+ };
626
+ };
627
+
628
+ /**
629
+ * Version information
630
+ */
631
+ quikdown.version = quikdownVersion;
632
+
633
+ // Export for both CommonJS and ES6
634
+ /* istanbul ignore next */
635
+ if (typeof module !== 'undefined' && module.exports) {
636
+ module.exports = quikdown;
637
+ }
638
+
639
+ // For browser global
640
+ /* istanbul ignore next */
641
+ if (typeof window !== 'undefined') {
642
+ window.quikdown = quikdown;
643
+ }
644
+
645
+ /**
646
+ * quikdown_bd - Bidirectional markdown/HTML converter
647
+ * Extends core quikdown with HTML→Markdown conversion
648
+ *
649
+ * Uses data-qd attributes to preserve original markdown syntax
650
+ * Enables HTML→Markdown conversion for quikdown-generated HTML
651
+ */
652
+
653
+
654
+ /**
655
+ * Create bidirectional version by extending quikdown
656
+ * This wraps quikdown and adds the toMarkdown method
657
+ */
658
+ function quikdown_bd(markdown, options = {}) {
659
+ // Use core quikdown with bidirectional flag to add data-qd attributes
660
+ return quikdown(markdown, { ...options, bidirectional: true });
661
+ }
662
+
663
+ // Copy all properties and methods from quikdown (including version)
664
+ Object.keys(quikdown).forEach(key => {
665
+ quikdown_bd[key] = quikdown[key];
666
+ });
667
+
668
+ // Add the toMarkdown method for HTML→Markdown conversion
669
+ quikdown_bd.toMarkdown = function(htmlOrElement) {
670
+ // Accept either HTML string or DOM element
671
+ let container;
672
+ if (typeof htmlOrElement === 'string') {
673
+ container = document.createElement('div');
674
+ container.innerHTML = htmlOrElement;
675
+ } else if (htmlOrElement instanceof Element) {
676
+ /* istanbul ignore next - browser-only code path, not testable in jsdom */
677
+ container = htmlOrElement;
678
+ } else {
679
+ return '';
680
+ }
681
+
682
+ // Walk the DOM tree and reconstruct markdown
683
+ function walkNode(node, parentContext = {}) {
684
+ if (node.nodeType === Node.TEXT_NODE) {
685
+ // Return text content, preserving whitespace where needed
686
+ return node.textContent;
687
+ }
688
+
689
+ if (node.nodeType !== Node.ELEMENT_NODE) {
690
+ return '';
691
+ }
692
+
693
+ const tag = node.tagName.toLowerCase();
694
+ const dataQd = node.getAttribute('data-qd');
695
+
696
+ // Process children with context
697
+ let childContent = '';
698
+ for (let child of node.childNodes) {
699
+ childContent += walkNode(child, { parentTag: tag, ...parentContext });
700
+ }
701
+
702
+ // Determine markdown based on element and attributes
703
+ switch (tag) {
704
+ case 'h1':
705
+ case 'h2':
706
+ case 'h3':
707
+ case 'h4':
708
+ case 'h5':
709
+ case 'h6':
710
+ const level = parseInt(tag[1]);
711
+ const prefix = dataQd || '#'.repeat(level);
712
+ return `${prefix} ${childContent.trim()}\n\n`;
713
+
714
+ case 'strong':
715
+ case 'b':
716
+ if (!childContent) return ''; // Don't add markers for empty content
717
+ const boldMarker = dataQd || '**';
718
+ return `${boldMarker}${childContent}${boldMarker}`;
719
+
720
+ case 'em':
721
+ case 'i':
722
+ if (!childContent) return ''; // Don't add markers for empty content
723
+ const emMarker = dataQd || '*';
724
+ return `${emMarker}${childContent}${emMarker}`;
725
+
726
+ case 'del':
727
+ case 's':
728
+ case 'strike':
729
+ if (!childContent) return ''; // Don't add markers for empty content
730
+ const delMarker = dataQd || '~~';
731
+ return `${delMarker}${childContent}${delMarker}`;
732
+
733
+ case 'code':
734
+ // Note: code inside pre is handled directly by the pre case using querySelector
735
+ if (!childContent) return ''; // Don't add markers for empty content
736
+ const codeMarker = dataQd || '`';
737
+ return `${codeMarker}${childContent}${codeMarker}`;
738
+
739
+ case 'pre':
740
+ const fence = node.getAttribute('data-qd-fence') || dataQd || '```';
741
+ const lang = node.getAttribute('data-qd-lang') || '';
742
+ // Look for code element child
743
+ const codeEl = node.querySelector('code');
744
+ const codeContent = codeEl ? codeEl.textContent : childContent;
745
+ return `${fence}${lang}\n${codeContent.trimEnd()}\n${fence}\n\n`;
746
+
747
+ case 'blockquote':
748
+ const quoteMarker = dataQd || '>';
749
+ const lines = childContent.trim().split('\n');
750
+ return lines.map(line => `${quoteMarker} ${line}`).join('\n') + '\n\n';
751
+
752
+ case 'hr':
753
+ const hrMarker = dataQd || '---';
754
+ return `${hrMarker}\n\n`;
755
+
756
+ case 'br':
757
+ const brMarker = dataQd || ' ';
758
+ return `${brMarker}\n`;
759
+
760
+ case 'a':
761
+ const linkText = node.getAttribute('data-qd-text') || childContent.trim();
762
+ const href = node.getAttribute('href') || '';
763
+ // Check for autolinks
764
+ if (linkText === href && !dataQd) {
765
+ return `<${href}>`;
766
+ }
767
+ return `[${linkText}](${href})`;
768
+
769
+ case 'img':
770
+ const alt = node.getAttribute('data-qd-alt') || node.getAttribute('alt') || '';
771
+ const src = node.getAttribute('data-qd-src') || node.getAttribute('src') || '';
772
+ const imgMarker = dataQd || '!';
773
+ return `${imgMarker}[${alt}](${src})`;
774
+
775
+ case 'ul':
776
+ case 'ol':
777
+ return walkList(node, tag === 'ol') + '\n';
778
+
779
+ case 'li':
780
+ // Handled by list processor
781
+ return childContent;
782
+
783
+ case 'table':
784
+ return walkTable(node) + '\n\n';
785
+
786
+ case 'p':
787
+ // Check if it's actually a paragraph or just a wrapper
788
+ if (childContent.trim()) {
789
+ return childContent.trim() + '\n\n';
790
+ }
791
+ return '';
792
+
793
+ case 'div':
794
+ // Check if it's a mermaid container
795
+ if (node.classList && node.classList.contains('mermaid-container')) {
796
+ const fence = node.getAttribute('data-qd-fence') || '```';
797
+ const lang = node.getAttribute('data-qd-lang') || 'mermaid';
798
+
799
+ // First check for data-qd-source attribute on the container
800
+ const source = node.getAttribute('data-qd-source');
801
+ if (source) {
802
+ // Decode HTML entities from the attribute (mainly &quot;)
803
+ const temp = document.createElement('textarea');
804
+ temp.innerHTML = source;
805
+ const code = temp.value;
806
+ return `${fence}${lang}\n${code}\n${fence}\n\n`;
807
+ }
808
+
809
+ // Check for source on the pre.mermaid element
810
+ const mermaidPre = node.querySelector('pre.mermaid');
811
+ if (mermaidPre) {
812
+ const preSource = mermaidPre.getAttribute('data-qd-source');
813
+ if (preSource) {
814
+ const temp = document.createElement('textarea');
815
+ temp.innerHTML = preSource;
816
+ const code = temp.value;
817
+ return `${fence}${lang}\n${code}\n${fence}\n\n`;
818
+ }
819
+ }
820
+
821
+ // Fallback: Look for the legacy .mermaid-source element
822
+ const sourceElement = node.querySelector('.mermaid-source');
823
+ if (sourceElement) {
824
+ // Decode HTML entities
825
+ const temp = document.createElement('div');
826
+ temp.innerHTML = sourceElement.innerHTML;
827
+ const code = temp.textContent;
828
+ return `${fence}${lang}\n${code}\n${fence}\n\n`;
829
+ }
830
+
831
+ // Final fallback: try to extract from the mermaid element (unreliable after rendering)
832
+ const mermaidElement = node.querySelector('.mermaid');
833
+ if (mermaidElement && mermaidElement.textContent.includes('graph')) {
834
+ return `${fence}${lang}\n${mermaidElement.textContent.trim()}\n${fence}\n\n`;
835
+ }
836
+ }
837
+ // Check if it's a standalone mermaid diagram (legacy)
838
+ if (node.classList && node.classList.contains('mermaid')) {
839
+ const fence = node.getAttribute('data-qd-fence') || '```';
840
+ const lang = node.getAttribute('data-qd-lang') || 'mermaid';
841
+ const code = node.textContent.trim();
842
+ return `${fence}${lang}\n${code}\n${fence}\n\n`;
843
+ }
844
+ // Pass through other divs
845
+ return childContent;
846
+
847
+ case 'span':
848
+ // Pass through container elements
849
+ return childContent;
850
+
851
+ default:
852
+ return childContent;
853
+ }
854
+ }
855
+
856
+ // Walk list elements
857
+ function walkList(listNode, isOrdered, depth = 0) {
858
+ let result = '';
859
+ let index = 1;
860
+ const indent = ' '.repeat(depth);
861
+
862
+ for (let child of listNode.children) {
863
+ if (child.tagName !== 'LI') continue;
864
+
865
+ const dataQd = child.getAttribute('data-qd');
866
+ let marker = dataQd || (isOrdered ? `${index}.` : '-');
867
+
868
+ // Check for task list checkbox
869
+ const checkbox = child.querySelector('input[type="checkbox"]');
870
+ if (checkbox) {
871
+ const checked = checkbox.checked ? 'x' : ' ';
872
+ marker = '-';
873
+ // Get text without the checkbox
874
+ let text = '';
875
+ for (let node of child.childNodes) {
876
+ if (node.nodeType === Node.TEXT_NODE) {
877
+ text += node.textContent;
878
+ } else if (node.tagName && node.tagName !== 'INPUT') {
879
+ text += walkNode(node);
880
+ }
881
+ }
882
+ result += `${indent}${marker} [${checked}] ${text.trim()}\n`;
883
+ } else {
884
+ let itemContent = '';
885
+
886
+ for (let node of child.childNodes) {
887
+ if (node.tagName === 'UL' || node.tagName === 'OL') {
888
+ itemContent += walkList(node, node.tagName === 'OL', depth + 1);
889
+ } else {
890
+ itemContent += walkNode(node);
891
+ }
892
+ }
893
+
894
+ result += `${indent}${marker} ${itemContent.trim()}\n`;
895
+ }
896
+
897
+ index++;
898
+ }
899
+
900
+ return result;
901
+ }
902
+
903
+ // Walk table elements
904
+ function walkTable(table) {
905
+ let result = '';
906
+ const alignData = table.getAttribute('data-qd-align');
907
+ const alignments = alignData ? alignData.split(',') : [];
908
+
909
+ // Process header
910
+ const thead = table.querySelector('thead');
911
+ if (thead) {
912
+ const headerRow = thead.querySelector('tr');
913
+ if (headerRow) {
914
+ const headers = [];
915
+ for (let th of headerRow.querySelectorAll('th')) {
916
+ headers.push(th.textContent.trim());
917
+ }
918
+ result += '| ' + headers.join(' | ') + ' |\n';
919
+
920
+ // Add separator with alignment
921
+ const separators = headers.map((_, i) => {
922
+ const align = alignments[i] || 'left';
923
+ if (align === 'center') return ':---:';
924
+ if (align === 'right') return '---:';
925
+ return '---';
926
+ });
927
+ result += '| ' + separators.join(' | ') + ' |\n';
928
+ }
929
+ }
930
+
931
+ // Process body
932
+ const tbody = table.querySelector('tbody');
933
+ if (tbody) {
934
+ for (let row of tbody.querySelectorAll('tr')) {
935
+ const cells = [];
936
+ for (let td of row.querySelectorAll('td')) {
937
+ cells.push(td.textContent.trim());
938
+ }
939
+ if (cells.length > 0) {
940
+ result += '| ' + cells.join(' | ') + ' |\n';
941
+ }
942
+ }
943
+ }
944
+
945
+ return result.trim();
946
+ }
947
+
948
+ // Process the DOM tree
949
+ let markdown = walkNode(container);
950
+
951
+ // Clean up
952
+ markdown = markdown.replace(/\n{3,}/g, '\n\n'); // Remove excessive newlines
953
+ markdown = markdown.trim();
954
+
955
+ return markdown;
956
+ };
957
+
958
+ // Override the configure method to return a bidirectional version
959
+ quikdown_bd.configure = function(options) {
960
+ return function(markdown) {
961
+ return quikdown_bd(markdown, options);
962
+ };
963
+ };
964
+
965
+ // Set version
966
+ // Version is already copied from quikdown via Object.keys loop
967
+
968
+ // Export for both module and browser
969
+ /* istanbul ignore next */
970
+ if (typeof module !== 'undefined' && module.exports) {
971
+ module.exports = quikdown_bd;
972
+ }
973
+
974
+ /* istanbul ignore next */
975
+ if (typeof window !== 'undefined') {
976
+ window.quikdown_bd = quikdown_bd;
977
+ }
978
+
979
+ /**
980
+ * Quikdown Editor - A drop-in markdown editor control
981
+ * @version 1.0.5
982
+ * @license BSD-2-Clause
983
+ */
984
+
985
+
986
+ // Default options
987
+ const DEFAULT_OPTIONS = {
988
+ mode: 'split', // 'source' | 'preview' | 'split'
989
+ showToolbar: true,
990
+ theme: 'auto', // 'light' | 'dark' | 'auto'
991
+ lazy_linefeeds: false,
992
+ debounceDelay: 100,
993
+ placeholder: 'Start typing markdown...',
994
+ plugins: {
995
+ highlightjs: false,
996
+ mermaid: false
997
+ },
998
+ customFences: {}, // { 'language': (code, lang) => html }
999
+ enableComplexFences: true // Enable CSV tables, math rendering, SVG, etc.
1000
+ };
1001
+
1002
+ /**
1003
+ * Quikdown Editor - A complete markdown editing solution
1004
+ */
1005
+ class QuikdownEditor {
1006
+ constructor(container, options = {}) {
1007
+ // Resolve container
1008
+ this.container = typeof container === 'string'
1009
+ ? document.querySelector(container)
1010
+ : container;
1011
+
1012
+ if (!this.container) {
1013
+ throw new Error('QuikdownEditor: Invalid container');
1014
+ }
1015
+
1016
+ // Merge options
1017
+ this.options = { ...DEFAULT_OPTIONS, ...options };
1018
+
1019
+ // State
1020
+ this._markdown = '';
1021
+ this._html = '';
1022
+ this.currentMode = this.options.mode;
1023
+ this.updateTimer = null;
1024
+
1025
+ // Initialize
1026
+ this.initPromise = this.init();
1027
+ }
1028
+
1029
+ /**
1030
+ * Initialize the editor
1031
+ */
1032
+ async init() {
1033
+ // Load plugins if requested
1034
+ await this.loadPlugins();
1035
+
1036
+ // Build UI
1037
+ this.buildUI();
1038
+
1039
+ // Attach event listeners
1040
+ this.attachEvents();
1041
+
1042
+ // Apply initial theme
1043
+ this.applyTheme();
1044
+
1045
+ // Set initial mode
1046
+ this.setMode(this.currentMode);
1047
+
1048
+ // Set initial content if provided
1049
+ if (this.options.initialContent) {
1050
+ this.setMarkdown(this.options.initialContent);
1051
+ }
1052
+ }
1053
+
1054
+ /**
1055
+ * Build the editor UI
1056
+ */
1057
+ buildUI() {
1058
+ // Clear container
1059
+ this.container.innerHTML = '';
1060
+
1061
+ // Add editor class
1062
+ this.container.classList.add('qde-container');
1063
+
1064
+ // Create toolbar if enabled
1065
+ if (this.options.showToolbar) {
1066
+ this.toolbar = this.createToolbar();
1067
+ this.container.appendChild(this.toolbar);
1068
+ }
1069
+
1070
+ // Create editor area
1071
+ this.editorArea = document.createElement('div');
1072
+ this.editorArea.className = 'qde-editor';
1073
+
1074
+ // Create source panel
1075
+ this.sourcePanel = document.createElement('div');
1076
+ this.sourcePanel.className = 'qde-source';
1077
+
1078
+ this.sourceTextarea = document.createElement('textarea');
1079
+ this.sourceTextarea.className = 'qde-textarea';
1080
+ this.sourceTextarea.placeholder = this.options.placeholder;
1081
+ this.sourcePanel.appendChild(this.sourceTextarea);
1082
+
1083
+ // Create preview panel
1084
+ this.previewPanel = document.createElement('div');
1085
+ this.previewPanel.className = 'qde-preview';
1086
+ this.previewPanel.contentEditable = true;
1087
+
1088
+ // Add panels to editor
1089
+ this.editorArea.appendChild(this.sourcePanel);
1090
+ this.editorArea.appendChild(this.previewPanel);
1091
+ this.container.appendChild(this.editorArea);
1092
+
1093
+ // Add built-in styles if not already present
1094
+ this.injectStyles();
1095
+ }
1096
+
1097
+ /**
1098
+ * Create toolbar
1099
+ */
1100
+ createToolbar() {
1101
+ const toolbar = document.createElement('div');
1102
+ toolbar.className = 'qde-toolbar';
1103
+
1104
+ // Mode buttons
1105
+ const modes = ['source', 'split', 'preview'];
1106
+ const modeLabels = { source: 'Source', split: 'Split', preview: 'Rendered' };
1107
+ modes.forEach(mode => {
1108
+ const btn = document.createElement('button');
1109
+ btn.className = 'qde-btn';
1110
+ btn.dataset.mode = mode;
1111
+ btn.textContent = modeLabels[mode];
1112
+ btn.title = `Switch to ${modeLabels[mode]} view`;
1113
+ toolbar.appendChild(btn);
1114
+ });
1115
+
1116
+ // Spacer
1117
+ const spacer = document.createElement('span');
1118
+ spacer.className = 'qde-spacer';
1119
+ toolbar.appendChild(spacer);
1120
+
1121
+ // Copy buttons
1122
+ const copyButtons = [
1123
+ { action: 'copy-markdown', text: 'Copy MD', title: 'Copy markdown to clipboard' },
1124
+ { action: 'copy-html', text: 'Copy HTML', title: 'Copy HTML to clipboard' }
1125
+ ];
1126
+
1127
+ copyButtons.forEach(({ action, text, title }) => {
1128
+ const btn = document.createElement('button');
1129
+ btn.className = 'qde-btn';
1130
+ btn.dataset.action = action;
1131
+ btn.textContent = text;
1132
+ btn.title = title;
1133
+ toolbar.appendChild(btn);
1134
+ });
1135
+
1136
+ return toolbar;
1137
+ }
1138
+
1139
+ /**
1140
+ * Inject built-in styles
1141
+ */
1142
+ injectStyles() {
1143
+ if (document.getElementById('qde-styles')) return;
1144
+
1145
+ const style = document.createElement('style');
1146
+ style.id = 'qde-styles';
1147
+ style.textContent = `
1148
+ .qde-container {
1149
+ display: flex;
1150
+ flex-direction: column;
1151
+ height: 100%;
1152
+ border: 1px solid #ddd;
1153
+ border-radius: 4px;
1154
+ overflow: hidden;
1155
+ background: white;
1156
+ }
1157
+
1158
+ .qde-toolbar {
1159
+ display: flex;
1160
+ align-items: center;
1161
+ padding: 8px;
1162
+ background: #f5f5f5;
1163
+ border-bottom: 1px solid #ddd;
1164
+ gap: 4px;
1165
+ }
1166
+
1167
+ .qde-btn {
1168
+ padding: 6px 12px;
1169
+ border: 1px solid #ccc;
1170
+ background: white;
1171
+ border-radius: 3px;
1172
+ cursor: pointer;
1173
+ font-size: 14px;
1174
+ transition: all 0.2s;
1175
+ }
1176
+
1177
+ .qde-btn:hover {
1178
+ background: #e9e9e9;
1179
+ border-color: #999;
1180
+ }
1181
+
1182
+ .qde-btn.active {
1183
+ background: #007bff;
1184
+ color: white;
1185
+ border-color: #0056b3;
1186
+ }
1187
+
1188
+ .qde-spacer {
1189
+ flex: 1;
1190
+ }
1191
+
1192
+ .qde-editor {
1193
+ display: flex;
1194
+ flex: 1;
1195
+ overflow: hidden;
1196
+ }
1197
+
1198
+ .qde-source, .qde-preview {
1199
+ flex: 1;
1200
+ overflow: auto;
1201
+ padding: 16px;
1202
+ }
1203
+
1204
+ .qde-source {
1205
+ border-right: 1px solid #ddd;
1206
+ }
1207
+
1208
+ .qde-textarea {
1209
+ width: 100%;
1210
+ height: 100%;
1211
+ border: none;
1212
+ outline: none;
1213
+ resize: none;
1214
+ font-family: 'Monaco', 'Courier New', monospace;
1215
+ font-size: 14px;
1216
+ line-height: 1.5;
1217
+ }
1218
+
1219
+ .qde-preview {
1220
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
1221
+ font-size: 16px;
1222
+ line-height: 1.6;
1223
+ outline: none;
1224
+ cursor: text; /* Standard text cursor */
1225
+ }
1226
+
1227
+ /* Fence-specific styles */
1228
+ .qde-svg-container {
1229
+ max-width: 100%;
1230
+ overflow: auto;
1231
+ }
1232
+
1233
+ .qde-svg-container svg {
1234
+ max-width: 100%;
1235
+ height: auto;
1236
+ }
1237
+
1238
+ .qde-html-container {
1239
+ /* HTML containers inherit background */
1240
+ margin: 12px 0;
1241
+ }
1242
+
1243
+ .qde-math-container {
1244
+ text-align: center;
1245
+ margin: 16px 0;
1246
+ overflow-x: auto;
1247
+ }
1248
+
1249
+ /* All tables in preview (both regular markdown and CSV) */
1250
+ .qde-preview table {
1251
+ width: 100%;
1252
+ border-collapse: collapse;
1253
+ margin: 12px 0;
1254
+ font-size: 14px;
1255
+ }
1256
+
1257
+ .qde-preview table th,
1258
+ .qde-preview table td {
1259
+ border: 1px solid #ddd;
1260
+ padding: 8px;
1261
+ }
1262
+
1263
+ /* Support for alignment classes from quikdown */
1264
+ .qde-preview .quikdown-left { text-align: left; }
1265
+ .qde-preview .quikdown-center { text-align: center; }
1266
+ .qde-preview .quikdown-right { text-align: right; }
1267
+
1268
+ .qde-preview table th {
1269
+ background: #f5f5f5;
1270
+ font-weight: bold;
1271
+ }
1272
+
1273
+ .qde-preview table tr:nth-child(even) {
1274
+ background: #f9f9f9;
1275
+ }
1276
+
1277
+ /* Specific to CSV-generated tables */
1278
+ .qde-data-table {
1279
+ /* Can add specific CSV table styles here if needed */
1280
+ }
1281
+
1282
+ .qde-json {
1283
+ /* Let highlight.js handle styling */
1284
+ overflow-x: auto;
1285
+ }
1286
+
1287
+ .qde-error {
1288
+ background: #fee;
1289
+ border: 1px solid #fcc;
1290
+ color: #c00;
1291
+ padding: 8px;
1292
+ border-radius: 4px;
1293
+ font-family: monospace;
1294
+ font-size: 12px;
1295
+ }
1296
+
1297
+ /* Read-only complex fence blocks in preview */
1298
+ .qde-preview [contenteditable="false"] {
1299
+ cursor: auto; /* Use automatic cursor (arrow for non-text) */
1300
+ user-select: text;
1301
+ position: relative;
1302
+ }
1303
+
1304
+ /* Ensure proper cursor for editable text elements */
1305
+ .qde-preview p,
1306
+ .qde-preview h1,
1307
+ .qde-preview h2,
1308
+ .qde-preview h3,
1309
+ .qde-preview h4,
1310
+ .qde-preview h5,
1311
+ .qde-preview h6,
1312
+ .qde-preview li,
1313
+ .qde-preview td,
1314
+ .qde-preview th,
1315
+ .qde-preview blockquote,
1316
+ .qde-preview pre[contenteditable="true"],
1317
+ .qde-preview code[contenteditable="true"] {
1318
+ cursor: text;
1319
+ }
1320
+
1321
+
1322
+ /* Non-editable complex renderers */
1323
+ .qde-preview .qde-svg-container[contenteditable="false"],
1324
+ .qde-preview .qde-html-container[contenteditable="false"],
1325
+ .qde-preview .qde-math-container[contenteditable="false"],
1326
+ .qde-preview .mermaid[contenteditable="false"] {
1327
+ opacity: 0.98;
1328
+ }
1329
+
1330
+ /* Subtle hover effect for read-only blocks */
1331
+ .qde-preview [contenteditable="false"]:hover::after {
1332
+ content: "Read-only";
1333
+ position: absolute;
1334
+ top: 2px;
1335
+ right: 2px;
1336
+ font-size: 10px;
1337
+ color: #999;
1338
+ background: rgba(255, 255, 255, 0.9);
1339
+ padding: 2px 4px;
1340
+ border-radius: 2px;
1341
+ pointer-events: none;
1342
+ }
1343
+
1344
+ /* Fix list padding in preview */
1345
+ .qde-preview ul,
1346
+ .qde-preview ol {
1347
+ padding-left: 2em;
1348
+ margin: 0.5em 0;
1349
+ }
1350
+
1351
+ .qde-preview li {
1352
+ margin: 0.25em 0;
1353
+ }
1354
+
1355
+ /* Mode-specific visibility */
1356
+ .qde-mode-source .qde-preview { display: none; }
1357
+ .qde-mode-source .qde-source { border-right: none; }
1358
+ .qde-mode-preview .qde-source { display: none; }
1359
+ .qde-mode-split .qde-source,
1360
+ .qde-mode-split .qde-preview { display: block; }
1361
+
1362
+ /* Dark theme */
1363
+ .qde-dark {
1364
+ background: #1e1e1e;
1365
+ color: #e0e0e0;
1366
+ }
1367
+
1368
+ .qde-dark .qde-toolbar {
1369
+ background: #2d2d2d;
1370
+ border-color: #444;
1371
+ }
1372
+
1373
+ .qde-dark .qde-btn {
1374
+ background: #3a3a3a;
1375
+ color: #e0e0e0;
1376
+ border-color: #555;
1377
+ }
1378
+
1379
+ .qde-dark .qde-btn:hover {
1380
+ background: #4a4a4a;
1381
+ }
1382
+
1383
+ .qde-dark .qde-source {
1384
+ border-color: #444;
1385
+ }
1386
+
1387
+ .qde-dark .qde-textarea {
1388
+ background: #1e1e1e;
1389
+ color: #e0e0e0;
1390
+ }
1391
+
1392
+ .qde-dark .qde-preview {
1393
+ background: #1e1e1e;
1394
+ color: #e0e0e0;
1395
+ }
1396
+
1397
+ /* Dark mode table styles */
1398
+ .qde-dark .qde-preview table th,
1399
+ .qde-dark .qde-preview table td {
1400
+ border-color: #3a3a3a;
1401
+ }
1402
+
1403
+ .qde-dark .qde-preview table th {
1404
+ background: #2d2d2d;
1405
+ }
1406
+
1407
+ .qde-dark .qde-preview table tr:nth-child(even) {
1408
+ background: #252525;
1409
+ }
1410
+
1411
+ /* Mobile responsive */
1412
+ @media (max-width: 768px) {
1413
+ .qde-mode-split .qde-editor {
1414
+ flex-direction: column;
1415
+ }
1416
+
1417
+ .qde-mode-split .qde-source {
1418
+ border-right: none;
1419
+ border-bottom: 1px solid #ddd;
1420
+ }
1421
+ }
1422
+ `;
1423
+
1424
+ document.head.appendChild(style);
1425
+ }
1426
+
1427
+ /**
1428
+ * Attach event listeners
1429
+ */
1430
+ attachEvents() {
1431
+ // Source textarea input
1432
+ this.sourceTextarea.addEventListener('input', () => {
1433
+ this.handleSourceInput();
1434
+ });
1435
+
1436
+ // Preview contenteditable input
1437
+ this.previewPanel.addEventListener('input', () => {
1438
+ this.handlePreviewInput();
1439
+ });
1440
+
1441
+ // Toolbar buttons
1442
+ if (this.toolbar) {
1443
+ this.toolbar.addEventListener('click', (e) => {
1444
+ const btn = e.target.closest('.qde-btn');
1445
+ if (!btn) return;
1446
+
1447
+ if (btn.dataset.mode) {
1448
+ this.setMode(btn.dataset.mode);
1449
+ } else if (btn.dataset.action) {
1450
+ this.handleAction(btn.dataset.action);
1451
+ }
1452
+ });
1453
+ }
1454
+
1455
+ // Keyboard shortcuts
1456
+ document.addEventListener('keydown', (e) => {
1457
+ if (e.ctrlKey || e.metaKey) {
1458
+ switch(e.key) {
1459
+ case '1':
1460
+ e.preventDefault();
1461
+ this.setMode('source');
1462
+ break;
1463
+ case '2':
1464
+ e.preventDefault();
1465
+ this.setMode('split');
1466
+ break;
1467
+ case '3':
1468
+ e.preventDefault();
1469
+ this.setMode('preview');
1470
+ break;
1471
+ }
1472
+ }
1473
+ });
1474
+ }
1475
+
1476
+ /**
1477
+ * Handle source textarea input
1478
+ */
1479
+ handleSourceInput() {
1480
+ clearTimeout(this.updateTimer);
1481
+ this.updateTimer = setTimeout(() => {
1482
+ this.updateFromMarkdown(this.sourceTextarea.value);
1483
+ }, this.options.debounceDelay);
1484
+ }
1485
+
1486
+ /**
1487
+ * Handle preview panel input
1488
+ */
1489
+ handlePreviewInput() {
1490
+ clearTimeout(this.updateTimer);
1491
+ this.updateTimer = setTimeout(() => {
1492
+ this.updateFromHTML();
1493
+ }, this.options.debounceDelay);
1494
+ }
1495
+
1496
+ /**
1497
+ * Update from markdown source
1498
+ */
1499
+ updateFromMarkdown(markdown) {
1500
+ this._markdown = markdown || '';
1501
+
1502
+ // Show placeholder if empty
1503
+ if (!this._markdown.trim()) {
1504
+ this._html = '';
1505
+ if (this.currentMode !== 'source') {
1506
+ this.previewPanel.innerHTML = '<div style="color: #999; font-style: italic; padding: 16px;">Start typing markdown in the source panel...</div>';
1507
+ }
1508
+ } else {
1509
+ this._html = quikdown_bd(markdown, {
1510
+ fence_plugin: this.createFencePlugin(),
1511
+ lazy_linefeeds: this.options.lazy_linefeeds
1512
+ });
1513
+
1514
+ // Update preview if visible
1515
+ if (this.currentMode !== 'source') {
1516
+ this.previewPanel.innerHTML = this._html;
1517
+ // Make all fence blocks non-editable
1518
+ this.makeFencesNonEditable();
1519
+ }
1520
+ }
1521
+
1522
+ // Trigger change event
1523
+ if (this.options.onChange) {
1524
+ this.options.onChange(this._markdown, this._html);
1525
+ }
1526
+ }
1527
+
1528
+ /**
1529
+ * Update from HTML preview
1530
+ */
1531
+ updateFromHTML() {
1532
+ // Clone the preview panel to avoid modifying the actual DOM
1533
+ const clonedPanel = this.previewPanel.cloneNode(true);
1534
+
1535
+ // Pre-process special elements on the clone
1536
+ this.preprocessSpecialElements(clonedPanel);
1537
+
1538
+ this._html = this.previewPanel.innerHTML;
1539
+ this._markdown = quikdown_bd.toMarkdown(clonedPanel);
1540
+
1541
+ // Update source if visible
1542
+ if (this.currentMode !== 'preview') {
1543
+ this.sourceTextarea.value = this._markdown;
1544
+ }
1545
+
1546
+ // Trigger change event
1547
+ if (this.options.onChange) {
1548
+ this.options.onChange(this._markdown, this._html);
1549
+ }
1550
+ }
1551
+
1552
+ /**
1553
+ * Pre-process special elements before markdown conversion
1554
+ */
1555
+ preprocessSpecialElements(panel) {
1556
+ if (!panel) return;
1557
+
1558
+
1559
+ // Restore non-editable complex fences from their data attributes
1560
+ const complexFences = panel.querySelectorAll('[contenteditable="false"][data-qd-source]');
1561
+ complexFences.forEach(element => {
1562
+ const source = element.getAttribute('data-qd-source');
1563
+ const fence = element.getAttribute('data-qd-fence') || '```';
1564
+ const lang = element.getAttribute('data-qd-lang') || '';
1565
+
1566
+ // Create a pre element with the original source
1567
+ const pre = document.createElement('pre');
1568
+ pre.setAttribute('data-qd-fence', fence);
1569
+ if (lang) pre.setAttribute('data-qd-lang', lang);
1570
+ const code = document.createElement('code');
1571
+ // The source is already the original unescaped content when using setAttribute
1572
+ // No need to unescape since browser handles it automatically
1573
+ code.textContent = source;
1574
+ pre.appendChild(code);
1575
+
1576
+ // Replace the complex element with pre
1577
+ element.parentNode.replaceChild(pre, element);
1578
+ });
1579
+
1580
+ // Convert CSV tables back to CSV fence blocks (these ARE editable)
1581
+ const csvTables = panel.querySelectorAll('table.qde-csv-table[data-qd-lang]');
1582
+ csvTables.forEach(table => {
1583
+ const lang = table.getAttribute('data-qd-lang');
1584
+ if (!lang || !['csv', 'psv', 'tsv'].includes(lang)) return;
1585
+
1586
+ const delimiter = lang === 'csv' ? ',' : lang === 'psv' ? '|' : '\t';
1587
+
1588
+ // Extract data from table
1589
+ let csv = '';
1590
+
1591
+ // Get headers
1592
+ const headers = [];
1593
+ const headerCells = table.querySelectorAll('thead th');
1594
+ headerCells.forEach(th => {
1595
+ const text = th.textContent.trim();
1596
+ // Quote if contains delimiter or quotes
1597
+ const needsQuoting = text.includes(delimiter) || text.includes('"') || text.includes('\n');
1598
+ headers.push(needsQuoting ? `"${text.replace(/"/g, '""')}"` : text);
1599
+ });
1600
+ csv += headers.join(delimiter) + '\n';
1601
+
1602
+ // Get rows
1603
+ const rows = table.querySelectorAll('tbody tr');
1604
+ rows.forEach(tr => {
1605
+ const cells = [];
1606
+ tr.querySelectorAll('td').forEach(td => {
1607
+ const text = td.textContent.trim();
1608
+ const needsQuoting = text.includes(delimiter) || text.includes('"') || text.includes('\n');
1609
+ cells.push(needsQuoting ? `"${text.replace(/"/g, '""')}"` : text);
1610
+ });
1611
+ csv += cells.join(delimiter) + '\n';
1612
+ });
1613
+
1614
+ // Create a pre element with the CSV data
1615
+ const pre = document.createElement('pre');
1616
+ pre.setAttribute('data-qd-fence', '```');
1617
+ pre.setAttribute('data-qd-lang', lang);
1618
+ const code = document.createElement('code');
1619
+ code.textContent = csv.trim();
1620
+ pre.appendChild(code);
1621
+
1622
+ // Replace table with pre
1623
+ table.parentNode.replaceChild(pre, table);
1624
+ });
1625
+ }
1626
+
1627
+ /**
1628
+ * Create fence plugin for syntax highlighting
1629
+ */
1630
+ createFencePlugin() {
1631
+ return (code, lang) => {
1632
+ // Check custom fences first (they take precedence)
1633
+ if (this.options.customFences && this.options.customFences[lang]) {
1634
+ try {
1635
+ return this.options.customFences[lang](code, lang);
1636
+ } catch (err) {
1637
+ console.error(`Custom fence plugin error for ${lang}:`, err);
1638
+ return `<pre><code class="language-${lang}">${this.escapeHtml(code)}</code></pre>`;
1639
+ }
1640
+ }
1641
+
1642
+ // For bidirectional editing, only apply syntax highlighting
1643
+ // Skip complex transformations that break round-trip conversion
1644
+ const skipComplexRendering = !this.options.enableComplexFences;
1645
+
1646
+ if (!skipComplexRendering) {
1647
+ // Built-in lazy loading fence handlers (disabled for now)
1648
+ switch(lang) {
1649
+ case 'svg':
1650
+ return this.renderSVG(code);
1651
+
1652
+ case 'html':
1653
+ return this.renderHTML(code);
1654
+
1655
+ case 'math':
1656
+ case 'katex':
1657
+ case 'tex':
1658
+ case 'latex':
1659
+ return this.renderMath(code, lang);
1660
+
1661
+ case 'csv':
1662
+ case 'psv':
1663
+ case 'tsv':
1664
+ return this.renderTable(code, lang);
1665
+
1666
+ case 'json':
1667
+ case 'json5':
1668
+ return this.renderJSON(code, lang);
1669
+
1670
+ case 'mermaid':
1671
+ if (window.mermaid) {
1672
+ return this.renderMermaid(code);
1673
+ }
1674
+ break;
1675
+ }
1676
+ }
1677
+
1678
+ // Syntax highlighting support - keep editable for bidirectional
1679
+ if (window.hljs && lang && hljs.getLanguage(lang)) {
1680
+ const highlighted = hljs.highlight(code, { language: lang }).value;
1681
+ // Don't add contenteditable="false" - the bidirectional system can extract text from the highlighted code
1682
+ return `<pre data-qd-fence="\`\`\`" data-qd-lang="${lang}"><code class="hljs language-${lang}">${highlighted}</code></pre>`;
1683
+ }
1684
+
1685
+ // Default: let quikdown handle it
1686
+ return undefined;
1687
+ };
1688
+ }
1689
+
1690
+ /**
1691
+ * Render SVG content
1692
+ */
1693
+ renderSVG(code) {
1694
+ try {
1695
+ // Basic SVG validation
1696
+ const parser = new DOMParser();
1697
+ const doc = parser.parseFromString(code, 'image/svg+xml');
1698
+ const parseError = doc.querySelector('parsererror');
1699
+
1700
+ if (parseError) {
1701
+ throw new Error('Invalid SVG');
1702
+ }
1703
+
1704
+ // Sanitize SVG by removing script tags and event handlers
1705
+ const svg = doc.documentElement;
1706
+ svg.querySelectorAll('script').forEach(el => el.remove());
1707
+
1708
+ // Remove event handlers
1709
+ const walker = document.createTreeWalker(svg, NodeFilter.SHOW_ELEMENT);
1710
+ let node;
1711
+ while (node = walker.nextNode()) {
1712
+ for (let i = node.attributes.length - 1; i >= 0; i--) {
1713
+ const attr = node.attributes[i];
1714
+ if (attr.name.startsWith('on') || attr.value.includes('javascript:')) {
1715
+ node.removeAttribute(attr.name);
1716
+ }
1717
+ }
1718
+ }
1719
+
1720
+ // Create container element programmatically to avoid attribute escaping issues
1721
+ const container = document.createElement('div');
1722
+ container.className = 'qde-svg-container';
1723
+ container.contentEditable = 'false';
1724
+ container.setAttribute('data-qd-fence', '```');
1725
+ container.setAttribute('data-qd-lang', 'svg');
1726
+ container.setAttribute('data-qd-source', code); // No escaping needed when using setAttribute!
1727
+ container.innerHTML = new XMLSerializer().serializeToString(svg);
1728
+
1729
+ // Return the HTML string
1730
+ return container.outerHTML;
1731
+ } catch (err) {
1732
+ const errorContainer = document.createElement('pre');
1733
+ errorContainer.className = 'qde-error';
1734
+ errorContainer.contentEditable = 'false';
1735
+ errorContainer.setAttribute('data-qd-fence', '```');
1736
+ errorContainer.setAttribute('data-qd-lang', 'svg');
1737
+ errorContainer.textContent = `Invalid SVG: ${err.message}`;
1738
+ return errorContainer.outerHTML;
1739
+ }
1740
+ }
1741
+
1742
+ /**
1743
+ * Render HTML content with DOMPurify if available
1744
+ */
1745
+ renderHTML(code) {
1746
+ const id = `html-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
1747
+
1748
+ // If DOMPurify is loaded, use it
1749
+ if (window.DOMPurify) {
1750
+ const clean = DOMPurify.sanitize(code);
1751
+
1752
+ // Create container programmatically
1753
+ const container = document.createElement('div');
1754
+ container.className = 'qde-html-container';
1755
+ container.contentEditable = 'false';
1756
+ container.setAttribute('data-qd-fence', '```');
1757
+ container.setAttribute('data-qd-lang', 'html');
1758
+ container.setAttribute('data-qd-source', code);
1759
+ container.innerHTML = clean;
1760
+
1761
+ return container.outerHTML;
1762
+ }
1763
+
1764
+ // Try to lazy load DOMPurify
1765
+ this.lazyLoadLibrary(
1766
+ 'DOMPurify',
1767
+ () => window.DOMPurify,
1768
+ 'https://unpkg.com/dompurify/dist/purify.min.js'
1769
+ ).then(loaded => {
1770
+ if (loaded) {
1771
+ const element = document.getElementById(id);
1772
+ if (element) {
1773
+ const clean = DOMPurify.sanitize(code);
1774
+ element.innerHTML = clean;
1775
+ // Update attributes after loading
1776
+ element.setAttribute('data-qd-source', code);
1777
+ element.setAttribute('data-qd-fence', '```');
1778
+ element.setAttribute('data-qd-lang', 'html');
1779
+ }
1780
+ }
1781
+ });
1782
+
1783
+ // Return placeholder with bidirectional attributes - non-editable
1784
+ const placeholder = document.createElement('div');
1785
+ placeholder.id = id;
1786
+ placeholder.className = 'qde-html-container';
1787
+ placeholder.contentEditable = 'false';
1788
+ placeholder.setAttribute('data-qd-fence', '```');
1789
+ placeholder.setAttribute('data-qd-lang', 'html');
1790
+ placeholder.setAttribute('data-qd-source', code);
1791
+ const pre = document.createElement('pre');
1792
+ pre.textContent = code;
1793
+ placeholder.appendChild(pre);
1794
+
1795
+ return placeholder.outerHTML;
1796
+ }
1797
+
1798
+ /**
1799
+ * Render math with KaTeX if available
1800
+ */
1801
+ renderMath(code, lang) {
1802
+ const id = `math-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
1803
+
1804
+ // If KaTeX is loaded, use it
1805
+ if (window.katex) {
1806
+ try {
1807
+ const rendered = katex.renderToString(code, {
1808
+ displayMode: true,
1809
+ throwOnError: false
1810
+ });
1811
+
1812
+ // Create container programmatically
1813
+ const container = document.createElement('div');
1814
+ container.className = 'qde-math-container';
1815
+ container.contentEditable = 'false';
1816
+ container.setAttribute('data-qd-fence', '```');
1817
+ container.setAttribute('data-qd-lang', lang);
1818
+ container.setAttribute('data-qd-source', code);
1819
+ container.innerHTML = rendered;
1820
+
1821
+ return container.outerHTML;
1822
+ } catch (err) {
1823
+ const errorContainer = document.createElement('pre');
1824
+ errorContainer.className = 'qde-error';
1825
+ errorContainer.contentEditable = 'false';
1826
+ errorContainer.setAttribute('data-qd-fence', '```');
1827
+ errorContainer.setAttribute('data-qd-lang', lang);
1828
+ errorContainer.setAttribute('data-qd-source', code);
1829
+ errorContainer.textContent = `Math error: ${err.message}`;
1830
+ return errorContainer.outerHTML;
1831
+ }
1832
+ }
1833
+
1834
+ // Try to lazy load KaTeX
1835
+ this.lazyLoadLibrary(
1836
+ 'KaTeX',
1837
+ () => window.katex,
1838
+ 'https://unpkg.com/katex/dist/katex.min.js',
1839
+ 'https://unpkg.com/katex/dist/katex.min.css'
1840
+ ).then(loaded => {
1841
+ if (loaded) {
1842
+ const element = document.getElementById(id);
1843
+ if (element) {
1844
+ try {
1845
+ katex.render(code, element, {
1846
+ displayMode: true,
1847
+ throwOnError: false
1848
+ });
1849
+ // Update attributes after rendering
1850
+ element.setAttribute('data-qd-source', code);
1851
+ element.setAttribute('data-qd-fence', '```');
1852
+ element.setAttribute('data-qd-lang', lang);
1853
+ } catch (err) {
1854
+ element.innerHTML = `<pre class="qde-error">Math error: ${this.escapeHtml(err.message)}</pre>`;
1855
+ }
1856
+ }
1857
+ }
1858
+ });
1859
+
1860
+ // Return placeholder with bidirectional attributes - non-editable
1861
+ const placeholder = document.createElement('div');
1862
+ placeholder.id = id;
1863
+ placeholder.className = 'qde-math-container';
1864
+ placeholder.contentEditable = 'false';
1865
+ placeholder.setAttribute('data-qd-fence', '```');
1866
+ placeholder.setAttribute('data-qd-lang', lang);
1867
+ placeholder.setAttribute('data-qd-source', code);
1868
+ const pre = document.createElement('pre');
1869
+ pre.textContent = code;
1870
+ placeholder.appendChild(pre);
1871
+
1872
+ return placeholder.outerHTML;
1873
+ }
1874
+
1875
+ /**
1876
+ * Render CSV/PSV/TSV as HTML table
1877
+ */
1878
+ renderTable(code, lang) {
1879
+ const escapedCode = this.escapeHtml(code);
1880
+ try {
1881
+ const delimiter = lang === 'csv' ? ',' : lang === 'psv' ? '|' : '\t';
1882
+ const lines = code.trim().split('\n');
1883
+
1884
+ if (lines.length === 0) {
1885
+ return `<pre data-qd-fence="\`\`\`" data-qd-lang="${lang}" data-qd-source="${escapedCode}">${escapedCode}</pre>`;
1886
+ }
1887
+
1888
+ // CSV tables CAN be editable - we'll convert HTML table back to CSV
1889
+ // Don't need data-qd-source since we convert the table structure back to CSV
1890
+ let html = `<table class="qde-data-table qde-csv-table" data-qd-fence="\`\`\`" data-qd-lang="${lang}">`;
1891
+
1892
+ // Parse header
1893
+ const header = this.parseCSVLine(lines[0], delimiter);
1894
+ html += '<thead><tr>';
1895
+ header.forEach(cell => {
1896
+ html += `<th>${this.escapeHtml(cell.trim())}</th>`;
1897
+ });
1898
+ html += '</tr></thead>';
1899
+
1900
+ // Parse body
1901
+ if (lines.length > 1) {
1902
+ html += '<tbody>';
1903
+ for (let i = 1; i < lines.length; i++) {
1904
+ const row = this.parseCSVLine(lines[i], delimiter);
1905
+ html += '<tr>';
1906
+ row.forEach(cell => {
1907
+ html += `<td>${this.escapeHtml(cell.trim())}</td>`;
1908
+ });
1909
+ html += '</tr>';
1910
+ }
1911
+ html += '</tbody>';
1912
+ }
1913
+
1914
+ html += '</table>';
1915
+ return html;
1916
+ } catch (err) {
1917
+ return `<pre data-qd-fence="\`\`\`" data-qd-lang="${lang}" data-qd-source="${escapedCode}">${escapedCode}</pre>`;
1918
+ }
1919
+ }
1920
+
1921
+ /**
1922
+ * Parse CSV line handling quoted values
1923
+ */
1924
+ parseCSVLine(line, delimiter) {
1925
+ const result = [];
1926
+ let current = '';
1927
+ let inQuotes = false;
1928
+
1929
+ for (let i = 0; i < line.length; i++) {
1930
+ const char = line[i];
1931
+ const nextChar = line[i + 1];
1932
+
1933
+ if (char === '"') {
1934
+ if (inQuotes && nextChar === '"') {
1935
+ current += '"';
1936
+ i++; // Skip next quote
1937
+ } else {
1938
+ inQuotes = !inQuotes;
1939
+ }
1940
+ } else if (char === delimiter && !inQuotes) {
1941
+ result.push(current);
1942
+ current = '';
1943
+ } else {
1944
+ current += char;
1945
+ }
1946
+ }
1947
+
1948
+ result.push(current);
1949
+ return result;
1950
+ }
1951
+
1952
+ /**
1953
+ * Render JSON with syntax highlighting
1954
+ */
1955
+ renderJSON(code, lang) {
1956
+ // If highlight.js is available, use it for all JSON
1957
+ if (window.hljs && hljs.getLanguage('json')) {
1958
+ try {
1959
+ // Try to format if valid JSON
1960
+ let toHighlight = code;
1961
+ try {
1962
+ const data = JSON.parse(code);
1963
+ toHighlight = JSON.stringify(data, null, 2);
1964
+ } catch (e) {
1965
+ // Use original if not valid JSON
1966
+ }
1967
+
1968
+ const highlighted = hljs.highlight(toHighlight, { language: 'json' }).value;
1969
+ return `<pre class="qde-json" data-qd-fence="\`\`\`" data-qd-lang="${lang}"><code class="hljs language-json">${highlighted}</code></pre>`;
1970
+ } catch (e) {
1971
+ // Fall through if highlighting fails
1972
+ }
1973
+ }
1974
+
1975
+ // No highlighting available - return plain
1976
+ return `<pre class="qde-json" data-qd-fence="\`\`\`" data-qd-lang="${lang}">${this.escapeHtml(code)}</pre>`;
1977
+ }
1978
+
1979
+ /**
1980
+ * Render Mermaid diagram
1981
+ */
1982
+ renderMermaid(code) {
1983
+ const id = `mermaid-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
1984
+ setTimeout(() => {
1985
+ const element = document.getElementById(id);
1986
+ if (element && window.mermaid) {
1987
+ mermaid.render(id + '-svg', code).then(result => {
1988
+ element.innerHTML = result.svg;
1989
+ }).catch(err => {
1990
+ element.innerHTML = `<pre>Error rendering diagram: ${err.message}</pre>`;
1991
+ });
1992
+ }
1993
+ }, 0);
1994
+
1995
+ // Create container programmatically
1996
+ const container = document.createElement('div');
1997
+ container.id = id;
1998
+ container.className = 'mermaid';
1999
+ container.contentEditable = 'false';
2000
+ container.setAttribute('data-qd-source', code);
2001
+ container.setAttribute('data-qd-fence', '```');
2002
+ container.setAttribute('data-qd-lang', 'mermaid');
2003
+ container.textContent = 'Loading diagram...';
2004
+
2005
+ return container.outerHTML;
2006
+ }
2007
+
2008
+ /**
2009
+ * Escape HTML for attributes
2010
+ */
2011
+ escapeHtml(text) {
2012
+ return (text ?? "").replace(/[&"'<>]/g, m =>
2013
+ ({'&':'&amp;','"':'&quot;',"'":'&#39;','<':'&lt;','>':'&gt;'}[m]));
2014
+ }
2015
+
2016
+ /**
2017
+ * Make complex fence blocks non-editable
2018
+ */
2019
+ makeFencesNonEditable() {
2020
+ if (!this.previewPanel) return;
2021
+
2022
+ // Only make specific complex fence types non-editable
2023
+ // SVG, HTML, Math, Mermaid already have contenteditable="false" set
2024
+ // Syntax-highlighted code also has it set
2025
+
2026
+ // Don't make regular code blocks or tables non-editable
2027
+ // They can be edited and properly round-trip
2028
+ }
2029
+
2030
+ /**
2031
+ * Load plugins dynamically
2032
+ */
2033
+ async loadPlugins() {
2034
+ const promises = [];
2035
+
2036
+ // Load highlight.js (check if already loaded)
2037
+ if (this.options.plugins.highlightjs && !window.hljs) {
2038
+ promises.push(
2039
+ this.loadScript('https://unpkg.com/@highlightjs/cdn-assets/highlight.min.js'),
2040
+ this.loadCSS('https://unpkg.com/@highlightjs/cdn-assets/styles/github.min.css')
2041
+ );
2042
+ }
2043
+
2044
+ // Load mermaid (check if already loaded)
2045
+ if (this.options.plugins.mermaid && !window.mermaid) {
2046
+ promises.push(
2047
+ this.loadScript('https://unpkg.com/mermaid/dist/mermaid.min.js').then(() => {
2048
+ if (window.mermaid) {
2049
+ mermaid.initialize({ startOnLoad: false });
2050
+ }
2051
+ })
2052
+ );
2053
+ }
2054
+
2055
+ await Promise.all(promises);
2056
+ }
2057
+
2058
+ /**
2059
+ * Lazy load library if not already loaded
2060
+ */
2061
+ async lazyLoadLibrary(name, check, scriptUrl, cssUrl = null) {
2062
+ // Check if library is already loaded
2063
+ if (check()) {
2064
+ return true;
2065
+ }
2066
+
2067
+ try {
2068
+ const promises = [];
2069
+
2070
+ // Load script
2071
+ if (scriptUrl) {
2072
+ promises.push(this.loadScript(scriptUrl));
2073
+ }
2074
+
2075
+ // Load CSS if provided
2076
+ if (cssUrl) {
2077
+ promises.push(this.loadCSS(cssUrl));
2078
+ }
2079
+
2080
+ await Promise.all(promises);
2081
+
2082
+ // Verify library loaded
2083
+ return check();
2084
+ } catch (err) {
2085
+ console.error(`Failed to load ${name}:`, err);
2086
+ return false;
2087
+ }
2088
+ }
2089
+
2090
+ /**
2091
+ * Load external script
2092
+ */
2093
+ loadScript(src) {
2094
+ return new Promise((resolve, reject) => {
2095
+ const script = document.createElement('script');
2096
+ script.src = src;
2097
+ script.onload = resolve;
2098
+ script.onerror = reject;
2099
+ document.head.appendChild(script);
2100
+ });
2101
+ }
2102
+
2103
+ /**
2104
+ * Load external CSS
2105
+ */
2106
+ loadCSS(href) {
2107
+ return new Promise((resolve) => {
2108
+ const link = document.createElement('link');
2109
+ link.rel = 'stylesheet';
2110
+ link.href = href;
2111
+ link.onload = resolve;
2112
+ document.head.appendChild(link);
2113
+ // Resolve anyway after timeout (CSS doesn't always fire onload)
2114
+ setTimeout(resolve, 1000);
2115
+ });
2116
+ }
2117
+
2118
+ /**
2119
+ * Apply theme
2120
+ */
2121
+ applyTheme() {
2122
+ const theme = this.options.theme;
2123
+
2124
+ if (theme === 'auto') {
2125
+ // Check system preference
2126
+ const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
2127
+ this.container.classList.toggle('qde-dark', isDark);
2128
+
2129
+ // Listen for changes
2130
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
2131
+ this.container.classList.toggle('qde-dark', e.matches);
2132
+ });
2133
+ } else {
2134
+ this.container.classList.toggle('qde-dark', theme === 'dark');
2135
+ }
2136
+ }
2137
+
2138
+ /**
2139
+ * Set lazy linefeeds option
2140
+ * @param {boolean} enabled - Whether to enable lazy linefeeds
2141
+ */
2142
+ setLazyLinefeeds(enabled) {
2143
+ this.options.lazy_linefeeds = enabled;
2144
+ // Re-render if we have content
2145
+ if (this._markdown) {
2146
+ this.updateFromSource();
2147
+ }
2148
+ }
2149
+
2150
+ /**
2151
+ * Get lazy linefeeds option
2152
+ * @returns {boolean}
2153
+ */
2154
+ getLazyLinefeeds() {
2155
+ return this.options.lazy_linefeeds;
2156
+ }
2157
+
2158
+ /**
2159
+ * Set editor mode
2160
+ */
2161
+ setMode(mode) {
2162
+ if (!['source', 'preview', 'split'].includes(mode)) return;
2163
+
2164
+ this.currentMode = mode;
2165
+ this.container.className = `qde-container qde-mode-${mode}`;
2166
+
2167
+ // Update toolbar buttons
2168
+ if (this.toolbar) {
2169
+ this.toolbar.querySelectorAll('.qde-btn[data-mode]').forEach(btn => {
2170
+ btn.classList.toggle('active', btn.dataset.mode === mode);
2171
+ });
2172
+ }
2173
+
2174
+ // Apply theme class
2175
+ if (this.container.classList.contains('qde-dark')) {
2176
+ this.container.classList.add('qde-dark');
2177
+ }
2178
+
2179
+ // Make fence blocks non-editable when showing preview
2180
+ if (mode !== 'source') {
2181
+ setTimeout(() => this.makeFencesNonEditable(), 0);
2182
+ }
2183
+
2184
+ // Trigger mode change event
2185
+ if (this.options.onModeChange) {
2186
+ this.options.onModeChange(mode);
2187
+ }
2188
+ }
2189
+
2190
+ /**
2191
+ * Handle toolbar actions
2192
+ */
2193
+ handleAction(action) {
2194
+ switch(action) {
2195
+ case 'copy-markdown':
2196
+ this.copy('markdown');
2197
+ break;
2198
+ case 'copy-html':
2199
+ this.copy('html');
2200
+ break;
2201
+ }
2202
+ }
2203
+
2204
+ /**
2205
+ * Copy content to clipboard
2206
+ */
2207
+ async copy(type) {
2208
+ const content = type === 'markdown' ? this._markdown : this._html;
2209
+
2210
+ try {
2211
+ await navigator.clipboard.writeText(content);
2212
+
2213
+ // Visual feedback
2214
+ const btn = this.toolbar.querySelector(`[data-action="copy-${type}"]`);
2215
+ if (btn) {
2216
+ const originalText = btn.textContent;
2217
+ btn.textContent = 'Copied!';
2218
+ setTimeout(() => {
2219
+ btn.textContent = originalText;
2220
+ }, 1500);
2221
+ }
2222
+ } catch (err) {
2223
+ console.error('Failed to copy:', err);
2224
+ }
2225
+ }
2226
+
2227
+ // Public API
2228
+
2229
+ /**
2230
+ * Get current markdown
2231
+ */
2232
+ get markdown() {
2233
+ return this._markdown;
2234
+ }
2235
+
2236
+ /**
2237
+ * Set markdown content
2238
+ */
2239
+ set markdown(value) {
2240
+ this.setMarkdown(value);
2241
+ }
2242
+
2243
+ /**
2244
+ * Get current HTML
2245
+ */
2246
+ get html() {
2247
+ return this._html;
2248
+ }
2249
+
2250
+ /**
2251
+ * Get current mode
2252
+ */
2253
+ get mode() {
2254
+ return this.currentMode;
2255
+ }
2256
+
2257
+ /**
2258
+ * Set markdown content
2259
+ */
2260
+ async setMarkdown(markdown) {
2261
+ // Wait for initialization if needed
2262
+ if (this.initPromise) {
2263
+ await this.initPromise;
2264
+ }
2265
+
2266
+ this._markdown = markdown;
2267
+ if (this.sourceTextarea) {
2268
+ this.sourceTextarea.value = markdown;
2269
+ }
2270
+ this.updateFromMarkdown(markdown);
2271
+ }
2272
+
2273
+ /**
2274
+ * Get markdown content
2275
+ */
2276
+ getMarkdown() {
2277
+ return this._markdown;
2278
+ }
2279
+
2280
+ /**
2281
+ * Get HTML content
2282
+ */
2283
+ getHTML() {
2284
+ return this._html;
2285
+ }
2286
+
2287
+ /**
2288
+ * Destroy the editor
2289
+ */
2290
+ destroy() {
2291
+ // Clear timers
2292
+ clearTimeout(this.updateTimer);
2293
+
2294
+ // Clear container
2295
+ this.container.innerHTML = '';
2296
+ this.container.classList.remove('qde-container', 'qde-dark');
2297
+
2298
+ // Remove injected styles (only if no other editors exist)
2299
+ const otherEditors = document.querySelectorAll('.qde-container');
2300
+ if (otherEditors.length === 0) {
2301
+ const style = document.getElementById('qde-styles');
2302
+ if (style) style.remove();
2303
+ }
2304
+ }
2305
+ }
2306
+
2307
+ // Export for CommonJS (needed for bundled ESM to work with Jest)
2308
+ if (typeof module !== 'undefined' && module.exports) {
2309
+ module.exports = QuikdownEditor;
2310
+ }
2311
+
2312
+ // Also export for UMD builds
2313
+ if (typeof window !== 'undefined') {
2314
+ window.QuikdownEditor = QuikdownEditor;
2315
+ }
2316
+
2317
+ export { QuikdownEditor as default };