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.
package/dist/quikdown.cjs CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * quikdown - Lightweight 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
  */
@@ -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.4';
23
+ const quikdownVersion = '1.1.0';
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
@@ -127,18 +146,21 @@ function quikdown(markdown, options = {}) {
127
146
  // Trim the language specification
128
147
  const langTrimmed = lang ? lang.trim() : '';
129
148
 
130
- // If custom fence plugin is provided, use it
131
- if (fence_plugin && typeof fence_plugin === 'function') {
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') {
132
151
  codeBlocks.push({
133
152
  lang: langTrimmed,
134
153
  code: code.trimEnd(),
135
- custom: true
154
+ custom: true,
155
+ fence: fence,
156
+ hasReverse: !!fence_plugin.reverse
136
157
  });
137
158
  } else {
138
159
  codeBlocks.push({
139
160
  lang: langTrimmed,
140
161
  code: escapeHtml(code.trimEnd()),
141
- custom: false
162
+ custom: false,
163
+ fence: fence
142
164
  });
143
165
  }
144
166
  return placeholder;
@@ -162,7 +184,7 @@ function quikdown(markdown, options = {}) {
162
184
  // Process headings (supports optional trailing #'s)
163
185
  html = html.replace(/^(#{1,6})\s+(.+?)\s*#*$/gm, (match, hashes, content) => {
164
186
  const level = hashes.length;
165
- return `<h${level}${getAttr('h' + level)}>${content}</h${level}>`;
187
+ return `<h${level}${getAttr('h' + level)}${dataQd(hashes)}>${content}</h${level}>`;
166
188
  });
167
189
 
168
190
  // Process blockquotes (must handle escaped > since we already escaped HTML)
@@ -170,18 +192,20 @@ function quikdown(markdown, options = {}) {
170
192
  // Merge consecutive blockquotes
171
193
  html = html.replace(/<\/blockquote>\n<blockquote>/g, '\n');
172
194
 
173
- // Process horizontal rules
174
- html = html.replace(/^---+$/gm, `<hr${getAttr('hr')}>`);
195
+ // Process horizontal rules (allow trailing spaces)
196
+ html = html.replace(/^---+\s*$/gm, `<hr${getAttr('hr')}>`);
175
197
 
176
198
  // Process lists
177
- html = processLists(html, getAttr, inline_styles);
199
+ html = processLists(html, getAttr, inline_styles, bidirectional);
178
200
 
179
201
  // Phase 3: Process inline elements
180
202
 
181
203
  // Images (must come before links, with URL sanitization)
182
204
  html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
183
205
  const sanitizedSrc = sanitizeUrl(src, options.allow_unsafe_urls);
184
- return `<img${getAttr('img')} src="${sanitizedSrc}" alt="${alt}">`;
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('!')}>`;
185
209
  });
186
210
 
187
211
  // Links (with URL sanitization)
@@ -190,7 +214,8 @@ function quikdown(markdown, options = {}) {
190
214
  const sanitizedHref = sanitizeUrl(href, options.allow_unsafe_urls);
191
215
  const isExternal = /^https?:\/\//i.test(sanitizedHref);
192
216
  const rel = isExternal ? ' rel="noopener noreferrer"' : '';
193
- return `<a${getAttr('a')} href="${sanitizedHref}"${rel}>${text}</a>`;
217
+ const textAttr = bidirectional ? ` data-qd-text="${escapeHtml(text)}"` : '';
218
+ return `<a${getAttr('a')} href="${sanitizedHref}"${rel}${textAttr}${dataQd('[')}>${text}</a>`;
194
219
  });
195
220
 
196
221
  // Autolinks - convert bare URLs to clickable links
@@ -201,23 +226,64 @@ function quikdown(markdown, options = {}) {
201
226
 
202
227
  // Process inline formatting (bold, italic, strikethrough)
203
228
  const inlinePatterns = [
204
- [/\*\*(.+?)\*\*/g, 'strong'],
205
- [/__(.+?)__/g, 'strong'],
206
- [/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, 'em'],
207
- [/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, 'em'],
208
- [/~~(.+?)~~/g, 'del']
229
+ [/\*\*(.+?)\*\*/g, 'strong', '**'],
230
+ [/__(.+?)__/g, 'strong', '__'],
231
+ [/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, 'em', '*'],
232
+ [/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, 'em', '_'],
233
+ [/~~(.+?)~~/g, 'del', '~~']
209
234
  ];
210
235
 
211
- inlinePatterns.forEach(([pattern, tag]) => {
212
- html = html.replace(pattern, `<${tag}${getAttr(tag)}>$1</${tag}>`);
236
+ inlinePatterns.forEach(([pattern, tag, marker]) => {
237
+ html = html.replace(pattern, `<${tag}${getAttr(tag)}${dataQd(marker)}>$1</${tag}>`);
213
238
  });
214
239
 
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>';
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
+ }
221
287
 
222
288
  // Clean up empty paragraphs and unwrap block elements
223
289
  const cleanupPatterns = [
@@ -240,26 +306,39 @@ function quikdown(markdown, options = {}) {
240
306
  html = html.replace(pattern, replacement);
241
307
  });
242
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
+
243
313
  // Phase 4: Restore code blocks and inline code
244
314
 
245
315
  // Restore code blocks
246
316
  codeBlocks.forEach((block, i) => {
247
317
  let replacement;
248
318
 
249
- if (block.custom && fence_plugin) {
250
- // Use custom fence plugin
251
- 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
+
252
323
  // If plugin returns undefined, fall back to default rendering
253
324
  if (replacement === undefined) {
254
325
  const langClass = !inline_styles && block.lang ? ` class="language-${block.lang}"` : '';
255
326
  const codeAttr = inline_styles ? getAttr('code') : langClass;
256
- replacement = `<pre${getAttr('pre')}><code${codeAttr}>${escapeHtml(block.code)}</code></pre>`;
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)}"`);
257
334
  }
