quikdown 1.0.3 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/quikdown.cjs CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * quikdown - Lightweight Markdown Parser
3
- * @version 1.0.3
3
+ * @version 1.0.5
4
4
  * @license BSD-2-Clause
5
5
  * @copyright DeftIO 2025
6
6
  */
@@ -14,11 +14,13 @@
14
14
  * @param {Function} options.fence_plugin - Custom renderer for fenced code blocks
15
15
  * (content, fence_string) => html string
16
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
17
19
  * @returns {string} - The rendered HTML
18
20
  */
19
21
 
20
22
  // Version will be injected at build time
21
- const quikdownVersion = '1.0.3';
23
+ const quikdownVersion = '1.0.5';
22
24
 
23
25
  // Constants for reuse
24
26
  const CLASS_PREFIX = 'quikdown-';
@@ -60,12 +62,25 @@ const QUIKDOWN_STYLES = {
60
62
  function createGetAttr(inline_styles, styles) {
61
63
  return function(tag, additionalStyle = '') {
62
64
  if (inline_styles) {
63
- const style = styles[tag];
65
+ let style = styles[tag];
64
66
  if (!style && !additionalStyle) return '';
65
- const fullStyle = additionalStyle ? (style ? `${style};${additionalStyle}` : additionalStyle) : style;
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 += ';';
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;
66
76
  return ` style="${fullStyle}"`;
67
77
  } else {
68
- return ` class="${CLASS_PREFIX}${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;
69
84
  }
70
85
  };
71
86
  }
@@ -75,7 +90,7 @@ function quikdown(markdown, options = {}) {
75
90
  return '';
76
91
  }
77
92
 
78
- const { fence_plugin, inline_styles = false } = options;
93
+ const { fence_plugin, inline_styles = false, bidirectional = false, lazy_linefeeds = false } = options;
79
94
  const styles = QUIKDOWN_STYLES; // Use module-level styles
80
95
  const getAttr = createGetAttr(inline_styles, styles); // Create getAttr once
81
96
 
@@ -84,8 +99,12 @@ function quikdown(markdown, options = {}) {
84
99
  return text.replace(/[&<>"']/g, m => ESC_MAP[m]);
85
100
  }
86
101
 
102
+ // Helper to add data-qd attributes for bidirectional support
103
+ const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';
104
+
87
105
  // Sanitize URLs to prevent XSS attacks
88
106
  function sanitizeUrl(url, allowUnsafe = false) {
107
+ /* istanbul ignore next - defensive programming, regex ensures url is never empty */
89
108
  if (!url) return '';
90
109
 
91
110
  // If unsafe URLs are explicitly allowed, return as-is
@@ -132,13 +151,15 @@ function quikdown(markdown, options = {}) {
132
151
  codeBlocks.push({
133
152
  lang: langTrimmed,
134
153
  code: code.trimEnd(),
135
- custom: true
154
+ custom: true,
155
+ fence: fence
136
156
  });
137
157
  } else {
138
158
  codeBlocks.push({
139
159
  lang: langTrimmed,
140
160
  code: escapeHtml(code.trimEnd()),
141
- custom: false
161
+ custom: false,
162
+ fence: fence
142
163
  });
143
164
  }
144
165
  return placeholder;
@@ -162,7 +183,7 @@ function quikdown(markdown, options = {}) {
162
183
  // Process headings (supports optional trailing #'s)
163
184
  html = html.replace(/^(#{1,6})\s+(.+?)\s*#*$/gm, (match, hashes, content) => {
164
185
  const level = hashes.length;
165
- return `<h${level}${getAttr('h' + level)}>${content}</h${level}>`;
186
+ return `<h${level}${getAttr('h' + level)}${dataQd(hashes)}>${content}</h${level}>`;
166
187
  });
167
188
 
168
189
  // Process blockquotes (must handle escaped > since we already escaped HTML)
@@ -174,14 +195,16 @@ function quikdown(markdown, options = {}) {
174
195
  html = html.replace(/^---+$/gm, `<hr${getAttr('hr')}>`);
175
196
 
176
197
  // Process lists
177
- html = processLists(html, getAttr, inline_styles);
198
+ html = processLists(html, getAttr, inline_styles, bidirectional);
178
199
 
179
200
  // Phase 3: Process inline elements
180
201
 
181
202
  // Images (must come before links, with URL sanitization)
182
203
  html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
183
204
  const sanitizedSrc = sanitizeUrl(src, options.allow_unsafe_urls);
184
- return `<img${getAttr('img')} src="${sanitizedSrc}" alt="${alt}">`;
205
+ const altAttr = bidirectional && alt ? ` data-qd-alt="${escapeHtml(alt)}"` : '';
206
+ const srcAttr = bidirectional ? ` data-qd-src="${escapeHtml(src)}"` : '';
207
+ return `<img${getAttr('img')} src="${sanitizedSrc}" alt="${alt}"${altAttr}${srcAttr}${dataQd('!')}>`;
185
208
  });
186
209
 
187
210
  // Links (with URL sanitization)
@@ -190,7 +213,8 @@ function quikdown(markdown, options = {}) {
190
213
  const sanitizedHref = sanitizeUrl(href, options.allow_unsafe_urls);
191
214
  const isExternal = /^https?:\/\//i.test(sanitizedHref);
192
215
  const rel = isExternal ? ' rel="noopener noreferrer"' : '';
193
- return `<a${getAttr('a')} href="${sanitizedHref}"${rel}>${text}</a>`;
216
+ const textAttr = bidirectional ? ` data-qd-text="${escapeHtml(text)}"` : '';
217
+ return `<a${getAttr('a')} href="${sanitizedHref}"${rel}${textAttr}${dataQd('[')}>${text}</a>`;
194
218
  });
195
219
 
196
220
  // Autolinks - convert bare URLs to clickable links
@@ -201,23 +225,56 @@ function quikdown(markdown, options = {}) {
201
225
 
202
226
  // Process inline formatting (bold, italic, strikethrough)
203
227
  const inlinePatterns = [
204
- [/\*\*(.+?)\*\*/g, 'strong'],
205
- [/__(.+?)__/g, 'strong'],
206
- [/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, 'em'],
207
- [/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, 'em'],
208
- [/~~(.+?)~~/g, 'del']
228
+ [/\*\*(.+?)\*\*/g, 'strong', '**'],
229
+ [/__(.+?)__/g, 'strong', '__'],
230
+ [/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, 'em', '*'],
231
+ [/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, 'em', '_'],
232
+ [/~~(.+?)~~/g, 'del', '~~']
209
233
  ];
210
234
 
211
- inlinePatterns.forEach(([pattern, tag]) => {
212
- html = html.replace(pattern, `<${tag}${getAttr(tag)}>$1</${tag}>`);
235
+ inlinePatterns.forEach(([pattern, tag, marker]) => {
236
+ html = html.replace(pattern, `<${tag}${getAttr(tag)}${dataQd(marker)}>$1</${tag}>`);
213
237
  });
214
238
 
215
- // Line breaks (two spaces at end of line)
216
- html = html.replace(/ $/gm, `<br${getAttr('br')}>`);
217
-
218
- // Paragraphs (double newlines)
219
- html = html.replace(/\n\n+/g, '</p><p>');
220
- html = '<p>' + html + '</p>';
239
+ // Line breaks
240
+ if (lazy_linefeeds) {
241
+ // Lazy linefeeds: single newline becomes <br> (except between paragraphs and after/before block elements)
242
+ const blocks = [];
243
+ let bi = 0;
244
+
245
+ // Protect tables and lists
246
+ html = html.replace(/<(table|[uo]l)[^>]*>[\s\S]*?<\/\1>/g, m => {
247
+ blocks[bi] = m;
248
+ return `§B${bi++}§`;
249
+ });
250
+
251
+ // Handle paragraphs and block elements
252
+ html = html.replace(/\n\n+/g, '§P§')
253
+ // After block elements
254
+ .replace(/(<\/(?:h[1-6]|blockquote|pre)>)\n/g, '$1§N§')
255
+ .replace(/(<(?:h[1-6]|blockquote|pre|hr)[^>]*>)\n/g, '$1§N§')
256
+ // Before block elements
257
+ .replace(/\n(<(?:h[1-6]|blockquote|pre|hr)[^>]*>)/g, '§N§$1')
258
+ .replace(/\n(§B\d+§)/g, '§N§$1')
259
+ .replace(/(§B\d+§)\n/g, '$1§N§')
260
+ // Convert remaining newlines
261
+ .replace(/\n/g, `<br${getAttr('br')}>`)
262
+ // Restore
263
+ .replace(/§N§/g, '\n')
264
+ .replace(/§P§/g, '</p><p>');
265
+
266
+ // Restore protected blocks
267
+ blocks.forEach((b, i) => html = html.replace(`§B${i}§`, b));
268
+
269
+ html = '<p>' + html + '</p>';
270
+ } else {
271
+ // Standard: two spaces at end of line for line breaks
272
+ html = html.replace(/ $/gm, `<br${getAttr('br')}>`);
273
+
274
+ // Paragraphs (double newlines)
275
+ html = html.replace(/\n\n+/g, '</p><p>');
276
+ html = '<p>' + html + '</p>';
277
+ }
221
278
 
222
279
  // Clean up empty paragraphs and unwrap block elements
223
280
  const cleanupPatterns = [
@@ -253,13 +310,17 @@ function quikdown(markdown, options = {}) {
253
310
  if (replacement === undefined) {
254
311
  const langClass = !inline_styles && block.lang ? ` class="language-${block.lang}"` : '';
255
312
  const codeAttr = inline_styles ? getAttr('code') : langClass;
256
- replacement = `<pre${getAttr('pre')}><code${codeAttr}>${escapeHtml(block.code)}</code></pre>`;
313
+ const langAttr = bidirectional && block.lang ? ` data-qd-lang="${escapeHtml(block.lang)}"` : '';
314
+ const fenceAttr = bidirectional ? ` data-qd-fence="${escapeHtml(block.fence)}"` : '';
315
+ replacement = `<pre${getAttr('pre')}${fenceAttr}${langAttr}><code${codeAttr}>${escapeHtml(block.code)}</code></pre>`;
257
316
  }
258
317
  } else {
259
318
  // Default rendering
260
319
  const langClass = !inline_styles && block.lang ? ` class="language-${block.lang}"` : '';
261
320
  const codeAttr = inline_styles ? getAttr('code') : langClass;
262
- replacement = `<pre${getAttr('pre')}><code${codeAttr}>${block.code}</code></pre>`;
321
+ const langAttr = bidirectional && block.lang ? ` data-qd-lang="${escapeHtml(block.lang)}"` : '';
322
+ const fenceAttr = bidirectional ? ` data-qd-fence="${escapeHtml(block.fence)}"` : '';
323
+ replacement = `<pre${getAttr('pre')}${fenceAttr}${langAttr}><code${codeAttr}>${block.code}</code></pre>`;
263
324
  }
264
325
 
265
326
  const placeholder = `${PLACEHOLDER_CB}${i}§`;
@@ -269,7 +330,7 @@ function quikdown(markdown, options = {}) {
269
330
  // Restore inline code
270
331
  inlineCodes.forEach((code, i) => {
271
332
  const placeholder = `${PLACEHOLDER_IC}${i}§`;
272
- html = html.replace(placeholder, `<code${getAttr('code')}>${code}</code>`);
333
+ html = html.replace(placeholder, `<code${getAttr('code')}${dataQd('`')}>${code}</code>`);
273
334
  });
274
335
 
275
336
  return html.trim();
@@ -383,9 +444,9 @@ function buildTable(lines, getAttr) {
383
444
  let html = `<table${getAttr('table')}>\n`;
384
445
 
385
446
  // Build header
386
- if (headerLines.length > 0) {
387
- html += `<thead${getAttr('thead')}>\n`;
388
- headerLines.forEach(line => {
447
+ // Note: headerLines will always have length > 0 since separatorIndex starts from 1
448
+ html += `<thead${getAttr('thead')}>\n`;
449
+ headerLines.forEach(line => {
389
450
  html += `<tr${getAttr('tr')}>\n`;
390
451
  // Handle pipes at start/end or not
391
452
  const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
@@ -395,9 +456,8 @@ function buildTable(lines, getAttr) {
395
456
  html += `<th${getAttr('th', alignStyle)}>${processedCell}</th>\n`;
396
457
  });
397
458
  html += '</tr>\n';
398
- });
399
- html += '</thead>\n';
400
- }
459
+ });
460
+ html += '</thead>\n';
401
461
 
402
462
  // Build body
403
463
  if (bodyLines.length > 0) {
@@ -423,12 +483,16 @@ function buildTable(lines, getAttr) {
423
483
  /**
424
484
  * Process markdown lists (ordered and unordered)
425
485
  */
426
- function processLists(text, getAttr, inline_styles) {
486
+ function processLists(text, getAttr, inline_styles, bidirectional) {
427
487
 
428
488
  const lines = text.split('\n');
429
489
  const result = [];
430
490
  let listStack = []; // Track nested lists
431
491
 
492
+ // Helper to escape HTML for data-qd attributes
493
+ const escapeHtml = (text) => text.replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[m]);
494
+ const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';
495
+
432
496
  for (let i = 0; i < lines.length; i++) {
433
497
  const line = lines[i];
434
498
  const match = line.match(/^(\s*)([*\-+]|\d+\.)\s+(.+)$/);
@@ -476,7 +540,7 @@ function processLists(text, getAttr, inline_styles) {
476
540
  }
477
541
 
478
542
  const liAttr = taskListClass || getAttr('li');
479
- result.push(`<li${liAttr}>${listItemContent}</li>`);
543
+ result.push(`<li${liAttr}${dataQd(marker)}>${listItemContent}</li>`);
480
544
  } else {
481
545
  // Not a list item, close all lists
482
546
  while (listStack.length > 0) {
@@ -522,8 +586,7 @@ quikdown.emitStyles = function(prefix = 'quikdown-', theme = 'light') {
522
586
 
523
587
  let css = '';
524
588
  for (const [tag, style] of Object.entries(styles)) {
525
- if (style) {
526
- let themedStyle = style;
589
+ let themedStyle = style;
527
590
 
528
591
  // Apply theme overrides if dark theme
529
592
  if (theme === 'dark' && themeOverrides.dark) {
@@ -546,9 +609,8 @@ quikdown.emitStyles = function(prefix = 'quikdown-', theme = 'light') {
546
609
  themedStyle += `;color:${themeOverrides.light._textColor}`;
547
610
  }
548
611
  }
549
-
550
- css += `.${prefix}${tag} { ${themedStyle} }\n`;
551
- }
612
+
613
+ css += `.${prefix}${tag} { ${themedStyle} }\n`;
552
614
  }
553
615
 
554
616
  return css;
@@ -571,11 +633,13 @@ quikdown.configure = function(options) {
571
633
  quikdown.version = quikdownVersion;
572
634
 
573
635
  // Export for both CommonJS and ES6
636
+ /* istanbul ignore next */
574
637
  if (typeof module !== 'undefined' && module.exports) {
575
638
  module.exports = quikdown;
576
639
  }
577
640
 
578
641
  // For browser global
642
+ /* istanbul ignore next */
579
643
  if (typeof window !== 'undefined') {
580
644
  window.quikdown = quikdown;
581
645
  }
@@ -30,6 +30,20 @@ declare module 'quikdown' {
30
30
  * @default false
31
31
  */
32
32
  allow_unsafe_urls?: boolean;
33
+
34
+ /**
35
+ * If true, adds data-qd attributes for bidirectional conversion.
36
+ * Enables HTML to Markdown conversion.
37
+ * @default false
38
+ */
39
+ bidirectional?: boolean;
40
+
41
+ /**
42
+ * If true, single newlines become <br> tags.
43
+ * Useful for chat/LLM applications where Enter should create a line break.
44
+ * @default false
45
+ */
46
+ lazy_linefeeds?: boolean;
33
47
  }
34
48
 
35
49
  /**
@@ -5,7 +5,7 @@
5
5
  * Theme with container-based scoping.
6
6
  * Usage: <div class="quikdown-dark">...content...</div>
7
7
  *
8
- * @generated 2025-08-17T11:14:25.791Z
8
+ * @version 1.0.5
9
9
  * @source tools/generateThemeCSS.js
10
10
  */
11
11
 
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * quikdown - Lightweight Markdown Parser
3
- * @version 1.0.3
3
+ * @version 1.0.5
4
4
  * @license BSD-2-Clause
5
5
  * @copyright DeftIO 2025
6
6
  */
@@ -12,11 +12,13 @@
12
12
  * @param {Function} options.fence_plugin - Custom renderer for fenced code blocks
13
13
  * (content, fence_string) => html string
14
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
15
17
  * @returns {string} - The rendered HTML
16
18
  */
17
19
 
18
20
  // Version will be injected at build time
19
- const quikdownVersion = '1.0.3';
21
+ const quikdownVersion = '1.0.5';
20
22
 
21
23
  // Constants for reuse
22
24
  const CLASS_PREFIX = 'quikdown-';
@@ -58,12 +60,25 @@ const QUIKDOWN_STYLES = {
58
60
  function createGetAttr(inline_styles, styles) {
59
61
  return function(tag, additionalStyle = '') {
60
62
  if (inline_styles) {
61
- const style = styles[tag];
63
+ let style = styles[tag];
62
64
  if (!style && !additionalStyle) return '';
63
- const fullStyle = additionalStyle ? (style ? `${style};${additionalStyle}` : additionalStyle) : style;
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 += ';';
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;
64
74
  return ` style="${fullStyle}"`;
65
75
  } else {
66
- return ` class="${CLASS_PREFIX}${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;
67
82
  }
68
83
  };
69
84
  }
@@ -73,7 +88,7 @@ function quikdown(markdown, options = {}) {
73
88
  return '';
74
89
  }
75
90
 
76
- const { fence_plugin, inline_styles = false } = options;
91
+ const { fence_plugin, inline_styles = false, bidirectional = false, lazy_linefeeds = false } = options;
77
92
  const styles = QUIKDOWN_STYLES; // Use module-level styles
78
93
  const getAttr = createGetAttr(inline_styles, styles); // Create getAttr once
79
94
 
@@ -82,8 +97,12 @@ function quikdown(markdown, options = {}) {
82
97
  return text.replace(/[&<>"']/g, m => ESC_MAP[m]);
83
98
  }
84
99
 
100
+ // Helper to add data-qd attributes for bidirectional support
101
+ const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';
102
+
85
103
  // Sanitize URLs to prevent XSS attacks
86
104
  function sanitizeUrl(url, allowUnsafe = false) {
105
+ /* istanbul ignore next - defensive programming, regex ensures url is never empty */
87
106
  if (!url) return '';
88
107
 
89
108
  // If unsafe URLs are explicitly allowed, return as-is
@@ -130,13 +149,15 @@ function quikdown(markdown, options = {}) {
130
149
  codeBlocks.push({
131
150
  lang: langTrimmed,
132
151
  code: code.trimEnd(),
133
- custom: true
152
+ custom: true,
153
+ fence: fence
134
154
  });
135
155
  } else {
136
156
  codeBlocks.push({
137
157
  lang: langTrimmed,
138
158
  code: escapeHtml(code.trimEnd()),
139
- custom: false
159
+ custom: false,
160
+ fence: fence
140
161
  });
141
162
  }
142
163
  return placeholder;
@@ -160,7 +181,7 @@ function quikdown(markdown, options = {}) {
160
181
  // Process headings (supports optional trailing #'s)
161
182
  html = html.replace(/^(#{1,6})\s+(.+?)\s*#*$/gm, (match, hashes, content) => {
162
183
  const level = hashes.length;
163
- return `<h${level}${getAttr('h' + level)}>${content}</h${level}>`;
184
+ return `<h${level}${getAttr('h' + level)}${dataQd(hashes)}>${content}</h${level}>`;
164
185
  });
165
186
 
166
187
  // Process blockquotes (must handle escaped > since we already escaped HTML)
@@ -172,14 +193,16 @@ function quikdown(markdown, options = {}) {
172
193
  html = html.replace(/^---+$/gm, `<hr${getAttr('hr')}>`);
173
194
 
174
195
  // Process lists
175
- html = processLists(html, getAttr, inline_styles);
196
+ html = processLists(html, getAttr, inline_styles, bidirectional);
176
197
 
177
198
  // Phase 3: Process inline elements
178
199
 
179
200
  // Images (must come before links, with URL sanitization)
180
201
  html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
181
202
  const sanitizedSrc = sanitizeUrl(src, options.allow_unsafe_urls);
182
- return `<img${getAttr('img')} src="${sanitizedSrc}" alt="${alt}">`;
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('!')}>`;
183
206
  });
184
207
 
185
208
  // Links (with URL sanitization)
@@ -188,7 +211,8 @@ function quikdown(markdown, options = {}) {
188
211
  const sanitizedHref = sanitizeUrl(href, options.allow_unsafe_urls);
189
212
  const isExternal = /^https?:\/\//i.test(sanitizedHref);
190
213
  const rel = isExternal ? ' rel="noopener noreferrer"' : '';
191
- return `<a${getAttr('a')} href="${sanitizedHref}"${rel}>${text}</a>`;
214
+ const textAttr = bidirectional ? ` data-qd-text="${escapeHtml(text)}"` : '';
215
+ return `<a${getAttr('a')} href="${sanitizedHref}"${rel}${textAttr}${dataQd('[')}>${text}</a>`;
192
216
  });
193
217
 
194
218
  // Autolinks - convert bare URLs to clickable links
@@ -199,23 +223,56 @@ function quikdown(markdown, options = {}) {
199
223
 
200
224
  // Process inline formatting (bold, italic, strikethrough)
201
225
  const inlinePatterns = [
202
- [/\*\*(.+?)\*\*/g, 'strong'],
203
- [/__(.+?)__/g, 'strong'],
204
- [/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, 'em'],
205
- [/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, 'em'],
206
- [/~~(.+?)~~/g, 'del']
226
+ [/\*\*(.+?)\*\*/g, 'strong', '**'],
227
+ [/__(.+?)__/g, 'strong', '__'],
228
+ [/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, 'em', '*'],
229
+ [/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, 'em', '_'],
230
+ [/~~(.+?)~~/g, 'del', '~~']
207
231
  ];
208
232
 
209
- inlinePatterns.forEach(([pattern, tag]) => {
210
- html = html.replace(pattern, `<${tag}${getAttr(tag)}>$1</${tag}>`);
233
+ inlinePatterns.forEach(([pattern, tag, marker]) => {
234
+ html = html.replace(pattern, `<${tag}${getAttr(tag)}${dataQd(marker)}>$1</${tag}>`);
211
235
  });
212
236
 
213
- // Line breaks (two spaces at end of line)
214
- html = html.replace(/ $/gm, `<br${getAttr('br')}>`);
215
-
216
- // Paragraphs (double newlines)
217
- html = html.replace(/\n\n+/g, '</p><p>');
218
- 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
+ }
219
276
 
220
277
  // Clean up empty paragraphs and unwrap block elements
221
278
  const cleanupPatterns = [
@@ -251,13 +308,17 @@ function quikdown(markdown, options = {}) {
251
308
  if (replacement === undefined) {
252
309
  const langClass = !inline_styles && block.lang ? ` class="language-${block.lang}"` : '';
253
310
  const codeAttr = inline_styles ? getAttr('code') : langClass;
254
- replacement = `<pre${getAttr('pre')}><code${codeAttr}>${escapeHtml(block.code)}</code></pre>`;
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>`;
255
314
  }
256
315
  } else {
257
316
  // Default rendering
258
317
  const langClass = !inline_styles && block.lang ? ` class="language-${block.lang}"` : '';
259
318
  const codeAttr = inline_styles ? getAttr('code') : langClass;
260
- replacement = `<pre${getAttr('pre')}><code${codeAttr}>${block.code}</code></pre>`;
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>`;
261
322
  }
262
323
 
263
324
  const placeholder = `${PLACEHOLDER_CB}${i}§`;
@@ -267,7 +328,7 @@ function quikdown(markdown, options = {}) {
267
328
  // Restore inline code
268
329
  inlineCodes.forEach((code, i) => {
269
330
  const placeholder = `${PLACEHOLDER_IC}${i}§`;
270
- html = html.replace(placeholder, `<code${getAttr('code')}>${code}</code>`);
331
+ html = html.replace(placeholder, `<code${getAttr('code')}${dataQd('`')}>${code}</code>`);
271
332
  });
272
333
 
273
334
  return html.trim();
@@ -381,9 +442,9 @@ function buildTable(lines, getAttr) {
381
442
  let html = `<table${getAttr('table')}>\n`;
382
443
 
383
444
  // Build header
384
- if (headerLines.length > 0) {
385
- html += `<thead${getAttr('thead')}>\n`;
386
- headerLines.forEach(line => {
445
+ // Note: headerLines will always have length > 0 since separatorIndex starts from 1
446
+ html += `<thead${getAttr('thead')}>\n`;
447
+ headerLines.forEach(line => {
387
448
  html += `<tr${getAttr('tr')}>\n`;
388
449
  // Handle pipes at start/end or not
389
450
  const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
@@ -393,9 +454,8 @@ function buildTable(lines, getAttr) {
393
454
  html += `<th${getAttr('th', alignStyle)}>${processedCell}</th>\n`;
394
455
  });
395
456
  html += '</tr>\n';
396
- });
397
- html += '</thead>\n';
398
- }
457
+ });
458
+ html += '</thead>\n';
399
459
 
400
460
  // Build body
401
461
  if (bodyLines.length > 0) {
@@ -421,12 +481,16 @@ function buildTable(lines, getAttr) {
421
481
  /**
422
482
  * Process markdown lists (ordered and unordered)
423
483
  */
424
- function processLists(text, getAttr, inline_styles) {
484
+ function processLists(text, getAttr, inline_styles, bidirectional) {
425
485
 
426
486
  const lines = text.split('\n');
427
487
  const result = [];
428
488
  let listStack = []; // Track nested lists
429
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
+
430
494
  for (let i = 0; i < lines.length; i++) {
431
495
  const line = lines[i];
432
496
  const match = line.match(/^(\s*)([*\-+]|\d+\.)\s+(.+)$/);
@@ -474,7 +538,7 @@ function processLists(text, getAttr, inline_styles) {
474
538
  }
475
539
 
476
540
  const liAttr = taskListClass || getAttr('li');
477
- result.push(`<li${liAttr}>${listItemContent}</li>`);
541
+ result.push(`<li${liAttr}${dataQd(marker)}>${listItemContent}</li>`);
478
542
  } else {
479
543
  // Not a list item, close all lists
480
544
  while (listStack.length > 0) {
@@ -520,8 +584,7 @@ quikdown.emitStyles = function(prefix = 'quikdown-', theme = 'light') {
520
584
 
521
585
  let css = '';
522
586
  for (const [tag, style] of Object.entries(styles)) {
523
- if (style) {
524
- let themedStyle = style;
587
+ let themedStyle = style;
525
588
 
526
589
  // Apply theme overrides if dark theme
527
590
  if (theme === 'dark' && themeOverrides.dark) {
@@ -544,9 +607,8 @@ quikdown.emitStyles = function(prefix = 'quikdown-', theme = 'light') {
544
607
  themedStyle += `;color:${themeOverrides.light._textColor}`;
545
608
  }
546
609
  }
547
-
548
- css += `.${prefix}${tag} { ${themedStyle} }\n`;
549
- }
610
+
611
+ css += `.${prefix}${tag} { ${themedStyle} }\n`;
550
612
  }
551
613
 
552
614
  return css;
@@ -569,11 +631,13 @@ quikdown.configure = function(options) {
569
631
  quikdown.version = quikdownVersion;
570
632
 
571
633
  // Export for both CommonJS and ES6
634
+ /* istanbul ignore next */
572
635
  if (typeof module !== 'undefined' && module.exports) {
573
636
  module.exports = quikdown;
574
637
  }
575
638
 
576
639
  // For browser global
640
+ /* istanbul ignore next */
577
641
  if (typeof window !== 'undefined') {
578
642
  window.quikdown = quikdown;
579
643
  }