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