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