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,36 +1,251 @@
1
1
  /**
2
2
  * Quikdown Editor - Drop-in Markdown Parser
3
- * @version 1.2.7
3
+ * @version 1.2.9
4
4
  * @license BSD-2-Clause
5
5
  * @copyright DeftIO 2025
6
6
  */
7
7
  'use strict';
8
8
 
9
9
  /**
10
- * quikdown - A minimal markdown parser optimized for chat/LLM output
11
- * Supports tables, code blocks, lists, and common formatting
12
- * @param {string} markdown - The markdown source text
13
- * @param {Object} options - Optional configuration object
14
- * @param {Function} options.fence_plugin - Custom renderer for fenced code blocks
15
- * (content, fence_string) => html string
16
- * @param {boolean} options.inline_styles - If true, uses inline styles instead of classes
17
- * @param {boolean} options.bidirectional - If true, adds data-qd attributes for source tracking
18
- * @param {boolean} options.lazy_linefeeds - If true, single newlines become <br> tags
19
- * @returns {string} - The rendered HTML
10
+ * quikdown_classify Shared line-classification utilities
11
+ * ═════════════════════════════════════════════════════════
12
+ *
13
+ * Pure functions for classifying markdown lines. Used by both the main
14
+ * parser (quikdown.js) and the editor (quikdown_edit.js) so the logic
15
+ * lives in one place.
16
+ *
17
+ * All functions operate on a **trimmed** line (caller must trim).
18
+ * None use regexes with nested quantifiers every check is either a
19
+ * simple regex or a linear scan, so there is zero ReDoS risk.
20
20
  */
21
21
 
