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,981 @@
1
+ /**
2
+ * quikdown_bd - Bidirectional Markdown Parser
3
+ * @version 1.0.5
4
+ * @license BSD-2-Clause
5
+ * @copyright DeftIO 2025
6
+ */
7
+ 'use strict';
8
+
9
+ /**
10
+ * quikdown - A minimal markdown parser optimized for chat/LLM output
11
+ * Supports tables, code blocks, lists, and common formatting
12
+ * @param {string} markdown - The markdown source text
13
+ * @param {Object} options - Optional configuration object
14
+ * @param {Function} options.fence_plugin - Custom renderer for fenced code blocks
15
+ * (content, fence_string) => html string
16
+ * @param {boolean} options.inline_styles - If true, uses inline styles instead of classes
17
+ * @param {boolean} options.bidirectional - If true, adds data-qd attributes for source tracking
18
+ * @param {boolean} options.lazy_linefeeds - If true, single newlines become <br> tags
19
+ * @returns {string} - The rendered HTML
20
+ */
21
+
22
+ // Version will be injected at build time
23
+ const quikdownVersion = '1.0.5';
24
+
25
+ // Constants for reuse
26
+ const CLASS_PREFIX = 'quikdown-';
27
+ const PLACEHOLDER_CB = '§CB';
28
+ const PLACEHOLDER_IC = '§IC';
29
+
30
+ // Escape map at module level
31
+ const ESC_MAP = {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'};
32
+
33
+ // Single source of truth for all style definitions - optimized
34
+ const QUIKDOWN_STYLES = {
35
+ h1: 'font-size:2em;font-weight:600;margin:.67em 0;text-align:left',
36
+ h2: 'font-size:1.5em;font-weight:600;margin:.83em 0',
37
+ h3: 'font-size:1.25em;font-weight:600;margin:1em 0',
38
+ h4: 'font-size:1em;font-weight:600;margin:1.33em 0',
39
+ h5: 'font-size:.875em;font-weight:600;margin:1.67em 0',
40
+ h6: 'font-size:.85em;font-weight:600;margin:2em 0',
41
+ pre: 'background:#f4f4f4;padding:10px;border-radius:4px;overflow-x:auto;margin:1em 0',
42
+ code: 'background:#f0f0f0;padding:2px 4px;border-radius:3px;font-family:monospace',
43
+ blockquote: 'border-left:4px solid #ddd;margin-left:0;padding-left:1em',
44
+ table: 'border-collapse:collapse;width:100%;margin:1em 0',
45
+ th: 'border:1px solid #ddd;padding:8px;background-color:#f2f2f2;font-weight:bold;text-align:left',
46
+ td: 'border:1px solid #ddd;padding:8px;text-align:left',
47
+ hr: 'border:none;border-top:1px solid #ddd;margin:1em 0',
48
+ img: 'max-width:100%;height:auto',
49
+ a: 'color:#06c;text-decoration:underline',
50
+ strong: 'font-weight:bold',
51
+ em: 'font-style:italic',
52
+ del: 'text-decoration:line-through',
53
+ ul: 'margin:.5em 0;padding-left:2em',
54
+ ol: 'margin:.5em 0;padding-left:2em',
55
+ li: 'margin:.25em 0',
56
+ // Task list specific styles
57
+ 'task-item': 'list-style:none',
58
+ 'task-checkbox': 'margin-right:.5em'
59
+ };
60
+
61
+ // Factory function to create getAttr for a given context
62
+ function createGetAttr(inline_styles, styles) {
63
+ return function(tag, additionalStyle = '') {
64
+ if (inline_styles) {
65
+ let style = styles[tag];
66
+ if (!style && !additionalStyle) return '';
67
+
68
+ // Remove default text-align if we're adding a different alignment
69
+ if (additionalStyle && additionalStyle.includes('text-align') && style && style.includes('text-align')) {
70
+ style = style.replace(/text-align:[^;]+;?/, '').trim();
71
+ if (style && !style.endsWith(';')) style += ';';
72
+ }
73
+
74
+ /* istanbul ignore next - defensive: additionalStyle without style doesn't occur with current tags */
75
+ const fullStyle = additionalStyle ? (style ? `${style}${additionalStyle}` : additionalStyle) : style;
76
+ return ` style="${fullStyle}"`;
77
+ } else {
78
+ const classAttr = ` class="${CLASS_PREFIX}${tag}"`;
79
+ // Apply inline styles for alignment even when using CSS classes
80
+ if (additionalStyle) {
81
+ return `${classAttr} style="${additionalStyle}"`;
82
+ }
83
+ return classAttr;
84
+ }
85
+ };
86
+ }
87
+
88
+ function quikdown(markdown, options = {}) {
89
+ if (!markdown || typeof markdown !== 'string') {
90
+ return '';
91
+ }
92
+
93
+ const { fence_plugin, inline_styles = false, bidirectional = false, lazy_linefeeds = false } = options;
94
+ const styles = QUIKDOWN_STYLES; // Use module-level styles
95
+ const getAttr = createGetAttr(inline_styles, styles); // Create getAttr once
96
+
97
+ // Escape HTML entities to prevent XSS
98
+ function escapeHtml(text) {
99
+ return text.replace(/[&<>"']/g, m => ESC_MAP[m]);
100
+ }
101
+
102
+ // Helper to add data-qd attributes for bidirectional support
103
+ const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';
104
+
105
+ // Sanitize URLs to prevent XSS attacks
106
+ function sanitizeUrl(url, allowUnsafe = false) {
107
+ /* istanbul ignore next - defensive programming, regex ensures url is never empty */
108
+ if (!url) return '';
109
+
110
+ // If unsafe URLs are explicitly allowed, return as-is
111
+ if (allowUnsafe) return url;
112
+
113
+ const trimmedUrl = url.trim();
114
+ const lowerUrl = trimmedUrl.toLowerCase();
115
+
116
+ // Block dangerous protocols
117
+ const dangerousProtocols = ['javascript:', 'vbscript:', 'data:'];
118
+
119
+ for (const protocol of dangerousProtocols) {
120
+ if (lowerUrl.startsWith(protocol)) {
121
+ // Exception: Allow data:image/* for images
122
+ if (protocol === 'data:' && lowerUrl.startsWith('data:image/')) {
123
+ return trimmedUrl;
124
+ }
125
+ // Return safe empty link for dangerous protocols
126
+ return '#';
127
+ }
128
+ }
129
+
130
+ return trimmedUrl;
131
+ }
132
+
133
+ // Process the markdown in phases
134
+ let html = markdown;
135
+
136
+ // Phase 1: Extract and protect code blocks and inline code
137
+ const codeBlocks = [];
138
+ const inlineCodes = [];
139
+
140
+ // Extract fenced code blocks first (supports both ``` and ~~~)
141
+ // Match paired fences - ``` with ``` and ~~~ with ~~~
142
+ // Fence must be at start of line
143
+ html = html.replace(/^(```|~~~)([^\n]*)\n([\s\S]*?)^\1$/gm, (match, fence, lang, code) => {
144
+ const placeholder = `${PLACEHOLDER_CB}${codeBlocks.length}§`;
145
+
146
+ // Trim the language specification
147
+ const langTrimmed = lang ? lang.trim() : '';
148
+
149
+ // If custom fence plugin is provided, use it
150
+ if (fence_plugin && typeof fence_plugin === 'function') {
151
+ codeBlocks.push({
152
+ lang: langTrimmed,
153
+ code: code.trimEnd(),
154
+ custom: true,
155
+ fence: fence
156
+ });
157
+ } else {
158
+ codeBlocks.push({
159
+ lang: langTrimmed,
160
+ code: escapeHtml(code.trimEnd()),
161
+ custom: false,
162
+ fence: fence
163
+ });
164
+ }
165
+ return placeholder;
166
+ });
167
+
168
+ // Extract inline code
169
+ html = html.replace(/`([^`]+)`/g, (match, code) => {
170
+ const placeholder = `${PLACEHOLDER_IC}${inlineCodes.length}§`;
171
+ inlineCodes.push(escapeHtml(code));
172
+ return placeholder;
173
+ });
174
+
175
+ // Now escape HTML in the rest of the content
176
+ html = escapeHtml(html);
177
+
178
+ // Phase 2: Process block elements
179
+
180
+ // Process tables
181
+ html = processTable(html, getAttr);
182
+
183
+ // Process headings (supports optional trailing #'s)
184
+ html = html.replace(/^(#{1,6})\s+(.+?)\s*#*$/gm, (match, hashes, content) => {
185
+ const level = hashes.length;
186
+ return `<h${level}${getAttr('h' + level)}${dataQd(hashes)}>${content}</h${level}>`;
187
+ });
188
+
189
+ // Process blockquotes (must handle escaped > since we already escaped HTML)
190
+ html = html.replace(/^&gt;\s+(.+)$/gm, `<blockquote${getAttr('blockquote')}>$1</blockquote>`);
191
+ // Merge consecutive blockquotes
192
+ html = html.replace(/<\/blockquote>\n<blockquote>/g, '\n');
193
+
194
+ // Process horizontal rules
195
+ html = html.replace(/^---+$/gm, `<hr${getAttr('hr')}>`);
196
+
197
+ // Process lists
198
+ html = processLists(html, getAttr, inline_styles, bidirectional);
199
+
200
+ // Phase 3: Process inline elements
201
+
202
+ // Images (must come before links, with URL sanitization)
203
+ html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
204
+ const sanitizedSrc = sanitizeUrl(src, options.allow_unsafe_urls);
205
+ const altAttr = bidirectional && alt ? ` data-qd-alt="${escapeHtml(alt)}"` : '';
206
+ const srcAttr = bidirectional ? ` data-qd-src="${escapeHtml(src)}"` : '';
207
+ return `<img${getAttr('img')} src="${sanitizedSrc}" alt="${alt}"${altAttr}${srcAttr}${dataQd('!')}>`;
208
+ });
209
+
210
+ // Links (with URL sanitization)
211
+ html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, href) => {
212
+ // Sanitize URL to prevent XSS
213
+ const sanitizedHref = sanitizeUrl(href, options.allow_unsafe_urls);
214
+ const isExternal = /^https?:\/\//i.test(sanitizedHref);
215
+ const rel = isExternal ? ' rel="noopener noreferrer"' : '';
216
+ const textAttr = bidirectional ? ` data-qd-text="${escapeHtml(text)}"` : '';
217
+ return `<a${getAttr('a')} href="${sanitizedHref}"${rel}${textAttr}${dataQd('[')}>${text}</a>`;
218
+ });
219
+
220
+ // Autolinks - convert bare URLs to clickable links
221
+ html = html.replace(/(^|\s)(https?:\/\/[^\s<]+)/g, (match, prefix, url) => {
222
+ const sanitizedUrl = sanitizeUrl(url, options.allow_unsafe_urls);
223
+ return `${prefix}<a${getAttr('a')} href="${sanitizedUrl}" rel="noopener noreferrer">${url}</a>`;
224
+ });
225
+
226
+ // Process inline formatting (bold, italic, strikethrough)
227
+ const inlinePatterns = [
228
+ [/\*\*(.+?)\*\*/g, 'strong', '**'],
229
+ [/__(.+?)__/g, 'strong', '__'],
230
+ [/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, 'em', '*'],
231
+ [/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, 'em', '_'],
232
+ [/~~(.+?)~~/g, 'del', '~~']
233
+ ];
234
+
235
+ inlinePatterns.forEach(([pattern, tag, marker]) => {
236
+ html = html.replace(pattern, `<${tag}${getAttr(tag)}${dataQd(marker)}>$1</${tag}>`);
237
+ });
238
+
239
+ // Line breaks
240
+ if (lazy_linefeeds) {
241
+ // Lazy linefeeds: single newline becomes <br> (except between paragraphs and after/before block elements)
242
+ const blocks = [];
243
+ let bi = 0;
244
+
245
+ // Protect tables and lists
246
+ html = html.replace(/<(table|[uo]l)[^>]*>[\s\S]*?<\/\1>/g, m => {
247
+ blocks[bi] = m;
248
+ return `§B${bi++}§`;
249
+ });
250
+
251
+ // Handle paragraphs and block elements
252
+ html = html.replace(/\n\n+/g, '§P§')
253
+ // After block elements
254
+ .replace(/(<\/(?:h[1-6]|blockquote|pre)>)\n/g, '$1§N§')
255
+ .replace(/(<(?:h[1-6]|blockquote|pre|hr)[^>]*>)\n/g, '$1§N§')
256
+ // Before block elements
257
+ .replace(/\n(<(?:h[1-6]|blockquote|pre|hr)[^>]*>)/g, '§N§$1')
258
+ .replace(/\n(§B\d+§)/g, '§N§$1')
259
+ .replace(/(§B\d+§)\n/g, '$1§N§')
260
+ // Convert remaining newlines
261
+ .replace(/\n/g, `<br${getAttr('br')}>`)
262
+ // Restore
263
+ .replace(/§N§/g, '\n')
264
+ .replace(/§P§/g, '</p><p>');
265
+
266
+ // Restore protected blocks
267
+ blocks.forEach((b, i) => html = html.replace(`§B${i}§`, b));
268
+
269
+ html = '<p>' + html + '</p>';
270
+ } else {
271
+ // Standard: two spaces at end of line for line breaks
272
+ html = html.replace(/ $/gm, `<br${getAttr('br')}>`);
273
+
274
+ // Paragraphs (double newlines)
275
+ html = html.replace(/\n\n+/g, '</p><p>');
276
+ html = '<p>' + html + '</p>';
277
+ }
278
+
279
+ // Clean up empty paragraphs and unwrap block elements
280
+ const cleanupPatterns = [
281
+ [/<p><\/p>/g, ''],
282
+ [/<p>(<h[1-6][^>]*>)/g, '$1'],
283
+ [/(<\/h[1-6]>)<\/p>/g, '$1'],
284
+ [/<p>(<blockquote[^>]*>)/g, '$1'],
285
+ [/(<\/blockquote>)<\/p>/g, '$1'],
286
+ [/<p>(<ul[^>]*>|<ol[^>]*>)/g, '$1'],
287
+ [/(<\/ul>|<\/ol>)<\/p>/g, '$1'],
288
+ [/<p>(<hr[^>]*>)<\/p>/g, '$1'],
289
+ [/<p>(<table[^>]*>)/g, '$1'],
290
+ [/(<\/table>)<\/p>/g, '$1'],
291
+ [/<p>(<pre[^>]*>)/g, '$1'],
292
+ [/(<\/pre>)<\/p>/g, '$1'],
293
+ [new RegExp(`<p>(${PLACEHOLDER_CB}\\d+§)<\/p>`, 'g'), '$1']
294
+ ];
295
+
296
+ cleanupPatterns.forEach(([pattern, replacement]) => {
297
+ html = html.replace(pattern, replacement);
298
+ });
299
+
300
+ // Phase 4: Restore code blocks and inline code
301
+
302
+ // Restore code blocks
303
+ codeBlocks.forEach((block, i) => {
304
+ let replacement;
305
+
306
+ if (block.custom && fence_plugin) {
307
+ // Use custom fence plugin
308
+ replacement = fence_plugin(block.code, block.lang);
309
+ // If plugin returns undefined, fall back to default rendering
310
+ if (replacement === undefined) {
311
+ const langClass = !inline_styles && block.lang ? ` class="language-${block.lang}"` : '';
312
+ const codeAttr = inline_styles ? getAttr('code') : langClass;
313
+ const langAttr = bidirectional && block.lang ? ` data-qd-lang="${escapeHtml(block.lang)}"` : '';
314
+ const fenceAttr = bidirectional ? ` data-qd-fence="${escapeHtml(block.fence)}"` : '';
315
+ replacement = `<pre${getAttr('pre')}${fenceAttr}${langAttr}><code${codeAttr}>${escapeHtml(block.code)}</code></pre>`;
316
+ }
317
+ } else {
318
+ // Default rendering
319
+ const langClass = !inline_styles && block.lang ? ` class="language-${block.lang}"` : '';
320
+ const codeAttr = inline_styles ? getAttr('code') : langClass;
321
+ const langAttr = bidirectional && block.lang ? ` data-qd-lang="${escapeHtml(block.lang)}"` : '';
322
+ const fenceAttr = bidirectional ? ` data-qd-fence="${escapeHtml(block.fence)}"` : '';
323
+ replacement = `<pre${getAttr('pre')}${fenceAttr}${langAttr}><code${codeAttr}>${block.code}</code></pre>`;
324
+ }
325
+
326
+ const placeholder = `${PLACEHOLDER_CB}${i}§`;
327
+ html = html.replace(placeholder, replacement);
328
+ });
329
+
330
+ // Restore inline code
331
+ inlineCodes.forEach((code, i) => {
332
+ const placeholder = `${PLACEHOLDER_IC}${i}§`;
333
+ html = html.replace(placeholder, `<code${getAttr('code')}${dataQd('`')}>${code}</code>`);
334
+ });
335
+
336
+ return html.trim();
337
+ }
338
+
339
+ /**
340
+ * Process inline markdown formatting
341
+ */
342
+ function processInlineMarkdown(text, getAttr) {
343
+
344
+ // Process inline formatting patterns
345
+ const patterns = [
346
+ [/\*\*(.+?)\*\*/g, 'strong'],
347
+ [/__(.+?)__/g, 'strong'],
348
+ [/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, 'em'],
349
+ [/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, 'em'],
350
+ [/~~(.+?)~~/g, 'del'],
351
+ [/`([^`]+)`/g, 'code']
352
+ ];
353
+
354
+ patterns.forEach(([pattern, tag]) => {
355
+ text = text.replace(pattern, `<${tag}${getAttr(tag)}>$1</${tag}>`);
356
+ });
357
+
358
+ return text;
359
+ }
360
+
361
+ /**
362
+ * Process markdown tables
363
+ */
364
+ function processTable(text, getAttr) {
365
+ const lines = text.split('\n');
366
+ const result = [];
367
+ let inTable = false;
368
+ let tableLines = [];
369
+
370
+ for (let i = 0; i < lines.length; i++) {
371
+ const line = lines[i].trim();
372
+
373
+ // Check if this line looks like a table row (with or without trailing |)
374
+ if (line.includes('|') && (line.startsWith('|') || /[^\\|]/.test(line))) {
375
+ if (!inTable) {
376
+ inTable = true;
377
+ tableLines = [];
378
+ }
379
+ tableLines.push(line);
380
+ } else {
381
+ // Not a table line
382
+ if (inTable) {
383
+ // Process the accumulated table
384
+ const tableHtml = buildTable(tableLines, getAttr);
385
+ if (tableHtml) {
386
+ result.push(tableHtml);
387
+ } else {
388
+ // Not a valid table, restore original lines
389
+ result.push(...tableLines);
390
+ }
391
+ inTable = false;
392
+ tableLines = [];
393
+ }
394
+ result.push(lines[i]);
395
+ }
396
+ }
397
+
398
+ // Handle table at end of text
399
+ if (inTable && tableLines.length > 0) {
400
+ const tableHtml = buildTable(tableLines, getAttr);
401
+ if (tableHtml) {
402
+ result.push(tableHtml);
403
+ } else {
404
+ result.push(...tableLines);
405
+ }
406
+ }
407
+
408
+ return result.join('\n');
409
+ }
410
+
411
+ /**
412
+ * Build an HTML table from markdown table lines
413
+ */
414
+ function buildTable(lines, getAttr) {
415
+
416
+ if (lines.length < 2) return null;
417
+
418
+ // Check for separator line (second line should be the separator)
419
+ let separatorIndex = -1;
420
+ for (let i = 1; i < lines.length; i++) {
421
+ // Support separator with or without leading/trailing pipes
422
+ if (/^\|?[\s\-:|]+\|?$/.test(lines[i]) && lines[i].includes('-')) {
423
+ separatorIndex = i;
424
+ break;
425
+ }
426
+ }
427
+
428
+ if (separatorIndex === -1) return null;
429
+
430
+ const headerLines = lines.slice(0, separatorIndex);
431
+ const bodyLines = lines.slice(separatorIndex + 1);
432
+
433
+ // Parse alignment from separator
434
+ const separator = lines[separatorIndex];
435
+ // Handle pipes at start/end or not
436
+ const separatorCells = separator.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
437
+ const alignments = separatorCells.map(cell => {
438
+ const trimmed = cell.trim();
439
+ if (trimmed.startsWith(':') && trimmed.endsWith(':')) return 'center';
440
+ if (trimmed.endsWith(':')) return 'right';
441
+ return 'left';
442
+ });
443
+
444
+ let html = `<table${getAttr('table')}>\n`;
445
+
446
+ // Build header
447
+ // Note: headerLines will always have length > 0 since separatorIndex starts from 1
448
+ html += `<thead${getAttr('thead')}>\n`;
449
+ headerLines.forEach(line => {
450
+ html += `<tr${getAttr('tr')}>\n`;
451
+ // Handle pipes at start/end or not
452
+ const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
453
+ cells.forEach((cell, i) => {
454
+ const alignStyle = alignments[i] && alignments[i] !== 'left' ? `text-align:${alignments[i]}` : '';
455
+ const processedCell = processInlineMarkdown(cell.trim(), getAttr);
456
+ html += `<th${getAttr('th', alignStyle)}>${processedCell}</th>\n`;
457
+ });
458
+ html += '</tr>\n';
459
+ });
460
+ html += '</thead>\n';
461
+
462
+ // Build body
463
+ if (bodyLines.length > 0) {
464
+ html += `<tbody${getAttr('tbody')}>\n`;
465
+ bodyLines.forEach(line => {
466
+ html += `<tr${getAttr('tr')}>\n`;
467
+ // Handle pipes at start/end or not
468
+ const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
469
+ cells.forEach((cell, i) => {
470
+ const alignStyle = alignments[i] && alignments[i] !== 'left' ? `text-align:${alignments[i]}` : '';
471
+ const processedCell = processInlineMarkdown(cell.trim(), getAttr);
472
+ html += `<td${getAttr('td', alignStyle)}>${processedCell}</td>\n`;
473
+ });
474
+ html += '</tr>\n';
475
+ });
476
+ html += '</tbody>\n';
477
+ }
478
+
479
+ html += '</table>';
480
+ return html;
481
+ }
482
+
483
+ /**
484
+ * Process markdown lists (ordered and unordered)
485
+ */
486
+ function processLists(text, getAttr, inline_styles, bidirectional) {
487
+
488
+ const lines = text.split('\n');
489
+ const result = [];
490
+ let listStack = []; // Track nested lists
491
+
492
+ // Helper to escape HTML for data-qd attributes
493
+ const escapeHtml = (text) => text.replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[m]);
494
+ const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';
495
+
496
+ for (let i = 0; i < lines.length; i++) {
497
+ const line = lines[i];
498
+ const match = line.match(/^(\s*)([*\-+]|\d+\.)\s+(.+)$/);
499
+
500
+ if (match) {
501
+ const [, indent, marker, content] = match;
502
+ const level = Math.floor(indent.length / 2);
503
+ const isOrdered = /^\d+\./.test(marker);
504
+ const listType = isOrdered ? 'ol' : 'ul';
505
+
506
+ // Check for task list items
507
+ let listItemContent = content;
508
+ let taskListClass = '';
509
+ const taskMatch = content.match(/^\[([x ])\]\s+(.*)$/i);
510
+ if (taskMatch && !isOrdered) {
511
+ const [, checked, taskContent] = taskMatch;
512
+ const isChecked = checked.toLowerCase() === 'x';
513
+ const checkboxAttr = inline_styles
514
+ ? ' style="margin-right:.5em"'
515
+ : ` class="${CLASS_PREFIX}task-checkbox"`;
516
+ listItemContent = `<input type="checkbox"${checkboxAttr}${isChecked ? ' checked' : ''} disabled> ${taskContent}`;
517
+ taskListClass = inline_styles ? ' style="list-style:none"' : ` class="${CLASS_PREFIX}task-item"`;
518
+ }
519
+
520
+ // Close deeper levels
521
+ while (listStack.length > level + 1) {
522
+ const list = listStack.pop();
523
+ result.push(`</${list.type}>`);
524
+ }
525
+
526
+ // Open new level if needed
527
+ if (listStack.length === level) {
528
+ // Need to open a new list
529
+ listStack.push({ type: listType, level });
530
+ result.push(`<${listType}${getAttr(listType)}>`);
531
+ } else if (listStack.length === level + 1) {
532
+ // Check if we need to switch list type
533
+ const currentList = listStack[listStack.length - 1];
534
+ if (currentList.type !== listType) {
535
+ result.push(`</${currentList.type}>`);
536
+ listStack.pop();
537
+ listStack.push({ type: listType, level });
538
+ result.push(`<${listType}${getAttr(listType)}>`);
539
+ }
540
+ }
541
+
542
+ const liAttr = taskListClass || getAttr('li');
543
+ result.push(`<li${liAttr}${dataQd(marker)}>${listItemContent}</li>`);
544
+ } else {
545
+ // Not a list item, close all lists
546
+ while (listStack.length > 0) {
547
+ const list = listStack.pop();
548
+ result.push(`</${list.type}>`);
549
+ }
550
+ result.push(line);
551
+ }
552
+ }
553
+
554
+ // Close any remaining lists
555
+ while (listStack.length > 0) {
556
+ const list = listStack.pop();
557
+ result.push(`</${list.type}>`);
558
+ }
559
+
560
+ return result.join('\n');
561
+ }
562
+
563
+ /**
564
+ * Emit CSS styles for quikdown elements
565
+ * @param {string} prefix - Optional class prefix (default: 'quikdown-')
566
+ * @param {string} theme - Optional theme: 'light' (default) or 'dark'
567
+ * @returns {string} CSS string with quikdown styles
568
+ */
569
+ quikdown.emitStyles = function(prefix = 'quikdown-', theme = 'light') {
570
+ const styles = QUIKDOWN_STYLES;
571
+
572
+ // Define theme color overrides
573
+ const themeOverrides = {
574
+ dark: {
575
+ '#f4f4f4': '#2a2a2a', // pre background
576
+ '#f0f0f0': '#2a2a2a', // code background
577
+ '#f2f2f2': '#2a2a2a', // th background
578
+ '#ddd': '#3a3a3a', // borders
579
+ '#06c': '#6db3f2', // links
580
+ _textColor: '#e0e0e0'
581
+ },
582
+ light: {
583
+ _textColor: '#333' // Explicit text color for light theme
584
+ }
585
+ };
586
+
587
+ let css = '';
588
+ for (const [tag, style] of Object.entries(styles)) {
589
+ let themedStyle = style;
590
+
591
+ // Apply theme overrides if dark theme
592
+ if (theme === 'dark' && themeOverrides.dark) {
593
+ // Replace colors
594
+ for (const [oldColor, newColor] of Object.entries(themeOverrides.dark)) {
595
+ if (!oldColor.startsWith('_')) {
596
+ themedStyle = themedStyle.replace(new RegExp(oldColor, 'g'), newColor);
597
+ }
598
+ }
599
+
600
+ // Add text color for certain elements in dark theme
601
+ const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote'];
602
+ if (needsTextColor.includes(tag)) {
603
+ themedStyle += `;color:${themeOverrides.dark._textColor}`;
604
+ }
605
+ } else if (theme === 'light' && themeOverrides.light) {
606
+ // Add explicit text color for light theme elements too
607
+ const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote'];
608
+ if (needsTextColor.includes(tag)) {
609
+ themedStyle += `;color:${themeOverrides.light._textColor}`;
610
+ }
611
+ }
612
+
613
+ css += `.${prefix}${tag} { ${themedStyle} }\n`;
614
+ }
615
+
616
+ return css;
617
+ };
618
+
619
+ /**
620
+ * Configure quikdown with options and return a function
621
+ * @param {Object} options - Configuration options
622
+ * @returns {Function} Configured quikdown function
623
+ */
624
+ quikdown.configure = function(options) {
625
+ return function(markdown) {
626
+ return quikdown(markdown, options);
627
+ };
628
+ };
629
+
630
+ /**
631
+ * Version information
632
+ */
633
+ quikdown.version = quikdownVersion;
634
+
635
+ // Export for both CommonJS and ES6
636
+ /* istanbul ignore next */
637
+ if (typeof module !== 'undefined' && module.exports) {
638
+ module.exports = quikdown;
639
+ }
640
+
641
+ // For browser global
642
+ /* istanbul ignore next */
643
+ if (typeof window !== 'undefined') {
644
+ window.quikdown = quikdown;
645
+ }
646
+
647
+ /**
648
+ * quikdown_bd - Bidirectional markdown/HTML converter
649
+ * Extends core quikdown with HTML→Markdown conversion
650
+ *
651
+ * Uses data-qd attributes to preserve original markdown syntax
652
+ * Enables HTML→Markdown conversion for quikdown-generated HTML
653
+ */
654
+
655
+
656
+ /**
657
+ * Create bidirectional version by extending quikdown
658
+ * This wraps quikdown and adds the toMarkdown method
659
+ */
660
+ function quikdown_bd(markdown, options = {}) {
661
+ // Use core quikdown with bidirectional flag to add data-qd attributes
662
+ return quikdown(markdown, { ...options, bidirectional: true });
663
+ }
664
+
665
+ // Copy all properties and methods from quikdown (including version)
666
+ Object.keys(quikdown).forEach(key => {
667
+ quikdown_bd[key] = quikdown[key];
668
+ });
669
+
670
+ // Add the toMarkdown method for HTML→Markdown conversion
671
+ quikdown_bd.toMarkdown = function(htmlOrElement) {
672
+ // Accept either HTML string or DOM element
673
+ let container;
674
+ if (typeof htmlOrElement === 'string') {
675
+ container = document.createElement('div');
676
+ container.innerHTML = htmlOrElement;
677
+ } else if (htmlOrElement instanceof Element) {
678
+ /* istanbul ignore next - browser-only code path, not testable in jsdom */
679
+ container = htmlOrElement;
680
+ } else {
681
+ return '';
682
+ }
683
+
684
+ // Walk the DOM tree and reconstruct markdown
685
+ function walkNode(node, parentContext = {}) {
686
+ if (node.nodeType === Node.TEXT_NODE) {
687
+ // Return text content, preserving whitespace where needed
688
+ return node.textContent;
689
+ }
690
+
691
+ if (node.nodeType !== Node.ELEMENT_NODE) {
692
+ return '';
693
+ }
694
+
695
+ const tag = node.tagName.toLowerCase();
696
+ const dataQd = node.getAttribute('data-qd');
697
+
698
+ // Process children with context
699
+ let childContent = '';
700
+ for (let child of node.childNodes) {
701
+ childContent += walkNode(child, { parentTag: tag, ...parentContext });
702
+ }
703
+
704
+ // Determine markdown based on element and attributes
705
+ switch (tag) {
706
+ case 'h1':
707
+ case 'h2':
708
+ case 'h3':
709
+ case 'h4':
710
+ case 'h5':
711
+ case 'h6':
712
+ const level = parseInt(tag[1]);
713
+ const prefix = dataQd || '#'.repeat(level);
714
+ return `${prefix} ${childContent.trim()}\n\n`;
715
+
716
+ case 'strong':
717
+ case 'b':
718
+ if (!childContent) return ''; // Don't add markers for empty content
719
+ const boldMarker = dataQd || '**';
720
+ return `${boldMarker}${childContent}${boldMarker}`;
721
+
722
+ case 'em':
723
+ case 'i':
724
+ if (!childContent) return ''; // Don't add markers for empty content
725
+ const emMarker = dataQd || '*';
726
+ return `${emMarker}${childContent}${emMarker}`;
727
+
728
+ case 'del':
729
+ case 's':
730
+ case 'strike':
731
+ if (!childContent) return ''; // Don't add markers for empty content
732
+ const delMarker = dataQd || '~~';
733
+ return `${delMarker}${childContent}${delMarker}`;
734
+
735
+ case 'code':
736
+ // Note: code inside pre is handled directly by the pre case using querySelector
737
+ if (!childContent) return ''; // Don't add markers for empty content
738
+ const codeMarker = dataQd || '`';
739
+ return `${codeMarker}${childContent}${codeMarker}`;
740
+
741
+ case 'pre':
742
+ const fence = node.getAttribute('data-qd-fence') || dataQd || '```';
743
+ const lang = node.getAttribute('data-qd-lang') || '';
744
+ // Look for code element child
745
+ const codeEl = node.querySelector('code');
746
+ const codeContent = codeEl ? codeEl.textContent : childContent;
747
+ return `${fence}${lang}\n${codeContent.trimEnd()}\n${fence}\n\n`;
748
+
749
+ case 'blockquote':
750
+ const quoteMarker = dataQd || '>';
751
+ const lines = childContent.trim().split('\n');
752
+ return lines.map(line => `${quoteMarker} ${line}`).join('\n') + '\n\n';
753
+
754
+ case 'hr':
755
+ const hrMarker = dataQd || '---';
756
+ return `${hrMarker}\n\n`;
757
+
758
+ case 'br':
759
+ const brMarker = dataQd || ' ';
760
+ return `${brMarker}\n`;
761
+
762
+ case 'a':
763
+ const linkText = node.getAttribute('data-qd-text') || childContent.trim();
764
+ const href = node.getAttribute('href') || '';
765
+ // Check for autolinks
766
+ if (linkText === href && !dataQd) {
767
+ return `<${href}>`;
768
+ }
769
+ return `[${linkText}](${href})`;
770
+
771
+ case 'img':
772
+ const alt = node.getAttribute('data-qd-alt') || node.getAttribute('alt') || '';
773
+ const src = node.getAttribute('data-qd-src') || node.getAttribute('src') || '';
774
+ const imgMarker = dataQd || '!';
775
+ return `${imgMarker}[${alt}](${src})`;
776
+
777
+ case 'ul':
778
+ case 'ol':
779
+ return walkList(node, tag === 'ol') + '\n';
780
+
781
+ case 'li':
782
+ // Handled by list processor
783
+ return childContent;
784
+
785
+ case 'table':
786
+ return walkTable(node) + '\n\n';
787
+
788
+ case 'p':
789
+ // Check if it's actually a paragraph or just a wrapper
790
+ if (childContent.trim()) {
791
+ return childContent.trim() + '\n\n';
792
+ }
793
+ return '';
794
+
795
+ case 'div':
796
+ // Check if it's a mermaid container
797
+ if (node.classList && node.classList.contains('mermaid-container')) {
798
+ const fence = node.getAttribute('data-qd-fence') || '```';
799
+ const lang = node.getAttribute('data-qd-lang') || 'mermaid';
800
+
801
+ // First check for data-qd-source attribute on the container
802
+ const source = node.getAttribute('data-qd-source');
803
+ if (source) {
804
+ // Decode HTML entities from the attribute (mainly &quot;)
805
+ const temp = document.createElement('textarea');
806
+ temp.innerHTML = source;
807
+ const code = temp.value;
808
+ return `${fence}${lang}\n${code}\n${fence}\n\n`;
809
+ }
810
+
811
+ // Check for source on the pre.mermaid element
812
+ const mermaidPre = node.querySelector('pre.mermaid');
813
+ if (mermaidPre) {
814
+ const preSource = mermaidPre.getAttribute('data-qd-source');
815
+ if (preSource) {
816
+ const temp = document.createElement('textarea');
817
+ temp.innerHTML = preSource;
818
+ const code = temp.value;
819
+ return `${fence}${lang}\n${code}\n${fence}\n\n`;
820
+ }
821
+ }
822
+
823
+ // Fallback: Look for the legacy .mermaid-source element
824
+ const sourceElement = node.querySelector('.mermaid-source');
825
+ if (sourceElement) {
826
+ // Decode HTML entities
827
+ const temp = document.createElement('div');
828
+ temp.innerHTML = sourceElement.innerHTML;
829
+ const code = temp.textContent;
830
+ return `${fence}${lang}\n${code}\n${fence}\n\n`;
831
+ }
832
+
833
+ // Final fallback: try to extract from the mermaid element (unreliable after rendering)
834
+ const mermaidElement = node.querySelector('.mermaid');
835
+ if (mermaidElement && mermaidElement.textContent.includes('graph')) {
836
+ return `${fence}${lang}\n${mermaidElement.textContent.trim()}\n${fence}\n\n`;
837
+ }
838
+ }
839
+ // Check if it's a standalone mermaid diagram (legacy)
840
+ if (node.classList && node.classList.contains('mermaid')) {
841
+ const fence = node.getAttribute('data-qd-fence') || '```';
842
+ const lang = node.getAttribute('data-qd-lang') || 'mermaid';
843
+ const code = node.textContent.trim();
844
+ return `${fence}${lang}\n${code}\n${fence}\n\n`;
845
+ }
846
+ // Pass through other divs
847
+ return childContent;
848
+
849
+ case 'span':
850
+ // Pass through container elements
851
+ return childContent;
852
+
853
+ default:
854
+ return childContent;
855
+ }
856
+ }
857
+
858
+ // Walk list elements
859
+ function walkList(listNode, isOrdered, depth = 0) {
860
+ let result = '';
861
+ let index = 1;
862
+ const indent = ' '.repeat(depth);
863
+
864
+ for (let child of listNode.children) {
865
+ if (child.tagName !== 'LI') continue;
866
+
867
+ const dataQd = child.getAttribute('data-qd');
868
+ let marker = dataQd || (isOrdered ? `${index}.` : '-');
869
+
870
+ // Check for task list checkbox
871
+ const checkbox = child.querySelector('input[type="checkbox"]');
872
+ if (checkbox) {
873
+ const checked = checkbox.checked ? 'x' : ' ';
874
+ marker = '-';
875
+ // Get text without the checkbox
876
+ let text = '';
877
+ for (let node of child.childNodes) {
878
+ if (node.nodeType === Node.TEXT_NODE) {
879
+ text += node.textContent;
880
+ } else if (node.tagName && node.tagName !== 'INPUT') {
881
+ text += walkNode(node);
882
+ }
883
+ }
884
+ result += `${indent}${marker} [${checked}] ${text.trim()}\n`;
885
+ } else {
886
+ let itemContent = '';
887
+
888
+ for (let node of child.childNodes) {
889
+ if (node.tagName === 'UL' || node.tagName === 'OL') {
890
+ itemContent += walkList(node, node.tagName === 'OL', depth + 1);
891
+ } else {
892
+ itemContent += walkNode(node);
893
+ }
894
+ }
895
+
896
+ result += `${indent}${marker} ${itemContent.trim()}\n`;
897
+ }
898
+
899
+ index++;
900
+ }
901
+
902
+ return result;
903
+ }
904
+
905
+ // Walk table elements
906
+ function walkTable(table) {
907
+ let result = '';
908
+ const alignData = table.getAttribute('data-qd-align');
909
+ const alignments = alignData ? alignData.split(',') : [];
910
+
911
+ // Process header
912
+ const thead = table.querySelector('thead');
913
+ if (thead) {
914
+ const headerRow = thead.querySelector('tr');
915
+ if (headerRow) {
916
+ const headers = [];
917
+ for (let th of headerRow.querySelectorAll('th')) {
918
+ headers.push(th.textContent.trim());
919
+ }
920
+ result += '| ' + headers.join(' | ') + ' |\n';
921
+
922
+ // Add separator with alignment
923
+ const separators = headers.map((_, i) => {
924
+ const align = alignments[i] || 'left';
925
+ if (align === 'center') return ':---:';
926
+ if (align === 'right') return '---:';
927
+ return '---';
928
+ });
929
+ result += '| ' + separators.join(' | ') + ' |\n';
930
+ }
931
+ }
932
+
933
+ // Process body
934
+ const tbody = table.querySelector('tbody');
935
+ if (tbody) {
936
+ for (let row of tbody.querySelectorAll('tr')) {
937
+ const cells = [];
938
+ for (let td of row.querySelectorAll('td')) {
939
+ cells.push(td.textContent.trim());
940
+ }
941
+ if (cells.length > 0) {
942
+ result += '| ' + cells.join(' | ') + ' |\n';
943
+ }
944
+ }
945
+ }
946
+
947
+ return result.trim();
948
+ }
949
+
950
+ // Process the DOM tree
951
+ let markdown = walkNode(container);
952
+
953
+ // Clean up
954
+ markdown = markdown.replace(/\n{3,}/g, '\n\n'); // Remove excessive newlines
955
+ markdown = markdown.trim();
956
+
957
+ return markdown;
958
+ };
959
+
960
+ // Override the configure method to return a bidirectional version
961
+ quikdown_bd.configure = function(options) {
962
+ return function(markdown) {
963
+ return quikdown_bd(markdown, options);
964
+ };
965
+ };
966
+
967
+ // Set version
968
+ // Version is already copied from quikdown via Object.keys loop
969
+
970
+ // Export for both module and browser
971
+ /* istanbul ignore next */
972
+ if (typeof module !== 'undefined' && module.exports) {
973
+ module.exports = quikdown_bd;
974
+ }
975
+
976
+ /* istanbul ignore next */
977
+ if (typeof window !== 'undefined') {
978
+ window.quikdown_bd = quikdown_bd;
979
+ }
980
+
981
+ module.exports = quikdown_bd;