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