quikdown 1.0.4 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,275 +1,383 @@
1
1
  /**
2
2
  * quikdown_bd - Bidirectional Markdown Parser
3
- * @version 1.0.4
3
+ * @version 1.1.0
4
4
  * @license BSD-2-Clause
5
5
  * @copyright DeftIO 2025
6
6
  */
7
7
  /**
8
- * quikdown_bd - Bidirectional markdown/HTML converter
9
- * Standalone version with round-trip conversion support
10
- *
11
- * Uses data-qd attributes to preserve original markdown syntax
12
- * Enables HTML→Markdown conversion for quikdown-generated HTML
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
13
18
  */
14
19
 
15
- // Version - uses same version as core quikdown
16
- const VERSION = '1.0.4';
20
+ // Version will be injected at build time
21
+ const quikdownVersion = '1.1.0';
17
22
 
18
- // Helper to escape HTML (same as core)
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
19
29
  const ESC_MAP = {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'};
20
- function escapeHtml(text) {
21
- return text.replace(/[&<>"']/g, m => ESC_MAP[m]);
22
- }
23
30
 
24
- // Modified getAttr that adds data-qd attributes
25
- function createGetAttrBD(inline_styles, styles) {
26
- return function(tag, additionalStyle = '', sourceMarker = '') {
27
- let attrs = '';
28
-
29
- // Add data-qd attribute if source marker provided
30
- if (sourceMarker) {
31
- attrs += ` data-qd="${escapeHtml(sourceMarker)}"`;
32
- }
33
-
34
- // Add style or class
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 = '') {
35
62
  if (inline_styles) {
36
- const style = styles[tag];
37
- if (style || additionalStyle) {
38
- const fullStyle = additionalStyle ? (style ? `${style};${additionalStyle}` : additionalStyle) : style;
39
- attrs += ` style="${fullStyle}"`;
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 += ';';
40
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}"`;
41
75
  } else {
42
- attrs += ` class="quikdown-${tag}"`;
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;
43
82
  }
44
-
45
- return attrs;
46
83
  };
47
84
  }
48
85
 
49
- /**
50
- * Enhanced markdown parser with bidirectional support
51
- * Wraps the core parser and adds data-qd attributes
52
- */
53
- function quikdown_bd(markdown, options = {}) {
86
+ function quikdown(markdown, options = {}) {
54
87
  if (!markdown || typeof markdown !== 'string') {
55
88
  return '';
56
89
  }
57
90
 
58
- const { fence_plugin, inline_styles = false, bidirectional = true } = options;
59
-
60
- // If not bidirectional mode, process without data-qd attributes
61
- if (!bidirectional) {
62
- // Process without bidirectional tracking
63
- options.bidirectional = false;
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]);
64
98
  }
65
99
 
66
- // For bidirectional, we need to manually process with source tracking
67
- // This is a custom implementation that adds data-qd attributes
68
-
69
- const QUIKDOWN_STYLES = {
70
- h1: 'font-size:2em;font-weight:600;margin:.67em 0;text-align:left',
71
- h2: 'font-size:1.5em;font-weight:600;margin:.83em 0',
72
- h3: 'font-size:1.25em;font-weight:600;margin:1em 0',
73
- h4: 'font-size:1em;font-weight:600;margin:1.33em 0',
74
- h5: 'font-size:.875em;font-weight:600;margin:1.67em 0',
75
- h6: 'font-size:.85em;font-weight:600;margin:2em 0',
76
- pre: 'background:#f4f4f4;padding:10px;border-radius:4px;overflow-x:auto;margin:1em 0',
77
- code: 'background:#f0f0f0;padding:2px 4px;border-radius:3px;font-family:monospace',
78
- blockquote: 'border-left:4px solid #ddd;margin-left:0;padding-left:1em',
79
- table: 'border-collapse:collapse;width:100%;margin:1em 0',
80
- th: 'border:1px solid #ddd;padding:8px;background-color:#f2f2f2;font-weight:bold;text-align:left',
81
- td: 'border:1px solid #ddd;padding:8px;text-align:left',
82
- hr: 'border:none;border-top:1px solid #ddd;margin:1em 0',
83
- img: 'max-width:100%;height:auto',
84
- a: 'color:#06c;text-decoration:underline',
85
- strong: 'font-weight:bold',
86
- em: 'font-style:italic',
87
- del: 'text-decoration:line-through',
88
- ul: 'margin:.5em 0;padding-left:2em',
89
- ol: 'margin:.5em 0;padding-left:2em',
90
- li: 'margin:.25em 0',
91
- 'task-item': 'list-style:none',
92
- 'task-checkbox': 'margin-right:.5em'
93
- };
94
-
95
- const getAttr = createGetAttrBD(inline_styles, QUIKDOWN_STYLES);
100
+ // Helper to add data-qd attributes for bidirectional support
101
+ const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';
96
102
 
97
- // Process markdown with source tracking
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
98
132
  let html = markdown;
99
133
 
100
- // Phase 1: Extract and protect code blocks
134
+ // Phase 1: Extract and protect code blocks and inline code
101
135
  const codeBlocks = [];
102
136
  const inlineCodes = [];
103
137
 
104
- // Extract fenced code blocks
138
+ // Extract fenced code blocks first (supports both ``` and ~~~)
139
+ // Match paired fences - ``` with ``` and ~~~ with ~~~
140
+ // Fence must be at start of line
105
141
  html = html.replace(/^(```|~~~)([^\n]*)\n([\s\S]*?)^\1$/gm, (match, fence, lang, code) => {
106
- const placeholder = `§CB${codeBlocks.length}§`;
107
- codeBlocks.push({
108
- fence,
109
- lang: lang.trim(),
110
- code: escapeHtml(code.trimEnd()),
111
- original: match
112
- });
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 (v1.1.0: object format required)
148
+ if (fence_plugin && fence_plugin.render && typeof fence_plugin.render === 'function') {
149
+ codeBlocks.push({
150
+ lang: langTrimmed,
151
+ code: code.trimEnd(),
152
+ custom: true,
153
+ fence: fence,
154
+ hasReverse: !!fence_plugin.reverse
155
+ });
156
+ } else {
157
+ codeBlocks.push({
158
+ lang: langTrimmed,
159
+ code: escapeHtml(code.trimEnd()),
160
+ custom: false,
161
+ fence: fence
162
+ });
163
+ }
113
164
  return placeholder;
114
165
  });
115
166
 
116
167
  // Extract inline code
117
168
  html = html.replace(/`([^`]+)`/g, (match, code) => {
118
- const placeholder = `§IC${inlineCodes.length}§`;
119
- inlineCodes.push({
120
- code: escapeHtml(code),
121
- original: match
122
- });
169
+ const placeholder = `${PLACEHOLDER_IC}${inlineCodes.length}§`;
170
+ inlineCodes.push(escapeHtml(code));
123
171
  return placeholder;
124
172
  });
125
173
 
126
- // Escape HTML
174
+ // Now escape HTML in the rest of the content
127
175
  html = escapeHtml(html);
128
176
 
129
- // Process headings with source tracking
177
+ // Phase 2: Process block elements
178
+
179
+ // Process tables
180
+ html = processTable(html, getAttr);
181
+
182
+ // Process headings (supports optional trailing #'s)
130
183
  html = html.replace(/^(#{1,6})\s+(.+?)\s*#*$/gm, (match, hashes, content) => {
131
184
  const level = hashes.length;
132
- const sourceMarker = hashes;
133
- return `<h${level}${getAttr('h' + level, '', sourceMarker)}>${content}</h${level}>`;
185
+ return `<h${level}${getAttr('h' + level)}${dataQd(hashes)}>${content}</h${level}>`;
134
186
  });
135
187
 
136
- // Process bold/italic/strikethrough with source tracking
137
- html = html.replace(/\*\*(.+?)\*\*/g, `<strong${getAttr('strong', '', '**')}>$1</strong>`);
138
- html = html.replace(/__(.+?)__/g, `<strong${getAttr('strong', '', '__')}>$1</strong>`);
139
- html = html.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, `<em${getAttr('em', '', '*')}>$1</em>`);
140
- html = html.replace(/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, `<em${getAttr('em', '', '_')}>$1</em>`);
141
- html = html.replace(/~~(.+?)~~/g, `<del${getAttr('del', '', '~~')}>$1</del>`);
188
+ // Process blockquotes (must handle escaped > since we already escaped HTML)
189
+ html = html.replace(/^&gt;\s+(.+)$/gm, `<blockquote${getAttr('blockquote')}>$1</blockquote>`);
190
+ // Merge consecutive blockquotes
191
+ html = html.replace(/<\/blockquote>\n<blockquote>/g, '\n');
142
192
 
143
- // Process blockquotes
144
- html = html.replace(/^&gt;\s+(.+)$/gm, `<blockquote${getAttr('blockquote', '', '>')}>$1</blockquote>`);
145
- html = html.replace(/<\/blockquote>\n<blockquote[^>]*>/g, '\n');
193
+ // Process horizontal rules (allow trailing spaces)
194
+ html = html.replace(/^---+\s*$/gm, `<hr${getAttr('hr')}>`);
146
195
 
147
- // Process horizontal rules
148
- html = html.replace(/^---+$/gm, `<hr${getAttr('hr', '', '---')}>`);
196
+ // Process lists
197
+ html = processLists(html, getAttr, inline_styles, bidirectional);
149
198
 
150
- // Process lists (simplified for now)
151
- html = processListsBD(html, getAttr);
199
+ // Phase 3: Process inline elements
152
200
 
153
- // Process links and images
201
+ // Images (must come before links, with URL sanitization)
154
202
  html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
155
- return `<img${getAttr('img', '', '!')} src="${src}" alt="${alt}" data-qd-alt="${escapeHtml(alt)}" data-qd-src="${escapeHtml(src)}">`;
203
+ const sanitizedSrc = sanitizeUrl(src, options.allow_unsafe_urls);
204
+ const altAttr = bidirectional && alt ? ` data-qd-alt="${escapeHtml(alt)}"` : '';
205
+ const srcAttr = bidirectional ? ` data-qd-src="${escapeHtml(src)}"` : '';
206
+ return `<img${getAttr('img')} src="${sanitizedSrc}" alt="${alt}"${altAttr}${srcAttr}${dataQd('!')}>`;
156
207
  });
157
208
 
209
+ // Links (with URL sanitization)
158
210
  html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, href) => {
159
- return `<a${getAttr('a', '', '[')} href="${href}" data-qd-text="${escapeHtml(text)}">${text}</a>`;
211
+ // Sanitize URL to prevent XSS
212
+ const sanitizedHref = sanitizeUrl(href, options.allow_unsafe_urls);
213
+ const isExternal = /^https?:\/\//i.test(sanitizedHref);
214
+ const rel = isExternal ? ' rel="noopener noreferrer"' : '';
215
+ const textAttr = bidirectional ? ` data-qd-text="${escapeHtml(text)}"` : '';
216
+ return `<a${getAttr('a')} href="${sanitizedHref}"${rel}${textAttr}${dataQd('[')}>${text}</a>`;
160
217
  });
161
218
 
162
- // Process tables
163
- html = processTablesBD(html, getAttr);
219
+ // Autolinks - convert bare URLs to clickable links
220
+ html = html.replace(/(^|\s)(https?:\/\/[^\s<]+)/g, (match, prefix, url) => {
221
+ const sanitizedUrl = sanitizeUrl(url, options.allow_unsafe_urls);
222
+ return `${prefix}<a${getAttr('a')} href="${sanitizedUrl}" rel="noopener noreferrer">${url}</a>`;
223
+ });
164
224
 
165
- // Line breaks
166
- html = html.replace(/ $/gm, '<br data-qd=" ">');
225
+ // Process inline formatting (bold, italic, strikethrough)
226
+ const inlinePatterns = [
227
+ [/\*\*(.+?)\*\*/g, 'strong', '**'],
228
+ [/__(.+?)__/g, 'strong', '__'],
229
+ [/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, 'em', '*'],
230
+ [/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, 'em', '_'],
231
+ [/~~(.+?)~~/g, 'del', '~~']
232
+ ];
167
233
 
168
- // Paragraphs
169
- html = html.replace(/\n\n+/g, '</p><p>');
170
- html = '<p>' + html + '</p>';
234
+ inlinePatterns.forEach(([pattern, tag, marker]) => {
235
+ html = html.replace(pattern, `<${tag}${getAttr(tag)}${dataQd(marker)}>$1</${tag}>`);
236
+ });
237
+
238
+ // Line breaks
239
+ if (lazy_linefeeds) {
240
+ // Lazy linefeeds: single newline becomes <br> (except between paragraphs and after/before block elements)
241
+ const blocks = [];
242
+ let bi = 0;
243
+
244
+ // Protect tables and lists
245
+ html = html.replace(/<(table|[uo]l)[^>]*>[\s\S]*?<\/\1>/g, m => {
246
+ blocks[bi] = m;
247
+ return `§B${bi++}§`;
248
+ });
249
+
250
+ // Handle paragraphs and block elements
251
+ html = html.replace(/\n\n+/g, '§P§')
252
+ // After block elements
253
+ .replace(/(<\/(?:h[1-6]|blockquote|pre)>)\n/g, '$1§N§')
254
+ .replace(/(<(?:h[1-6]|blockquote|pre|hr)[^>]*>)\n/g, '$1§N§')
255
+ // Before block elements
256
+ .replace(/\n(<(?:h[1-6]|blockquote|pre|hr)[^>]*>)/g, '§N§$1')
257
+ .replace(/\n(§B\d+§)/g, '§N§$1')
258
+ .replace(/(§B\d+§)\n/g, '$1§N§')
259
+ // Convert remaining newlines
260
+ .replace(/\n/g, `<br${getAttr('br')}>`)
261
+ // Restore
262
+ .replace(/§N§/g, '\n')
263
+ .replace(/§P§/g, '</p><p>');
264
+
265
+ // Restore protected blocks
266
+ blocks.forEach((b, i) => html = html.replace(`§B${i}§`, b));
267
+
268
+ html = '<p>' + html + '</p>';
269
+ } else {
270
+ // Standard: two spaces at end of line for line breaks
271
+ html = html.replace(/ $/gm, `<br${getAttr('br')}>`);
272
+
273
+ // Paragraphs (double newlines)
274
+ // Don't add </p> after block elements (they're not in paragraphs)
275
+ html = html.replace(/\n\n+/g, (match, offset) => {
276
+ // Check if we're after a block element closing tag
277
+ const before = html.substring(0, offset);
278
+ if (before.match(/<\/(h[1-6]|blockquote|ul|ol|table|pre|hr)>$/)) {
279
+ return '<p>'; // Just open a new paragraph
280
+ }
281
+ return '</p><p>'; // Normal paragraph break
282
+ });
283
+ html = '<p>' + html + '</p>';
284
+ }
171
285
 
172
286
  // Clean up empty paragraphs and unwrap block elements
173
- html = html.replace(/<p><\/p>/g, '');
174
- html = html.replace(/<p>(<h[1-6][^>]*>)/g, '$1');
175
- html = html.replace(/(<\/h[1-6]>)<\/p>/g, '$1');
176
- html = html.replace(/<p>(<blockquote[^>]*>)/g, '$1');
177
- html = html.replace(/(<\/blockquote>)<\/p>/g, '$1');
178
- html = html.replace(/<p>(<ul[^>]*>|<ol[^>]*>)/g, '$1');
179
- html = html.replace(/(<\/ul>|<\/ol>)<\/p>/g, '$1');
180
- html = html.replace(/<p>(<hr[^>]*>)<\/p>/g, '$1');
181
- html = html.replace(/<p>(<table[^>]*>)/g, '$1');
182
- html = html.replace(/(<\/table>)<\/p>/g, '$1');
287
+ const cleanupPatterns = [
288
+ [/<p><\/p>/g, ''],
289
+ [/<p>(<h[1-6][^>]*>)/g, '$1'],
290
+ [/(<\/h[1-6]>)<\/p>/g, '$1'],
291
+ [/<p>(<blockquote[^>]*>)/g, '$1'],
292
+ [/(<\/blockquote>)<\/p>/g, '$1'],
293
+ [/<p>(<ul[^>]*>|<ol[^>]*>)/g, '$1'],
294
+ [/(<\/ul>|<\/ol>)<\/p>/g, '$1'],
295
+ [/<p>(<hr[^>]*>)<\/p>/g, '$1'],
296
+ [/<p>(<table[^>]*>)/g, '$1'],
297
+ [/(<\/table>)<\/p>/g, '$1'],
298
+ [/<p>(<pre[^>]*>)/g, '$1'],
299
+ [/(<\/pre>)<\/p>/g, '$1'],
300
+ [new RegExp(`<p>(${PLACEHOLDER_CB}\\d+§)<\/p>`, 'g'), '$1']
301
+ ];
302
+
303
+ cleanupPatterns.forEach(([pattern, replacement]) => {
304
+ html = html.replace(pattern, replacement);
305
+ });
306
+
307
+ // Fix orphaned closing </p> tags after block elements
308
+ // When a paragraph follows a block element, ensure it has opening <p>
309
+ html = html.replace(/(<\/(?:h[1-6]|blockquote|ul|ol|table|pre|hr)>)\n([^<])/g, '$1\n<p>$2');
310
+
311
+ // Phase 4: Restore code blocks and inline code
183
312
 
184
313
  // Restore code blocks
185
314
  codeBlocks.forEach((block, i) => {
186
- const placeholder = `§CB${i}§`;
187
315
  let replacement;
188
316
 
189
- if (fence_plugin && typeof fence_plugin === 'function') {
190
- replacement = fence_plugin(block.code, block.lang);
317
+ if (block.custom && fence_plugin && fence_plugin.render) {
318
+ // Use custom fence plugin (v1.1.0: object format with render function)
319
+ replacement = fence_plugin.render(block.code, block.lang);
320
+
321
+ // If plugin returns undefined, fall back to default rendering
191
322
  if (replacement === undefined) {
192
- replacement = `<pre${getAttr('pre', '', block.fence)}><code data-qd-lang="${block.lang}">${block.code}</code></pre>`;
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}>${escapeHtml(block.code)}</code></pre>`;
328
+ } else if (bidirectional) {
329
+ // If bidirectional and plugin provided HTML, add data attributes for roundtrip
330
+ replacement = replacement.replace(/^<(\w+)/,
331
+ `<$1 data-qd-fence="${escapeHtml(block.fence)}" data-qd-lang="${escapeHtml(block.lang)}" data-qd-source="${escapeHtml(block.code)}"`);
193
332
  }
194
333
  } else {
195
- replacement = `<pre${getAttr('pre', '', block.fence)} data-qd-fence="${block.fence}" data-qd-lang="${block.lang}"><code>${block.code}</code></pre>`;
334
+ // Default rendering
335
+ const langClass = !inline_styles && block.lang ? ` class="language-${block.lang}"` : '';
336
+ const codeAttr = inline_styles ? getAttr('code') : langClass;
337
+ const langAttr = bidirectional && block.lang ? ` data-qd-lang="${escapeHtml(block.lang)}"` : '';
338
+ const fenceAttr = bidirectional ? ` data-qd-fence="${escapeHtml(block.fence)}"` : '';
339
+ replacement = `<pre${getAttr('pre')}${fenceAttr}${langAttr}><code${codeAttr}>${block.code}</code></pre>`;
196
340
  }
197
341
 
342
+ const placeholder = `${PLACEHOLDER_CB}${i}§`;
198
343
  html = html.replace(placeholder, replacement);
199
344
  });
200
345
 
201
- // Restore inline codes
202
- inlineCodes.forEach((item, i) => {
203
- const placeholder = `§IC${i}§`;
204
- html = html.replace(placeholder, `<code${getAttr('code', '', '`')}>${item.code}</code>`);
346
+ // Restore inline code
347
+ inlineCodes.forEach((code, i) => {
348
+ const placeholder = `${PLACEHOLDER_IC}${i}§`;
349
+ html = html.replace(placeholder, `<code${getAttr('code')}${dataQd('`')}>${code}</code>`);
205
350
  });
206
351
 
207
352
  return html.trim();
208
353
  }
209
354
 
210
- // Process lists with source tracking
211
- function processListsBD(text, getAttr, inline_styles) {
212
- const lines = text.split('\n');
213
- const result = [];
214
- let listStack = [];
355
+ /**
356
+ * Process inline markdown formatting
357
+ */
358
+ function processInlineMarkdown(text, getAttr) {
215
359
 
216
- for (let i = 0; i < lines.length; i++) {
217
- const line = lines[i];
218
- const match = line.match(/^(\s*)([*\-+]|\d+\.)\s+(.+)$/);
219
-
220
- if (match) {
221
- const [, indent, marker, content] = match;
222
- const level = Math.floor(indent.length / 2);
223
- const isOrdered = /^\d+\./.test(marker);
224
- const listType = isOrdered ? 'ol' : 'ul';
225
- const sourceMarker = isOrdered ? '1.' : marker;
226
-
227
- // Handle task lists
228
- let listItemContent = content;
229
- let taskAttrs = '';
230
- const taskMatch = content.match(/^\[([x ])\]\s+(.*)$/i);
231
- if (taskMatch && !isOrdered) {
232
- const [, checked, taskContent] = taskMatch;
233
- const isChecked = checked.toLowerCase() === 'x';
234
- listItemContent = `<input type="checkbox"${getAttr('task-checkbox', '', '[')}${isChecked ? ' checked' : ''}> ${taskContent}`;
235
- taskAttrs = getAttr('task-item', '', '- [ ]');
236
- }
237
-
238
- // Close deeper levels
239
- while (listStack.length > level + 1) {
240
- const list = listStack.pop();
241
- result.push(`</${list.type}>`);
242
- }
243
-
244
- // Open new level if needed
245
- if (listStack.length === level) {
246
- listStack.push({ type: listType, level, marker: sourceMarker });
247
- result.push(`<${listType}${getAttr(listType, '', sourceMarker)}>`);
248
- }
249
-
250
- const liAttr = taskAttrs || getAttr('li', '', sourceMarker);
251
- result.push(`<li${liAttr}>${listItemContent}</li>`);
252
- } else {
253
- // Close all lists
254
- while (listStack.length > 0) {
255
- const list = listStack.pop();
256
- result.push(`</${list.type}>`);
257
- }
258
- result.push(line);
259
- }
260
- }
360
+ // Process inline formatting patterns
361
+ const patterns = [
362
+ [/\*\*(.+?)\*\*/g, 'strong'],
363
+ [/__(.+?)__/g, 'strong'],
364
+ [/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, 'em'],
365
+ [/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, 'em'],
366
+ [/~~(.+?)~~/g, 'del'],
367
+ [/`([^`]+)`/g, 'code']
368
+ ];
261
369
 
262
- // Close remaining lists
263
- while (listStack.length > 0) {
264
- const list = listStack.pop();
265
- result.push(`</${list.type}>`);
266
- }
370
+ patterns.forEach(([pattern, tag]) => {
371
+ text = text.replace(pattern, `<${tag}${getAttr(tag)}>$1</${tag}>`);
372
+ });
267
373
 
268
- return result.join('\n');
374
+ return text;
269
375
  }
270
376
 
271
- // Process tables with source tracking
272
- function processTablesBD(text, getAttr) {
377
+ /**
378
+ * Process markdown tables
379
+ */
380
+ function processTable(text, getAttr) {
273
381
  const lines = text.split('\n');
274
382
  const result = [];
275
383
  let inTable = false;
@@ -278,18 +386,22 @@ function processTablesBD(text, getAttr) {
278
386
  for (let i = 0; i < lines.length; i++) {
279
387
  const line = lines[i].trim();
280
388
 
281
- if (line.includes('|')) {
389
+ // Check if this line looks like a table row (with or without trailing |)
390
+ if (line.includes('|') && (line.startsWith('|') || /[^\\|]/.test(line))) {
282
391
  if (!inTable) {
283
392
  inTable = true;
284
393
  tableLines = [];
285
394
  }
286
395
  tableLines.push(line);
287
396
  } else {
397
+ // Not a table line
288
398
  if (inTable) {
289
- const tableHtml = buildTableBD(tableLines, getAttr);
399
+ // Process the accumulated table
400
+ const tableHtml = buildTable(tableLines, getAttr);
290
401
  if (tableHtml) {
291
402
  result.push(tableHtml);
292
403
  } else {
404
+ // Not a valid table, restore original lines
293
405
  result.push(...tableLines);
294
406
  }
295
407
  inTable = false;
@@ -299,8 +411,9 @@ function processTablesBD(text, getAttr) {
299
411
  }
300
412
  }
301
413
 
414
+ // Handle table at end of text
302
415
  if (inTable && tableLines.length > 0) {
303
- const tableHtml = buildTableBD(tableLines, getAttr);
416
+ const tableHtml = buildTable(tableLines, getAttr);
304
417
  if (tableHtml) {
305
418
  result.push(tableHtml);
306
419
  } else {
@@ -311,53 +424,68 @@ function processTablesBD(text, getAttr) {
311
424
  return result.join('\n');
312
425
  }
313
426
 
314
- // Build table with source tracking
315
- function buildTableBD(lines, getAttr) {
427
+ /**
428
+ * Build an HTML table from markdown table lines
429
+ */
430
+ function buildTable(lines, getAttr) {
431
+
316
432
  if (lines.length < 2) return null;
317
433
 
318
- // Find separator
434
+ // Check for separator line (second line should be the separator)
319
435
  let separatorIndex = -1;
320
- let alignments = [];
321
-
322
436
  for (let i = 1; i < lines.length; i++) {
437
+ // Support separator with or without leading/trailing pipes
323
438
  if (/^\|?[\s\-:|]+\|?$/.test(lines[i]) && lines[i].includes('-')) {
324
439
  separatorIndex = i;
325
- const cells = lines[i].replace(/^\|/, '').replace(/\|$/, '').split('|');
326
- alignments = cells.map(cell => {
327
- const trimmed = cell.trim();
328
- if (trimmed.startsWith(':') && trimmed.endsWith(':')) return 'center';
329
- if (trimmed.endsWith(':')) return 'right';
330
- return 'left';
331
- });
332
440
  break;
333
441
  }
334
442
  }
335
443
 
336
444
  if (separatorIndex === -1) return null;
337
445
 
338
- let html = `<table${getAttr('table', '', '|')} data-qd-align="${alignments.join(',')}">\n`;
446
+ const headerLines = lines.slice(0, separatorIndex);
447
+ const bodyLines = lines.slice(separatorIndex + 1);
339
448
 
340
- // Headers
341
- if (separatorIndex > 0) {
342
- html += `<thead${getAttr('thead', '', '|')}>\n<tr${getAttr('tr', '', '|')}>\n`;
343
- const cells = lines[0].replace(/^\|/, '').replace(/\|$/, '').split('|');
344
- cells.forEach((cell, i) => {
345
- const align = alignments[i] && alignments[i] !== 'left' ? `text-align:${alignments[i]}` : '';
346
- html += `<th${getAttr('th', align, '|')} data-qd-align="${alignments[i] || 'left'}">${escapeHtml(cell.trim())}</th>\n`;
347
- });
348
- html += '</tr>\n</thead>\n';
349
- }
449
+ // Parse alignment from separator
450
+ const separator = lines[separatorIndex];
451
+ // Handle pipes at start/end or not
452
+ const separatorCells = separator.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
453
+ const alignments = separatorCells.map(cell => {
454
+ const trimmed = cell.trim();
455
+ if (trimmed.startsWith(':') && trimmed.endsWith(':')) return 'center';
456
+ if (trimmed.endsWith(':')) return 'right';
457
+ return 'left';
458
+ });
350
459
 
351
- // Body
352
- const bodyLines = lines.slice(separatorIndex + 1);
460
+ let html = `<table${getAttr('table')}>\n`;
461
+
462
+ // Build header
463
+ // Note: headerLines will always have length > 0 since separatorIndex starts from 1
464
+ html += `<thead${getAttr('thead')}>\n`;
465
+ headerLines.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 += `<th${getAttr('th', alignStyle)}>${processedCell}</th>\n`;
473
+ });
474
+ html += '</tr>\n';
475
+ });
476
+ html += '</thead>\n';
477
+
478
+ // Build body
353
479
  if (bodyLines.length > 0) {
354
- html += `<tbody${getAttr('tbody', '', '|')}>\n`;
480
+ html += `<tbody${getAttr('tbody')}>\n`;
355
481
  bodyLines.forEach(line => {
356
- html += `<tr${getAttr('tr', '', '|')}>\n`;
357
- const cells = line.replace(/^\|/, '').replace(/\|$/, '').split('|');
482
+ html += `<tr${getAttr('tr')}>\n`;
483
+ // Handle pipes at start/end or not
484
+ const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
358
485
  cells.forEach((cell, i) => {
359
- const align = alignments[i] && alignments[i] !== 'left' ? `text-align:${alignments[i]}` : '';
360
- html += `<td${getAttr('td', align, '|')} data-qd-align="${alignments[i] || 'left'}">${escapeHtml(cell.trim())}</td>\n`;
486
+ const alignStyle = alignments[i] && alignments[i] !== 'left' ? `text-align:${alignments[i]}` : '';
487
+ const processedCell = processInlineMarkdown(cell.trim(), getAttr);
488
+ html += `<td${getAttr('td', alignStyle)}>${processedCell}</td>\n`;
361
489
  });
362
490
  html += '</tr>\n';
363
491
  });
@@ -369,17 +497,201 @@ function buildTableBD(lines, getAttr) {
369
497
  }
370
498
 
371
499
  /**
372
- * Convert HTML back to Markdown by walking the DOM tree
373
- * Uses data-qd attributes when available, falls back to canonical forms
374
- * Assumes browser environment with DOM API available
500
+ * Process markdown lists (ordered and unordered)
501
+ */
502
+ function processLists(text, getAttr, inline_styles, bidirectional) {
503
+
504
+ const lines = text.split('\n');
505
+ const result = [];
506
+ let listStack = []; // Track nested lists
507
+
508
+ // Helper to escape HTML for data-qd attributes
509
+ const escapeHtml = (text) => text.replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[m]);
510
+ const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';
511
+
512
+ for (let i = 0; i < lines.length; i++) {
513
+ const line = lines[i];
514
+ const match = line.match(/^(\s*)([*\-+]|\d+\.)\s+(.+)$/);
515
+
516
+ if (match) {
517
+ const [, indent, marker, content] = match;
518
+ const level = Math.floor(indent.length / 2);
519
+ const isOrdered = /^\d+\./.test(marker);
520
+ const listType = isOrdered ? 'ol' : 'ul';
521
+
522
+ // Check for task list items
523
+ let listItemContent = content;
524
+ let taskListClass = '';
525
+ const taskMatch = content.match(/^\[([x ])\]\s+(.*)$/i);
526
+ if (taskMatch && !isOrdered) {
527
+ const [, checked, taskContent] = taskMatch;
528
+ const isChecked = checked.toLowerCase() === 'x';
529
+ const checkboxAttr = inline_styles
530
+ ? ' style="margin-right:.5em"'
531
+ : ` class="${CLASS_PREFIX}task-checkbox"`;
532
+ listItemContent = `<input type="checkbox"${checkboxAttr}${isChecked ? ' checked' : ''} disabled> ${taskContent}`;
533
+ taskListClass = inline_styles ? ' style="list-style:none"' : ` class="${CLASS_PREFIX}task-item"`;
534
+ }
535
+
536
+ // Close deeper levels
537
+ while (listStack.length > level + 1) {
538
+ const list = listStack.pop();
539
+ result.push(`</${list.type}>`);
540
+ }
541
+
542
+ // Open new level if needed
543
+ if (listStack.length === level) {
544
+ // Need to open a new list
545
+ listStack.push({ type: listType, level });
546
+ result.push(`<${listType}${getAttr(listType)}>`);
547
+ } else if (listStack.length === level + 1) {
548
+ // Check if we need to switch list type
549
+ const currentList = listStack[listStack.length - 1];
550
+ if (currentList.type !== listType) {
551
+ result.push(`</${currentList.type}>`);
552
+ listStack.pop();
553
+ listStack.push({ type: listType, level });
554
+ result.push(`<${listType}${getAttr(listType)}>`);
555
+ }
556
+ }
557
+
558
+ const liAttr = taskListClass || getAttr('li');
559
+ result.push(`<li${liAttr}${dataQd(marker)}>${listItemContent}</li>`);
560
+ } else {
561
+ // Not a list item, close all lists
562
+ while (listStack.length > 0) {
563
+ const list = listStack.pop();
564
+ result.push(`</${list.type}>`);
565
+ }
566
+ result.push(line);
567
+ }
568
+ }
569
+
570
+ // Close any remaining lists
571
+ while (listStack.length > 0) {
572
+ const list = listStack.pop();
573
+ result.push(`</${list.type}>`);
574
+ }
575
+
576
+ return result.join('\n');
577
+ }
578
+
579
+ /**
580
+ * Emit CSS styles for quikdown elements
581
+ * @param {string} prefix - Optional class prefix (default: 'quikdown-')
582
+ * @param {string} theme - Optional theme: 'light' (default) or 'dark'
583
+ * @returns {string} CSS string with quikdown styles
375
584
  */
376
- quikdown_bd.toMarkdown = function(htmlOrElement) {
585
+ quikdown.emitStyles = function(prefix = 'quikdown-', theme = 'light') {
586
+ const styles = QUIKDOWN_STYLES;
587
+
588
+ // Define theme color overrides
589
+ const themeOverrides = {
590
+ dark: {
591
+ '#f4f4f4': '#2a2a2a', // pre background
592
+ '#f0f0f0': '#2a2a2a', // code background
593
+ '#f2f2f2': '#2a2a2a', // th background
594
+ '#ddd': '#3a3a3a', // borders
595
+ '#06c': '#6db3f2', // links
596
+ _textColor: '#e0e0e0'
597
+ },
598
+ light: {
599
+ _textColor: '#333' // Explicit text color for light theme
600
+ }
601
+ };
602
+
603
+ let css = '';
604
+ for (const [tag, style] of Object.entries(styles)) {
605
+ let themedStyle = style;
606
+
607
+ // Apply theme overrides if dark theme
608
+ if (theme === 'dark' && themeOverrides.dark) {
609
+ // Replace colors
610
+ for (const [oldColor, newColor] of Object.entries(themeOverrides.dark)) {
611
+ if (!oldColor.startsWith('_')) {
612
+ themedStyle = themedStyle.replace(new RegExp(oldColor, 'g'), newColor);
613
+ }
614
+ }
615
+
616
+ // Add text color for certain elements in dark theme
617
+ const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote'];
618
+ if (needsTextColor.includes(tag)) {
619
+ themedStyle += `;color:${themeOverrides.dark._textColor}`;
620
+ }
621
+ } else if (theme === 'light' && themeOverrides.light) {
622
+ // Add explicit text color for light theme elements too
623
+ const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote'];
624
+ if (needsTextColor.includes(tag)) {
625
+ themedStyle += `;color:${themeOverrides.light._textColor}`;
626
+ }
627
+ }
628
+
629
+ css += `.${prefix}${tag} { ${themedStyle} }\n`;
630
+ }
631
+
632
+ return css;
633
+ };
634
+
635
+ /**
636
+ * Configure quikdown with options and return a function
637
+ * @param {Object} options - Configuration options
638
+ * @returns {Function} Configured quikdown function
639
+ */
640
+ quikdown.configure = function(options) {
641
+ return function(markdown) {
642
+ return quikdown(markdown, options);
643
+ };
644
+ };
645
+
646
+ /**
647
+ * Version information
648
+ */
649
+ quikdown.version = quikdownVersion;
650
+
651
+ // Export for both CommonJS and ES6
652
+ /* istanbul ignore next */
653
+ if (typeof module !== 'undefined' && module.exports) {
654
+ module.exports = quikdown;
655
+ }
656
+
657
+ // For browser global
658
+ /* istanbul ignore next */
659
+ if (typeof window !== 'undefined') {
660
+ window.quikdown = quikdown;
661
+ }
662
+
663
+ /**
664
+ * quikdown_bd - Bidirectional markdown/HTML converter
665
+ * Extends core quikdown with HTML→Markdown conversion
666
+ *
667
+ * Uses data-qd attributes to preserve original markdown syntax
668
+ * Enables HTML→Markdown conversion for quikdown-generated HTML
669
+ */
670
+
671
+
672
+ /**
673
+ * Create bidirectional version by extending quikdown
674
+ * This wraps quikdown and adds the toMarkdown method
675
+ */
676
+ function quikdown_bd(markdown, options = {}) {
677
+ // Use core quikdown with bidirectional flag to add data-qd attributes
678
+ return quikdown(markdown, { ...options, bidirectional: true });
679
+ }
680
+
681
+ // Copy all properties and methods from quikdown (including version)
682
+ Object.keys(quikdown).forEach(key => {
683
+ quikdown_bd[key] = quikdown[key];
684
+ });
685
+
686
+ // Add the toMarkdown method for HTML→Markdown conversion
687
+ quikdown_bd.toMarkdown = function(htmlOrElement, options = {}) {
377
688
  // Accept either HTML string or DOM element
378
689
  let container;
379
690
  if (typeof htmlOrElement === 'string') {
380
691
  container = document.createElement('div');
381
692
  container.innerHTML = htmlOrElement;
382
693
  } else if (htmlOrElement instanceof Element) {
694
+ /* istanbul ignore next - browser-only code path, not testable in jsdom */
383
695
  container = htmlOrElement;
384
696
  } else {
385
697
  return '';
@@ -398,7 +710,6 @@ quikdown_bd.toMarkdown = function(htmlOrElement) {
398
710
 
399
711
  const tag = node.tagName.toLowerCase();
400
712
  const dataQd = node.getAttribute('data-qd');
401
- const styles = window.getComputedStyle ? window.getComputedStyle(node) : {};
402
713
 
403
714
  // Process children with context
404
715
  let childContent = '';
@@ -420,40 +731,55 @@ quikdown_bd.toMarkdown = function(htmlOrElement) {
420
731
 
421
732
  case 'strong':
422
733
  case 'b':
423
- // Check if it's bold through style too
424
- if (styles.fontWeight === 'bold' || styles.fontWeight >= 700 || tag === 'strong' || tag === 'b') {
425
- const boldMarker = dataQd || '**';
426
- return `${boldMarker}${childContent}${boldMarker}`;
427
- }
428
- return childContent;
734
+ if (!childContent) return ''; // Don't add markers for empty content
735
+ const boldMarker = dataQd || '**';
736
+ return `${boldMarker}${childContent}${boldMarker}`;
429
737
 
430
738
  case 'em':
431
739
  case 'i':
432
- // Check for italic through style
433
- if (styles.fontStyle === 'italic' || tag === 'em' || tag === 'i') {
434
- const emMarker = dataQd || '*';
435
- return `${emMarker}${childContent}${emMarker}`;
436
- }
437
- return childContent;
740
+ if (!childContent) return ''; // Don't add markers for empty content
741
+ const emMarker = dataQd || '*';
742
+ return `${emMarker}${childContent}${emMarker}`;
438
743
 
439
744
  case 'del':
440
745
  case 's':
441
746
  case 'strike':
747
+ if (!childContent) return ''; // Don't add markers for empty content
442
748
  const delMarker = dataQd || '~~';
443
749
  return `${delMarker}${childContent}${delMarker}`;
444
750
 
445
751
  case 'code':
446
- // Skip if inside pre (handled by pre)
447
- if (parentContext.parentTag === 'pre') {
448
- return childContent;
449
- }
752
+ // Note: code inside pre is handled directly by the pre case using querySelector
753
+ if (!childContent) return ''; // Don't add markers for empty content
450
754
  const codeMarker = dataQd || '`';
451
755
  return `${codeMarker}${childContent}${codeMarker}`;
452
756
 
453
757
  case 'pre':
454
758
  const fence = node.getAttribute('data-qd-fence') || dataQd || '```';
455
759
  const lang = node.getAttribute('data-qd-lang') || '';
456
- // Look for code element child
760
+
761
+ // Check if this was created by a fence plugin with reverse handler
762
+ if (options.fence_plugin && options.fence_plugin.reverse && lang) {
763
+ try {
764
+ const result = options.fence_plugin.reverse(node);
765
+ if (result && result.content) {
766
+ const fenceMarker = result.fence || fence;
767
+ const langStr = result.lang || lang;
768
+ return `${fenceMarker}${langStr}\n${result.content}\n${fenceMarker}\n\n`;
769
+ }
770
+ } catch (err) {
771
+ console.warn('Fence reverse handler error:', err);
772
+ // Fall through to default handling
773
+ }
774
+ }
775
+
776
+ // Fallback: use data-qd-source if available
777
+ const source = node.getAttribute('data-qd-source');
778
+ if (source) {
779
+ return `${fence}${lang}\n${source}\n${fence}\n\n`;
780
+ }
781
+
782
+ // Final fallback: extract text content
457
783
  const codeEl = node.querySelector('code');
458
784
  const codeContent = codeEl ? codeEl.textContent : childContent;
459
785
  return `${fence}${lang}\n${codeContent.trimEnd()}\n${fence}\n\n`;
@@ -500,16 +826,87 @@ quikdown_bd.toMarkdown = function(htmlOrElement) {
500
826
  case 'p':
501
827
  // Check if it's actually a paragraph or just a wrapper
502
828
  if (childContent.trim()) {
503
- return childContent.trim() + '\n\n';
829
+ // Check if paragraph ends with a line that's just whitespace
830
+ // This indicates an intentional blank line before the next element
831
+ const lines = childContent.split('\n');
832
+ let content = childContent.trim();
833
+
834
+ // If the last line(s) are just whitespace, preserve one blank line
835
+ if (lines.length > 1) {
836
+ let trailingBlankLines = 0;
837
+ for (let i = lines.length - 1; i >= 0; i--) {
838
+ if (lines[i].trim() === '') {
839
+ trailingBlankLines++;
840
+ } else {
841
+ break;
842
+ }
843
+ }
844
+ if (trailingBlankLines > 0) {
845
+ // Add a line with just a space, followed by single newline
846
+ // The \n\n will be added below for paragraph separation
847
+ content = content + '\n ';
848
+ // Only add one newline since we're preserving the space line
849
+ return content + '\n';
850
+ }
851
+ }
852
+
853
+ return content + '\n\n';
504
854
  }
505
855
  return '';
506
856
 
507
857
  case 'div':
858
+ // Check if this was created by a fence plugin with reverse handler
859
+ const divLang = node.getAttribute('data-qd-lang');
860
+ const divFence = node.getAttribute('data-qd-fence');
861
+
862
+ if (divLang && options.fence_plugin && options.fence_plugin.reverse) {
863
+ try {
864
+ const result = options.fence_plugin.reverse(node);
865
+ if (result && result.content) {
866
+ const fenceMarker = result.fence || divFence || '```';
867
+ const langStr = result.lang || divLang;
868
+ return `${fenceMarker}${langStr}\n${result.content}\n${fenceMarker}\n\n`;
869
+ }
870
+ } catch (err) {
871
+ console.warn('Fence reverse handler error:', err);
872
+ // Fall through to default handling
873
+ }
874
+ }
875
+
876
+ // Fallback: use data-qd-source if available
877
+ const divSource = node.getAttribute('data-qd-source');
878
+ if (divSource && divFence) {
879
+ return `${divFence}${divLang || ''}\n${divSource}\n${divFence}\n\n`;
880
+ }
881
+
508
882
  // Check if it's a mermaid container
509
883
  if (node.classList && node.classList.contains('mermaid-container')) {
510
884
  const fence = node.getAttribute('data-qd-fence') || '```';
511
885
  const lang = node.getAttribute('data-qd-lang') || 'mermaid';
512
- // Look for the source element
886
+
887
+ // First check for data-qd-source attribute on the container
888
+ const source = node.getAttribute('data-qd-source');
889
+ if (source) {
890
+ // Decode HTML entities from the attribute (mainly &quot;)
891
+ const temp = document.createElement('textarea');
892
+ temp.innerHTML = source;
893
+ const code = temp.value;
894
+ return `${fence}${lang}\n${code}\n${fence}\n\n`;
895
+ }
896
+
897
+ // Check for source on the pre.mermaid element
898
+ const mermaidPre = node.querySelector('pre.mermaid');
899
+ if (mermaidPre) {
900
+ const preSource = mermaidPre.getAttribute('data-qd-source');
901
+ if (preSource) {
902
+ const temp = document.createElement('textarea');
903
+ temp.innerHTML = preSource;
904
+ const code = temp.value;
905
+ return `${fence}${lang}\n${code}\n${fence}\n\n`;
906
+ }
907
+ }
908
+
909
+ // Fallback: Look for the legacy .mermaid-source element
513
910
  const sourceElement = node.querySelector('.mermaid-source');
514
911
  if (sourceElement) {
515
912
  // Decode HTML entities
@@ -518,7 +915,8 @@ quikdown_bd.toMarkdown = function(htmlOrElement) {
518
915
  const code = temp.textContent;
519
916
  return `${fence}${lang}\n${code}\n${fence}\n\n`;
520
917
  }
521
- // Fallback: try to extract from the mermaid element
918
+
919
+ // Final fallback: try to extract from the mermaid element (unreliable after rendering)
522
920
  const mermaidElement = node.querySelector('.mermaid');
523
921
  if (mermaidElement && mermaidElement.textContent.includes('graph')) {
524
922
  return `${fence}${lang}\n${mermaidElement.textContent.trim()}\n${fence}\n\n`;
@@ -609,7 +1007,7 @@ quikdown_bd.toMarkdown = function(htmlOrElement) {
609
1007
 
610
1008
  // Add separator with alignment
611
1009
  const separators = headers.map((_, i) => {
612
- const align = alignments[i] || th.getAttribute('data-qd-align') || 'left';
1010
+ const align = alignments[i] || 'left';
613
1011
  if (align === 'center') return ':---:';
614
1012
  if (align === 'right') return '---:';
615
1013
  return '---';
@@ -645,29 +1043,23 @@ quikdown_bd.toMarkdown = function(htmlOrElement) {
645
1043
  return markdown;
646
1044
  };
647
1045
 
648
- // Add emitStyles method (same as core)
649
- quikdown_bd.emitStyles = function(prefix = 'quikdown-', theme = 'light') {
650
- // This would generate CSS based on the styles
651
- // For now, returning empty string as placeholder
652
- // In production, this would generate the full CSS
653
- return '';
654
- };
655
-
656
- // Configure method
1046
+ // Override the configure method to return a bidirectional version
657
1047
  quikdown_bd.configure = function(options) {
658
1048
  return function(markdown) {
659
1049
  return quikdown_bd(markdown, options);
660
1050
  };
661
1051
  };
662
1052
 
663
- // Version property
664
- quikdown_bd.version = VERSION;
1053
+ // Set version
1054
+ // Version is already copied from quikdown via Object.keys loop
665
1055
 
666
1056
  // Export for both module and browser
1057
+ /* istanbul ignore next */
667
1058
  if (typeof module !== 'undefined' && module.exports) {
668
1059
  module.exports = quikdown_bd;
669
1060
  }
670
1061
 
1062
+ /* istanbul ignore next */
671
1063
  if (typeof window !== 'undefined') {
672
1064
  window.quikdown_bd = quikdown_bd;
673
1065
  }