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