quikchat 1.2.4 → 1.2.6

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 (41) hide show
  1. package/README.md +2 -2
  2. package/dist/build-manifest.json +73 -80
  3. package/dist/quikchat-md-full.cjs.js +536 -255
  4. package/dist/quikchat-md-full.cjs.js.map +1 -1
  5. package/dist/quikchat-md-full.cjs.min.js +3 -3
  6. package/dist/quikchat-md-full.cjs.min.js.map +1 -1
  7. package/dist/quikchat-md-full.esm.js +536 -255
  8. package/dist/quikchat-md-full.esm.js.map +1 -1
  9. package/dist/quikchat-md-full.esm.min.js +3 -3
  10. package/dist/quikchat-md-full.esm.min.js.map +1 -1
  11. package/dist/quikchat-md-full.umd.js +536 -255
  12. package/dist/quikchat-md-full.umd.js.map +1 -1
  13. package/dist/quikchat-md-full.umd.min.js +3 -3
  14. package/dist/quikchat-md-full.umd.min.js.map +1 -1
  15. package/dist/quikchat-md.cjs.js +520 -245
  16. package/dist/quikchat-md.cjs.js.map +1 -1
  17. package/dist/quikchat-md.cjs.min.js +3 -3
  18. package/dist/quikchat-md.cjs.min.js.map +1 -1
  19. package/dist/quikchat-md.esm.js +520 -245
  20. package/dist/quikchat-md.esm.js.map +1 -1
  21. package/dist/quikchat-md.esm.min.js +3 -3
  22. package/dist/quikchat-md.esm.min.js.map +1 -1
  23. package/dist/quikchat-md.umd.js +520 -245
  24. package/dist/quikchat-md.umd.js.map +1 -1
  25. package/dist/quikchat-md.umd.min.js +3 -3
  26. package/dist/quikchat-md.umd.min.js.map +1 -1
  27. package/dist/quikchat.cjs.js +2 -2
  28. package/dist/quikchat.cjs.js.map +1 -1
  29. package/dist/quikchat.cjs.min.js +1 -1
  30. package/dist/quikchat.cjs.min.js.map +1 -1
  31. package/dist/quikchat.css +351 -120
  32. package/dist/quikchat.esm.js +2 -2
  33. package/dist/quikchat.esm.js.map +1 -1
  34. package/dist/quikchat.esm.min.js +1 -1
  35. package/dist/quikchat.esm.min.js.map +1 -1
  36. package/dist/quikchat.min.css +1 -1
  37. package/dist/quikchat.umd.js +2 -2
  38. package/dist/quikchat.umd.js.map +1 -1
  39. package/dist/quikchat.umd.min.js +1 -1
  40. package/dist/quikchat.umd.min.js.map +1 -1
  41. package/package.json +6 -5
@@ -436,7 +436,7 @@
436
436
  value: function _autoGrowTextarea() {
437
437
  var el = this._textEntry;
438
438
  el.style.height = 'auto';
439
- var maxHeight = parseInt(getComputedStyle(el).getPropertyValue('--quikchat-input-max-height')) || 120;
439
+ var maxHeight = 120;
440
440
  el.style.height = Math.min(el.scrollHeight, maxHeight) + 'px';
441
441
  el.style.overflowY = el.scrollHeight > maxHeight ? 'auto' : 'hidden';
442
442
  }
@@ -904,7 +904,7 @@
904
904
  key: "version",