258
335
  } else {
259
336
  // Default rendering
260
337
  const langClass = !inline_styles && block.lang ? ` class="language-${block.lang}"` : '';
261
338
  const codeAttr = inline_styles ? getAttr('code') : langClass;
262
- replacement = `<pre${getAttr('pre')}><code${codeAttr}>${block.code}</code></pre>`;
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>`;
263
342
  }
264
343
 
265
344
  const placeholder = `${PLACEHOLDER_CB}${i}§`;
@@ -269,7 +348,7 @@ function quikdown(markdown, options = {}) {
269
348
  // Restore inline code
270
349
  inlineCodes.forEach((code, i) => {
271
350
  const placeholder = `${PLACEHOLDER_IC}${i}§`;
272
- html = html.replace(placeholder, `<code${getAttr('code')}>${code}</code>`);
351
+ html = html.replace(placeholder, `<code${getAttr('code')}${dataQd('`')}>${code}</code>`);
273
352
  });
274
353
 
275
354
  return html.trim();
@@ -383,9 +462,9 @@ function buildTable(lines, getAttr) {
383
462
  let html = `<table${getAttr('table')}>\n`;
384
463
 
385
464
  // Build header
386
- if (headerLines.length > 0) {
387
- html += `<thead${getAttr('thead')}>\n`;
388
- headerLines.forEach(line => {
465
+ // Note: headerLines will always have length > 0 since separatorIndex starts from 1
466
+ html += `<thead${getAttr('thead')}>\n`;
467
+ headerLines.forEach(line => {
389
468
  html += `<tr${getAttr('tr')}>\n`;
390
469
  // Handle pipes at start/end or not
391
470
  const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
@@ -395,9 +474,8 @@ function buildTable(lines, getAttr) {
395
474
  html += `<th${getAttr('th', alignStyle)}>${processedCell}</th>\n`;
396
475
  });
397
476
  html += '</tr>\n';
398
- });
399
- html += '</thead>\n';
400
- }
477
+ });
478
+ html += '</thead>\n';
401
479
 
402
480
  // Build body
403
481
  if (bodyLines.length > 0) {
@@ -423,12 +501,16 @@ function buildTable(lines, getAttr) {
423
501
  /**
424
502
  * Process markdown lists (ordered and unordered)
425
503
  */
426
- function processLists(text, getAttr, inline_styles) {
504
+ function processLists(text, getAttr, inline_styles, bidirectional) {
427
505
 
428
506
  const lines = text.split('\n');
429
507
  const result = [];
430
508
  let listStack = []; // Track nested lists
431
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
+
432
514
  for (let i = 0; i < lines.length; i++) {
433
515
  const line = lines[i];
434
516
  const match = line.match(/^(\s*)([*\-+]|\d+\.)\s+(.+)$/);
@@ -476,7 +558,7 @@ function processLists(text, getAttr, inline_styles) {
476
558
  }
477
559
 
478
560
  const liAttr = taskListClass || getAttr('li');
479
- result.push(`<li${liAttr}>${listItemContent}</li>`);
561
+ result.push(`<li${liAttr}${dataQd(marker)}>${listItemContent}</li>`);
480
562
  } else {
481
563
  // Not a list item, close all lists
482
564
  while (listStack.length > 0) {
@@ -522,8 +604,7 @@ quikdown.emitStyles = function(prefix = 'quikdown-', theme = 'light') {
522
604
 
523
605
  let css = '';
524
606
  for (const [tag, style] of Object.entries(styles)) {
525
- if (style) {
526
- let themedStyle = style;
607
+ let themedStyle = style;
527
608
 
528
609
  // Apply theme overrides if dark theme
529
610
  if (theme === 'dark' && themeOverrides.dark) {
@@ -546,9 +627,8 @@ quikdown.emitStyles = function(prefix = 'quikdown-', theme = 'light') {
546
627
  themedStyle += `;color:${themeOverrides.light._textColor}`;
547
628
  }
548
629
  }
549
-
550
- css += `.${prefix}${tag} { ${themedStyle} }\n`;
551
- }
630
+
631
+ css += `.${prefix}${tag} { ${themedStyle} }\n`;
552
632
  }
553
633
 
554
634
  return css;
@@ -571,11 +651,13 @@ quikdown.configure = function(options) {
571
651
  quikdown.version = quikdownVersion;
572
652
 
573
653
  // Export for both CommonJS and ES6
654
+ /* istanbul ignore next */
574
655
  if (typeof module !== 'undefined' && module.exports) {
575
656
  module.exports = quikdown;
576
657
  }
577
658
 
578
659
  // For browser global
660
+ /* istanbul ignore next */
579
661
  if (typeof window !== 'undefined') {
580
662
  window.quikdown = quikdown;
581
663
  }
@@ -5,17 +5,38 @@
5
5
 
6
6
  declare module 'quikdown' {
7
7
  /**
8
- * Options for configuring the quikdown parser
8
+ * Fence plugin for custom code block rendering (v1.1.0+)
9
9
  */
10
- export interface QuikdownOptions {
10
+ export interface FencePlugin {
11
11
  /**
12
- * Custom renderer for fenced code blocks.
13
- * Return undefined to use default rendering.
12
+ * Render markdown fence to HTML
14
13
  * @param content - The code block content (unescaped)
15
14
  * @param language - The language identifier (or empty string)
16
15
  * @returns HTML string or undefined for default rendering
17
16
  */
18
- fence_plugin?: (content: string, language: string) => string | undefined;
17
+ render: (content: string, language: string) => string | undefined;
18
+
19
+ /**
20
+ * Convert HTML element back to markdown fence (optional)
21
+ * @param element - The HTML element to convert
22
+ * @returns Fence details or null to use default
23
+ */
24
+ reverse?: (element: HTMLElement) => {
25
+ fence: string;
26
+ lang: string;
27
+ content: string;
28
+ } | null;
29
+ }
30
+
31
+ /**
32
+ * Options for configuring the quikdown parser
33
+ */
34
+ export interface QuikdownOptions {
35
+ /**
36
+ * Custom renderer for fenced code blocks (v1.1.0: object format required)
37
+ * @since 1.1.0 - Must be an object with render function
38
+ */
39
+ fence_plugin?: FencePlugin;
19
40
 
20
41
  /**
21
42
  * If true, uses inline styles instead of CSS classes.
@@ -30,6 +51,20 @@ declare module 'quikdown' {
30
51
  * @default false
31
52
  */
32
53
  allow_unsafe_urls?: boolean;
54
+
55
+ /**
56
+ * If true, adds data-qd attributes for bidirectional conversion.
57
+ * Enables HTML to Markdown conversion.
58
+ * @default false
59
+ */
60
+ bidirectional?: boolean;
61
+
62
+ /**
63
+ * If true, single newlines become <br> tags.
64
+ * Useful for chat/LLM applications where Enter should create a line break.
65
+ * @default false
66
+ */
67
+ lazy_linefeeds?: boolean;
33
68
  }
34
69
 
35
70
  /**
@@ -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-18T00:32:14.951Z
8
+ * @version 1.1.0
9
9
  * @source tools/generateThemeCSS.js
10
10
  */
11
11