quikdown 1.0.4 → 1.1.0

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