quikdown 1.0.4 → 1.0.5

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