quikdown 1.2.7 → 1.2.9

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.
Files changed (61) hide show
  1. package/README.md +9 -4
  2. package/dist/quikdown.cjs +496 -243
  3. package/dist/quikdown.dark.css +1 -1
  4. package/dist/quikdown.esm.js +496 -243
  5. package/dist/quikdown.esm.min.js +2 -2
  6. package/dist/quikdown.esm.min.js.gz +0 -0
  7. package/dist/quikdown.esm.min.js.map +1 -1
  8. package/dist/quikdown.light.css +1 -1
  9. package/dist/quikdown.umd.js +496 -243
  10. package/dist/quikdown.umd.min.js +2 -2
  11. package/dist/quikdown.umd.min.js.gz +0 -0
  12. package/dist/quikdown.umd.min.js.map +1 -1
  13. package/dist/quikdown_ast.cjs +2 -2
  14. package/dist/quikdown_ast.esm.js +2 -2
  15. package/dist/quikdown_ast.esm.min.js +2 -2
  16. package/dist/quikdown_ast.esm.min.js.gz +0 -0
  17. package/dist/quikdown_ast.umd.js +2 -2
  18. package/dist/quikdown_ast.umd.min.js +2 -2
  19. package/dist/quikdown_ast.umd.min.js.gz +0 -0
  20. package/dist/quikdown_ast_html.cjs +3 -3
  21. package/dist/quikdown_ast_html.esm.js +3 -3
  22. package/dist/quikdown_ast_html.esm.min.js +2 -2
  23. package/dist/quikdown_ast_html.esm.min.js.gz +0 -0
  24. package/dist/quikdown_ast_html.umd.js +3 -3
  25. package/dist/quikdown_ast_html.umd.min.js +2 -2
  26. package/dist/quikdown_ast_html.umd.min.js.gz +0 -0
  27. package/dist/quikdown_bd.cjs +496 -243
  28. package/dist/quikdown_bd.esm.js +496 -243
  29. package/dist/quikdown_bd.esm.min.js +2 -2
  30. package/dist/quikdown_bd.esm.min.js.gz +0 -0
  31. package/dist/quikdown_bd.esm.min.js.map +1 -1
  32. package/dist/quikdown_bd.umd.js +496 -243
  33. package/dist/quikdown_bd.umd.min.js +2 -2
  34. package/dist/quikdown_bd.umd.min.js.gz +0 -0
  35. package/dist/quikdown_bd.umd.min.js.map +1 -1
  36. package/dist/quikdown_edit.cjs +760 -327
  37. package/dist/quikdown_edit.esm.js +760 -327
  38. package/dist/quikdown_edit.esm.min.js +3 -3
  39. package/dist/quikdown_edit.esm.min.js.gz +0 -0
  40. package/dist/quikdown_edit.esm.min.js.map +1 -1
  41. package/dist/quikdown_edit.umd.js +760 -327
  42. package/dist/quikdown_edit.umd.min.js +3 -3
  43. package/dist/quikdown_edit.umd.min.js.gz +0 -0
  44. package/dist/quikdown_edit.umd.min.js.map +1 -1
  45. package/dist/quikdown_edit_standalone.esm.min.js.gz +0 -0
  46. package/dist/quikdown_edit_standalone.umd.min.js.gz +0 -0
  47. package/dist/quikdown_json.cjs +3 -3
  48. package/dist/quikdown_json.esm.js +3 -3
  49. package/dist/quikdown_json.esm.min.js +2 -2
  50. package/dist/quikdown_json.esm.min.js.gz +0 -0
  51. package/dist/quikdown_json.umd.js +3 -3
  52. package/dist/quikdown_json.umd.min.js +2 -2
  53. package/dist/quikdown_json.umd.min.js.gz +0 -0
  54. package/dist/quikdown_yaml.cjs +3 -3
  55. package/dist/quikdown_yaml.esm.js +3 -3
  56. package/dist/quikdown_yaml.esm.min.js +2 -2
  57. package/dist/quikdown_yaml.esm.min.js.gz +0 -0
  58. package/dist/quikdown_yaml.umd.js +3 -3
  59. package/dist/quikdown_yaml.umd.min.js +2 -2
  60. package/dist/quikdown_yaml.umd.min.js.gz +0 -0
  61. package/package.json +18 -13
package/dist/quikdown.cjs CHANGED
@@ -1,36 +1,141 @@
1
1
  /**
2
2
  * quikdown - Lightweight Markdown Parser
3
- * @version 1.2.7
3
+ * @version 1.2.9
4
4
  * @license BSD-2-Clause
5
5
  * @copyright DeftIO 2025
6
6
  */
7
7
  'use strict';
8
8
 
9
9
  /**
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
10
+ * quikdown_classify Shared line-classification utilities
11
+ * ═════════════════════════════════════════════════════════
12
+ *
13
+ * Pure functions for classifying markdown lines. Used by both the main
14
+ * parser (quikdown.js) and the editor (quikdown_edit.js) so the logic
15
+ * lives in one place.
16
+ *
17
+ * All functions operate on a **trimmed** line (caller must trim).
18
+ * None use regexes with nested quantifiers every check is either a
19
+ * simple regex or a linear scan, so there is zero ReDoS risk.
20
20
  */
21
21
 
22
- // Version will be injected at build time
23
- const quikdownVersion = '1.2.7';
24
22
 
