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