quikdown 1.2.7 → 1.2.9

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