25
- // Constants for reuse
23
+ /**
24
+ * Dash-only HR check — exact parity with the main parser's original
25
+ * regex `/^---+\s*$/`. Only matches lines of three or more dashes
26
+ * with optional trailing whitespace (no interspersed spaces).
27
+ *
28
+ * @param {string} trimmed The line, already trimmed
29
+ * @returns {boolean}
30
+ */
31
+ function isDashHRLine(trimmed) {
32
+ if (trimmed.length < 3) return false;
33
+ for (let i = 0; i < trimmed.length; i++) {
34
+ const ch = trimmed[i];
35
+ if (ch === '-') continue;
36
+ // Allow trailing whitespace only
37
+ if (ch === ' ' || ch === '\t') {
38
+ for (let j = i + 1; j < trimmed.length; j++) {
39
+ if (trimmed[j] !== ' ' && trimmed[j] !== '\t') return false;
40
+ }
41
+ return i >= 3; // at least 3 dashes before whitespace
42
+ }
43
+ return false;
44
+ }
45
+ return true; // all dashes
46
+ }
47
+
48
+ /**
49
+ * quikdown — A compact, scanner-based markdown parser
50
+ * ════════════════════════════════════════════════════
51
+ *
52
+ * Architecture overview (v1.2.8 — lexer rewrite)
53
+ * ───────────────────────────────────────────────
54
+ * Prior to v1.2.8, quikdown used a multi-pass regex pipeline: each block
55
+ * type (headings, blockquotes, HR, lists, tables) and each inline format
56
+ * (bold, italic, links, …) was handled by its own global regex applied
57
+ * sequentially to the full document string. That worked but made the code
58
+ * hard to extend and debug — a new construct meant adding another regex
59
+ * pass, and ordering bugs between passes were subtle.
60
+ *
61
+ * Starting in v1.2.8 the parser uses a **line-scanning** approach for
62
+ * block detection and a **per-block inline pass** for formatting:
63
+ *
64
+ * ┌─────────────────────────────────────────────────────────┐
65
+ * │ Phase 1 — Code Extraction │
66
+ * │ Scan for fenced code blocks (``` / ~~~) and inline │
67
+ * │ code spans (`…`). Replace with §CB§ / §IC§ place- │
68
+ * │ holders so code content is never touched by later │
69
+ * │ phases. │
70
+ * ├─────────────────────────────────────────────────────────┤
71
+ * │ Phase 2 — HTML Escaping │
72
+ * │ Escape &, <, >, ", ' in the remaining text to prevent │
73
+ * │ XSS. (Skipped when allow_unsafe_html is true.) │
74
+ * ├─────────────────────────────────────────────────────────┤
75
+ * │ Phase 3 — Block Scanning │
76
+ * │ Walk the text **line by line**. At each line, the │
77
+ * │ scanner checks (in order): │
78
+ * │ • table rows (|) │
79
+ * │ • headings (#) │
80
+ * │ • HR (---) │
81
+ * │ • blockquotes (&gt;) │
82
+ * │ • list items (-, *, +, 1.) │
83
+ * │ • code-block placeholder (§CB…§) │
84
+ * │ • paragraph text (everything else) │
85
+ * │ │
86
+ * │ Block text is run through the **inline formatter** │
87
+ * │ which handles bold, italic, strikethrough, links, │
88
+ * │ images, and autolinks. │
89
+ * │ │
90
+ * │ Paragraphs are wrapped in <p> tags. Lazy linefeeds │
91
+ * │ (single \n → <br>) are handled here too. │
92
+ * ├─────────────────────────────────────────────────────────┤
93
+ * │ Phase 4 — Code Restoration │
94
+ * │ Replace §CB§ / §IC§ placeholders with rendered <pre> │
95
+ * │ / <code> HTML, applying the fence_plugin if present. │
96
+ * └─────────────────────────────────────────────────────────┘
97
+ *
98
+ * Why this design?
99
+ * • Single pass over lines for block identification — no re-scanning.
100
+ * • Each block type is a clearly separated branch, easy to add new ones.
101
+ * • Inline formatting is confined to block text — can't accidentally
102
+ * match across block boundaries or inside HTML tags.
103
+ * • Code extraction still uses a simple regex (it's one pattern, not a
104
+ * chain) because the §-placeholder approach is proven and simple.
105
+ *
106
+ * @param {string} markdown The markdown source text
107
+ * @param {Object} options Configuration (see below)
108
+ * @returns {string} Rendered HTML
109
+ */
110
+
111
+
112
+ // ────────────────────────────────────────────────────────────────────
113
+ // Constants
114
+ // ────────────────────────────────────────────────────────────────────
115
+
116
+ /** Build-time version stamp (injected by tools/updateVersion) */
117
+ const quikdownVersion = '1.2.9';
118
+
119
+ /** CSS class prefix used for all generated elements */
26
120
  const CLASS_PREFIX = 'quikdown-';
27
- const PLACEHOLDER_CB = '§CB';
28
- const PLACEHOLDER_IC = '§IC';
29
121
 
