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,979 @@
1
+ /**
2
+ * quikdown_bd - Bidirectional 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
+ export { quikdown_bd as default };