quikdown 1.2.7 → 1.2.9

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