905
905
  value: function version() {
906
906
  return {
907
- "version": "1.2.4",
907
+ "version": "1.2.6",
908
908
  "license": "BSD-2",
909
909
  "url": "https://github/deftio/quikchat"
910
910
  };
@@ -964,35 +964,140 @@
964
964
 
965
965
  /**
966
966
  * quikdown - Lightweight Markdown Parser
967
- * @version 1.1.1
967
+ * @version 1.2.10
968
968
  * @license BSD-2-Clause
969
969
  * @copyright DeftIO 2025
970
970
  */
971
971
  /**
972
- * quikdown - A minimal markdown parser optimized for chat/LLM output
973
- * Supports tables, code blocks, lists, and common formatting
974
- * @param {string} markdown - The markdown source text
975
- * @param {Object} options - Optional configuration object
976
- * @param {Function} options.fence_plugin - Custom renderer for fenced code blocks
977
- * (content, fence_string) => html string
978
- * @param {boolean} options.inline_styles - If true, uses inline styles instead of classes
979
- * @param {boolean} options.bidirectional - If true, adds data-qd attributes for source tracking
980
- * @param {boolean} options.lazy_linefeeds - If true, single newlines become <br> tags
981
- * @returns {string} - The rendered HTML
972
+ * quikdown_classify Shared line-classification utilities
973
+ * ═════════════════════════════════════════════════════════
974
+ *
975
+ * Pure functions for classifying markdown lines. Used by both the main
976
+ * parser (quikdown.js) and the editor (quikdown_edit.js) so the logic
977
+ * lives in one place.
978
+ *
979
+ * All functions operate on a **trimmed** line (caller must trim).
980
+ * None use regexes with nested quantifiers every check is either a
981
+ * simple regex or a linear scan, so there is zero ReDoS risk.
982
982
  */
983
983
 
984
- // Version will be injected at build time
985
- const quikdownVersion = '1.1.1';
986
984
 
987
- // Constants for reuse
985
+ /**
986
+ * Dash-only HR check — exact parity with the main parser's original
987
+ * regex `/^---+\s*$/`. Only matches lines of three or more dashes
988
+ * with optional trailing whitespace (no interspersed spaces).
989
+ *
990
+ * @param {string} trimmed The line, already trimmed
991
+ * @returns {boolean}
992
+ */
993
+ function isDashHRLine(trimmed) {
994
+ if (trimmed.length < 3) return false;
995
+ for (let i = 0; i < trimmed.length; i++) {
996
+ const ch = trimmed[i];
997
+ if (ch === '-') continue;
998
+ // Allow trailing whitespace only
999
+ if (ch === ' ' || ch === '\t') {
1000
+ for (let j = i + 1; j < trimmed.length; j++) {
1001
+ if (trimmed[j] !== ' ' && trimmed[j] !== '\t') return false;
1002
+ }
1003
+ return i >= 3; // at least 3 dashes before whitespace
1004
+ }
1005
+ return false;
1006
+ }
1007
+ return true; // all dashes
1008
+ }
1009
+
1010
+ /**
1011
+ * quikdown — A compact, scanner-based markdown parser
1012
+ * ════════════════════════════════════════════════════
1013
+ *
1014
+ * Architecture overview (v1.2.8 — lexer rewrite)
1015
+ * ───────────────────────────────────────────────
1016
+ * Prior to v1.2.8, quikdown used a multi-pass regex pipeline: each block
1017
+ * type (headings, blockquotes, HR, lists, tables) and each inline format
1018
+ * (bold, italic, links, …) was handled by its own global regex applied
1019
+ * sequentially to the full document string. That worked but made the code
1020
+ * hard to extend and debug — a new construct meant adding another regex
1021
+ * pass, and ordering bugs between passes were subtle.
1022
+ *
1023
+ * Starting in v1.2.8 the parser uses a **line-scanning** approach for
1024
+ * block detection and a **per-block inline pass** for formatting:
1025
+ *
1026
+ * ┌─────────────────────────────────────────────────────────┐
1027
+ * │ Phase 1 — Code Extraction │
1028
+ * │ Scan for fenced code blocks (``` / ~~~) and inline │
1029
+ * │ code spans (`…`). Replace with §CB§ / §IC§ place- │
1030
+ * │ holders so code content is never touched by later │
1031
+ * │ phases. │
1032
+ * ├─────────────────────────────────────────────────────────┤
1033
+ * │ Phase 2 — HTML Escaping │
1034
+ * │ Escape &, <, >, ", ' in the remaining text to prevent │
1035
+ * │ XSS. (Skipped when allow_unsafe_html is true.) │
1036
+ * ├─────────────────────────────────────────────────────────┤
1037
+ * │ Phase 3 — Block Scanning │
1038
+ * │ Walk the text **line by line**. At each line, the │
1039
+ * │ scanner checks (in order): │
1040
+ * │ • table rows (|) │
1041
+ * │ • headings (#) │
1042
+ * │ • HR (---) │
1043
+ * │ • blockquotes (&gt;) │
1044
+ * │ • list items (-, *, +, 1.) │
1045
+ * │ • code-block placeholder (§CB…§) │
1046
+ * │ • paragraph text (everything else) │
1047
+ * │ │
1048
+ * │ Block text is run through the **inline formatter** │
1049
+ * │ which handles bold, italic, strikethrough, links, │
1050
+ * │ images, and autolinks. │
1051
+ * │ │
1052
+ * │ Paragraphs are wrapped in <p> tags. Lazy linefeeds │
1053
+ * │ (single \n → <br>) are handled here too. │
1054
+ * ├─────────────────────────────────────────────────────────┤
1055
+ * │ Phase 4 — Code Restoration │
1056
+ * │ Replace §CB§ / §IC§ placeholders with rendered <pre> │
1057
+ * │ / <code> HTML, applying the fence_plugin if present. │
1058
+ * └─────────────────────────────────────────────────────────┘
1059
+ *
1060
+ * Why this design?
1061
+ * • Single pass over lines for block identification — no re-scanning.
1062
+ * • Each block type is a clearly separated branch, easy to add new ones.
1063
+ * • Inline formatting is confined to block text — can't accidentally
1064
+ * match across block boundaries or inside HTML tags.
1065
+ * • Code extraction still uses a simple regex (it's one pattern, not a
1066
+ * chain) because the §-placeholder approach is proven and simple.
1067
+ *
1068
+ * @param {string} markdown The markdown source text
1069
+ * @param {Object} options Configuration (see below)
1070
+ * @returns {string} Rendered HTML
1071
+ */
1072
+
1073
+
1074
+ // ────────────────────────────────────────────────────────────────────
1075
+ // Constants
1076
+ // ────────────────────────────────────────────────────────────────────
1077
+
1078
+ /** Build-time version stamp (injected by tools/updateVersion) */
1079
+ const quikdownVersion = '1.2.10';
1080
+
1081
+ /** CSS class prefix used for all generated elements */
988
1082
  const CLASS_PREFIX = 'quikdown-';
989
- const PLACEHOLDER_CB = '§CB';
990
- const PLACEHOLDER_IC = '§IC';
991
1083
 
992
- // Escape map at module level
1084
+ /** Placeholder sigils chosen to be extremely unlikely in real text */
1085
+ const PLACEHOLDER_CB = '§CB'; // fenced code blocks
1086
+ const PLACEHOLDER_IC = '§IC'; // inline code spans
1087
+
1088
+ /** HTML entity escape map */
993
1089
  const ESC_MAP = {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'};
994
1090
 
995
- // Single source of truth for all style definitions - optimized
1091
+ // ────────────────────────────────────────────────────────────────────
1092
+ // Style definitions
1093
+ // ────────────────────────────────────────────────────────────────────
1094
+
1095
+ /**
1096
+ * Inline styles for every element quikdown can emit.
1097
+ * When `inline_styles: true` these are injected as style="…" attributes.
1098
+ * When `inline_styles: false` (default) we use class="quikdown-<tag>"
1099
+ * and these same values are emitted by `quikdown.emitStyles()`.
1100
+ */
996
1101
  const QUIKDOWN_STYLES = {
997
1102
  h1: 'font-size:2em;font-weight:600;margin:.67em 0;text-align:left',
998
1103
  h2: 'font-size:1.5em;font-weight:600;margin:.83em 0',
@@ -1015,30 +1120,41 @@
1015
1120
  ul: 'margin:.5em 0;padding-left:2em',
1016
1121
  ol: 'margin:.5em 0;padding-left:2em',
1017
1122
  li: 'margin:.25em 0',
1018
- // Task list specific styles
1019
1123
  'task-item': 'list-style:none',
1020
1124
  'task-checkbox': 'margin-right:.5em'
1021
1125
  };
1022
1126
 
1023
- // Factory function to create getAttr for a given context
1127
+ // ────────────────────────────────────────────────────────────────────
1128
+ // Attribute factory
1129
+ // ────────────────────────────────────────────────────────────────────
1130
+
1131
+ /**
1132
+ * Creates a `getAttr(tag, additionalStyle?)` helper that returns
1133
+ * either a class="…" or style="…" attribute string depending on mode.
1134
+ *
1135
+ * @param {boolean} inline_styles True → emit style="…"; false → class="…"
1136
+ * @param {Object} styles The QUIKDOWN_STYLES map
1137
+ * @returns {Function}
1138
+ */
1024
1139
  function createGetAttr(inline_styles, styles) {
1025
1140
  return function(tag, additionalStyle = '') {
1026
1141
  if (inline_styles) {
1027
1142
  let style = styles[tag];
1028
1143
  if (!style && !additionalStyle) return '';
1029
-
1030
- // Remove default text-align if we're adding a different alignment
1144
+
1145
+ // When adding alignment that conflicts with the tag's default,
1146
+ // strip the default text-align first.
1031
1147
  if (additionalStyle && additionalStyle.includes('text-align') && style && style.includes('text-align')) {
1032
1148
  style = style.replace(/text-align:[^;]+;?/, '').trim();
1149
+ /* istanbul ignore next */
1033
1150
  if (style && !style.endsWith(';')) style += ';';
1034
1151
  }
1035
-
1152
+
1036
1153
  /* istanbul ignore next - defensive: additionalStyle without style doesn't occur with current tags */
1037
1154
  const fullStyle = additionalStyle ? (style ? `${style}${additionalStyle}` : additionalStyle) : style;
1038
1155
  return ` style="${fullStyle}"`;
1039
1156
  } else {
1040
1157
  const classAttr = ` class="${CLASS_PREFIX}${tag}"`;
1041
- // Apply inline styles for alignment even when using CSS classes
1042
1158
  if (additionalStyle) {
1043
1159
  return `${classAttr} style="${additionalStyle}"`;
1044
1160
  }
@@ -1047,69 +1163,84 @@
1047
1163
  };
1048
1164
  }
1049
1165
 
1166
+ // ════════════════════════════════════════════════════════════════════
1167
+ // Main parser function
1168
+ // ════════════════════════════════════════════════════════════════════
1169
+
1050
1170
  function quikdown(markdown, options = {}) {
1171
+ // ── Guard: only process non-empty strings ──
1051
1172
  if (!markdown || typeof markdown !== 'string') {
1052
1173
  return '';
1053
1174
  }
1054
-
1055
- const { fence_plugin, inline_styles = false, bidirectional = false, lazy_linefeeds = false } = options;
1056
- const styles = QUIKDOWN_STYLES; // Use module-level styles
1057
- const getAttr = createGetAttr(inline_styles, styles); // Create getAttr once
1058
1175
 
1059
- // Escape HTML entities to prevent XSS
1176
+ // ── Unpack options ──
1177
+ const { fence_plugin, inline_styles = false, bidirectional = false, lazy_linefeeds = false, allow_unsafe_html = false } = options;
1178
+ const styles = QUIKDOWN_STYLES;
1179
+ const getAttr = createGetAttr(inline_styles, styles);
1180
+
1181
+ // ── Helpers (closed over options) ──
1182
+
1183
+ /** Escape the five HTML-special characters. */
1060
1184
  function escapeHtml(text) {
1061
1185
  return text.replace(/[&<>"']/g, m => ESC_MAP[m]);
1062
1186
  }
1063
-
1064
- // Helper to add data-qd attributes for bidirectional support
1187
+
1188
+ /**
1189
+ * Bidirectional marker helper.
1190
+ * When bidirectional mode is on, returns ` data-qd="…"`.
1191
+ * The non-bidirectional branch is a trivial no-op arrow; it is
1192
+ * exercised in the core bundle but never in quikdown_bd.
1193
+ */
1194
+ /* istanbul ignore next - trivial no-op fallback */
1065
1195
  const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';
1066
-
1067
- // Sanitize URLs to prevent XSS attacks
1196
+
1197
+ /**
1198
+ * Sanitize a URL to block javascript:, vbscript:, and non-image data: URIs.
1199
+ * Returns '#' for blocked URLs.
1200
+ */
1068
1201
  function sanitizeUrl(url, allowUnsafe = false) {
1069
1202
  /* istanbul ignore next - defensive programming, regex ensures url is never empty */
1070
1203
  if (!url) return '';
1071
-
1072
- // If unsafe URLs are explicitly allowed, return as-is
1073
1204
  if (allowUnsafe) return url;
1074
-
1205
+
1075
1206
  const trimmedUrl = url.trim();
1076
1207
  const lowerUrl = trimmedUrl.toLowerCase();
1077
-
1078
- // Block dangerous protocols
1079
1208
  const dangerousProtocols = ['javascript:', 'vbscript:', 'data:'];
1080
-
1209
+
1081
1210
  for (const protocol of dangerousProtocols) {
1082
1211
  if (lowerUrl.startsWith(protocol)) {
1083
- // Exception: Allow data:image/* for images
1084
1212
  if (protocol === 'data:' && lowerUrl.startsWith('data:image/')) {
1085
1213
  return trimmedUrl;
1086
1214
  }
1087
- // Return safe empty link for dangerous protocols
1088
1215
  return '#';
1089
1216
  }
1090
1217
  }
1091
-
1092
1218
  return trimmedUrl;
1093
1219
  }
1094
1220
 
1095
- // Process the markdown in phases
1221
+ // ────────────────────────────────────────────────────────────────
1222
+ // Phase 1 — Code Extraction
1223
+ // ────────────────────────────────────────────────────────────────
1224
+ // Why extract code first? Fenced blocks and inline code spans can
1225
+ // contain markdown-like characters (*, _, #, |, etc.) that must NOT
1226
+ // be interpreted as formatting. By pulling them out and replacing
1227
+ // with unique placeholders, the rest of the pipeline never sees them.
1228
+
1096
1229
  let html = markdown;
1097
-
1098
- // Phase 1: Extract and protect code blocks and inline code
1099
- const codeBlocks = [];
1100
- const inlineCodes = [];
1101
-
1102
- // Extract fenced code blocks first (supports both ``` and ~~~)
1103
- // Match paired fences - ``` with ``` and ~~~ with ~~~
1104
- // Fence must be at start of line
1230
+ const codeBlocks = []; // Array of {lang, code, custom, fence, hasReverse}
1231
+ const inlineCodes = []; // Array of escaped-HTML strings
1232
+
1233
+ // ── Fenced code blocks ──
1234
+ // Matches paired fences: ``` with ``` and ~~~ with ~~~.
1235
+ // The fence must start at column 0 of a line (^ with /m flag).
1236
+ // Group 1 = fence marker, Group 2 = language hint, Group 3 = code body.
1105
1237
  html = html.replace(/^(```|~~~)([^\n]*)\n([\s\S]*?)^\1$/gm, (match, fence, lang, code) => {
1106
1238
  const placeholder = `${PLACEHOLDER_CB}${codeBlocks.length}§`;
1107
-
1108
- // Trim the language specification
1109
1239
  const langTrimmed = lang ? lang.trim() : '';
1110
-
1111
- // If custom fence plugin is provided, use it (v1.1.0: object format required)
1240
+
1112
1241
  if (fence_plugin && fence_plugin.render && typeof fence_plugin.render === 'function') {
1242
+ // Custom plugin — store raw code (un-escaped) so the plugin
1243
+ // receives the original source.
1113
1244
  codeBlocks.push({
1114
1245
  lang: langTrimmed,
1115
1246
  code: code.trimEnd(),
@@ -1118,6 +1249,7 @@
1118
1249
  hasReverse: !!fence_plugin.reverse
1119
1250
  });
1120
1251
  } else {
1252
+ // Default — pre-escape the code for safe HTML output.
1121
1253
  codeBlocks.push({
1122
1254
  lang: langTrimmed,
1123
1255
  code: escapeHtml(code.trimEnd()),
@@ -1127,66 +1259,97 @@
1127
1259
  }
1128
1260
  return placeholder;
1129
1261
  });
1130
-
1131
- // Extract inline code
1262
+
1263
+ // ── Inline code spans ──
1264
+ // Matches a single backtick pair: `content`.
1265
+ // Content is captured and HTML-escaped immediately.
1132
1266
  html = html.replace(/`([^`]+)`/g, (match, code) => {
1133
1267
  const placeholder = `${PLACEHOLDER_IC}${inlineCodes.length}§`;
1134
1268
  inlineCodes.push(escapeHtml(code));
1135
1269
  return placeholder;
1136
1270
  });
1137
-
1138
- // Now escape HTML in the rest of the content
1139
- html = escapeHtml(html);
1140
-
1141
- // Phase 2: Process block elements
1142
-
1143
- // Process tables
1271
+
1272
+ // ────────────────────────────────────────────────────────────────
1273
+ // Phase 2 — HTML Escaping
1274
+ // ────────────────────────────────────────────────────────────────
1275
+ // All remaining text (everything except code placeholders) is escaped
1276
+ // to prevent XSS. The `allow_unsafe_html` option skips this for
1277
+ // trusted pipelines that intentionally embed raw HTML.
1278
+
1279
+ if (!allow_unsafe_html) {
1280
+ html = escapeHtml(html);
1281
+ }
1282
+
1283
+ // ────────────────────────────────────────────────────────────────
1284
+ // Phase 3 — Block Scanning + Inline Formatting + Paragraphs
1285
+ // ────────────────────────────────────────────────────────────────
1286
+ // This is the heart of the lexer rewrite. Instead of applying
1287
+ // 10+ global regex passes, we:
1288
+ // 1. Process tables (line walker — tables need multi-line lookahead)
1289
+ // 2. Scan remaining lines for headings, HR, blockquotes
1290
+ // 3. Process lists (line walker — lists need indent tracking)
1291
+ // 4. Apply inline formatting to all text content
1292
+ // 5. Wrap remaining text in <p> tags
1293
+ //
1294
+ // Steps 1 and 3 are line-walkers that process the full text in a
1295
+ // single pass each. Step 2 replaces global regex with a per-line
1296
+ // scanner. Steps 4-5 are applied to the result.
1297
+ //
1298
+ // Total: 3 structured passes instead of 10+ regex passes.
1299
+
1300
+ // ── Step 1: Tables ──
1301
+ // Tables need multi-line lookahead (header → separator → body rows)
1302
+ // so they're handled by a dedicated line-walker first.
1144
1303
  html = processTable(html, getAttr);
1145
-
1146
- // Process headings (supports optional trailing #'s)
1147
- html = html.replace(/^(#{1,6})\s+(.+?)\s*#*$/gm, (match, hashes, content) => {
1148
- const level = hashes.length;
1149
- return `<h${level}${getAttr('h' + level)}${dataQd(hashes)}>${content}</h${level}>`;
1150
- });
1151
-
1152
- // Process blockquotes (must handle escaped > since we already escaped HTML)
1153
- html = html.replace(/^&gt;\s+(.+)$/gm, `<blockquote${getAttr('blockquote')}>$1</blockquote>`);
1154
- // Merge consecutive blockquotes
1155
- html = html.replace(/<\/blockquote>\n<blockquote>/g, '\n');
1156
-
1157
- // Process horizontal rules (allow trailing spaces)
1158
- html = html.replace(/^---+\s*$/gm, `<hr${getAttr('hr')}>`);
1159
-
1160
- // Process lists
1304
+
1305
+ // ── Step 2: Headings, HR, Blockquotes ──
1306
+ // These are simple line-level constructs. We scan each line once
1307
+ // and replace matching lines with their HTML representation.
1308
+ html = scanLineBlocks(html, getAttr, dataQd);
1309
+
1310
+ // ── Step 3: Lists ──
1311
+ // Lists need indent-level tracking across lines, so they get their
1312
+ // own line-walker.
1161
1313
  html = processLists(html, getAttr, inline_styles, bidirectional);
1162
-
1163
- // Phase 3: Process inline elements
1164
-
1165
- // Images (must come before links, with URL sanitization)
1314
+
1315
+ // ── Step 4: Inline formatting ──
1316
+ // Apply bold, italic, strikethrough, images, links, and autolinks
1317
+ // to all text content. This runs on the output of steps 1-3, so
1318
+ // it sees text inside headings, blockquotes, table cells, list
1319
+ // items, and paragraph text.
1320
+
1321
+ // Images (must come before links — ![alt](src) vs [text](url))
1166
1322
  html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
1167
1323
  const sanitizedSrc = sanitizeUrl(src, options.allow_unsafe_urls);
1324
+ /* istanbul ignore next - bd-only branch */
1168
1325
  const altAttr = bidirectional && alt ? ` data-qd-alt="${escapeHtml(alt)}"` : '';
1326
+ /* istanbul ignore next - bd-only branch */
1169
1327
  const srcAttr = bidirectional ? ` data-qd-src="${escapeHtml(src)}"` : '';
1170
1328
  return `<img${getAttr('img')} src="${sanitizedSrc}" alt="${alt}"${altAttr}${srcAttr}${dataQd('!')}>`;
1171
1329
  });
1172
-
1173
- // Links (with URL sanitization)
1330
+
1331
+ // Links
1174
1332
  html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, href) => {
1175
- // Sanitize URL to prevent XSS
1176
1333
  const sanitizedHref = sanitizeUrl(href, options.allow_unsafe_urls);
1177
1334
  const isExternal = /^https?:\/\//i.test(sanitizedHref);
1178
1335
  const rel = isExternal ? ' rel="noopener noreferrer"' : '';
1336
+ /* istanbul ignore next - bd-only branch */
1179
1337
  const textAttr = bidirectional ? ` data-qd-text="${escapeHtml(text)}"` : '';
1180
1338
  return `<a${getAttr('a')} href="${sanitizedHref}"${rel}${textAttr}${dataQd('[')}>${text}</a>`;
1181
1339
  });
1182
-
1183
- // Autolinks - convert bare URLs to clickable links
1340
+
1341
+ // Autolinks bare https?:// URLs become clickable <a> tags
1184
1342
  html = html.replace(/(^|\s)(https?:\/\/[^\s<]+)/g, (match, prefix, url) => {
1185
1343
  const sanitizedUrl = sanitizeUrl(url, options.allow_unsafe_urls);
1186
1344
  return `${prefix}<a${getAttr('a')} href="${sanitizedUrl}" rel="noopener noreferrer">${url}</a>`;
1187
1345
  });
1188
-
1189
- // Process inline formatting (bold, italic, strikethrough)
1346
+
1347
+ // Protect rendered tags so emphasis regexes don't see attribute
1348
+ // values — fixes #3 (underscores in URLs interpreted as emphasis).
1349
+ const savedTags = [];
1350
+ html = html.replace(/<[^>]+>/g, m => { savedTags.push(m); return `%%T${savedTags.length - 1}%%`; });
1351
+
1352
+ // Bold, italic, strikethrough
1190
1353
  const inlinePatterns = [
1191
1354
  [/\*\*(.+?)\*\*/g, 'strong', '**'],
1192
1355
  [/__(.+?)__/g, 'strong', '__'],
@@ -1194,60 +1357,66 @@
1194
1357
  [/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, 'em', '_'],
1195
1358
  [/~~(.+?)~~/g, 'del', '~~']
1196
1359
  ];
1197
-
1198
1360
  inlinePatterns.forEach(([pattern, tag, marker]) => {
1199
1361
  html = html.replace(pattern, `<${tag}${getAttr(tag)}${dataQd(marker)}>$1</${tag}>`);
1200
1362
  });
1201
-
1202
- // Line breaks
1363
+
1364
+ // Restore protected tags
1365
+ html = html.replace(/%%T(\d+)%%/g, (_, i) => savedTags[i]);
1366
+
1367
+ // ── Step 5: Line breaks + paragraph wrapping ──
1203
1368
  if (lazy_linefeeds) {
1204
- // Lazy linefeeds: single newline becomes <br> (except between paragraphs and after/before block elements)
1369
+ // Lazy linefeeds mode: every single \n becomes <br> EXCEPT:
1370
+ // • Double newlines → paragraph break
1371
+ // • Newlines adjacent to block elements (h, blockquote, pre, hr, table, list)
1372
+ //
1373
+ // Strategy: protect block-adjacent newlines with §N§, convert
1374
+ // the rest, then restore.
1375
+
1205
1376
  const blocks = [];
1206
1377
  let bi = 0;
1207
-
1208
- // Protect tables and lists
1378
+
1379
+ // Protect tables and lists from <br> injection
1209
1380
  html = html.replace(/<(table|[uo]l)[^>]*>[\s\S]*?<\/\1>/g, m => {
1210
1381
  blocks[bi] = m;
1211
1382
  return `§B${bi++}§`;
1212
1383
  });
1213
-
1214
- // Handle paragraphs and block elements
1384
+
1215
1385
  html = html.replace(/\n\n+/g, '§P§')
1216
- // After block elements
1386
+ // After block-level closing tags
1217
1387
  .replace(/(<\/(?:h[1-6]|blockquote|pre)>)\n/g, '$1§N§')
1218
1388
  .replace(/(<(?:h[1-6]|blockquote|pre|hr)[^>]*>)\n/g, '$1§N§')
1219
- // Before block elements
1389
+ // Before block-level opening tags
1220
1390
  .replace(/\n(<(?:h[1-6]|blockquote|pre|hr)[^>]*>)/g, '§N§$1')
1221
1391
  .replace(/\n(§B\d+§)/g, '§N§$1')
1222
1392
  .replace(/(§B\d+§)\n/g, '$1§N§')
1223
- // Convert remaining newlines
1393
+ // Convert surviving newlines to <br>
1224
1394
  .replace(/\n/g, `<br${getAttr('br')}>`)
1225
1395
  // Restore
1226
1396
  .replace(/§N§/g, '\n')
1227
1397
  .replace(/§P§/g, '</p><p>');
1228
-
1398
+
1229
1399
  // Restore protected blocks
1230
1400
  blocks.forEach((b, i) => html = html.replace(`§B${i}§`, b));
1231
-
1401
+
1232
1402
  html = '<p>' + html + '</p>';
1233
1403
  } else {
1234
- // Standard: two spaces at end of line for line breaks
1235
- html = html.replace(/ $/gm, `<br${getAttr('br')}>`);
1236
-
1237
- // Paragraphs (double newlines)
1238
- // Don't add </p> after block elements (they're not in paragraphs)
1404
+ // Standard mode: two trailing spaces <br>, double newline new paragraph
1405
+ html = html.replace(/ {2}$/gm, `<br${getAttr('br')}>`);
1406
+
1239
1407
  html = html.replace(/\n\n+/g, (match, offset) => {
1240
- // Check if we're after a block element closing tag
1241
1408
  const before = html.substring(0, offset);
1242
1409
  if (before.match(/<\/(h[1-6]|blockquote|ul|ol|table|pre|hr)>$/)) {
1243
- return '<p>'; // Just open a new paragraph
1410
+ return '<p>';
1244
1411
  }
1245
- return '</p><p>'; // Normal paragraph break
1412
+ return '</p><p>';
1246
1413
  });
1247
1414
  html = '<p>' + html + '</p>';
1248
1415
  }
1249
-
1250
- // Clean up empty paragraphs and unwrap block elements
1416
+
1417
+ // ── Step 6: Cleanup ──
1418
+ // Remove <p> wrappers that accidentally enclose block elements.
1419
+ // This is simpler than trying to prevent them during wrapping.
1251
1420
  const cleanupPatterns = [
1252
1421
  [/<p><\/p>/g, ''],
1253
1422
  [/<p>(<h[1-6][^>]*>)/g, '$1'],
@@ -1261,67 +1430,154 @@
1261
1430
  [/(<\/table>)<\/p>/g, '$1'],
1262
1431
  [/<p>(<pre[^>]*>)/g, '$1'],
1263
1432
  [/(<\/pre>)<\/p>/g, '$1'],
1264
- [new RegExp(`<p>(${PLACEHOLDER_CB}\\d+§)<\/p>`, 'g'), '$1']
1433
+ [new RegExp(`<p>(${PLACEHOLDER_CB}\\d+§)</p>`, 'g'), '$1']
1265
1434
  ];
1266
-
1267
1435
  cleanupPatterns.forEach(([pattern, replacement]) => {
1268
1436
  html = html.replace(pattern, replacement);
1269
1437
  });
1270
-
1271
- // Fix orphaned closing </p> tags after block elements
1272
- // When a paragraph follows a block element, ensure it has opening <p>
1438
+
1439
+ // When a block element is followed by a newline and then text, open a <p>.
1273
1440
  html = html.replace(/(<\/(?:h[1-6]|blockquote|ul|ol|table|pre|hr)>)\n([^<])/g, '$1\n<p>$2');
1274
-
1275
- // Phase 4: Restore code blocks and inline code
1276
-
1277
- // Restore code blocks
1441
+
1442
+ // ────────────────────────────────────────────────────────────────
1443
+ // Phase 4 — Code Restoration
1444
+ // ────────────────────────────────────────────────────────────────
1445
+ // Replace placeholders with rendered HTML. For fenced blocks this
1446
+ // means wrapping in <pre><code>…</code></pre> (or calling the
1447
+ // fence_plugin). For inline code it means <code>…</code>.
1448
+
1278
1449
  codeBlocks.forEach((block, i) => {
1279
1450
  let replacement;
1280
-
1451
+
1281
1452
  if (block.custom && fence_plugin && fence_plugin.render) {
1282
- // Use custom fence plugin (v1.1.0: object format with render function)
1453
+ // Delegate to the user-provided fence plugin.
1283
1454
  replacement = fence_plugin.render(block.code, block.lang);
1284
-
1285
- // If plugin returns undefined, fall back to default rendering
1455
+
1286
1456
  if (replacement === undefined) {
1457
+ // Plugin declined — fall back to default rendering.
1287
1458
  const langClass = !inline_styles && block.lang ? ` class="language-${block.lang}"` : '';
1288
1459
  const codeAttr = inline_styles ? getAttr('code') : langClass;
1460
+ /* istanbul ignore next - bd-only branch */
1289
1461
  const langAttr = bidirectional && block.lang ? ` data-qd-lang="${escapeHtml(block.lang)}"` : '';
1462
+ /* istanbul ignore next - bd-only branch */
1290
1463
  const fenceAttr = bidirectional ? ` data-qd-fence="${escapeHtml(block.fence)}"` : '';
1291
1464
  replacement = `<pre${getAttr('pre')}${fenceAttr}${langAttr}><code${codeAttr}>${escapeHtml(block.code)}</code></pre>`;
1292
- } else if (bidirectional) {
1293
- // If bidirectional and plugin provided HTML, add data attributes for roundtrip
1294
- replacement = replacement.replace(/^<(\w+)/,
1465
+ } else /* istanbul ignore next - bd-only branch */ if (bidirectional) {
1466
+ // Plugin returned HTML inject data attributes for roundtrip.
1467
+ replacement = replacement.replace(/^<(\w+)/,
1295
1468
  `<$1 data-qd-fence="${escapeHtml(block.fence)}" data-qd-lang="${escapeHtml(block.lang)}" data-qd-source="${escapeHtml(block.code)}"`);
1296
1469
  }
1297
1470
  } else {
1298
- // Default rendering
1471
+ // Default rendering — wrap in <pre><code>.
1299
1472
  const langClass = !inline_styles && block.lang ? ` class="language-${block.lang}"` : '';
1300
1473
  const codeAttr = inline_styles ? getAttr('code') : langClass;
1474
+ /* istanbul ignore next - bd-only branch */
1301
1475
  const langAttr = bidirectional && block.lang ? ` data-qd-lang="${escapeHtml(block.lang)}"` : '';
1476
+ /* istanbul ignore next - bd-only branch */
1302
1477
  const fenceAttr = bidirectional ? ` data-qd-fence="${escapeHtml(block.fence)}"` : '';
1303
1478
  replacement = `<pre${getAttr('pre')}${fenceAttr}${langAttr}><code${codeAttr}>${block.code}</code></pre>`;
1304
1479
  }
1305
-
1480
+
1306
1481
  const placeholder = `${PLACEHOLDER_CB}${i}§`;
1307
1482
  html = html.replace(placeholder, replacement);
1308
1483
  });
1309
-
1310
- // Restore inline code
1484
+
1485
+ // Restore inline code spans
1311
1486
  inlineCodes.forEach((code, i) => {
1312
1487
  const placeholder = `${PLACEHOLDER_IC}${i}§`;
1313
1488
  html = html.replace(placeholder, `<code${getAttr('code')}${dataQd('`')}>${code}</code>`);
1314
1489
  });
1315
-
1490
+
1316
1491
  return html.trim();
1317
1492
  }
1318
1493
 
1494
+ // ════════════════════════════════════════════════════════════════════
1495
+ // Block-level line scanner
1496
+ // ════════════════════════════════════════════════════════════════════
1497
+
1319
1498
  /**
1320
- * Process inline markdown formatting
1499
+ * scanLineBlocks single-pass line scanner for headings, HR, blockquotes
1500
+ *
1501
+ * Walks the text line by line. For each line it checks (in order):
1502
+ * 1. Heading — starts with 1-6 '#' followed by a space
1503
+ * 2. HR — line is entirely '---…' (3+ dashes, optional trailing space)
1504
+ * 3. Blockquote — starts with '&gt; ' (the > was already HTML-escaped)
1505
+ *
1506
+ * Lines that don't match any block pattern are passed through unchanged.
1507
+ *
1508
+ * This replaces three separate global regex passes from the pre-1.2.8
1509
+ * architecture with one structured scan.
1510
+ *
1511
+ * @param {string} text The document text (HTML-escaped, code extracted)
1512
+ * @param {Function} getAttr Attribute factory (class or style)
1513
+ * @param {Function} dataQd Bidirectional marker factory
1514
+ * @returns {string} Text with block-level elements rendered
1515
+ */
1516
+ function scanLineBlocks(text, getAttr, dataQd) {
1517
+ const lines = text.split('\n');
1518
+ const result = [];
1519
+ let i = 0;
1520
+
1521
+ while (i < lines.length) {
1522
+ const line = lines[i];
1523
+
1524
+ // ── Heading ──
1525
+ // Count leading '#' characters. Valid heading: 1-6 hashes then a space.
1526
+ // Example: "## Hello World ##" → <h2>Hello World</h2>
1527
+ let hashCount = 0;
1528
+ while (hashCount < line.length && hashCount < 7 && line[hashCount] === '#') {
1529
+ hashCount++;
1530
+ }
1531
+ if (hashCount >= 1 && hashCount <= 6 && line[hashCount] === ' ') {
1532
+ // Extract content after "# " and strip trailing hashes
1533
+ const content = line.slice(hashCount + 1).replace(/\s*#+\s*$/, '');
1534
+ const tag = 'h' + hashCount;
1535
+ result.push(`<${tag}${getAttr(tag)}${dataQd('#'.repeat(hashCount))}>${content}</${tag}>`);
1536
+ i++;
1537
+ continue;
1538
+ }
1539
+
1540
+ // ── Horizontal Rule ──
1541
+ // Three or more dashes, optional trailing whitespace, nothing else.
1542
+ if (isDashHRLine(line)) {
1543
+ result.push(`<hr${getAttr('hr')}>`);
1544
+ i++;
1545
+ continue;
1546
+ }
1547
+
1548
+ // ── Blockquote ──
1549
+ // After Phase 2, the '>' character has been escaped to '&gt;'.
1550
+ // Pattern: "&gt; content" or merged consecutive blockquotes.
1551
+ if (/^&gt;\s+/.test(line)) {
1552
+ result.push(`<blockquote${getAttr('blockquote')}>${line.replace(/^&gt;\s+/, '')}</blockquote>`);
1553
+ i++;
1554
+ continue;
1555
+ }
1556
+
1557
+ // ── Pass-through ──
1558
+ result.push(line);
1559
+ i++;
1560
+ }
1561
+
1562
+ // Merge consecutive blockquotes into a single element.
1563
+ // <blockquote>A</blockquote>\n<blockquote>B</blockquote>
1564
+ // → <blockquote>A\nB</blockquote>
1565
+ let joined = result.join('\n');
1566
+ joined = joined.replace(/<\/blockquote>\n<blockquote>/g, '\n');
1567
+ return joined;
1568
+ }
1569
+
1570
+ // ════════════════════════════════════════════════════════════════════
1571
+ // Table processing (line walker)
1572
+ // ════════════════════════════════════════════════════════════════════
1573
+
1574
+ /**
1575
+ * Inline markdown formatter for table cells.
1576
+ * Handles bold, italic, strikethrough, and code within cell text.
1577
+ * Links / images / autolinks are handled by the global inline pass
1578
+ * (Phase 3 Step 4) which runs after table processing.
1321
1579
  */
1322
1580
  function processInlineMarkdown(text, getAttr) {
1323
-
1324
- // Process inline formatting patterns
1325
1581
  const patterns = [
1326
1582
  [/\*\*(.+?)\*\*/g, 'strong'],
1327
1583
  [/__(.+?)__/g, 'strong'],
@@ -1330,27 +1586,32 @@
1330
1586
  [/~~(.+?)~~/g, 'del'],
1331
1587
  [/`([^`]+)`/g, 'code']
1332
1588
  ];
1333
-
1334
1589
  patterns.forEach(([pattern, tag]) => {
1335
1590
  text = text.replace(pattern, `<${tag}${getAttr(tag)}>$1</${tag}>`);
1336
1591
  });
1337
-
1338
1592
  return text;
1339
1593
  }
1340
1594
 
1341
1595
  /**
1342
- * Process markdown tables
1596
+ * processTable — line walker for markdown tables
1597
+ *
1598
+ * Walks through lines looking for runs of pipe-containing lines.
1599
+ * Each run is validated (must contain a separator row: |---|---|)
1600
+ * and rendered as an HTML <table>. Invalid runs are restored as-is.
1601
+ *
1602
+ * @param {string} text Full document text
1603
+ * @param {Function} getAttr Attribute factory
1604
+ * @returns {string} Text with tables rendered
1343
1605
  */
1344
1606
  function processTable(text, getAttr) {
1345
1607
  const lines = text.split('\n');
1346
1608
  const result = [];
1347
1609
  let inTable = false;
1348
1610
  let tableLines = [];
1349
-
1611
+
1350
1612
  for (let i = 0; i < lines.length; i++) {
1351
1613
  const line = lines[i].trim();
1352
-
1353
- // Check if this line looks like a table row (with or without trailing |)
1614
+
1354
1615
  if (line.includes('|') && (line.startsWith('|') || /[^\\|]/.test(line))) {
1355
1616
  if (!inTable) {
1356
1617
  inTable = true;
@@ -1358,14 +1619,11 @@
1358
1619
  }
1359
1620
  tableLines.push(line);
1360
1621
  } else {
1361
- // Not a table line
1362
1622
  if (inTable) {
1363
- // Process the accumulated table
1364
1623
  const tableHtml = buildTable(tableLines, getAttr);
1365
1624
  if (tableHtml) {
1366
1625
  result.push(tableHtml);
1367
1626
  } else {
1368
- // Not a valid table, restore original lines
1369
1627
  result.push(...tableLines);
1370
1628
  }
1371
1629
  inTable = false;
@@ -1374,8 +1632,8 @@
1374
1632
  result.push(lines[i]);
1375
1633
  }
1376
1634
  }
1377
-
1378
- // Handle table at end of text
1635
+
1636
+ // Handle table at end of document
1379
1637
  if (inTable && tableLines.length > 0) {
1380
1638
  const tableHtml = buildTable(tableLines, getAttr);
1381
1639
  if (tableHtml) {
@@ -1384,35 +1642,35 @@
1384
1642
  result.push(...tableLines);
1385
1643
  }
1386
1644
  }
1387
-
1645
+
1388
1646
  return result.join('\n');
1389
1647
  }
1390
1648
 
1391
1649
  /**
1392
- * Build an HTML table from markdown table lines
1650
+ * buildTable validate and render a table from accumulated lines
1651
+ *
1652
+ * @param {string[]} lines Array of pipe-containing lines
1653
+ * @param {Function} getAttr Attribute factory
1654
+ * @returns {string|null} HTML table string, or null if invalid
1393
1655
  */
1394
1656
  function buildTable(lines, getAttr) {
1395
-
1396
1657
  if (lines.length < 2) return null;
1397
-
1398
- // Check for separator line (second line should be the separator)
1658
+
1659
+ // Find the separator row (---|---|)
1399
1660
  let separatorIndex = -1;
1400
1661
  for (let i = 1; i < lines.length; i++) {
1401
- // Support separator with or without leading/trailing pipes
1402
1662
  if (/^\|?[\s\-:|]+\|?$/.test(lines[i]) && lines[i].includes('-')) {
1403
1663
  separatorIndex = i;
1404
1664
  break;
1405
1665
  }
1406
1666
  }
1407
-
1408
1667
  if (separatorIndex === -1) return null;
1409
-
1668
+
1410
1669
  const headerLines = lines.slice(0, separatorIndex);
1411
1670
  const bodyLines = lines.slice(separatorIndex + 1);
1412
-
1413
- // Parse alignment from separator
1671
+
1672
+ // Parse alignment from separator cells (:--- = left, :---: = center, ---: = right)
1414
1673
  const separator = lines[separatorIndex];
1415
- // Handle pipes at start/end or not
1416
1674
  const separatorCells = separator.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
1417
1675
  const alignments = separatorCells.map(cell => {
1418
1676
  const trimmed = cell.trim();
@@ -1420,31 +1678,28 @@
1420
1678
  if (trimmed.endsWith(':')) return 'right';
1421
1679
  return 'left';
1422
1680
  });
1423
-
1681
+
1424
1682
  let html = `<table${getAttr('table')}>\n`;
1425
-
1426
- // Build header
1427
- // Note: headerLines will always have length > 0 since separatorIndex starts from 1
1683
+
1684
+ // Header
1428
1685
  html += `<thead${getAttr('thead')}>\n`;
1429
1686
  headerLines.forEach(line => {
1430
- html += `<tr${getAttr('tr')}>\n`;
1431
- // Handle pipes at start/end or not
1432
- const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
1433
- cells.forEach((cell, i) => {
1434
- const alignStyle = alignments[i] && alignments[i] !== 'left' ? `text-align:${alignments[i]}` : '';
1435
- const processedCell = processInlineMarkdown(cell.trim(), getAttr);
1436
- html += `<th${getAttr('th', alignStyle)}>${processedCell}</th>\n`;
1437
- });
1438
- html += '</tr>\n';
1687
+ html += `<tr${getAttr('tr')}>\n`;
1688
+ const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
1689
+ cells.forEach((cell, i) => {
1690
+ const alignStyle = alignments[i] && alignments[i] !== 'left' ? `text-align:${alignments[i]}` : '';
1691
+ const processedCell = processInlineMarkdown(cell.trim(), getAttr);
1692
+ html += `<th${getAttr('th', alignStyle)}>${processedCell}</th>\n`;
1693
+ });
1694
+ html += '</tr>\n';
1439
1695
  });
1440
1696
  html += '</thead>\n';
1441
-
1442
- // Build body
1697
+
1698
+ // Body
1443
1699
  if (bodyLines.length > 0) {
1444
1700
  html += `<tbody${getAttr('tbody')}>\n`;
1445
1701
  bodyLines.forEach(line => {
1446
1702
  html += `<tr${getAttr('tr')}>\n`;
1447
- // Handle pipes at start/end or not
1448
1703
  const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
1449
1704
  cells.forEach((cell, i) => {
1450
1705
  const alignStyle = alignments[i] && alignments[i] !== 'left' ? `text-align:${alignments[i]}` : '';
@@ -1455,61 +1710,81 @@
1455
1710
  });
1456
1711
  html += '</tbody>\n';
1457
1712
  }
1458
-
1713
+
1459
1714
  html += '</table>';
1460
1715
  return html;
1461
1716
  }
1462
1717
 
1718
+ // ════════════════════════════════════════════════════════════════════
1719
+ // List processing (line walker)
1720
+ // ════════════════════════════════════════════════════════════════════
1721
+
1463
1722
  /**
1464
- * Process markdown lists (ordered and unordered)
1723
+ * processLists line walker for ordered, unordered, and task lists
1724
+ *
1725
+ * Scans each line for list markers (-, *, +, 1., 2., etc.) with
1726
+ * optional leading indentation for nesting. Non-list lines close
1727
+ * any open lists and pass through unchanged.
1728
+ *
1729
+ * Task lists (- [ ] / - [x]) are detected and rendered with
1730
+ * checkbox inputs.
1731
+ *
1732
+ * @param {string} text Full document text
1733
+ * @param {Function} getAttr Attribute factory
1734
+ * @param {boolean} inline_styles Whether to use inline styles
1735
+ * @param {boolean} bidirectional Whether to add data-qd markers
1736
+ * @returns {string} Text with lists rendered
1465
1737
  */
1466
1738
  function processLists(text, getAttr, inline_styles, bidirectional) {
1467
-
1468
1739
  const lines = text.split('\n');
1469
1740
  const result = [];
1470
- let listStack = []; // Track nested lists
1471
-
1472
- // Helper to escape HTML for data-qd attributes
1473
- const escapeHtml = (text) => text.replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[m]);
1741
+ const listStack = []; // tracks nesting: [{type:'ul', level:0}, …]
1742
+
1743
+ // Helper to escape HTML for data-qd attributes. List markers (`-`, `*`,
1744
+ // `+`, `1.`, etc.) never contain HTML-special chars, so the replace
1745
+ // callback is defensive-only and never actually fires in practice.
1746
+ /* istanbul ignore next - defensive: list markers never trigger escaping */
1747
+ const escapeHtml = (text) => text.replace(/[&<>"']/g,
1748
+ /* istanbul ignore next - defensive: list markers never contain HTML specials */
1749
+ m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[m]);
1750
+ /* istanbul ignore next - trivial no-op fallback; not exercised via bd bundle */
1474
1751
  const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';
1475
-
1752
+
1476
1753
  for (let i = 0; i < lines.length; i++) {
1477
1754
  const line = lines[i];
1478
1755
  const match = line.match(/^(\s*)([*\-+]|\d+\.)\s+(.+)$/);
1479
-
1756
+
1480
1757
  if (match) {
1481
1758
  const [, indent, marker, content] = match;
1482
1759
  const level = Math.floor(indent.length / 2);
1483
1760
  const isOrdered = /^\d+\./.test(marker);
1484
1761
  const listType = isOrdered ? 'ol' : 'ul';
1485
-
1486
- // Check for task list items
1762
+
1763
+ // Task list detection (only in unordered lists)
1487
1764
  let listItemContent = content;
1488
1765
  let taskListClass = '';
1489
1766
  const taskMatch = content.match(/^\[([x ])\]\s+(.*)$/i);
1490
1767
  if (taskMatch && !isOrdered) {
1491
1768
  const [, checked, taskContent] = taskMatch;
1492
1769
  const isChecked = checked.toLowerCase() === 'x';
1493
- const checkboxAttr = inline_styles
1494
- ? ' style="margin-right:.5em"'
1770
+ const checkboxAttr = inline_styles
1771
+ ? ' style="margin-right:.5em"'
1495
1772
  : ` class="${CLASS_PREFIX}task-checkbox"`;
1496
1773
  listItemContent = `<input type="checkbox"${checkboxAttr}${isChecked ? ' checked' : ''} disabled> ${taskContent}`;
1497
1774
  taskListClass = inline_styles ? ' style="list-style:none"' : ` class="${CLASS_PREFIX}task-item"`;
1498
1775
  }
1499
-
1500
- // Close deeper levels
1776
+
1777
+ // Close deeper nesting levels
1501
1778
  while (listStack.length > level + 1) {
1502
1779
  const list = listStack.pop();
1503
1780
  result.push(`</${list.type}>`);
1504
1781
  }
1505
-
1506
- // Open new level if needed
1782
+
1783
+ // Open new list or switch type at current level
1507
1784
  if (listStack.length === level) {
1508
- // Need to open a new list
1509
1785
  listStack.push({ type: listType, level });
1510
1786
  result.push(`<${listType}${getAttr(listType)}>`);
1511
1787
  } else if (listStack.length === level + 1) {
1512
- // Check if we need to switch list type
1513
1788
  const currentList = listStack[listStack.length - 1];
1514
1789
  if (currentList.type !== listType) {
1515
1790
  result.push(`</${currentList.type}>`);
@@ -1518,11 +1793,11 @@
1518
1793
  result.push(`<${listType}${getAttr(listType)}>`);
1519
1794
  }
1520
1795
  }
1521
-
1796
+
1522
1797
  const liAttr = taskListClass || getAttr('li');
1523
1798
  result.push(`<li${liAttr}${dataQd(marker)}>${listItemContent}</li>`);
1524
1799
  } else {
1525
- // Not a list item, close all lists
1800
+ // Not a list item close all open lists
1526
1801
  while (listStack.length > 0) {
1527
1802
  const list = listStack.pop();
1528
1803
  result.push(`</${list.type}>`);
@@ -1530,76 +1805,76 @@
1530
1805
  result.push(line);
1531
1806
  }
1532
1807
  }
1533
-
1534
- // Close any remaining lists
1808
+
1809
+ // Close any remaining open lists
1535
1810
  while (listStack.length > 0) {
1536
1811
  const list = listStack.pop();
1537
1812
  result.push(`</${list.type}>`);
1538
1813
  }
1539
-
1814
+
1540
1815
  return result.join('\n');
1541
1816
  }
1542
1817
 
1818
+ // ════════════════════════════════════════════════════════════════════
1819
+ // Static API
1820
+ // ════════════════════════════════════════════════════════════════════
1821
+
1543
1822
  /**
1544
- * Emit CSS styles for quikdown elements
1545
- * @param {string} prefix - Optional class prefix (default: 'quikdown-')
1546
- * @param {string} theme - Optional theme: 'light' (default) or 'dark'
1547
- * @returns {string} CSS string with quikdown styles
1823
+ * Emit CSS rules for all quikdown elements.
1824
+ *
1825
+ * @param {string} prefix Class prefix (default: 'quikdown-')
1826
+ * @param {string} theme 'light' (default) or 'dark'
1827
+ * @returns {string} CSS text
1548
1828
  */
1549
1829
  quikdown.emitStyles = function(prefix = 'quikdown-', theme = 'light') {
1550
1830
  const styles = QUIKDOWN_STYLES;
1551
-
1552
- // Define theme color overrides
1831
+
1553
1832
  const themeOverrides = {
1554
1833
  dark: {
1555
- '#f4f4f4': '#2a2a2a', // pre background
1556
- '#f0f0f0': '#2a2a2a', // code background
1557
- '#f2f2f2': '#2a2a2a', // th background
1558
- '#ddd': '#3a3a3a', // borders
1559
- '#06c': '#6db3f2', // links
1834
+ '#f4f4f4': '#2a2a2a', // pre background
1835
+ '#f0f0f0': '#2a2a2a', // code background
1836
+ '#f2f2f2': '#2a2a2a', // th background
1837
+ '#ddd': '#3a3a3a', // borders
1838
+ '#06c': '#6db3f2', // links
1560
1839
  _textColor: '#e0e0e0'
1561
1840
  },
1562
1841
  light: {
1563
- _textColor: '#333' // Explicit text color for light theme
1842
+ _textColor: '#333'
1564
1843
  }
1565
1844
  };
1566
-
1845
+
1567
1846
  let css = '';
1568
1847
  for (const [tag, style] of Object.entries(styles)) {
1569
1848
  let themedStyle = style;
1570
-
1571
- // Apply theme overrides if dark theme
1572
- if (theme === 'dark' && themeOverrides.dark) {
1573
- // Replace colors
1574
- for (const [oldColor, newColor] of Object.entries(themeOverrides.dark)) {
1575
- if (!oldColor.startsWith('_')) {
1576
- themedStyle = themedStyle.replace(new RegExp(oldColor, 'g'), newColor);
1577
- }
1578
- }
1579
-
1580
- // Add text color for certain elements in dark theme
1581
- const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote'];
1582
- if (needsTextColor.includes(tag)) {
1583
- themedStyle += `;color:${themeOverrides.dark._textColor}`;
1584
- }
1585
- } else if (theme === 'light' && themeOverrides.light) {
1586
- // Add explicit text color for light theme elements too
1587
- const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote'];
1588
- if (needsTextColor.includes(tag)) {
1589
- themedStyle += `;color:${themeOverrides.light._textColor}`;
1849
+
1850
+ if (theme === 'dark' && themeOverrides.dark) {
1851
+ for (const [oldColor, newColor] of Object.entries(themeOverrides.dark)) {
1852
+ if (!oldColor.startsWith('_')) {
1853
+ themedStyle = themedStyle.replaceAll(oldColor, newColor);
1590
1854
  }
1591
1855
  }
1592
-
1856
+ const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote'];
1857
+ if (needsTextColor.includes(tag)) {
1858
+ themedStyle += `;color:${themeOverrides.dark._textColor}`;
1859
+ }
1860
+ } else if (theme === 'light' && themeOverrides.light) {
1861
+ const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote'];
1862
+ if (needsTextColor.includes(tag)) {
1863
+ themedStyle += `;color:${themeOverrides.light._textColor}`;
1864
+ }
1865
+ }
1866
+
1593
1867
  css += `.${prefix}${tag} { ${themedStyle} }\n`;
1594
1868
  }
1595
-
1869
+
1596
1870
  return css;
1597
1871
  };
1598
1872
 
1599
1873
  /**
1600
- * Configure quikdown with options and return a function
1601
- * @param {Object} options - Configuration options
1602
- * @returns {Function} Configured quikdown function
1874
+ * Create a pre-configured parser with baked-in options.
1875
+ *
1876
+ * @param {Object} options Options to bake in
1877
+ * @returns {Function} Configured quikdown(markdown) function
1603
1878
  */
1604
1879
  quikdown.configure = function(options) {
1605
1880
  return function(markdown) {
@@ -1607,18 +1882,18 @@
1607
1882
  };
1608
1883
  };
1609
1884
 
1610
- /**
1611
- * Version information
1612
- */
1885
+ /** Semantic version (injected at build time) */
1613
1886
  quikdown.version = quikdownVersion;
1614
1887
 
1615
- // Export for both CommonJS and ES6
1888
+ // ════════════════════════════════════════════════════════════════════
1889
+ // Exports
1890
+ // ════════════════════════════════════════════════════════════════════
1891
+
1616
1892
  /* istanbul ignore next */
1617
1893
  if (typeof module !== 'undefined' && module.exports) {
1618
1894
  module.exports = quikdown;
1619
1895
  }
1620
1896
 
1621
- // For browser global
1622
1897
  /* istanbul ignore next */
1623
1898
  if (typeof window !== 'undefined') {
1624
1899
  window.quikdown = quikdown;