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