22
- // Version will be injected at build time
23
- const quikdownVersion = '1.2.7';
22
+ /**
23
+ * Full CommonMark HR check: three or more identical characters from
24
+ * {-, *, _} with optional interspersed whitespace.
25
+ *
26
+ * Examples that return true: ---, ***, ___, ----, - - -, * * *, _ _ _
27
+ * Examples that return false: --, - text, ---text, mixed -_*, empty
28
+ *
29
+ * Algorithm (O(n), single pass, no backtracking):
30
+ * 1. Strip all whitespace
31
+ * 2. Verify length >= 3
32
+ * 3. First char must be -, *, or _
33
+ * 4. Every remaining char must equal the first
34
+ *
35
+ * @param {string} trimmed The line, already trimmed
36
+ * @returns {boolean}
37
+ */
38
+ function isHRLine(trimmed) {
39
+ if (trimmed.length < 3) return false;
40
+
41
+ // Strip whitespace via linear scan
42
+ let stripped = '';
43
+ for (let i = 0; i < trimmed.length; i++) {
44
+ const ch = trimmed[i];
45
+ if (ch !== ' ' && ch !== '\t') stripped += ch;
46
+ }
47
+
48
+ if (stripped.length < 3) return false;
49
+
50
+ const ch = stripped[0];
51
+ if (ch !== '-' && ch !== '*' && ch !== '_') return false;
24
52
 
25
- // Constants for reuse
53
+ for (let i = 1; i < stripped.length; i++) {
54
+ if (stripped[i] !== ch) return false;
55
+ }
56
+ return true;
57
+ }
58
+
59
+ /**
60
+ * Dash-only HR check — exact parity with the main parser's original
61
+ * regex `/^---+\s*$/`. Only matches lines of three or more dashes
62
+ * with optional trailing whitespace (no interspersed spaces).
63
+ *
64
+ * @param {string} trimmed The line, already trimmed
65
+ * @returns {boolean}
66
+ */
67
+ function isDashHRLine(trimmed) {
68
+ if (trimmed.length < 3) return false;
69
+ for (let i = 0; i < trimmed.length; i++) {
70
+ const ch = trimmed[i];
71
+ if (ch === '-') continue;
72
+ // Allow trailing whitespace only
73
+ if (ch === ' ' || ch === '\t') {
74
+ for (let j = i + 1; j < trimmed.length; j++) {
75
+ if (trimmed[j] !== ' ' && trimmed[j] !== '\t') return false;
76
+ }
77
+ return i >= 3; // at least 3 dashes before whitespace
78
+ }
79
+ return false;
80
+ }
81
+ return true; // all dashes
82
+ }
83
+
84
+ /**
85
+ * Check if a trimmed line opens a code fence.
86
+ * Returns { char, len, lang } if it does, or null otherwise.
87
+ *
88
+ * A fence opener is 3+ identical backticks or tildes at the start of a line,
89
+ * optionally followed by a language tag.
90
+ *
91
+ * @param {string} trimmed The line, already trimmed
92
+ * @returns {{ char: string, len: number, lang: string } | null}
93
+ */
94
+ function fenceOpen(trimmed) {
95
+ if (trimmed.length < 3) return null;
96
+ const ch = trimmed[0];
97
+ if (ch !== '`' && ch !== '~') return null;
98
+
99
+ let len = 1;
100
+ while (len < trimmed.length && trimmed[len] === ch) len++;
101
+ if (len < 3) return null;
102
+
103
+ const lang = trimmed.slice(len).trim();
104
+ return { char: ch, len, lang };
105
+ }
106
+
107
+ /**
108
+ * Check if a trimmed line closes an open fence.
109
+ * The closing fence must use the same character, be at least as long,
110
+ * and have no content after (optional trailing whitespace only).
111
+ *
112
+ * @param {string} trimmed The line, already trimmed
113
+ * @param {string} openChar The fence character ('`' or '~')
114
+ * @param {number} openLen Length of the opening fence marker
115
+ * @returns {boolean}
116
+ */
117
+ function isFenceClose(trimmed, openChar, openLen) {
118
+ if (trimmed.length < openLen) return false;
119
+
120
+ let len = 0;
121
+ while (len < trimmed.length && trimmed[len] === openChar) len++;
122
+ if (len < openLen) return false;
123
+
124
+ // Rest must be whitespace only
125
+ for (let i = len; i < trimmed.length; i++) {
126
+ if (trimmed[i] !== ' ' && trimmed[i] !== '\t') return false;
127
+ }
128
+ return true;
129
+ }
130
+
131
+ /**
132
+ * Classify a content line into a category string.
133
+ * Order matters: HR before list-ul (since `- - -` looks like a list start).
134
+ *
135
+ * @param {string} trimmed The line, already trimmed
136
+ * @returns {string} One of: 'heading', 'hr', 'list-ol', 'list-ul',
137
+ * 'blockquote', 'table', 'paragraph'
138
+ */
139
+ function classifyLine(trimmed) {
140
+ if (/^#{1,6}\s/.test(trimmed)) return 'heading';
141
+ if (isHRLine(trimmed)) return 'hr';
142
+ if (/^\d+\.\s/.test(trimmed)) return 'list-ol';
143
+ if (/^[-*+]\s/.test(trimmed)) return 'list-ul';
144
+ if (/^>/.test(trimmed)) return 'blockquote';
145
+ if (/^\|/.test(trimmed)) return 'table';
146
+ return 'paragraph';
147
+ }
148
+
149
+ /**
150
+ * Heuristic: does a line look like a markdown table row?
151
+ * @param {string} line The line (trimmed or untrimmed)
152
+ * @returns {boolean}
153
+ */
154
+ function looksLikeTableRow(line) {
155
+ return line.includes('|');
156
+ }
157
+
158
+ /**
159
+ * quikdown — A compact, scanner-based markdown parser
160
+ * ════════════════════════════════════════════════════
161
+ *
162
+ * Architecture overview (v1.2.8 — lexer rewrite)
163
+ * ───────────────────────────────────────────────
164
+ * Prior to v1.2.8, quikdown used a multi-pass regex pipeline: each block
165
+ * type (headings, blockquotes, HR, lists, tables) and each inline format
166
+ * (bold, italic, links, …) was handled by its own global regex applied
167
+ * sequentially to the full document string. That worked but made the code
168
+ * hard to extend and debug — a new construct meant adding another regex
169
+ * pass, and ordering bugs between passes were subtle.
170
+ *
171
+ * Starting in v1.2.8 the parser uses a **line-scanning** approach for
172
+ * block detection and a **per-block inline pass** for formatting:
173
+ *
174
+ * ┌─────────────────────────────────────────────────────────┐
175
+ * │ Phase 1 — Code Extraction │
176
+ * │ Scan for fenced code blocks (``` / ~~~) and inline │
177
+ * │ code spans (`…`). Replace with §CB§ / §IC§ place- │
178
+ * │ holders so code content is never touched by later │
179
+ * │ phases. │
180
+ * ├─────────────────────────────────────────────────────────┤
181
+ * │ Phase 2 — HTML Escaping │
182
+ * │ Escape &, <, >, ", ' in the remaining text to prevent │
183
+ * │ XSS. (Skipped when allow_unsafe_html is true.) │
184
+ * ├─────────────────────────────────────────────────────────┤
185
+ * │ Phase 3 — Block Scanning │
186
+ * │ Walk the text **line by line**. At each line, the │
187
+ * │ scanner checks (in order): │
188
+ * │ • table rows (|) │
189
+ * │ • headings (#) │
190
+ * │ • HR (---) │
191
+ * │ • blockquotes (&gt;) │
192
+ * │ • list items (-, *, +, 1.) │
193
+ * │ • code-block placeholder (§CB…§) │
194
+ * │ • paragraph text (everything else) │
195
+ * │ │
196
+ * │ Block text is run through the **inline formatter** │
197
+ * │ which handles bold, italic, strikethrough, links, │
198
+ * │ images, and autolinks. │
199
+ * │ │
200
+ * │ Paragraphs are wrapped in <p> tags. Lazy linefeeds │
201
+ * │ (single \n → <br>) are handled here too. │
202
+ * ├─────────────────────────────────────────────────────────┤
203
+ * │ Phase 4 — Code Restoration │
204
+ * │ Replace §CB§ / §IC§ placeholders with rendered <pre> │
205
+ * │ / <code> HTML, applying the fence_plugin if present. │
206
+ * └─────────────────────────────────────────────────────────┘
207
+ *
208
+ * Why this design?
209
+ * • Single pass over lines for block identification — no re-scanning.
210
+ * • Each block type is a clearly separated branch, easy to add new ones.
211
+ * • Inline formatting is confined to block text — can't accidentally
212
+ * match across block boundaries or inside HTML tags.
213
+ * • Code extraction still uses a simple regex (it's one pattern, not a
214
+ * chain) because the §-placeholder approach is proven and simple.
215
+ *
216
+ * @param {string} markdown The markdown source text
217
+ * @param {Object} options Configuration (see below)
218
+ * @returns {string} Rendered HTML
219
+ */
220
+
221
+
222
+ // ────────────────────────────────────────────────────────────────────
223
+ // Constants
224
+ // ────────────────────────────────────────────────────────────────────
225
+
226
+ /** Build-time version stamp (injected by tools/updateVersion) */
227
+ const quikdownVersion = '1.2.9';
228
+
229
+ /** CSS class prefix used for all generated elements */
26
230
  const CLASS_PREFIX = 'quikdown-';
27
- const PLACEHOLDER_CB = '§CB';
28
- const PLACEHOLDER_IC = '§IC';
29
231
 
30
- // Escape map at module level
232
+ /** Placeholder sigils chosen to be extremely unlikely in real text */
233
+ const PLACEHOLDER_CB = '§CB'; // fenced code blocks
234
+ const PLACEHOLDER_IC = '§IC'; // inline code spans
235
+
236
+ /** HTML entity escape map */
31
237
  const ESC_MAP = {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'};
32
238
 
33
- // Single source of truth for all style definitions - optimized
239
+ // ────────────────────────────────────────────────────────────────────
240
+ // Style definitions
241
+ // ────────────────────────────────────────────────────────────────────
242
+
243
+ /**
244
+ * Inline styles for every element quikdown can emit.
245
+ * When `inline_styles: true` these are injected as style="…" attributes.
246
+ * When `inline_styles: false` (default) we use class="quikdown-<tag>"
247
+ * and these same values are emitted by `quikdown.emitStyles()`.
248
+ */
34
249
  const QUIKDOWN_STYLES = {
35
250
  h1: 'font-size:2em;font-weight:600;margin:.67em 0;text-align:left',
36
251
  h2: 'font-size:1.5em;font-weight:600;margin:.83em 0',
@@ -53,35 +268,41 @@ const QUIKDOWN_STYLES = {
53
268
  ul: 'margin:.5em 0;padding-left:2em',
54
269
  ol: 'margin:.5em 0;padding-left:2em',
55
270
  li: 'margin:.25em 0',
56
- // Task list specific styles
57
271
  'task-item': 'list-style:none',
58
272
  'task-checkbox': 'margin-right:.5em'
59
273
  };
60
274
 
61
- // Factory function to create getAttr for a given context
275
+ // ────────────────────────────────────────────────────────────────────
276
+ // Attribute factory
277
+ // ────────────────────────────────────────────────────────────────────
278
+
279
+ /**
280
+ * Creates a `getAttr(tag, additionalStyle?)` helper that returns
281
+ * either a class="…" or style="…" attribute string depending on mode.
282
+ *
283
+ * @param {boolean} inline_styles True → emit style="…"; false → class="…"
284
+ * @param {Object} styles The QUIKDOWN_STYLES map
285
+ * @returns {Function}
286
+ */
62
287
  function createGetAttr(inline_styles, styles) {
63
288
  return function(tag, additionalStyle = '') {
64
289
  if (inline_styles) {
65
290
  let style = styles[tag];
66
291
  if (!style && !additionalStyle) return '';
67
-
68
- // Remove default text-align if we're adding a different alignment
292
+
293
+ // When adding alignment that conflicts with the tag's default,
294
+ // strip the default text-align first.
69
295
  if (additionalStyle && additionalStyle.includes('text-align') && style && style.includes('text-align')) {
70
296
  style = style.replace(/text-align:[^;]+;?/, '').trim();
71
- // Ensure trailing semicolon before concatenating additionalStyle.
72
- // Both short-circuit paths of this guard (empty `style` or
73
- // already-has-`;`) are defensive and unreachable with the
74
- // current QUIKDOWN_STYLES values — istanbul ignore next.
75
297
  /* istanbul ignore next */
76
298
  if (style && !style.endsWith(';')) style += ';';
77
299
  }
78
-
300
+
79
301
  /* istanbul ignore next - defensive: additionalStyle without style doesn't occur with current tags */
80
302
  const fullStyle = additionalStyle ? (style ? `${style}${additionalStyle}` : additionalStyle) : style;
81
303
  return ` style="${fullStyle}"`;
82
304
  } else {
83
305
  const classAttr = ` class="${CLASS_PREFIX}${tag}"`;
84
- // Apply inline styles for alignment even when using CSS classes
85
306
  if (additionalStyle) {
86
307
  return `${classAttr} style="${additionalStyle}"`;
87
308
  }
@@ -90,72 +311,84 @@ function createGetAttr(inline_styles, styles) {
90
311
  };
91
312
  }
92
313
 
314
+ // ════════════════════════════════════════════════════════════════════
315
+ // Main parser function
316
+ // ════════════════════════════════════════════════════════════════════
317
+
93
318
  function quikdown(markdown, options = {}) {
319
+ // ── Guard: only process non-empty strings ──
94
320
  if (!markdown || typeof markdown !== 'string') {
95
321
  return '';
96
322
  }
97
-
323
+
324
+ // ── Unpack options ──
98
325
  const { fence_plugin, inline_styles = false, bidirectional = false, lazy_linefeeds = false, allow_unsafe_html = false } = options;
99
- const styles = QUIKDOWN_STYLES; // Use module-level styles
100
- const getAttr = createGetAttr(inline_styles, styles); // Create getAttr once
326
+ const styles = QUIKDOWN_STYLES;
327
+ const getAttr = createGetAttr(inline_styles, styles);
328
+
329
+ // ── Helpers (closed over options) ──
101
330
 
102
- // Escape HTML entities to prevent XSS
331
+ /** Escape the five HTML-special characters. */
103
332
  function escapeHtml(text) {
104
333
  return text.replace(/[&<>"']/g, m => ESC_MAP[m]);
105
334
  }
106
-
107
- // Helper to add data-qd attributes for bidirectional support.
108
- // The non-bidirectional branch is a trivial no-op arrow; it's exercised in
109
- // the core bundle but never in quikdown_bd (which always sets bidirectional=true).
335
+
336
+ /**
337
+ * Bidirectional marker helper.
338
+ * When bidirectional mode is on, returns ` data-qd="…"`.
339
+ * The non-bidirectional branch is a trivial no-op arrow; it is
340
+ * exercised in the core bundle but never in quikdown_bd.
341
+ */
110
342
  /* istanbul ignore next - trivial no-op fallback */
111
343
  const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';
112
344
 
113
- // Sanitize URLs to prevent XSS attacks
345
+ /**
346
+ * Sanitize a URL to block javascript:, vbscript:, and non-image data: URIs.
347
+ * Returns '#' for blocked URLs.
348
+ */
114
349
  function sanitizeUrl(url, allowUnsafe = false) {
115
350
  /* istanbul ignore next - defensive programming, regex ensures url is never empty */
116
351
  if (!url) return '';
117
-
118
- // If unsafe URLs are explicitly allowed, return as-is
119
352
  if (allowUnsafe) return url;
120
-
353
+
121
354
  const trimmedUrl = url.trim();
122
355
  const lowerUrl = trimmedUrl.toLowerCase();
123
-
124
- // Block dangerous protocols
125
356
  const dangerousProtocols = ['javascript:', 'vbscript:', 'data:'];
126
-
357
+
127
358
  for (const protocol of dangerousProtocols) {
128
359
  if (lowerUrl.startsWith(protocol)) {
129
- // Exception: Allow data:image/* for images
130
360
  if (protocol === 'data:' && lowerUrl.startsWith('data:image/')) {
131
361
  return trimmedUrl;
132
362
  }
133
- // Return safe empty link for dangerous protocols
134
363
  return '#';
135
364
  }
136
365
  }
137
-
138
366
  return trimmedUrl;
139
367
  }
140
368
 
141
- // Process the markdown in phases
369
+ // ────────────────────────────────────────────────────────────────
370
+ // Phase 1 — Code Extraction
371
+ // ────────────────────────────────────────────────────────────────
372
+ // Why extract code first? Fenced blocks and inline code spans can
373
+ // contain markdown-like characters (*, _, #, |, etc.) that must NOT
374
+ // be interpreted as formatting. By pulling them out and replacing
375
+ // with unique placeholders, the rest of the pipeline never sees them.
376
+
142
377
  let html = markdown;
143
-
144
- // Phase 1: Extract and protect code blocks and inline code
145
- const codeBlocks = [];
146
- const inlineCodes = [];
147
-
148
- // Extract fenced code blocks first (supports both ``` and ~~~)
149
- // Match paired fences - ``` with ``` and ~~~ with ~~~
150
- // Fence must be at start of line
378
+ const codeBlocks = []; // Array of {lang, code, custom, fence, hasReverse}
379
+ const inlineCodes = []; // Array of escaped-HTML strings
380
+
381
+ // ── Fenced code blocks ──
382
+ // Matches paired fences: ``` with ``` and ~~~ with ~~~.
383
+ // The fence must start at column 0 of a line (^ with /m flag).
384
+ // Group 1 = fence marker, Group 2 = language hint, Group 3 = code body.
151
385
  html = html.replace(/^(```|~~~)([^\n]*)\n([\s\S]*?)^\1$/gm, (match, fence, lang, code) => {
152
386
  const placeholder = `${PLACEHOLDER_CB}${codeBlocks.length}§`;
153
-
154
- // Trim the language specification
155
387
  const langTrimmed = lang ? lang.trim() : '';
156
-
157
- // If custom fence plugin is provided, use it (v1.1.0: object format required)
388
+
158
389
  if (fence_plugin && fence_plugin.render && typeof fence_plugin.render === 'function') {
390
+ // Custom plugin — store raw code (un-escaped) so the plugin
391
+ // receives the original source.
159
392
  codeBlocks.push({
160
393
  lang: langTrimmed,
161
394
  code: code.trimEnd(),
@@ -164,6 +397,7 @@ function quikdown(markdown, options = {}) {
164
397
  hasReverse: !!fence_plugin.reverse
165
398
  });
166
399
  } else {
400
+ // Default — pre-escape the code for safe HTML output.
167
401
  codeBlocks.push({
168
402
  lang: langTrimmed,
169
403
  code: escapeHtml(code.trimEnd()),
@@ -173,69 +407,94 @@ function quikdown(markdown, options = {}) {
173
407
  }
174
408
  return placeholder;
175
409
  });
176
-
177
- // Extract inline code
410
+
411
+ // ── Inline code spans ──
412
+ // Matches a single backtick pair: `content`.
413
+ // Content is captured and HTML-escaped immediately.
178
414
  html = html.replace(/`([^`]+)`/g, (match, code) => {
179
415
  const placeholder = `${PLACEHOLDER_IC}${inlineCodes.length}§`;
180
416
  inlineCodes.push(escapeHtml(code));
181
417
  return placeholder;
182
418
  });
183
-
184
- // Escape HTML in the rest of the content (skip if allow_unsafe_html is on —
185
- // useful for trusted pipelines where the markdown contains intentional HTML)
419
+
420
+ // ────────────────────────────────────────────────────────────────
421
+ // Phase 2 HTML Escaping
422
+ // ────────────────────────────────────────────────────────────────
423
+ // All remaining text (everything except code placeholders) is escaped
424
+ // to prevent XSS. The `allow_unsafe_html` option skips this for
425
+ // trusted pipelines that intentionally embed raw HTML.
426
+
186
427
  if (!allow_unsafe_html) {
187
428
  html = escapeHtml(html);
188
429
  }
189
-
190
- // Phase 2: Process block elements
191
-
192
- // Process tables
430
+
431
+ // ────────────────────────────────────────────────────────────────
432
+ // Phase 3 — Block Scanning + Inline Formatting + Paragraphs
433
+ // ────────────────────────────────────────────────────────────────
434
+ // This is the heart of the lexer rewrite. Instead of applying
435
+ // 10+ global regex passes, we:
436
+ // 1. Process tables (line walker — tables need multi-line lookahead)
437
+ // 2. Scan remaining lines for headings, HR, blockquotes
438
+ // 3. Process lists (line walker — lists need indent tracking)
439
+ // 4. Apply inline formatting to all text content
440
+ // 5. Wrap remaining text in <p> tags
441
+ //
442
+ // Steps 1 and 3 are line-walkers that process the full text in a
443
+ // single pass each. Step 2 replaces global regex with a per-line
444
+ // scanner. Steps 4-5 are applied to the result.
445
+ //
446
+ // Total: 3 structured passes instead of 10+ regex passes.
447
+
448
+ // ── Step 1: Tables ──
449
+ // Tables need multi-line lookahead (header → separator → body rows)
450
+ // so they're handled by a dedicated line-walker first.
193
451
  html = processTable(html, getAttr);
194
-
195
- // Process headings (supports optional trailing #'s)
196
- html = html.replace(/^(#{1,6})\s+(.+?)\s*#*$/gm, (match, hashes, content) => {
197
- const level = hashes.length;
198
- return `<h${level}${getAttr('h' + level)}${dataQd(hashes)}>${content}</h${level}>`;
199
- });
200
-
201
- // Process blockquotes (must handle escaped > since we already escaped HTML)
202
- html = html.replace(/^&gt;\s+(.+)$/gm, `<blockquote${getAttr('blockquote')}>$1</blockquote>`);
203
- // Merge consecutive blockquotes
204
- html = html.replace(/<\/blockquote>\n<blockquote>/g, '\n');
205
-
206
- // Process horizontal rules (allow trailing spaces)
207
- html = html.replace(/^---+\s*$/gm, `<hr${getAttr('hr')}>`);
208
-
209
- // Process lists
452
+
453
+ // ── Step 2: Headings, HR, Blockquotes ──
454
+ // These are simple line-level constructs. We scan each line once
455
+ // and replace matching lines with their HTML representation.
456
+ html = scanLineBlocks(html, getAttr, dataQd);
457
+
458
+ // ── Step 3: Lists ──
459
+ // Lists need indent-level tracking across lines, so they get their
460
+ // own line-walker.
210
461
  html = processLists(html, getAttr, inline_styles, bidirectional);
211
-
212
- // Phase 3: Process inline elements
213
-
214
- // Images (must come before links, with URL sanitization)
462
+
463
+ // ── Step 4: Inline formatting ──
464
+ // Apply bold, italic, strikethrough, images, links, and autolinks
465
+ // to all text content. This runs on the output of steps 1-3, so
466
+ // it sees text inside headings, blockquotes, table cells, list
467
+ // items, and paragraph text.
468
+
469
+ // Images (must come before links — ![alt](src) vs [text](url))
215
470
  html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
216
471
  const sanitizedSrc = sanitizeUrl(src, options.allow_unsafe_urls);
472
+ // Bidirectional attributes are only exercised via quikdown_bd bundle.
473
+ /* istanbul ignore next - bd-only branch */
217
474
  const altAttr = bidirectional && alt ? ` data-qd-alt="${escapeHtml(alt)}"` : '';
475
+ /* istanbul ignore next - bd-only branch */
218
476
  const srcAttr = bidirectional ? ` data-qd-src="${escapeHtml(src)}"` : '';
219
477
  return `<img${getAttr('img')} src="${sanitizedSrc}" alt="${alt}"${altAttr}${srcAttr}${dataQd('!')}>`;
220
478
  });
221
-
222
- // Links (with URL sanitization)
479
+
480
+ // Links
223
481
  html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, href) => {
224
- // Sanitize URL to prevent XSS
225
482
  const sanitizedHref = sanitizeUrl(href, options.allow_unsafe_urls);
226
483
  const isExternal = /^https?:\/\//i.test(sanitizedHref);
227
484
  const rel = isExternal ? ' rel="noopener noreferrer"' : '';
485
+ /* istanbul ignore next - bd-only branch */
228
486
  const textAttr = bidirectional ? ` data-qd-text="${escapeHtml(text)}"` : '';
229
487
  return `<a${getAttr('a')} href="${sanitizedHref}"${rel}${textAttr}${dataQd('[')}>${text}</a>`;
230
488
  });
231
-
232
- // Autolinks - convert bare URLs to clickable links
489
+
490
+ // Autolinks bare https?:// URLs become clickable <a> tags
233
491
  html = html.replace(/(^|\s)(https?:\/\/[^\s<]+)/g, (match, prefix, url) => {
234
492
  const sanitizedUrl = sanitizeUrl(url, options.allow_unsafe_urls);
235
493
  return `${prefix}<a${getAttr('a')} href="${sanitizedUrl}" rel="noopener noreferrer">${url}</a>`;
236
494
  });
237
-
238
- // Process inline formatting (bold, italic, strikethrough)
495
+
496
+ // Bold, italic, strikethrough
497
+ // Order matters: ** before * (so ** isn't consumed as two *s)
239
498
  const inlinePatterns = [
240
499
  [/\*\*(.+?)\*\*/g, 'strong', '**'],
241
500
  [/__(.+?)__/g, 'strong', '__'],
@@ -243,60 +502,63 @@ function quikdown(markdown, options = {}) {
243
502
  [/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, 'em', '_'],
244
503
  [/~~(.+?)~~/g, 'del', '~~']
245
504
  ];
246
-
247
505
  inlinePatterns.forEach(([pattern, tag, marker]) => {
248
506
  html = html.replace(pattern, `<${tag}${getAttr(tag)}${dataQd(marker)}>$1</${tag}>`);
249
507
  });
250
-
251
- // Line breaks
508
+
509
+ // ── Step 5: Line breaks + paragraph wrapping ──
252
510
  if (lazy_linefeeds) {
253
- // Lazy linefeeds: single newline becomes <br> (except between paragraphs and after/before block elements)
511
+ // Lazy linefeeds mode: every single \n becomes <br> EXCEPT:
512
+ // • Double newlines → paragraph break
513
+ // • Newlines adjacent to block elements (h, blockquote, pre, hr, table, list)
514
+ //
515
+ // Strategy: protect block-adjacent newlines with §N§, convert
516
+ // the rest, then restore.
517
+
254
518
  const blocks = [];
255
519
  let bi = 0;
256
-
257
- // Protect tables and lists
520
+
521
+ // Protect tables and lists from <br> injection
258
522
  html = html.replace(/<(table|[uo]l)[^>]*>[\s\S]*?<\/\1>/g, m => {
259
523
  blocks[bi] = m;
260
524
  return `§B${bi++}§`;
261
525
  });
262
-
263
- // Handle paragraphs and block elements
526
+
264
527
  html = html.replace(/\n\n+/g, '§P§')
265
- // After block elements
528
+ // After block-level closing tags
266
529
  .replace(/(<\/(?:h[1-6]|blockquote|pre)>)\n/g, '$1§N§')
267
530
  .replace(/(<(?:h[1-6]|blockquote|pre|hr)[^>]*>)\n/g, '$1§N§')
268
- // Before block elements
531
+ // Before block-level opening tags
269
532
  .replace(/\n(<(?:h[1-6]|blockquote|pre|hr)[^>]*>)/g, '§N§$1')
270
533
  .replace(/\n(§B\d+§)/g, '§N§$1')
271
534
  .replace(/(§B\d+§)\n/g, '$1§N§')
272
- // Convert remaining newlines
535
+ // Convert surviving newlines to <br>
273
536
  .replace(/\n/g, `<br${getAttr('br')}>`)
274
537
  // Restore
275
538
  .replace(/§N§/g, '\n')
276
539
  .replace(/§P§/g, '</p><p>');
277
-
540
+
278
541
  // Restore protected blocks
279
542
  blocks.forEach((b, i) => html = html.replace(`§B${i}§`, b));
280
-
543
+
281
544
  html = '<p>' + html + '</p>';
282
545
  } else {
283
- // Standard: two spaces at end of line for line breaks
546
+ // Standard mode: two trailing spaces <br>, double newline new paragraph
284
547
  html = html.replace(/ {2}$/gm, `<br${getAttr('br')}>`);
285
-
286
- // Paragraphs (double newlines)
287
- // Don't add </p> after block elements (they're not in paragraphs)
548
+
288
549
  html = html.replace(/\n\n+/g, (match, offset) => {
289
- // Check if we're after a block element closing tag
290
550
  const before = html.substring(0, offset);
291
551
  if (before.match(/<\/(h[1-6]|blockquote|ul|ol|table|pre|hr)>$/)) {
292
- return '<p>'; // Just open a new paragraph
552
+ return '<p>';
293
553
  }
294
- return '</p><p>'; // Normal paragraph break
554
+ return '</p><p>';
295
555
  });
296
556
  html = '<p>' + html + '</p>';
297
557
  }
298
-
299
- // Clean up empty paragraphs and unwrap block elements
558
+
559
+ // ── Step 6: Cleanup ──
560
+ // Remove <p> wrappers that accidentally enclose block elements.
561
+ // This is simpler than trying to prevent them during wrapping.
300
562
  const cleanupPatterns = [
301
563
  [/<p><\/p>/g, ''],
302
564
  [/<p>(<h[1-6][^>]*>)/g, '$1'],
@@ -312,65 +574,152 @@ function quikdown(markdown, options = {}) {
312
574
  [/(<\/pre>)<\/p>/g, '$1'],
313
575
  [new RegExp(`<p>(${PLACEHOLDER_CB}\\d+§)</p>`, 'g'), '$1']
314
576
  ];
315
-
316
577
  cleanupPatterns.forEach(([pattern, replacement]) => {
317
578
  html = html.replace(pattern, replacement);
318
579
  });
319
-
320
- // Fix orphaned closing </p> tags after block elements
321
- // When a paragraph follows a block element, ensure it has opening <p>
580
+
581
+ // When a block element is followed by a newline and then text, open a <p>.
322
582
  html = html.replace(/(<\/(?:h[1-6]|blockquote|ul|ol|table|pre|hr)>)\n([^<])/g, '$1\n<p>$2');
323
-
324
- // Phase 4: Restore code blocks and inline code
325
-
326
- // Restore code blocks
583
+
584
+ // ────────────────────────────────────────────────────────────────
585
+ // Phase 4 — Code Restoration
586
+ // ────────────────────────────────────────────────────────────────
587
+ // Replace placeholders with rendered HTML. For fenced blocks this
588
+ // means wrapping in <pre><code>…</code></pre> (or calling the
589
+ // fence_plugin). For inline code it means <code>…</code>.
590
+
327
591
  codeBlocks.forEach((block, i) => {
328
592
  let replacement;
329
-
593
+
330
594
  if (block.custom && fence_plugin && fence_plugin.render) {
331
- // Use custom fence plugin (v1.1.0: object format with render function)
595
+ // Delegate to the user-provided fence plugin.
332
596
  replacement = fence_plugin.render(block.code, block.lang);
333
-
334
- // If plugin returns undefined, fall back to default rendering
597
+
335
598
  if (replacement === undefined) {
599
+ // Plugin declined — fall back to default rendering.
336
600
  const langClass = !inline_styles && block.lang ? ` class="language-${block.lang}"` : '';
337
601
  const codeAttr = inline_styles ? getAttr('code') : langClass;
602
+ /* istanbul ignore next - bd-only branch */
338
603
  const langAttr = bidirectional && block.lang ? ` data-qd-lang="${escapeHtml(block.lang)}"` : '';
604
+ /* istanbul ignore next - bd-only branch */
339
605
  const fenceAttr = bidirectional ? ` data-qd-fence="${escapeHtml(block.fence)}"` : '';
340
606
  replacement = `<pre${getAttr('pre')}${fenceAttr}${langAttr}><code${codeAttr}>${escapeHtml(block.code)}</code></pre>`;
341
- } else if (bidirectional) {
342
- // If bidirectional and plugin provided HTML, add data attributes for roundtrip
343
- replacement = replacement.replace(/^<(\w+)/,
607
+ } else /* istanbul ignore next - bd-only branch */ if (bidirectional) {
608
+ // Plugin returned HTML inject data attributes for roundtrip.
609
+ replacement = replacement.replace(/^<(\w+)/,
344
610
  `<$1 data-qd-fence="${escapeHtml(block.fence)}" data-qd-lang="${escapeHtml(block.lang)}" data-qd-source="${escapeHtml(block.code)}"`);
345
611
  }
346
612
  } else {
347
- // Default rendering
613
+ // Default rendering — wrap in <pre><code>.
348
614
  const langClass = !inline_styles && block.lang ? ` class="language-${block.lang}"` : '';
349
615
  const codeAttr = inline_styles ? getAttr('code') : langClass;
616
+ /* istanbul ignore next - bd-only branch */
350
617
  const langAttr = bidirectional && block.lang ? ` data-qd-lang="${escapeHtml(block.lang)}"` : '';
618
+ /* istanbul ignore next - bd-only branch */
351
619
  const fenceAttr = bidirectional ? ` data-qd-fence="${escapeHtml(block.fence)}"` : '';
352
620
  replacement = `<pre${getAttr('pre')}${fenceAttr}${langAttr}><code${codeAttr}>${block.code}</code></pre>`;
353
621
  }
354
-
622
+
355
623
  const placeholder = `${PLACEHOLDER_CB}${i}§`;
356
624
  html = html.replace(placeholder, replacement);
357
625
  });
358
-
359
- // Restore inline code
626
+
627
+ // Restore inline code spans
360
628
  inlineCodes.forEach((code, i) => {
361
629
  const placeholder = `${PLACEHOLDER_IC}${i}§`;
362
630
  html = html.replace(placeholder, `<code${getAttr('code')}${dataQd('`')}>${code}</code>`);
363
631
  });
364
-
632
+
365
633
  return html.trim();
366
634
  }
367
635
 
636
+ // ════════════════════════════════════════════════════════════════════
637
+ // Block-level line scanner
638
+ // ════════════════════════════════════════════════════════════════════
639
+
368
640
  /**
369
- * Process inline markdown formatting
641
+ * scanLineBlocks single-pass line scanner for headings, HR, blockquotes
642
+ *
643
+ * Walks the text line by line. For each line it checks (in order):
644
+ * 1. Heading — starts with 1-6 '#' followed by a space
645
+ * 2. HR — line is entirely '---…' (3+ dashes, optional trailing space)
646
+ * 3. Blockquote — starts with '&gt; ' (the > was already HTML-escaped)
647
+ *
648
+ * Lines that don't match any block pattern are passed through unchanged.
649
+ *
650
+ * This replaces three separate global regex passes from the pre-1.2.8
651
+ * architecture with one structured scan.
652
+ *
653
+ * @param {string} text The document text (HTML-escaped, code extracted)
654
+ * @param {Function} getAttr Attribute factory (class or style)
655
+ * @param {Function} dataQd Bidirectional marker factory
656
+ * @returns {string} Text with block-level elements rendered
657
+ */
658
+ function scanLineBlocks(text, getAttr, dataQd) {
659
+ const lines = text.split('\n');
660
+ const result = [];
661
+ let i = 0;
662
+
663
+ while (i < lines.length) {
664
+ const line = lines[i];
665
+
666
+ // ── Heading ──
667
+ // Count leading '#' characters. Valid heading: 1-6 hashes then a space.
668
+ // Example: "## Hello World ##" → <h2>Hello World</h2>
669
+ let hashCount = 0;
670
+ while (hashCount < line.length && hashCount < 7 && line[hashCount] === '#') {
671
+ hashCount++;
672
+ }
673
+ if (hashCount >= 1 && hashCount <= 6 && line[hashCount] === ' ') {
674
+ // Extract content after "# " and strip trailing hashes
675
+ const content = line.slice(hashCount + 1).replace(/\s*#+\s*$/, '');
676
+ const tag = 'h' + hashCount;
677
+ result.push(`<${tag}${getAttr(tag)}${dataQd('#'.repeat(hashCount))}>${content}</${tag}>`);
678
+ i++;
679
+ continue;
680
+ }
681
+
682
+ // ── Horizontal Rule ──
683
+ // Three or more dashes, optional trailing whitespace, nothing else.
684
+ if (isDashHRLine(line)) {
685
+ result.push(`<hr${getAttr('hr')}>`);
686
+ i++;
687
+ continue;
688
+ }
689
+
690
+ // ── Blockquote ──
691
+ // After Phase 2, the '>' character has been escaped to '&gt;'.
692
+ // Pattern: "&gt; content" or merged consecutive blockquotes.
693
+ if (/^&gt;\s+/.test(line)) {
694
+ result.push(`<blockquote${getAttr('blockquote')}>${line.replace(/^&gt;\s+/, '')}</blockquote>`);
695
+ i++;
696
+ continue;
697
+ }
698
+
699
+ // ── Pass-through ──
700
+ result.push(line);
701
+ i++;
702
+ }
703
+
704
+ // Merge consecutive blockquotes into a single element.
705
+ // <blockquote>A</blockquote>\n<blockquote>B</blockquote>
706
+ // → <blockquote>A\nB</blockquote>
707
+ let joined = result.join('\n');
708
+ joined = joined.replace(/<\/blockquote>\n<blockquote>/g, '\n');
709
+ return joined;
710
+ }
711
+
712
+ // ════════════════════════════════════════════════════════════════════
713
+ // Table processing (line walker)
714
+ // ════════════════════════════════════════════════════════════════════
715
+
716
+ /**
717
+ * Inline markdown formatter for table cells.
718
+ * Handles bold, italic, strikethrough, and code within cell text.
719
+ * Links / images / autolinks are handled by the global inline pass
720
+ * (Phase 3 Step 4) which runs after table processing.
370
721
  */
371
722
  function processInlineMarkdown(text, getAttr) {
372
-
373
- // Process inline formatting patterns
374
723
  const patterns = [
375
724
  [/\*\*(.+?)\*\*/g, 'strong'],
376
725
  [/__(.+?)__/g, 'strong'],
@@ -379,27 +728,32 @@ function processInlineMarkdown(text, getAttr) {
379
728
  [/~~(.+?)~~/g, 'del'],
380
729
  [/`([^`]+)`/g, 'code']
381
730
  ];
382
-
383
731
  patterns.forEach(([pattern, tag]) => {
384
732
  text = text.replace(pattern, `<${tag}${getAttr(tag)}>$1</${tag}>`);
385
733
  });
386
-
387
734
  return text;
388
735
  }
389
736
 
390
737
  /**
391
- * Process markdown tables
738
+ * processTable — line walker for markdown tables
739
+ *
740
+ * Walks through lines looking for runs of pipe-containing lines.
741
+ * Each run is validated (must contain a separator row: |---|---|)
742
+ * and rendered as an HTML <table>. Invalid runs are restored as-is.
743
+ *
744
+ * @param {string} text Full document text
745
+ * @param {Function} getAttr Attribute factory
746
+ * @returns {string} Text with tables rendered
392
747
  */
393
748
  function processTable(text, getAttr) {
394
749
  const lines = text.split('\n');
395
750
  const result = [];
396
751
  let inTable = false;
397
752
  let tableLines = [];
398
-
753
+
399
754
  for (let i = 0; i < lines.length; i++) {
400
755
  const line = lines[i].trim();
401
-
402
- // Check if this line looks like a table row (with or without trailing |)
756
+
403
757
  if (line.includes('|') && (line.startsWith('|') || /[^\\|]/.test(line))) {
404
758
  if (!inTable) {
405
759
  inTable = true;
@@ -407,14 +761,11 @@ function processTable(text, getAttr) {
407
761
  }
408
762
  tableLines.push(line);
409
763
  } else {
410
- // Not a table line
411
764
  if (inTable) {
412
- // Process the accumulated table
413
765
  const tableHtml = buildTable(tableLines, getAttr);
414
766
  if (tableHtml) {
415
767
  result.push(tableHtml);
416
768
  } else {
417
- // Not a valid table, restore original lines
418
769
  result.push(...tableLines);
419
770
  }
420
771
  inTable = false;
@@ -423,8 +774,8 @@ function processTable(text, getAttr) {
423
774
  result.push(lines[i]);
424
775
  }
425
776
  }
426
-
427
- // Handle table at end of text
777
+
778
+ // Handle table at end of document
428
779
  if (inTable && tableLines.length > 0) {
429
780
  const tableHtml = buildTable(tableLines, getAttr);
430
781
  if (tableHtml) {
@@ -433,35 +784,35 @@ function processTable(text, getAttr) {
433
784
  result.push(...tableLines);
434
785
  }
435
786
  }
436
-
787
+
437
788
  return result.join('\n');
438
789
  }
439
790
 
440
791
  /**
441
- * Build an HTML table from markdown table lines
792
+ * buildTable validate and render a table from accumulated lines
793
+ *
794
+ * @param {string[]} lines Array of pipe-containing lines
795
+ * @param {Function} getAttr Attribute factory
796
+ * @returns {string|null} HTML table string, or null if invalid
442
797
  */
443
798
  function buildTable(lines, getAttr) {
444
-
445
799
  if (lines.length < 2) return null;
446
-
447
- // Check for separator line (second line should be the separator)
800
+
801
+ // Find the separator row (---|---|)
448
802
  let separatorIndex = -1;
449
803
  for (let i = 1; i < lines.length; i++) {
450
- // Support separator with or without leading/trailing pipes
451
804
  if (/^\|?[\s\-:|]+\|?$/.test(lines[i]) && lines[i].includes('-')) {
452
805
  separatorIndex = i;
453
806
  break;
454
807
  }
455
808
  }
456
-
457
809
  if (separatorIndex === -1) return null;
458
-
810
+
459
811
  const headerLines = lines.slice(0, separatorIndex);
460
812
  const bodyLines = lines.slice(separatorIndex + 1);
461
-
462
- // Parse alignment from separator
813
+
814
+ // Parse alignment from separator cells (:--- = left, :---: = center, ---: = right)
463
815
  const separator = lines[separatorIndex];
464
- // Handle pipes at start/end or not
465
816
  const separatorCells = separator.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
466
817
  const alignments = separatorCells.map(cell => {
467
818
  const trimmed = cell.trim();
@@ -469,31 +820,28 @@ function buildTable(lines, getAttr) {
469
820
  if (trimmed.endsWith(':')) return 'right';
470
821
  return 'left';
471
822
  });
472
-
823
+
473
824
  let html = `<table${getAttr('table')}>\n`;
474
-
475
- // Build header
476
- // Note: headerLines will always have length > 0 since separatorIndex starts from 1
825
+
826
+ // Header
477
827
  html += `<thead${getAttr('thead')}>\n`;
478
828
  headerLines.forEach(line => {
479
- html += `<tr${getAttr('tr')}>\n`;
480
- // Handle pipes at start/end or not
481
- const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
482
- cells.forEach((cell, i) => {
483
- const alignStyle = alignments[i] && alignments[i] !== 'left' ? `text-align:${alignments[i]}` : '';
484
- const processedCell = processInlineMarkdown(cell.trim(), getAttr);
485
- html += `<th${getAttr('th', alignStyle)}>${processedCell}</th>\n`;
486
- });
487
- html += '</tr>\n';
829
+ html += `<tr${getAttr('tr')}>\n`;
830
+ const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
831
+ cells.forEach((cell, i) => {
832
+ const alignStyle = alignments[i] && alignments[i] !== 'left' ? `text-align:${alignments[i]}` : '';
833
+ const processedCell = processInlineMarkdown(cell.trim(), getAttr);
834
+ html += `<th${getAttr('th', alignStyle)}>${processedCell}</th>\n`;
835
+ });
836
+ html += '</tr>\n';
488
837
  });
489
838
  html += '</thead>\n';
490
-
491
- // Build body
839
+
840
+ // Body
492
841
  if (bodyLines.length > 0) {
493
842
  html += `<tbody${getAttr('tbody')}>\n`;
494
843
  bodyLines.forEach(line => {
495
844
  html += `<tr${getAttr('tr')}>\n`;
496
- // Handle pipes at start/end or not
497
845
  const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
498
846
  cells.forEach((cell, i) => {
499
847
  const alignStyle = alignments[i] && alignments[i] !== 'left' ? `text-align:${alignments[i]}` : '';
@@ -504,66 +852,81 @@ function buildTable(lines, getAttr) {
504
852
  });
505
853
  html += '</tbody>\n';
506
854
  }
507
-
855
+
508
856
  html += '</table>';
509
857
  return html;
510
858
  }
511
859
 
860
+ // ════════════════════════════════════════════════════════════════════
861
+ // List processing (line walker)
862
+ // ════════════════════════════════════════════════════════════════════
863
+
512
864
  /**
513
- * Process markdown lists (ordered and unordered)
865
+ * processLists line walker for ordered, unordered, and task lists
866
+ *
867
+ * Scans each line for list markers (-, *, +, 1., 2., etc.) with
868
+ * optional leading indentation for nesting. Non-list lines close
869
+ * any open lists and pass through unchanged.
870
+ *
871
+ * Task lists (- [ ] / - [x]) are detected and rendered with
872
+ * checkbox inputs.
873
+ *
874
+ * @param {string} text Full document text
875
+ * @param {Function} getAttr Attribute factory
876
+ * @param {boolean} inline_styles Whether to use inline styles
877
+ * @param {boolean} bidirectional Whether to add data-qd markers
878
+ * @returns {string} Text with lists rendered
514
879
  */
515
880
  function processLists(text, getAttr, inline_styles, bidirectional) {
516
-
517
881
  const lines = text.split('\n');
518
882
  const result = [];
519
- const listStack = []; // Track nested lists
520
-
883
+ const listStack = []; // tracks nesting: [{type:'ul', level:0}, …]
884
+
521
885
  // Helper to escape HTML for data-qd attributes. List markers (`-`, `*`,
522
886
  // `+`, `1.`, etc.) never contain HTML-special chars, so the replace
523
887
  // callback is defensive-only and never actually fires in practice.
888
+ /* istanbul ignore next - defensive: list markers never trigger escaping */
524
889
  const escapeHtml = (text) => text.replace(/[&<>"']/g,
525
890
  /* istanbul ignore next - defensive: list markers never contain HTML specials */
526
891
  m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[m]);
527
892
  /* istanbul ignore next - trivial no-op fallback; not exercised via bd bundle */
528
893
  const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';
529
-
894
+
530
895
  for (let i = 0; i < lines.length; i++) {
531
896
  const line = lines[i];
532
897
  const match = line.match(/^(\s*)([*\-+]|\d+\.)\s+(.+)$/);
533
-
898
+
534
899
  if (match) {
535
900
  const [, indent, marker, content] = match;
536
901
  const level = Math.floor(indent.length / 2);
537
902
  const isOrdered = /^\d+\./.test(marker);
538
903
  const listType = isOrdered ? 'ol' : 'ul';
539
-
540
- // Check for task list items
904
+
905
+ // Task list detection (only in unordered lists)
541
906
  let listItemContent = content;
542
907
  let taskListClass = '';
543
908
  const taskMatch = content.match(/^\[([x ])\]\s+(.*)$/i);
544
909
  if (taskMatch && !isOrdered) {
545
910
  const [, checked, taskContent] = taskMatch;
546
911
  const isChecked = checked.toLowerCase() === 'x';
547
- const checkboxAttr = inline_styles
548
- ? ' style="margin-right:.5em"'
912
+ const checkboxAttr = inline_styles
913
+ ? ' style="margin-right:.5em"'
549
914
  : ` class="${CLASS_PREFIX}task-checkbox"`;
550
915
  listItemContent = `<input type="checkbox"${checkboxAttr}${isChecked ? ' checked' : ''} disabled> ${taskContent}`;
551
916
  taskListClass = inline_styles ? ' style="list-style:none"' : ` class="${CLASS_PREFIX}task-item"`;
552
917
  }
553
-
554
- // Close deeper levels
918
+
919
+ // Close deeper nesting levels
555
920
  while (listStack.length > level + 1) {
556
921
  const list = listStack.pop();
557
922
  result.push(`</${list.type}>`);
558
923
  }
559
-
560
- // Open new level if needed
924
+
925
+ // Open new list or switch type at current level
561
926
  if (listStack.length === level) {
562
- // Need to open a new list
563
927
  listStack.push({ type: listType, level });
564
928
  result.push(`<${listType}${getAttr(listType)}>`);
565
929
  } else if (listStack.length === level + 1) {
566
- // Check if we need to switch list type
567
930
  const currentList = listStack[listStack.length - 1];
568
931
  if (currentList.type !== listType) {
569
932
  result.push(`</${currentList.type}>`);
@@ -572,11 +935,11 @@ function processLists(text, getAttr, inline_styles, bidirectional) {
572
935
  result.push(`<${listType}${getAttr(listType)}>`);
573
936
  }
574
937
  }
575
-
938
+
576
939
  const liAttr = taskListClass || getAttr('li');
577
940
  result.push(`<li${liAttr}${dataQd(marker)}>${listItemContent}</li>`);
578
941
  } else {
579
- // Not a list item, close all lists
942
+ // Not a list item close all open lists
580
943
  while (listStack.length > 0) {
581
944
  const list = listStack.pop();
582
945
  result.push(`</${list.type}>`);
@@ -584,76 +947,76 @@ function processLists(text, getAttr, inline_styles, bidirectional) {
584
947
  result.push(line);
585
948
  }
586
949
  }
587
-
588
- // Close any remaining lists
950
+
951
+ // Close any remaining open lists
589
952
  while (listStack.length > 0) {
590
953
  const list = listStack.pop();
591
954
  result.push(`</${list.type}>`);
592
955
  }
593
-
956
+
594
957
  return result.join('\n');
595
958
  }
596
959
 
960
+ // ════════════════════════════════════════════════════════════════════
961
+ // Static API
962
+ // ════════════════════════════════════════════════════════════════════
963
+
597
964
  /**
598
- * Emit CSS styles for quikdown elements
599
- * @param {string} prefix - Optional class prefix (default: 'quikdown-')
600
- * @param {string} theme - Optional theme: 'light' (default) or 'dark'
601
- * @returns {string} CSS string with quikdown styles
965
+ * Emit CSS rules for all quikdown elements.
966
+ *
967
+ * @param {string} prefix Class prefix (default: 'quikdown-')
968
+ * @param {string} theme 'light' (default) or 'dark'
969
+ * @returns {string} CSS text
602
970
  */
603
971
  quikdown.emitStyles = function(prefix = 'quikdown-', theme = 'light') {
604
972
  const styles = QUIKDOWN_STYLES;
605
-
606
- // Define theme color overrides
973
+
607
974
  const themeOverrides = {
608
975
  dark: {
609
- '#f4f4f4': '#2a2a2a', // pre background
610
- '#f0f0f0': '#2a2a2a', // code background
611
- '#f2f2f2': '#2a2a2a', // th background
612
- '#ddd': '#3a3a3a', // borders
613
- '#06c': '#6db3f2', // links
976
+ '#f4f4f4': '#2a2a2a', // pre background
977
+ '#f0f0f0': '#2a2a2a', // code background
978
+ '#f2f2f2': '#2a2a2a', // th background
979
+ '#ddd': '#3a3a3a', // borders
980
+ '#06c': '#6db3f2', // links
614
981
  _textColor: '#e0e0e0'
615
982
  },
616
983
  light: {
617
- _textColor: '#333' // Explicit text color for light theme
984
+ _textColor: '#333'
618
985
  }
619
986
  };
620
-
987
+
621
988
  let css = '';
622
989
  for (const [tag, style] of Object.entries(styles)) {
623
990
  let themedStyle = style;
624
-
625
- // Apply theme overrides if dark theme
626
- if (theme === 'dark' && themeOverrides.dark) {
627
- // Replace colors
628
- for (const [oldColor, newColor] of Object.entries(themeOverrides.dark)) {
629
- if (!oldColor.startsWith('_')) {
630
- themedStyle = themedStyle.replace(new RegExp(oldColor, 'g'), newColor);
631
- }
632
- }
633
-
634
- // Add text color for certain elements in dark theme
635
- const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote'];
636
- if (needsTextColor.includes(tag)) {
637
- themedStyle += `;color:${themeOverrides.dark._textColor}`;
638
- }
639
- } else if (theme === 'light' && themeOverrides.light) {
640
- // Add explicit text color for light theme elements too
641
- const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote'];
642
- if (needsTextColor.includes(tag)) {
643
- themedStyle += `;color:${themeOverrides.light._textColor}`;
991
+
992
+ if (theme === 'dark' && themeOverrides.dark) {
993
+ for (const [oldColor, newColor] of Object.entries(themeOverrides.dark)) {
994
+ if (!oldColor.startsWith('_')) {
995
+ themedStyle = themedStyle.replaceAll(oldColor, newColor);
644
996
  }
645
997
  }
646
-
998
+ const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote'];
999
+ if (needsTextColor.includes(tag)) {
1000
+ themedStyle += `;color:${themeOverrides.dark._textColor}`;
1001
+ }
1002
+ } else if (theme === 'light' && themeOverrides.light) {
1003
+ const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote'];
1004
+ if (needsTextColor.includes(tag)) {
1005
+ themedStyle += `;color:${themeOverrides.light._textColor}`;
1006
+ }
1007
+ }
1008
+
647
1009
  css += `.${prefix}${tag} { ${themedStyle} }\n`;
648
1010
  }
649
-
1011
+
650
1012
  return css;
651
1013
  };
652
1014
 
653
1015
  /**
654
- * Configure quikdown with options and return a function
655
- * @param {Object} options - Configuration options
656
- * @returns {Function} Configured quikdown function
1016
+ * Create a pre-configured parser with baked-in options.
1017
+ *
1018
+ * @param {Object} options Options to bake in
1019
+ * @returns {Function} Configured quikdown(markdown) function
657
1020
  */
658
1021
  quikdown.configure = function(options) {
659
1022
  return function(markdown) {
@@ -661,18 +1024,18 @@ quikdown.configure = function(options) {
661
1024
  };
662
1025
  };
663
1026
 
664
- /**
665
- * Version information
666
- */
1027
+ /** Semantic version (injected at build time) */
667
1028
  quikdown.version = quikdownVersion;
668
1029
 
669
- // Export for both CommonJS and ES6
1030
+ // ════════════════════════════════════════════════════════════════════
1031
+ // Exports
1032
+ // ════════════════════════════════════════════════════════════════════
1033
+
670
1034
  /* istanbul ignore next */
671
1035
  if (typeof module !== 'undefined' && module.exports) {
672
1036
  module.exports = quikdown;
673
1037
  }
674
1038
 
675
- // For browser global
676
1039
  /* istanbul ignore next */
677
1040
  if (typeof window !== 'undefined') {
678
1041
  window.quikdown = quikdown;
@@ -2154,8 +2517,8 @@ async function getRenderedContent(previewPanel) {
2154
2517
  if (w > 0 && h > 0 && overlaps && style.display !== 'none' && style.visibility !== 'hidden') {
2155
2518
  ctx.drawImage(tile, x, y, w + 1, h + 1);
2156
2519
  }
2157
- } catch (e) {
2158
- console.warn('Failed to draw tile:', e);
2520
+ } catch (_e) {
2521
+ console.warn('Failed to draw tile:', _e);
2159
2522
  }
2160
2523
  }
2161
2524
 
@@ -2174,8 +2537,8 @@ async function getRenderedContent(previewPanel) {
2174
2537
  const h = Math.round(r.height);
2175
2538
  const overlaps = !(r.right <= leafRect.left || r.left >= leafRect.right || r.bottom <= leafRect.top || r.top >= leafRect.bottom);
2176
2539
  if (w > 0 && h > 0 && overlaps) ctx.drawImage(img, x, y, w, h);
2177
- } catch (e) {
2178
- console.warn('Failed to draw overlay SVG:', e);
2540
+ } catch (_e) {
2541
+ console.warn('Failed to draw overlay SVG:', _e);
2179
2542
  }
2180
2543
  }
2181
2544
 
@@ -2193,8 +2556,8 @@ async function getRenderedContent(previewPanel) {
2193
2556
  if (w > 0 && h > 0 && overlaps && style.display !== 'none' && style.visibility !== 'hidden') {
2194
2557
  ctx.drawImage(icon, x, y, w, h);
2195
2558
  }
2196
- } catch (e) {
2197
- console.warn('Failed to draw marker icon:', e);
2559
+ } catch (_e) {
2560
+ console.warn('Failed to draw marker icon:', _e);
2198
2561
  }
2199
2562
  }
2200
2563
 
@@ -2622,13 +2985,28 @@ const FENCE_LIBRARIES = {
2622
2985
  },
2623
2986
  math: {
2624
2987
  check: () => typeof window.MathJax !== 'undefined',
2625
- script: 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js',
2988
+ script: 'https://cdn.jsdelivr.net/npm/mathjax@3.2.2/es5/tex-svg.js',
2626
2989
  beforeLoad: () => {
2627
2990
  // Configure MathJax before loading (must be set on window before script runs)
2991
+ // Must match the config in ensureMathJaxLoaded() for consistent behavior
2628
2992
  if (!window.MathJax) {
2629
2993
  window.MathJax = {
2630
- tex: { inlineMath: [['$', '$'], ['\\(', '\\)']], displayMath: [['$$', '$$'], ['\\[', '\\]']] },
2631
- svg: { fontCache: 'global' },
2994
+ loader: { load: ['input/tex', 'output/svg'] },
2995
+ tex: {
2996
+ packages: { '[+]': ['ams'] },
2997
+ inlineMath: [['$', '$'], ['\\(', '\\)']],
2998
+ displayMath: [['$$', '$$'], ['\\[', '\\]']],
2999
+ processEscapes: true,
3000
+ processEnvironments: true
3001
+ },
3002
+ options: {
3003
+ renderActions: { addMenu: [] },
3004
+ ignoreHtmlClass: 'tex2jax_ignore',
3005
+ processHtmlClass: 'tex2jax_process'
3006
+ },
3007
+ svg: {
3008
+ fontCache: 'none' // self-contained SVGs (required for copy-rendered)
3009
+ },
2632
3010
  startup: { typeset: false }
2633
3011
  };
2634
3012
  }
@@ -2765,7 +3143,14 @@ class QuikdownEditor {
2765
3143
  btn.title = `Switch to ${modeLabels[mode]} view`;
2766
3144
  toolbar.appendChild(btn);
2767
3145
  });
2768
-
3146
+
3147
+ // Mobile split toggle (hidden by default, shown via CSS on narrow viewports)
3148
+ const splitToggle = document.createElement('button');
3149
+ splitToggle.className = 'qde-btn qde-split-toggle';
3150
+ splitToggle.textContent = 'Preview';
3151
+ splitToggle.title = 'Toggle between source and preview in split mode';
3152
+ toolbar.appendChild(splitToggle);
3153
+
2769
3154
  // Undo/Redo buttons (if enabled)
2770
3155
  if (this.options.showUndoRedo) {
2771
3156
  const undoBtn = document.createElement('button');
@@ -2850,6 +3235,7 @@ class QuikdownEditor {
2850
3235
  .qde-toolbar {
2851
3236
  display: flex;
2852
3237
  align-items: center;
3238
+ flex-wrap: wrap;
2853
3239
  padding: 8px;
2854
3240
  background: #f5f5f5;
2855
3241
  border-bottom: 1px solid #ddd;
@@ -3235,19 +3621,45 @@ class QuikdownEditor {
3235
3621
  background: #252525;
3236
3622
  }
3237
3623
 
3238
- /* Mobile responsive */
3239
- @media (max-width: 768px) {
3240
- .qde-mode-split .qde-editor {
3241
- flex-direction: column;
3624
+ /* Mobile split toggle — hidden by default */
3625
+ .qde-split-toggle { display: none; }
3626
+
3627
+ /* Mobile responsive — compact toolbar for all small screens */
3628
+ @media (max-width: 720px) {
3629
+ .qde-toolbar {
3630
+ padding: 6px;
3631
+ gap: 3px;
3242
3632
  }
3243
-
3244
- .qde-mode-split .qde-source {
3245
- border-right: none;
3246
- border-bottom: 1px solid #ddd;
3633
+ .qde-btn {
3634
+ padding: 5px 8px;
3635
+ font-size: 12px;
3247
3636
  }
3248
- .qde-dark.qde-mode-split .qde-source {
3249
- border-bottom-color: #444;
3637
+ .qde-source, .qde-preview {
3638
+ padding: 10px;
3250
3639
  }
3640
+ .qde-textarea {
3641
+ padding: 10px;
3642
+ }
3643
+ /* Undo/Redo: show circular arrows instead of text */
3644
+ .qde-btn[data-action="undo"] { font-size: 0; }
3645
+ .qde-btn[data-action="undo"]::after { content: "\\21B6"; font-size: 14px; }
3646
+ .qde-btn[data-action="redo"] { font-size: 0; }
3647
+ .qde-btn[data-action="redo"]::after { content: "\\21B7"; font-size: 14px; }
3648
+ /* Hide secondary utility buttons to reduce clutter */
3649
+ .qde-btn[data-action="remove-hr"],
3650
+ .qde-btn[data-action="lazy-linefeeds"],
3651
+ .qde-btn[data-action="copy-rendered"] { display: none; }
3652
+ }
3653
+
3654
+ /* Portrait mobile: drop split mode entirely */
3655
+ @media (max-width: 720px) and (orientation: portrait) {
3656
+ .qde-btn[data-mode="split"] { display: none; }
3657
+ .qde-split-toggle { display: none !important; }
3658
+ /* Fallback: if still in split mode, show source only */
3659
+ .qde-mode-split .qde-source { border-right: none; }
3660
+ .qde-mode-split .qde-preview { display: none; }
3661
+ .qde-mode-split.qde-split-preview .qde-source { display: none; }
3662
+ .qde-mode-split.qde-split-preview .qde-preview { display: block; }
3251
3663
  }
3252
3664
  `;
3253
3665
 
@@ -3273,7 +3685,15 @@ class QuikdownEditor {
3273
3685
  this.toolbar.addEventListener('click', (e) => {
3274
3686
  const btn = e.target.closest('.qde-btn');
3275
3687
  if (!btn) return;
3276
-
3688
+
3689
+ // Mobile split-toggle button
3690
+ if (btn.classList.contains('qde-split-toggle')) {
3691
+ this.container.classList.toggle('qde-split-preview');
3692
+ const showingPreview = this.container.classList.contains('qde-split-preview');
3693
+ btn.textContent = showingPreview ? 'Source' : 'Preview';
3694
+ return;
3695
+ }
3696
+
3277
3697
  if (btn.dataset.mode) {
3278
3698
  this.setMode(btn.dataset.mode);
3279
3699
  } else if (btn.dataset.action) {
@@ -3316,8 +3736,22 @@ class QuikdownEditor {
3316
3736
  }
3317
3737
  }
3318
3738
  });
3739
+
3740
+ // On narrow portrait viewports, auto-switch out of split mode to source.
3741
+ // Split is kept available on landscape where there is enough width.
3742
+ if (typeof window.matchMedia === 'function') {
3743
+ const portraitQuery = window.matchMedia('(max-width: 720px) and (orientation: portrait)');
3744
+ const switchIfPortrait = () => {
3745
+ if (portraitQuery.matches && this.currentMode === 'split') {
3746
+ this.setMode('source');
3747
+ }
3748
+ };
3749
+ // Check after init's setMode() has run (microtask fires after sync code).
3750
+ Promise.resolve().then(switchIfPortrait);
3751
+ portraitQuery.addEventListener('change', switchIfPortrait);
3752
+ }
3319
3753
  }
3320
-
3754
+
3321
3755
  /**
3322
3756
  * Handle source textarea input
3323
3757
  */
@@ -4424,6 +4858,7 @@ class QuikdownEditor {
4424
4858
  // below would otherwise wipe it out — this used to be a no-op bug
4425
4859
  // where dark mode was lost on every setMode call).
4426
4860
  const wasDark = this.container.classList.contains('qde-dark');
4861
+ const previousMode = this.currentMode;
4427
4862
 
4428
4863
  this.currentMode = mode;
4429
4864
  this.container.className = `qde-container qde-mode-${mode}`;
@@ -4431,18 +4866,37 @@ class QuikdownEditor {
4431
4866
  this.container.classList.add('qde-dark');
4432
4867
  }
4433
4868
 
4869
+ // Reset mobile split-toggle button text
4870
+ if (this.toolbar) {
4871
+ const splitToggle = this.toolbar.querySelector('.qde-split-toggle');
4872
+ if (splitToggle) {
4873
+ splitToggle.textContent = 'Preview';
4874
+ }
4875
+ }
4876
+
4434
4877
  // Update toolbar buttons
4435
4878
  if (this.toolbar) {
4436
4879
  this.toolbar.querySelectorAll('.qde-btn[data-mode]').forEach(btn => {
4437
4880
  btn.classList.toggle('active', btn.dataset.mode === mode);
4438
4881
  });
4439
4882
  }
4440
-
4441
- // Make fence blocks non-editable when showing preview
4442
- if (mode !== 'source') {
4883
+
4884
+ // If the preview was hidden (source-only) it may have missed content
4885
+ // updates. Re-render it now, including MathJax typesetting.
4886
+ // Do NOT re-render if the preview was already visible — that would
4887
+ // destroy MathJax-typeset SVG output with raw pre-typeset HTML.
4888
+ if (mode !== 'source' && previousMode === 'source' && this._html) {
4889
+ this.previewPanel.innerHTML = this._html;
4443
4890
  setTimeout(() => this.makeFencesNonEditable(), 0);
4891
+ if (typeof window !== 'undefined' && window.MathJax && window.MathJax.typesetPromise) {
4892
+ const mathElements = this.previewPanel.querySelectorAll('.math-display');
4893
+ if (mathElements.length > 0) {
4894
+ window.MathJax.typesetPromise(Array.from(mathElements))
4895
+ .catch(() => {});
4896
+ }
4897
+ }
4444
4898
  }
4445
-
4899
+
4446
4900
  // Trigger mode change event
4447
4901
  if (this.options.onModeChange) {
4448
4902
  this.options.onModeChange(mode);
@@ -4690,36 +5144,29 @@ class QuikdownEditor {
4690
5144
  const lines = (markdown || '').split('\n');
4691
5145
  const result = [];
4692
5146
  let inFence = false;
4693
- let fenceChar = null; // '`' or '~'
4694
- let fenceLen = 0; // length of opening fence marker
5147
+ let openChar = null;
5148
+ let openLen = 0;
4695
5149
 
4696
5150
  for (let i = 0; i < lines.length; i++) {
4697
5151
  const line = lines[i];
4698
5152
  const trimmed = line.trim();
4699
5153
 
4700
- // Track fence open/close (``` or ~~~, 3+ chars)
4701
- const fenceMatch = trimmed.match(/^(`{3,}|~{3,})/);
4702
- if (fenceMatch) {
4703
- const matchChar = fenceMatch[1][0];
4704
- const matchLen = fenceMatch[1].length;
4705
- if (!inFence) {
5154
+ // Track fence open/close
5155
+ if (!inFence) {
5156
+ const fo = fenceOpen(trimmed);
5157
+ if (fo) {
4706
5158
  inFence = true;
4707
- fenceChar = matchChar;
4708
- fenceLen = matchLen;
5159
+ openChar = fo.char;
5160
+ openLen = fo.len;
4709
5161
  result.push(line);
4710
5162
  continue;
4711
- } else if (matchChar === fenceChar && matchLen >= fenceLen && /^(`{3,}|~{3,})\s*$/.test(trimmed)) {
4712
- // Closing fence: same char, at least as many chars, no trailing content
5163
+ }
5164
+ } else {
5165
+ if (isFenceClose(trimmed, openChar, openLen)) {
4713
5166
  inFence = false;
4714
- fenceChar = null;
4715
- fenceLen = 0;
4716
- result.push(line);
4717
- continue;
5167
+ openChar = null;
5168
+ openLen = 0;
4718
5169
  }
4719
- }
4720
-
4721
- // Inside a fence — keep everything
4722
- if (inFence) {
4723
5170
  result.push(line);
4724
5171
  continue;
4725
5172
  }
@@ -4730,14 +5177,13 @@ class QuikdownEditor {
4730
5177
  continue;
4731
5178
  }
4732
5179
 
4733
- // Check if this line is a standalone HR
4734
- const isHR = /^[-_*](\s*[-_*]){2,}\s*$/.test(trimmed);
4735
- if (isHR) {
5180
+ // Check if this line is a standalone HR (no ReDoS — linear scan)
5181
+ if (isHRLine(trimmed)) {
4736
5182
  // Table separator heuristic: immediately adjacent lines (no blank
4737
5183
  // lines between) that look like table rows protect this HR-like line
4738
5184
  const prevLine = i > 0 ? lines[i - 1].trim() : '';
4739
5185
  const nextLine = i < lines.length - 1 ? lines[i + 1].trim() : '';
4740
- if (_looksLikeTableRow(prevLine) || _looksLikeTableRow(nextLine)) {
5186
+ if (looksLikeTableRow(prevLine) || looksLikeTableRow(nextLine)) {
4741
5187
  result.push(line);
4742
5188
  continue;
4743
5189
  }
@@ -4816,30 +5262,28 @@ class QuikdownEditor {
4816
5262
  // 'blockquote', 'table', 'heading', 'hr', 'paragraph'
4817
5263
  const items = [];
4818
5264
  let inFence = false;
4819
- let fenceChar = null;
4820
- let fenceLen = 0;
5265
+ let openChar = null;
5266
+ let openLen = 0;
4821
5267
 
4822
5268
  for (const rawLine of inputLines) {
4823
5269
  const line = rawLine;
4824
5270
  const trimmed = line.trim();
4825
5271
 
4826
- // Fence tracking
4827
- const fenceMatch = trimmed.match(/^(`{3,}|~{3,})/);
4828
- if (fenceMatch && !inFence) {
4829
- inFence = true;
4830
- fenceChar = fenceMatch[1][0];
4831
- fenceLen = fenceMatch[1].length;
4832
- items.push({ line, kind: 'fence-open' });
4833
- continue;
4834
- }
4835
- if (inFence) {
4836
- if (fenceMatch &&
4837
- fenceMatch[1][0] === fenceChar &&
4838
- fenceMatch[1].length >= fenceLen &&
4839
- /^(`{3,}|~{3,})\s*$/.test(trimmed)) {
5272
+ // Fence tracking via shared utilities
5273
+ if (!inFence) {
5274
+ const fo = fenceOpen(trimmed);
5275
+ if (fo) {
5276
+ inFence = true;
5277
+ openChar = fo.char;
5278
+ openLen = fo.len;
5279
+ items.push({ line, kind: 'fence-open' });
5280
+ continue;
5281
+ }
5282
+ } else {
5283
+ if (isFenceClose(trimmed, openChar, openLen)) {
4840
5284
  inFence = false;
4841
- fenceChar = null;
4842
- fenceLen = 0;
5285
+ openChar = null;
5286
+ openLen = 0;
4843
5287
  items.push({ line, kind: 'fence-close' });
4844
5288
  } else {
4845
5289
  items.push({ line, kind: 'fence-body' });
@@ -4853,16 +5297,12 @@ class QuikdownEditor {
4853
5297
  continue;
4854
5298
  }
4855
5299
 
4856
- // Categorize content lines so we can recognize adjacent same-kind blocks
4857
- let category = 'paragraph';
4858
- if (/^#{1,6}\s/.test(trimmed)) category = 'heading';
4859
- else if (/^[-_*](\s*[-_*]){2,}\s*$/.test(trimmed)) category = 'hr';
4860
- else if (/^(\d+\.)\s/.test(trimmed)) category = 'list-ol';
4861
- else if (/^[-*+]\s/.test(trimmed)) category = 'list-ul';
4862
- else if (/^>/.test(trimmed)) category = 'blockquote';
4863
- else if (/^\|/.test(trimmed)) category = 'table';
5300
+ // Categorize content lines (no ReDoS classifyLine uses linear scan for HR)
5301
+ let category = classifyLine(trimmed);
4864
5302
  // Indented continuation of a list (2+ leading spaces or tab)
4865
- else if (/^(?: {4}|\t| {2,}[-*+]| {2,}\d+\.)/.test(line)) category = 'list-cont';
5303
+ if (category === 'paragraph' && /^(?: {4}|\t| {2,}[-*+]| {2,}\d+\.)/.test(line)) {
5304
+ category = 'list-cont';
5305
+ }
4866
5306
 
4867
5307
  items.push({ line, kind: 'content', category });
4868
5308
  }
@@ -4962,13 +5402,6 @@ class QuikdownEditor {
4962
5402
  }
4963
5403
  }
4964
5404
 
4965
- // --- Internal helpers for removeHR fence/table awareness ---
4966
-
4967
- /** Heuristic: does this line look like a markdown table row? */
4968
- function _looksLikeTableRow(line) {
4969
- return line.includes('|');
4970
- }
4971
-
4972
5405
  // Export for CommonJS (needed for bundled ESM to work with Jest)
4973
5406
  if (typeof module !== 'undefined' && module.exports) {
4974
5407
  module.exports = QuikdownEditor;