30
- // Escape map at module level
122
+ /** Placeholder sigils chosen to be extremely unlikely in real text */
123
+ const PLACEHOLDER_CB = '§CB'; // fenced code blocks
124
+ const PLACEHOLDER_IC = '§IC'; // inline code spans
125
+
126
+ /** HTML entity escape map */
31
127
  const ESC_MAP = {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'};
32
128
 
33
- // Single source of truth for all style definitions - optimized
129
+ // ────────────────────────────────────────────────────────────────────
130
+ // Style definitions
131
+ // ────────────────────────────────────────────────────────────────────
132
+
133
+ /**
134
+ * Inline styles for every element quikdown can emit.
135
+ * When `inline_styles: true` these are injected as style="…" attributes.
136
+ * When `inline_styles: false` (default) we use class="quikdown-<tag>"
137
+ * and these same values are emitted by `quikdown.emitStyles()`.
138
+ */
34
139
  const QUIKDOWN_STYLES = {
35
140
  h1: 'font-size:2em;font-weight:600;margin:.67em 0;text-align:left',
36
141
  h2: 'font-size:1.5em;font-weight:600;margin:.83em 0',
@@ -53,35 +158,41 @@ const QUIKDOWN_STYLES = {
53
158
  ul: 'margin:.5em 0;padding-left:2em',
54
159
  ol: 'margin:.5em 0;padding-left:2em',
55
160
  li: 'margin:.25em 0',
56
- // Task list specific styles
57
161
  'task-item': 'list-style:none',
58
162
  'task-checkbox': 'margin-right:.5em'
59
163
  };
60
164
 
61
- // Factory function to create getAttr for a given context
165
+ // ────────────────────────────────────────────────────────────────────
166
+ // Attribute factory
167
+ // ────────────────────────────────────────────────────────────────────
168
+
169
+ /**
170
+ * Creates a `getAttr(tag, additionalStyle?)` helper that returns
171
+ * either a class="…" or style="…" attribute string depending on mode.
172
+ *
173
+ * @param {boolean} inline_styles True → emit style="…"; false → class="…"
174
+ * @param {Object} styles The QUIKDOWN_STYLES map
175
+ * @returns {Function}
176
+ */
62
177
  function createGetAttr(inline_styles, styles) {
63
178
  return function(tag, additionalStyle = '') {
64
179
  if (inline_styles) {
65
180
  let style = styles[tag];
66
181
  if (!style && !additionalStyle) return '';
67
-
68
- // Remove default text-align if we're adding a different alignment
182
+
183
+ // When adding alignment that conflicts with the tag's default,
184
+ // strip the default text-align first.
69
185
  if (additionalStyle && additionalStyle.includes('text-align') && style && style.includes('text-align')) {
70
186
  style = style.replace(/text-align:[^;]+;?/, '').trim();
71
- // Ensure trailing semicolon before concatenating additionalStyle.
72
- // Both short-circuit paths of this guard (empty `style` or
73
- // already-has-`;`) are defensive and unreachable with the
74
- // current QUIKDOWN_STYLES values — istanbul ignore next.
75
187
  /* istanbul ignore next */
76
188
  if (style && !style.endsWith(';')) style += ';';
77
189
  }
78
-
190
+
79
191
  /* istanbul ignore next - defensive: additionalStyle without style doesn't occur with current tags */
80
192
  const fullStyle = additionalStyle ? (style ? `${style}${additionalStyle}` : additionalStyle) : style;
81
193
  return ` style="${fullStyle}"`;
82
194
  } else {
83
195
  const classAttr = ` class="${CLASS_PREFIX}${tag}"`;
84
- // Apply inline styles for alignment even when using CSS classes
85
196
  if (additionalStyle) {
86
197
  return `${classAttr} style="${additionalStyle}"`;
87
198
  }
@@ -90,72 +201,84 @@ function createGetAttr(inline_styles, styles) {
90
201
  };
91
202
  }
92
203
 
204
+ // ════════════════════════════════════════════════════════════════════
205
+ // Main parser function
206
+ // ════════════════════════════════════════════════════════════════════
207
+
93
208
  function quikdown(markdown, options = {}) {
209
+ // ── Guard: only process non-empty strings ──
94
210
  if (!markdown || typeof markdown !== 'string') {
95
211
  return '';
96
212
  }
97
-
213
+
214
+ // ── Unpack options ──
98
215
  const { fence_plugin, inline_styles = false, bidirectional = false, lazy_linefeeds = false, allow_unsafe_html = false } = options;
99
- const styles = QUIKDOWN_STYLES; // Use module-level styles
100
- const getAttr = createGetAttr(inline_styles, styles); // Create getAttr once
216
+ const styles = QUIKDOWN_STYLES;
217
+ const getAttr = createGetAttr(inline_styles, styles);
218
+
219
+ // ── Helpers (closed over options) ──
101
220
 
102
- // Escape HTML entities to prevent XSS
221
+ /** Escape the five HTML-special characters. */
103
222
  function escapeHtml(text) {
104
223
  return text.replace(/[&<>"']/g, m => ESC_MAP[m]);
105
224
  }
106
-
107
- // Helper to add data-qd attributes for bidirectional support.
108
- // The non-bidirectional branch is a trivial no-op arrow; it's exercised in
109
- // the core bundle but never in quikdown_bd (which always sets bidirectional=true).
225
+
226
+ /**
227
+ * Bidirectional marker helper.
228
+ * When bidirectional mode is on, returns ` data-qd="…"`.
229
+ * The non-bidirectional branch is a trivial no-op arrow; it is
230
+ * exercised in the core bundle but never in quikdown_bd.
231
+ */
110
232
  /* istanbul ignore next - trivial no-op fallback */
111
233
  const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';
112
234
 
113
- // Sanitize URLs to prevent XSS attacks
235
+ /**
236
+ * Sanitize a URL to block javascript:, vbscript:, and non-image data: URIs.
237
+ * Returns '#' for blocked URLs.
238
+ */
114
239
  function sanitizeUrl(url, allowUnsafe = false) {
115
240
  /* istanbul ignore next - defensive programming, regex ensures url is never empty */
116
241
  if (!url) return '';
117
-
118
- // If unsafe URLs are explicitly allowed, return as-is
119
242
  if (allowUnsafe) return url;
120
-
243
+
121
244
  const trimmedUrl = url.trim();
122
245
  const lowerUrl = trimmedUrl.toLowerCase();
123
-
124
- // Block dangerous protocols
125
246
  const dangerousProtocols = ['javascript:', 'vbscript:', 'data:'];
126
-
247
+
127
248
  for (const protocol of dangerousProtocols) {
128
249
  if (lowerUrl.startsWith(protocol)) {
129
- // Exception: Allow data:image/* for images
130
250
  if (protocol === 'data:' && lowerUrl.startsWith('data:image/')) {
131
251
  return trimmedUrl;
132
252
  }
133
- // Return safe empty link for dangerous protocols
134
253
  return '#';
135
254
  }
136
255
  }
137
-
138
256
  return trimmedUrl;
139
257
  }
140
258
 
141
- // Process the markdown in phases
259
+ // ────────────────────────────────────────────────────────────────
260
+ // Phase 1 — Code Extraction
261
+ // ────────────────────────────────────────────────────────────────
262
+ // Why extract code first? Fenced blocks and inline code spans can
263
+ // contain markdown-like characters (*, _, #, |, etc.) that must NOT
264
+ // be interpreted as formatting. By pulling them out and replacing
265
+ // with unique placeholders, the rest of the pipeline never sees them.
266
+
142
267
  let html = markdown;
143
-
144
- // Phase 1: Extract and protect code blocks and inline code
145
- const codeBlocks = [];
146
- const inlineCodes = [];
147
-
148
- // Extract fenced code blocks first (supports both ``` and ~~~)
149
- // Match paired fences - ``` with ``` and ~~~ with ~~~
150
- // Fence must be at start of line
268
+ const codeBlocks = []; // Array of {lang, code, custom, fence, hasReverse}
269
+ const inlineCodes = []; // Array of escaped-HTML strings
270
+
271
+ // ── Fenced code blocks ──
272
+ // Matches paired fences: ``` with ``` and ~~~ with ~~~.
273
+ // The fence must start at column 0 of a line (^ with /m flag).
274
+ // Group 1 = fence marker, Group 2 = language hint, Group 3 = code body.
151
275
  html = html.replace(/^(```|~~~)([^\n]*)\n([\s\S]*?)^\1$/gm, (match, fence, lang, code) => {
152
276
  const placeholder = `${PLACEHOLDER_CB}${codeBlocks.length}§`;
153
-
154
- // Trim the language specification
155
277
  const langTrimmed = lang ? lang.trim() : '';
156
-
157
- // If custom fence plugin is provided, use it (v1.1.0: object format required)
278
+
158
279
  if (fence_plugin && fence_plugin.render && typeof fence_plugin.render === 'function') {
280
+ // Custom plugin — store raw code (un-escaped) so the plugin
281
+ // receives the original source.
159
282
  codeBlocks.push({
160
283
  lang: langTrimmed,
161
284
  code: code.trimEnd(),
@@ -164,6 +287,7 @@ function quikdown(markdown, options = {}) {
164
287
  hasReverse: !!fence_plugin.reverse
165
288
  });
166
289
  } else {
290
+ // Default — pre-escape the code for safe HTML output.
167
291
  codeBlocks.push({
168
292
  lang: langTrimmed,
169
293
  code: escapeHtml(code.trimEnd()),
@@ -173,69 +297,94 @@ function quikdown(markdown, options = {}) {
173
297
  }
174
298
  return placeholder;
175
299
  });
176
-
177
- // Extract inline code
300
+
301
+ // ── Inline code spans ──
302
+ // Matches a single backtick pair: `content`.
303
+ // Content is captured and HTML-escaped immediately.
178
304
  html = html.replace(/`([^`]+)`/g, (match, code) => {
179
305
  const placeholder = `${PLACEHOLDER_IC}${inlineCodes.length}§`;
180
306
  inlineCodes.push(escapeHtml(code));
181
307
  return placeholder;
182
308
  });
183
-
184
- // Escape HTML in the rest of the content (skip if allow_unsafe_html is on —
185
- // useful for trusted pipelines where the markdown contains intentional HTML)
309
+
310
+ // ────────────────────────────────────────────────────────────────
311
+ // Phase 2 HTML Escaping
312
+ // ────────────────────────────────────────────────────────────────
313
+ // All remaining text (everything except code placeholders) is escaped
314
+ // to prevent XSS. The `allow_unsafe_html` option skips this for
315
+ // trusted pipelines that intentionally embed raw HTML.
316
+
186
317
  if (!allow_unsafe_html) {
187
318
  html = escapeHtml(html);
188
319
  }
189
-
190
- // Phase 2: Process block elements
191
-
192
- // Process tables
320
+
321
+ // ────────────────────────────────────────────────────────────────
322
+ // Phase 3 — Block Scanning + Inline Formatting + Paragraphs
323
+ // ────────────────────────────────────────────────────────────────
324
+ // This is the heart of the lexer rewrite. Instead of applying
325
+ // 10+ global regex passes, we:
326
+ // 1. Process tables (line walker — tables need multi-line lookahead)
327
+ // 2. Scan remaining lines for headings, HR, blockquotes
328
+ // 3. Process lists (line walker — lists need indent tracking)
329
+ // 4. Apply inline formatting to all text content
330
+ // 5. Wrap remaining text in <p> tags
331
+ //
332
+ // Steps 1 and 3 are line-walkers that process the full text in a
333
+ // single pass each. Step 2 replaces global regex with a per-line
334
+ // scanner. Steps 4-5 are applied to the result.
335
+ //
336
+ // Total: 3 structured passes instead of 10+ regex passes.
337
+
338
+ // ── Step 1: Tables ──
339
+ // Tables need multi-line lookahead (header → separator → body rows)
340
+ // so they're handled by a dedicated line-walker first.
193
341
  html = processTable(html, getAttr);
194
-
195
- // Process headings (supports optional trailing #'s)
196
- html = html.replace(/^(#{1,6})\s+(.+?)\s*#*$/gm, (match, hashes, content) => {
197
- const level = hashes.length;
198
- return `<h${level}${getAttr('h' + level)}${dataQd(hashes)}>${content}</h${level}>`;
199
- });
200
-
201
- // Process blockquotes (must handle escaped > since we already escaped HTML)
202
- html = html.replace(/^&gt;\s+(.+)$/gm, `<blockquote${getAttr('blockquote')}>$1</blockquote>`);
203
- // Merge consecutive blockquotes
204
- html = html.replace(/<\/blockquote>\n<blockquote>/g, '\n');
205
-
206
- // Process horizontal rules (allow trailing spaces)
207
- html = html.replace(/^---+\s*$/gm, `<hr${getAttr('hr')}>`);
208
-
209
- // Process lists
342
+
343
+ // ── Step 2: Headings, HR, Blockquotes ──
344
+ // These are simple line-level constructs. We scan each line once
345
+ // and replace matching lines with their HTML representation.
346
+ html = scanLineBlocks(html, getAttr, dataQd);
347
+
348
+ // ── Step 3: Lists ──
349
+ // Lists need indent-level tracking across lines, so they get their
350
+ // own line-walker.
210
351
  html = processLists(html, getAttr, inline_styles, bidirectional);
211
-
212
- // Phase 3: Process inline elements
213
-
214
- // Images (must come before links, with URL sanitization)
352
+
353
+ // ── Step 4: Inline formatting ──
354
+ // Apply bold, italic, strikethrough, images, links, and autolinks
355
+ // to all text content. This runs on the output of steps 1-3, so
356
+ // it sees text inside headings, blockquotes, table cells, list
357
+ // items, and paragraph text.
358
+
359
+ // Images (must come before links — ![alt](src) vs [text](url))
215
360
  html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
216
361
  const sanitizedSrc = sanitizeUrl(src, options.allow_unsafe_urls);
362
+ // Bidirectional attributes are only exercised via quikdown_bd bundle.
363
+ /* istanbul ignore next - bd-only branch */
217
364
  const altAttr = bidirectional && alt ? ` data-qd-alt="${escapeHtml(alt)}"` : '';
365
+ /* istanbul ignore next - bd-only branch */
218
366
  const srcAttr = bidirectional ? ` data-qd-src="${escapeHtml(src)}"` : '';
219
367
  return `<img${getAttr('img')} src="${sanitizedSrc}" alt="${alt}"${altAttr}${srcAttr}${dataQd('!')}>`;
220
368
  });
221
-
222
- // Links (with URL sanitization)
369
+
370
+ // Links
223
371
  html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, href) => {
224
- // Sanitize URL to prevent XSS
225
372
  const sanitizedHref = sanitizeUrl(href, options.allow_unsafe_urls);
226
373
  const isExternal = /^https?:\/\//i.test(sanitizedHref);
227
374
  const rel = isExternal ? ' rel="noopener noreferrer"' : '';
375
+ /* istanbul ignore next - bd-only branch */
228
376
  const textAttr = bidirectional ? ` data-qd-text="${escapeHtml(text)}"` : '';
229
377
  return `<a${getAttr('a')} href="${sanitizedHref}"${rel}${textAttr}${dataQd('[')}>${text}</a>`;
230
378
  });
231
-
232
- // Autolinks - convert bare URLs to clickable links
379
+
380
+ // Autolinks bare https?:// URLs become clickable <a> tags
233
381
  html = html.replace(/(^|\s)(https?:\/\/[^\s<]+)/g, (match, prefix, url) => {
234
382
  const sanitizedUrl = sanitizeUrl(url, options.allow_unsafe_urls);
235
383
  return `${prefix}<a${getAttr('a')} href="${sanitizedUrl}" rel="noopener noreferrer">${url}</a>`;
236
384
  });
237
-
238
- // Process inline formatting (bold, italic, strikethrough)
385
+
386
+ // Bold, italic, strikethrough
387
+ // Order matters: ** before * (so ** isn't consumed as two *s)
239
388
  const inlinePatterns = [
240
389
  [/\*\*(.+?)\*\*/g, 'strong', '**'],
241
390
  [/__(.+?)__/g, 'strong', '__'],
@@ -243,60 +392,63 @@ function quikdown(markdown, options = {}) {
243
392
  [/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, 'em', '_'],
244
393
  [/~~(.+?)~~/g, 'del', '~~']
245
394
  ];
246
-
247
395
  inlinePatterns.forEach(([pattern, tag, marker]) => {
248
396
  html = html.replace(pattern, `<${tag}${getAttr(tag)}${dataQd(marker)}>$1</${tag}>`);
249
397
  });
250
-
251
- // Line breaks
398
+
399
+ // ── Step 5: Line breaks + paragraph wrapping ──
252
400
  if (lazy_linefeeds) {
253
- // Lazy linefeeds: single newline becomes <br> (except between paragraphs and after/before block elements)
401
+ // Lazy linefeeds mode: every single \n becomes <br> EXCEPT:
402
+ // • Double newlines → paragraph break
403
+ // • Newlines adjacent to block elements (h, blockquote, pre, hr, table, list)
404
+ //
405
+ // Strategy: protect block-adjacent newlines with §N§, convert
406
+ // the rest, then restore.
407
+
254
408
  const blocks = [];
255
409
  let bi = 0;
256
-
257
- // Protect tables and lists
410
+
411
+ // Protect tables and lists from <br> injection
258
412
  html = html.replace(/<(table|[uo]l)[^>]*>[\s\S]*?<\/\1>/g, m => {
259
413
  blocks[bi] = m;
260
414
  return `§B${bi++}§`;
261
415
  });
262
-
263
- // Handle paragraphs and block elements
416
+
264
417
  html = html.replace(/\n\n+/g, '§P§')
265
- // After block elements
418
+ // After block-level closing tags
266
419
  .replace(/(<\/(?:h[1-6]|blockquote|pre)>)\n/g, '$1§N§')
267
420
  .replace(/(<(?:h[1-6]|blockquote|pre|hr)[^>]*>)\n/g, '$1§N§')
268
- // Before block elements
421
+ // Before block-level opening tags
269
422
  .replace(/\n(<(?:h[1-6]|blockquote|pre|hr)[^>]*>)/g, '§N§$1')
270
423
  .replace(/\n(§B\d+§)/g, '§N§$1')
271
424
  .replace(/(§B\d+§)\n/g, '$1§N§')
272
- // Convert remaining newlines
425
+ // Convert surviving newlines to <br>
273
426
  .replace(/\n/g, `<br${getAttr('br')}>`)
274
427
  // Restore
275
428
  .replace(/§N§/g, '\n')
276
429
  .replace(/§P§/g, '</p><p>');
277
-
430
+
278
431
  // Restore protected blocks
279
432
  blocks.forEach((b, i) => html = html.replace(`§B${i}§`, b));
280
-
433
+
281
434
  html = '<p>' + html + '</p>';
282
435
  } else {
283
- // Standard: two spaces at end of line for line breaks
436
+ // Standard mode: two trailing spaces <br>, double newline new paragraph
284
437
  html = html.replace(/ {2}$/gm, `<br${getAttr('br')}>`);
285
-
286
- // Paragraphs (double newlines)
287
- // Don't add </p> after block elements (they're not in paragraphs)
438
+
288
439
  html = html.replace(/\n\n+/g, (match, offset) => {
289
- // Check if we're after a block element closing tag
290
440
  const before = html.substring(0, offset);
291
441
  if (before.match(/<\/(h[1-6]|blockquote|ul|ol|table|pre|hr)>$/)) {
292
- return '<p>'; // Just open a new paragraph
442
+ return '<p>';
293
443
  }
294
- return '</p><p>'; // Normal paragraph break
444
+ return '</p><p>';
295
445
  });
296
446
  html = '<p>' + html + '</p>';
297
447
  }
298
-
299
- // Clean up empty paragraphs and unwrap block elements
448
+
449
+ // ── Step 6: Cleanup ──
450
+ // Remove <p> wrappers that accidentally enclose block elements.
451
+ // This is simpler than trying to prevent them during wrapping.
300
452
  const cleanupPatterns = [
301
453
  [/<p><\/p>/g, ''],
302
454
  [/<p>(<h[1-6][^>]*>)/g, '$1'],
@@ -312,65 +464,152 @@ function quikdown(markdown, options = {}) {
312
464
  [/(<\/pre>)<\/p>/g, '$1'],
313
465
  [new RegExp(`<p>(${PLACEHOLDER_CB}\\d+§)</p>`, 'g'), '$1']
314
466
  ];
315
-
316
467
  cleanupPatterns.forEach(([pattern, replacement]) => {
317
468
  html = html.replace(pattern, replacement);
318
469
  });
319
-
320
- // Fix orphaned closing </p> tags after block elements
321
- // When a paragraph follows a block element, ensure it has opening <p>
470
+
471
+ // When a block element is followed by a newline and then text, open a <p>.
322
472
  html = html.replace(/(<\/(?:h[1-6]|blockquote|ul|ol|table|pre|hr)>)\n([^<])/g, '$1\n<p>$2');
323
-
324
- // Phase 4: Restore code blocks and inline code
325
-
326
- // Restore code blocks
473
+
474
+ // ────────────────────────────────────────────────────────────────
475
+ // Phase 4 — Code Restoration
476
+ // ────────────────────────────────────────────────────────────────
477
+ // Replace placeholders with rendered HTML. For fenced blocks this
478
+ // means wrapping in <pre><code>…</code></pre> (or calling the
479
+ // fence_plugin). For inline code it means <code>…</code>.
480
+
327
481
  codeBlocks.forEach((block, i) => {
328
482
  let replacement;
329
-
483
+
330
484
  if (block.custom && fence_plugin && fence_plugin.render) {
331
- // Use custom fence plugin (v1.1.0: object format with render function)
485
+ // Delegate to the user-provided fence plugin.
332
486
  replacement = fence_plugin.render(block.code, block.lang);
333
-
334
- // If plugin returns undefined, fall back to default rendering
487
+
335
488
  if (replacement === undefined) {
489
+ // Plugin declined — fall back to default rendering.
336
490
  const langClass = !inline_styles && block.lang ? ` class="language-${block.lang}"` : '';
337
491
  const codeAttr = inline_styles ? getAttr('code') : langClass;
492
+ /* istanbul ignore next - bd-only branch */
338
493
  const langAttr = bidirectional && block.lang ? ` data-qd-lang="${escapeHtml(block.lang)}"` : '';
494
+ /* istanbul ignore next - bd-only branch */
339
495
  const fenceAttr = bidirectional ? ` data-qd-fence="${escapeHtml(block.fence)}"` : '';
340
496
  replacement = `<pre${getAttr('pre')}${fenceAttr}${langAttr}><code${codeAttr}>${escapeHtml(block.code)}</code></pre>`;
341
- } else if (bidirectional) {
342
- // If bidirectional and plugin provided HTML, add data attributes for roundtrip
343
- replacement = replacement.replace(/^<(\w+)/,
497
+ } else /* istanbul ignore next - bd-only branch */ if (bidirectional) {
498
+ // Plugin returned HTML inject data attributes for roundtrip.
499
+ replacement = replacement.replace(/^<(\w+)/,
344
500
  `<$1 data-qd-fence="${escapeHtml(block.fence)}" data-qd-lang="${escapeHtml(block.lang)}" data-qd-source="${escapeHtml(block.code)}"`);
345
501
  }
346
502
  } else {
347
- // Default rendering
503
+ // Default rendering — wrap in <pre><code>.
348
504
  const langClass = !inline_styles && block.lang ? ` class="language-${block.lang}"` : '';
349
505
  const codeAttr = inline_styles ? getAttr('code') : langClass;
506
+ /* istanbul ignore next - bd-only branch */
350
507
  const langAttr = bidirectional && block.lang ? ` data-qd-lang="${escapeHtml(block.lang)}"` : '';
508
+ /* istanbul ignore next - bd-only branch */
351
509
  const fenceAttr = bidirectional ? ` data-qd-fence="${escapeHtml(block.fence)}"` : '';
352
510
  replacement = `<pre${getAttr('pre')}${fenceAttr}${langAttr}><code${codeAttr}>${block.code}</code></pre>`;
353
511
  }
354
-
512
+
355
513
  const placeholder = `${PLACEHOLDER_CB}${i}§`;
356
514
  html = html.replace(placeholder, replacement);
357
515
  });
358
-
359
- // Restore inline code
516
+
517
+ // Restore inline code spans
360
518
  inlineCodes.forEach((code, i) => {
361
519
  const placeholder = `${PLACEHOLDER_IC}${i}§`;
362
520
  html = html.replace(placeholder, `<code${getAttr('code')}${dataQd('`')}>${code}</code>`);
363
521
  });
364
-
522
+
365
523
  return html.trim();
366
524
  }
367
525
 
526
+ // ════════════════════════════════════════════════════════════════════
527
+ // Block-level line scanner
528
+ // ════════════════════════════════════════════════════════════════════
529
+
530
+ /**
531
+ * scanLineBlocks — single-pass line scanner for headings, HR, blockquotes
532
+ *
533
+ * Walks the text line by line. For each line it checks (in order):
534
+ * 1. Heading — starts with 1-6 '#' followed by a space
535
+ * 2. HR — line is entirely '---…' (3+ dashes, optional trailing space)
536
+ * 3. Blockquote — starts with '&gt; ' (the > was already HTML-escaped)
537
+ *
538
+ * Lines that don't match any block pattern are passed through unchanged.
539
+ *
540
+ * This replaces three separate global regex passes from the pre-1.2.8
541
+ * architecture with one structured scan.
542
+ *
543
+ * @param {string} text The document text (HTML-escaped, code extracted)
544
+ * @param {Function} getAttr Attribute factory (class or style)
545
+ * @param {Function} dataQd Bidirectional marker factory
546
+ * @returns {string} Text with block-level elements rendered
547
+ */
548
+ function scanLineBlocks(text, getAttr, dataQd) {
549
+ const lines = text.split('\n');
550
+ const result = [];
551
+ let i = 0;
552
+
553
+ while (i < lines.length) {
554
+ const line = lines[i];
555
+
556
+ // ── Heading ──
557
+ // Count leading '#' characters. Valid heading: 1-6 hashes then a space.
558
+ // Example: "## Hello World ##" → <h2>Hello World</h2>
559
+ let hashCount = 0;
560
+ while (hashCount < line.length && hashCount < 7 && line[hashCount] === '#') {
561
+ hashCount++;
562
+ }
563
+ if (hashCount >= 1 && hashCount <= 6 && line[hashCount] === ' ') {
564
+ // Extract content after "# " and strip trailing hashes
565
+ const content = line.slice(hashCount + 1).replace(/\s*#+\s*$/, '');
566
+ const tag = 'h' + hashCount;
567
+ result.push(`<${tag}${getAttr(tag)}${dataQd('#'.repeat(hashCount))}>${content}</${tag}>`);
568
+ i++;
569
+ continue;
570
+ }
571
+
572
+ // ── Horizontal Rule ──
573
+ // Three or more dashes, optional trailing whitespace, nothing else.
574
+ if (isDashHRLine(line)) {
575
+ result.push(`<hr${getAttr('hr')}>`);
576
+ i++;
577
+ continue;
578
+ }
579
+
580
+ // ── Blockquote ──
581
+ // After Phase 2, the '>' character has been escaped to '&gt;'.
582
+ // Pattern: "&gt; content" or merged consecutive blockquotes.
583
+ if (/^&gt;\s+/.test(line)) {
584
+ result.push(`<blockquote${getAttr('blockquote')}>${line.replace(/^&gt;\s+/, '')}</blockquote>`);
585
+ i++;
586
+ continue;
587
+ }
588
+
589
+ // ── Pass-through ──
590
+ result.push(line);
591
+ i++;
592
+ }
593
+
594
+ // Merge consecutive blockquotes into a single element.
595
+ // <blockquote>A</blockquote>\n<blockquote>B</blockquote>
596
+ // → <blockquote>A\nB</blockquote>
597
+ let joined = result.join('\n');
598
+ joined = joined.replace(/<\/blockquote>\n<blockquote>/g, '\n');
599
+ return joined;
600
+ }
601
+
602
+ // ════════════════════════════════════════════════════════════════════
603
+ // Table processing (line walker)
604
+ // ════════════════════════════════════════════════════════════════════
605
+
368
606
  /**
369
- * Process inline markdown formatting
607
+ * Inline markdown formatter for table cells.
608
+ * Handles bold, italic, strikethrough, and code within cell text.
609
+ * Links / images / autolinks are handled by the global inline pass
610
+ * (Phase 3 Step 4) which runs after table processing.
370
611
  */
371
612
  function processInlineMarkdown(text, getAttr) {
372
-
373
- // Process inline formatting patterns
374
613
  const patterns = [
375
614
  [/\*\*(.+?)\*\*/g, 'strong'],
376
615
  [/__(.+?)__/g, 'strong'],
@@ -379,27 +618,32 @@ function processInlineMarkdown(text, getAttr) {
379
618
  [/~~(.+?)~~/g, 'del'],
380
619
  [/`([^`]+)`/g, 'code']
381
620
  ];
382
-
383
621
  patterns.forEach(([pattern, tag]) => {
384
622
  text = text.replace(pattern, `<${tag}${getAttr(tag)}>$1</${tag}>`);
385
623
  });
386
-
387
624
  return text;
388
625
  }
389
626
 
390
627
  /**
391
- * Process markdown tables
628
+ * processTable — line walker for markdown tables
629
+ *
630
+ * Walks through lines looking for runs of pipe-containing lines.
631
+ * Each run is validated (must contain a separator row: |---|---|)
632
+ * and rendered as an HTML <table>. Invalid runs are restored as-is.
633
+ *
634
+ * @param {string} text Full document text
635
+ * @param {Function} getAttr Attribute factory
636
+ * @returns {string} Text with tables rendered
392
637
  */
393
638
  function processTable(text, getAttr) {
394
639
  const lines = text.split('\n');
395
640
  const result = [];
396
641
  let inTable = false;
397
642
  let tableLines = [];
398
-
643
+
399
644
  for (let i = 0; i < lines.length; i++) {
400
645
  const line = lines[i].trim();
401
-
402
- // Check if this line looks like a table row (with or without trailing |)
646
+
403
647
  if (line.includes('|') && (line.startsWith('|') || /[^\\|]/.test(line))) {
404
648
  if (!inTable) {
405
649
  inTable = true;
@@ -407,14 +651,11 @@ function processTable(text, getAttr) {
407
651
  }
408
652
  tableLines.push(line);
409
653
  } else {
410
- // Not a table line
411
654
  if (inTable) {
412
- // Process the accumulated table
413
655
  const tableHtml = buildTable(tableLines, getAttr);
414
656
  if (tableHtml) {
415
657
  result.push(tableHtml);
416
658
  } else {
417
- // Not a valid table, restore original lines
418
659
  result.push(...tableLines);
419
660
  }
420
661
  inTable = false;
@@ -423,8 +664,8 @@ function processTable(text, getAttr) {
423
664
  result.push(lines[i]);
424
665
  }
425
666
  }
426
-
427
- // Handle table at end of text
667
+
668
+ // Handle table at end of document
428
669
  if (inTable && tableLines.length > 0) {
429
670
  const tableHtml = buildTable(tableLines, getAttr);
430
671
  if (tableHtml) {
@@ -433,35 +674,35 @@ function processTable(text, getAttr) {
433
674
  result.push(...tableLines);
434
675
  }
435
676
  }
436
-
677
+
437
678
  return result.join('\n');
438
679
  }
439
680
 
440
681
  /**
441
- * Build an HTML table from markdown table lines
682
+ * buildTable validate and render a table from accumulated lines
683
+ *
684
+ * @param {string[]} lines Array of pipe-containing lines
685
+ * @param {Function} getAttr Attribute factory
686
+ * @returns {string|null} HTML table string, or null if invalid
442
687
  */
443
688
  function buildTable(lines, getAttr) {
444
-
445
689
  if (lines.length < 2) return null;
446
-
447
- // Check for separator line (second line should be the separator)
690
+
691
+ // Find the separator row (---|---|)
448
692
  let separatorIndex = -1;
449
693
  for (let i = 1; i < lines.length; i++) {
450
- // Support separator with or without leading/trailing pipes
451
694
  if (/^\|?[\s\-:|]+\|?$/.test(lines[i]) && lines[i].includes('-')) {
452
695
  separatorIndex = i;
453
696
  break;
454
697
  }
455
698
  }
456
-
457
699
  if (separatorIndex === -1) return null;
458
-
700
+
459
701
  const headerLines = lines.slice(0, separatorIndex);
460
702
  const bodyLines = lines.slice(separatorIndex + 1);
461
-
462
- // Parse alignment from separator
703
+
704
+ // Parse alignment from separator cells (:--- = left, :---: = center, ---: = right)
463
705
  const separator = lines[separatorIndex];
464
- // Handle pipes at start/end or not
465
706
  const separatorCells = separator.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
466
707
  const alignments = separatorCells.map(cell => {
467
708
  const trimmed = cell.trim();
@@ -469,31 +710,28 @@ function buildTable(lines, getAttr) {
469
710
  if (trimmed.endsWith(':')) return 'right';
470
711
  return 'left';
471
712
  });
472
-
713
+
473
714
  let html = `<table${getAttr('table')}>\n`;
474
-
475
- // Build header
476
- // Note: headerLines will always have length > 0 since separatorIndex starts from 1
715
+
716
+ // Header
477
717
  html += `<thead${getAttr('thead')}>\n`;
478
718
  headerLines.forEach(line => {
479
- html += `<tr${getAttr('tr')}>\n`;
480
- // Handle pipes at start/end or not
481
- const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
482
- cells.forEach((cell, i) => {
483
- const alignStyle = alignments[i] && alignments[i] !== 'left' ? `text-align:${alignments[i]}` : '';
484
- const processedCell = processInlineMarkdown(cell.trim(), getAttr);
485
- html += `<th${getAttr('th', alignStyle)}>${processedCell}</th>\n`;
486
- });
487
- html += '</tr>\n';
719
+ html += `<tr${getAttr('tr')}>\n`;
720
+ const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
721
+ cells.forEach((cell, i) => {
722
+ const alignStyle = alignments[i] && alignments[i] !== 'left' ? `text-align:${alignments[i]}` : '';
723
+ const processedCell = processInlineMarkdown(cell.trim(), getAttr);
724
+ html += `<th${getAttr('th', alignStyle)}>${processedCell}</th>\n`;
725
+ });
726
+ html += '</tr>\n';
488
727
  });
489
728
  html += '</thead>\n';
490
-
491
- // Build body
729
+
730
+ // Body
492
731
  if (bodyLines.length > 0) {
493
732
  html += `<tbody${getAttr('tbody')}>\n`;
494
733
  bodyLines.forEach(line => {
495
734
  html += `<tr${getAttr('tr')}>\n`;
496
- // Handle pipes at start/end or not
497
735
  const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
498
736
  cells.forEach((cell, i) => {
499
737
  const alignStyle = alignments[i] && alignments[i] !== 'left' ? `text-align:${alignments[i]}` : '';
@@ -504,66 +742,81 @@ function buildTable(lines, getAttr) {
504
742
  });
505
743
  html += '</tbody>\n';
506
744
  }
507
-
745
+
508
746
  html += '</table>';
509
747
  return html;
510
748
  }
511
749
 
750
+ // ════════════════════════════════════════════════════════════════════
751
+ // List processing (line walker)
752
+ // ════════════════════════════════════════════════════════════════════
753
+
512
754
  /**
513
- * Process markdown lists (ordered and unordered)
755
+ * processLists line walker for ordered, unordered, and task lists
756
+ *
757
+ * Scans each line for list markers (-, *, +, 1., 2., etc.) with
758
+ * optional leading indentation for nesting. Non-list lines close
759
+ * any open lists and pass through unchanged.
760
+ *
761
+ * Task lists (- [ ] / - [x]) are detected and rendered with
762
+ * checkbox inputs.
763
+ *
764
+ * @param {string} text Full document text
765
+ * @param {Function} getAttr Attribute factory
766
+ * @param {boolean} inline_styles Whether to use inline styles
767
+ * @param {boolean} bidirectional Whether to add data-qd markers
768
+ * @returns {string} Text with lists rendered
514
769
  */
515
770
  function processLists(text, getAttr, inline_styles, bidirectional) {
516
-
517
771
  const lines = text.split('\n');
518
772
  const result = [];
519
- const listStack = []; // Track nested lists
520
-
773
+ const listStack = []; // tracks nesting: [{type:'ul', level:0}, …]
774
+
521
775
  // Helper to escape HTML for data-qd attributes. List markers (`-`, `*`,
522
776
  // `+`, `1.`, etc.) never contain HTML-special chars, so the replace
523
777
  // callback is defensive-only and never actually fires in practice.
778
+ /* istanbul ignore next - defensive: list markers never trigger escaping */
524
779
  const escapeHtml = (text) => text.replace(/[&<>"']/g,
525
780
  /* istanbul ignore next - defensive: list markers never contain HTML specials */
526
781
  m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[m]);
527
782
  /* istanbul ignore next - trivial no-op fallback; not exercised via bd bundle */
528
783
  const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';
529
-
784
+
530
785
  for (let i = 0; i < lines.length; i++) {
531
786
  const line = lines[i];
532
787
  const match = line.match(/^(\s*)([*\-+]|\d+\.)\s+(.+)$/);
533
-
788
+
534
789
  if (match) {
535
790
  const [, indent, marker, content] = match;
536
791
  const level = Math.floor(indent.length / 2);
537
792
  const isOrdered = /^\d+\./.test(marker);
538
793
  const listType = isOrdered ? 'ol' : 'ul';
539
-
540
- // Check for task list items
794
+
795
+ // Task list detection (only in unordered lists)
541
796
  let listItemContent = content;
542
797
  let taskListClass = '';
543
798
  const taskMatch = content.match(/^\[([x ])\]\s+(.*)$/i);
544
799
  if (taskMatch && !isOrdered) {
545
800
  const [, checked, taskContent] = taskMatch;
546
801
  const isChecked = checked.toLowerCase() === 'x';
547
- const checkboxAttr = inline_styles
548
- ? ' style="margin-right:.5em"'
802
+ const checkboxAttr = inline_styles
803
+ ? ' style="margin-right:.5em"'
549
804
  : ` class="${CLASS_PREFIX}task-checkbox"`;
550
805
  listItemContent = `<input type="checkbox"${checkboxAttr}${isChecked ? ' checked' : ''} disabled> ${taskContent}`;
551
806
  taskListClass = inline_styles ? ' style="list-style:none"' : ` class="${CLASS_PREFIX}task-item"`;
552
807
  }
553
-
554
- // Close deeper levels
808
+
809
+ // Close deeper nesting levels
555
810
  while (listStack.length > level + 1) {
556
811
  const list = listStack.pop();
557
812
  result.push(`</${list.type}>`);
558
813
  }
559
-
560
- // Open new level if needed
814
+
815
+ // Open new list or switch type at current level
561
816
  if (listStack.length === level) {
562
- // Need to open a new list
563
817
  listStack.push({ type: listType, level });
564
818
  result.push(`<${listType}${getAttr(listType)}>`);
565
819
  } else if (listStack.length === level + 1) {
566
- // Check if we need to switch list type
567
820
  const currentList = listStack[listStack.length - 1];
568
821
  if (currentList.type !== listType) {
569
822
  result.push(`</${currentList.type}>`);
@@ -572,11 +825,11 @@ function processLists(text, getAttr, inline_styles, bidirectional) {
572
825
  result.push(`<${listType}${getAttr(listType)}>`);
573
826
  }
574
827
  }
575
-
828
+
576
829
  const liAttr = taskListClass || getAttr('li');
577
830
  result.push(`<li${liAttr}${dataQd(marker)}>${listItemContent}</li>`);
578
831
  } else {
579
- // Not a list item, close all lists
832
+ // Not a list item close all open lists
580
833
  while (listStack.length > 0) {
581
834
  const list = listStack.pop();
582
835
  result.push(`</${list.type}>`);
@@ -584,76 +837,76 @@ function processLists(text, getAttr, inline_styles, bidirectional) {
584
837
  result.push(line);
585
838
  }
586
839
  }
587
-
588
- // Close any remaining lists
840
+
841
+ // Close any remaining open lists
589
842
  while (listStack.length > 0) {
590
843
  const list = listStack.pop();
591
844
  result.push(`</${list.type}>`);
592
845
  }
593
-
846
+
594
847
  return result.join('\n');
595
848
  }
596
849
 
850
+ // ════════════════════════════════════════════════════════════════════
851
+ // Static API
852
+ // ════════════════════════════════════════════════════════════════════
853
+
597
854
  /**
598
- * Emit CSS styles for quikdown elements
599
- * @param {string} prefix - Optional class prefix (default: 'quikdown-')
600
- * @param {string} theme - Optional theme: 'light' (default) or 'dark'
601
- * @returns {string} CSS string with quikdown styles
855
+ * Emit CSS rules for all quikdown elements.
856
+ *
857
+ * @param {string} prefix Class prefix (default: 'quikdown-')
858
+ * @param {string} theme 'light' (default) or 'dark'
859
+ * @returns {string} CSS text
602
860
  */
603
861
  quikdown.emitStyles = function(prefix = 'quikdown-', theme = 'light') {
604
862
  const styles = QUIKDOWN_STYLES;
605
-
606
- // Define theme color overrides
863
+
607
864
  const themeOverrides = {
608
865
  dark: {
609
- '#f4f4f4': '#2a2a2a', // pre background
610
- '#f0f0f0': '#2a2a2a', // code background
611
- '#f2f2f2': '#2a2a2a', // th background
612
- '#ddd': '#3a3a3a', // borders
613
- '#06c': '#6db3f2', // links
866
+ '#f4f4f4': '#2a2a2a', // pre background
867
+ '#f0f0f0': '#2a2a2a', // code background
868
+ '#f2f2f2': '#2a2a2a', // th background
869
+ '#ddd': '#3a3a3a', // borders
870
+ '#06c': '#6db3f2', // links
614
871
  _textColor: '#e0e0e0'
615
872
  },
616
873
  light: {
617
- _textColor: '#333' // Explicit text color for light theme
874
+ _textColor: '#333'
618
875
  }
619
876
  };
620
-
877
+
621
878
  let css = '';
622
879
  for (const [tag, style] of Object.entries(styles)) {
623
880
  let themedStyle = style;
624
-
625
- // Apply theme overrides if dark theme
626
- if (theme === 'dark' && themeOverrides.dark) {
627
- // Replace colors
628
- for (const [oldColor, newColor] of Object.entries(themeOverrides.dark)) {
629
- if (!oldColor.startsWith('_')) {
630
- themedStyle = themedStyle.replace(new RegExp(oldColor, 'g'), newColor);
631
- }
632
- }
633
-
634
- // Add text color for certain elements in dark theme
635
- const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote'];
636
- if (needsTextColor.includes(tag)) {
637
- themedStyle += `;color:${themeOverrides.dark._textColor}`;
638
- }
639
- } else if (theme === 'light' && themeOverrides.light) {
640
- // Add explicit text color for light theme elements too
641
- const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote'];
642
- if (needsTextColor.includes(tag)) {
643
- themedStyle += `;color:${themeOverrides.light._textColor}`;
881
+
882
+ if (theme === 'dark' && themeOverrides.dark) {
883
+ for (const [oldColor, newColor] of Object.entries(themeOverrides.dark)) {
884
+ if (!oldColor.startsWith('_')) {
885
+ themedStyle = themedStyle.replaceAll(oldColor, newColor);
644
886
  }
645
887
  }
646
-
888
+ const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote'];
889
+ if (needsTextColor.includes(tag)) {
890
+ themedStyle += `;color:${themeOverrides.dark._textColor}`;
891
+ }
892
+ } else if (theme === 'light' && themeOverrides.light) {
893
+ const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote'];
894
+ if (needsTextColor.includes(tag)) {
895
+ themedStyle += `;color:${themeOverrides.light._textColor}`;
896
+ }
897
+ }
898
+
647
899
  css += `.${prefix}${tag} { ${themedStyle} }\n`;
648
900
  }
649
-
901
+
650
902
  return css;
651
903
  };
652
904
 
653
905
  /**
654
- * Configure quikdown with options and return a function
655
- * @param {Object} options - Configuration options
656
- * @returns {Function} Configured quikdown function
906
+ * Create a pre-configured parser with baked-in options.
907
+ *
908
+ * @param {Object} options Options to bake in
909
+ * @returns {Function} Configured quikdown(markdown) function
657
910
  */
658
911
  quikdown.configure = function(options) {
659
912
  return function(markdown) {
@@ -661,18 +914,18 @@ quikdown.configure = function(options) {
661
914
  };
662
915
  };
663
916
 
664
- /**
665
- * Version information
666
- */
917
+ /** Semantic version (injected at build time) */
667
918
  quikdown.version = quikdownVersion;
668
919
 
669
- // Export for both CommonJS and ES6
920
+ // ════════════════════════════════════════════════════════════════════
921
+ // Exports
922
+ // ════════════════════════════════════════════════════════════════════
923
+
670
924
  /* istanbul ignore next */
671
925
  if (typeof module !== 'undefined' && module.exports) {
672
926
  module.exports = quikdown;
673
927
  }
674
928
 
675
- // For browser global
676
929
  /* istanbul ignore next */
677
930
  if (typeof window !== 'undefined') {
678
931
  window.quikdown = quikdown;