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