quikchat 1.2.4 → 1.2.7

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