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