quikchat 1.2.4 → 1.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +2 -2
  2. package/dist/build-manifest.json +73 -80
  3. package/dist/quikchat-md-full.cjs.js +536 -255
  4. package/dist/quikchat-md-full.cjs.js.map +1 -1
  5. package/dist/quikchat-md-full.cjs.min.js +3 -3
  6. package/dist/quikchat-md-full.cjs.min.js.map +1 -1
  7. package/dist/quikchat-md-full.esm.js +536 -255
  8. package/dist/quikchat-md-full.esm.js.map +1 -1
  9. package/dist/quikchat-md-full.esm.min.js +3 -3
  10. package/dist/quikchat-md-full.esm.min.js.map +1 -1
  11. package/dist/quikchat-md-full.umd.js +536 -255
  12. package/dist/quikchat-md-full.umd.js.map +1 -1
  13. package/dist/quikchat-md-full.umd.min.js +3 -3
  14. package/dist/quikchat-md-full.umd.min.js.map +1 -1
  15. package/dist/quikchat-md.cjs.js +520 -245
  16. package/dist/quikchat-md.cjs.js.map +1 -1
  17. package/dist/quikchat-md.cjs.min.js +3 -3
  18. package/dist/quikchat-md.cjs.min.js.map +1 -1
  19. package/dist/quikchat-md.esm.js +520 -245
  20. package/dist/quikchat-md.esm.js.map +1 -1
  21. package/dist/quikchat-md.esm.min.js +3 -3
  22. package/dist/quikchat-md.esm.min.js.map +1 -1
  23. package/dist/quikchat-md.umd.js +520 -245
  24. package/dist/quikchat-md.umd.js.map +1 -1
  25. package/dist/quikchat-md.umd.min.js +3 -3
  26. package/dist/quikchat-md.umd.min.js.map +1 -1
  27. package/dist/quikchat.cjs.js +2 -2
  28. package/dist/quikchat.cjs.js.map +1 -1
  29. package/dist/quikchat.cjs.min.js +1 -1
  30. package/dist/quikchat.cjs.min.js.map +1 -1
  31. package/dist/quikchat.css +351 -120
  32. package/dist/quikchat.esm.js +2 -2
  33. package/dist/quikchat.esm.js.map +1 -1
  34. package/dist/quikchat.esm.min.js +1 -1
  35. package/dist/quikchat.esm.min.js.map +1 -1
  36. package/dist/quikchat.min.css +1 -1
  37. package/dist/quikchat.umd.js +2 -2
  38. package/dist/quikchat.umd.js.map +1 -1
  39. package/dist/quikchat.umd.min.js +1 -1
  40. package/dist/quikchat.umd.min.js.map +1 -1
  41. package/package.json +6 -5
@@ -600,7 +600,7 @@
600
600
  value: function _autoGrowTextarea() {
601
601
  var el = this._textEntry;
602
602
  el.style.height = 'auto';
603
- var maxHeight = parseInt(getComputedStyle(el).getPropertyValue('--quikchat-input-max-height')) || 120;
603
+ var maxHeight = 120;
604
604
  el.style.height = Math.min(el.scrollHeight, maxHeight) + 'px';
605
605
  el.style.overflowY = el.scrollHeight > maxHeight ? 'auto' : 'hidden';
606
606
  }
@@ -1068,7 +1068,7 @@
1068
1068
  key: "version",
1069
1069
  value: function version() {
1070
1070
  return {
1071
- "version": "1.2.4",
1071
+ "version": "1.2.6",
1072
1072
  "license": "BSD-2",
1073
1073
  "url": "https://github/deftio/quikchat"
1074
1074
  };
@@ -1128,35 +1128,140 @@
1128
1128
 
1129
1129
  /**
1130
1130
  * quikdown_bd - Bidirectional Markdown Parser
1131
- * @version 1.1.1
1131
+ * @version 1.2.10
1132
1132
  * @license BSD-2-Clause
1133
1133
  * @copyright DeftIO 2025
1134
1134
  */
1135
1135
  /**
1136
- * quikdown - A minimal markdown parser optimized for chat/LLM output
1137
- * Supports tables, code blocks, lists, and common formatting
1138
- * @param {string} markdown - The markdown source text
1139
- * @param {Object} options - Optional configuration object
1140
- * @param {Function} options.fence_plugin - Custom renderer for fenced code blocks
1141
- * (content, fence_string) => html string
1142
- * @param {boolean} options.inline_styles - If true, uses inline styles instead of classes
1143
- * @param {boolean} options.bidirectional - If true, adds data-qd attributes for source tracking
1144
- * @param {boolean} options.lazy_linefeeds - If true, single newlines become <br> tags
1145
- * @returns {string} - The rendered HTML
1136
+ * quikdown_classify Shared line-classification utilities
1137
+ * ═════════════════════════════════════════════════════════
1138
+ *
1139
+ * Pure functions for classifying markdown lines. Used by both the main
1140
+ * parser (quikdown.js) and the editor (quikdown_edit.js) so the logic
1141
+ * lives in one place.
1142
+ *
1143
+ * All functions operate on a **trimmed** line (caller must trim).
1144
+ * None use regexes with nested quantifiers every check is either a
1145
+ * simple regex or a linear scan, so there is zero ReDoS risk.
1146
1146
  */
1147
1147
 
1148
- // Version will be injected at build time
1149
- const quikdownVersion = '1.1.1';
1150
1148
 
1151
- // Constants for reuse
1149
+ /**
1150
+ * Dash-only HR check — exact parity with the main parser's original
1151
+ * regex `/^---+\s*$/`. Only matches lines of three or more dashes
1152
+ * with optional trailing whitespace (no interspersed spaces).
1153
+ *
1154
+ * @param {string} trimmed The line, already trimmed
1155
+ * @returns {boolean}
1156
+ */
1157
+ function isDashHRLine(trimmed) {
1158
+ if (trimmed.length < 3) return false;
1159
+ for (let i = 0; i < trimmed.length; i++) {
1160
+ const ch = trimmed[i];
1161
+ if (ch === '-') continue;
1162
+ // Allow trailing whitespace only
1163
+ if (ch === ' ' || ch === '\t') {
1164
+ for (let j = i + 1; j < trimmed.length; j++) {
1165
+ if (trimmed[j] !== ' ' && trimmed[j] !== '\t') return false;
1166
+ }
1167
+ return i >= 3; // at least 3 dashes before whitespace
1168
+ }
1169
+ return false;
1170
+ }
1171
+ return true; // all dashes
1172
+ }
1173
+
1174
+ /**
1175
+ * quikdown — A compact, scanner-based markdown parser
1176
+ * ════════════════════════════════════════════════════
1177
+ *
1178
+ * Architecture overview (v1.2.8 — lexer rewrite)
1179
+ * ───────────────────────────────────────────────
1180
+ * Prior to v1.2.8, quikdown used a multi-pass regex pipeline: each block
1181
+ * type (headings, blockquotes, HR, lists, tables) and each inline format
1182
+ * (bold, italic, links, …) was handled by its own global regex applied
1183
+ * sequentially to the full document string. That worked but made the code
1184
+ * hard to extend and debug — a new construct meant adding another regex
1185
+ * pass, and ordering bugs between passes were subtle.
1186
+ *
1187
+ * Starting in v1.2.8 the parser uses a **line-scanning** approach for
1188
+ * block detection and a **per-block inline pass** for formatting:
1189
+ *
1190
+ * ┌─────────────────────────────────────────────────────────┐
1191
+ * │ Phase 1 — Code Extraction │
1192
+ * │ Scan for fenced code blocks (``` / ~~~) and inline │
1193
+ * │ code spans (`…`). Replace with §CB§ / §IC§ place- │
1194
+ * │ holders so code content is never touched by later │
1195
+ * │ phases. │
1196
+ * ├─────────────────────────────────────────────────────────┤
1197
+ * │ Phase 2 — HTML Escaping │
1198
+ * │ Escape &, <, >, ", ' in the remaining text to prevent │
1199
+ * │ XSS. (Skipped when allow_unsafe_html is true.) │
1200
+ * ├─────────────────────────────────────────────────────────┤
1201
+ * │ Phase 3 — Block Scanning │
1202
+ * │ Walk the text **line by line**. At each line, the │
1203
+ * │ scanner checks (in order): │
1204
+ * │ • table rows (|) │
1205
+ * │ • headings (#) │
1206
+ * │ • HR (---) │
1207
+ * │ • blockquotes (&gt;) │
1208
+ * │ • list items (-, *, +, 1.) │
1209
+ * │ • code-block placeholder (§CB…§) │
1210
+ * │ • paragraph text (everything else) │
1211
+ * │ │
1212
+ * │ Block text is run through the **inline formatter** │
1213
+ * │ which handles bold, italic, strikethrough, links, │
1214
+ * │ images, and autolinks. │
1215
+ * │ │
1216
+ * │ Paragraphs are wrapped in <p> tags. Lazy linefeeds │
1217
+ * │ (single \n → <br>) are handled here too. │
1218
+ * ├─────────────────────────────────────────────────────────┤
1219
+ * │ Phase 4 — Code Restoration │
1220
+ * │ Replace §CB§ / §IC§ placeholders with rendered <pre> │
1221
+ * │ / <code> HTML, applying the fence_plugin if present. │
1222
+ * └─────────────────────────────────────────────────────────┘
1223
+ *
1224
+ * Why this design?
1225
+ * • Single pass over lines for block identification — no re-scanning.
1226
+ * • Each block type is a clearly separated branch, easy to add new ones.
1227
+ * • Inline formatting is confined to block text — can't accidentally
1228
+ * match across block boundaries or inside HTML tags.
1229
+ * • Code extraction still uses a simple regex (it's one pattern, not a
1230
+ * chain) because the §-placeholder approach is proven and simple.
1231
+ *
1232
+ * @param {string} markdown The markdown source text
1233
+ * @param {Object} options Configuration (see below)
1234
+ * @returns {string} Rendered HTML
1235
+ */
1236
+
1237
+
1238
+ // ────────────────────────────────────────────────────────────────────
1239
+ // Constants
1240
+ // ────────────────────────────────────────────────────────────────────
1241
+
1242
+ /** Build-time version stamp (injected by tools/updateVersion) */
1243
+ const quikdownVersion = '1.2.10';
1244
+
1245
+ /** CSS class prefix used for all generated elements */
1152
1246
  const CLASS_PREFIX = 'quikdown-';
1153
- const PLACEHOLDER_CB = '§CB';
1154
- const PLACEHOLDER_IC = '§IC';
1155
1247
 
1156
- // Escape map at module level
1248
+ /** Placeholder sigils chosen to be extremely unlikely in real text */
1249
+ const PLACEHOLDER_CB = '§CB'; // fenced code blocks
1250
+ const PLACEHOLDER_IC = '§IC'; // inline code spans
1251
+
1252
+ /** HTML entity escape map */
1157
1253
  const ESC_MAP = {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'};
1158
1254
 
1159
- // Single source of truth for all style definitions - optimized
1255
+ // ────────────────────────────────────────────────────────────────────
1256
+ // Style definitions
1257
+ // ────────────────────────────────────────────────────────────────────
1258
+
1259
+ /**
1260
+ * Inline styles for every element quikdown can emit.
1261
+ * When `inline_styles: true` these are injected as style="…" attributes.
1262
+ * When `inline_styles: false` (default) we use class="quikdown-<tag>"
1263
+ * and these same values are emitted by `quikdown.emitStyles()`.
1264
+ */
1160
1265
  const QUIKDOWN_STYLES = {
1161
1266
  h1: 'font-size:2em;font-weight:600;margin:.67em 0;text-align:left',
1162
1267
  h2: 'font-size:1.5em;font-weight:600;margin:.83em 0',
@@ -1179,30 +1284,41 @@
1179
1284
  ul: 'margin:.5em 0;padding-left:2em',
1180
1285
  ol: 'margin:.5em 0;padding-left:2em',
1181
1286
  li: 'margin:.25em 0',
1182
- // Task list specific styles
1183
1287
  'task-item': 'list-style:none',
1184
1288
  'task-checkbox': 'margin-right:.5em'
1185
1289
  };
1186
1290
 
1187
- // Factory function to create getAttr for a given context
1291
+ // ────────────────────────────────────────────────────────────────────
1292
+ // Attribute factory
1293
+ // ────────────────────────────────────────────────────────────────────
1294
+
1295
+ /**
1296
+ * Creates a `getAttr(tag, additionalStyle?)` helper that returns
1297
+ * either a class="…" or style="…" attribute string depending on mode.
1298
+ *
1299
+ * @param {boolean} inline_styles True → emit style="…"; false → class="…"
1300
+ * @param {Object} styles The QUIKDOWN_STYLES map
1301
+ * @returns {Function}
1302
+ */
1188
1303
  function createGetAttr(inline_styles, styles) {
1189
1304
  return function(tag, additionalStyle = '') {
1190
1305
  if (inline_styles) {
1191
1306
  let style = styles[tag];
1192
1307
  if (!style && !additionalStyle) return '';
1193
-
1194
- // Remove default text-align if we're adding a different alignment
1308
+
1309
+ // When adding alignment that conflicts with the tag's default,
1310
+ // strip the default text-align first.
1195
1311
  if (additionalStyle && additionalStyle.includes('text-align') && style && style.includes('text-align')) {
1196
1312
  style = style.replace(/text-align:[^;]+;?/, '').trim();
1313
+ /* istanbul ignore next */
1197
1314
  if (style && !style.endsWith(';')) style += ';';
1198
1315
  }
1199
-
1316
+
1200
1317
  /* istanbul ignore next - defensive: additionalStyle without style doesn't occur with current tags */
1201
1318
  const fullStyle = additionalStyle ? (style ? `${style}${additionalStyle}` : additionalStyle) : style;
1202
1319
  return ` style="${fullStyle}"`;
1203
1320
  } else {
1204
1321
  const classAttr = ` class="${CLASS_PREFIX}${tag}"`;
1205
- // Apply inline styles for alignment even when using CSS classes
1206
1322
  if (additionalStyle) {
1207
1323
  return `${classAttr} style="${additionalStyle}"`;
1208
1324
  }
@@ -1211,69 +1327,84 @@
1211
1327
  };
1212
1328
  }
1213
1329
 
1330
+ // ════════════════════════════════════════════════════════════════════
1331
+ // Main parser function
1332
+ // ════════════════════════════════════════════════════════════════════
1333
+
1214
1334
  function quikdown(markdown, options = {}) {
1335
+ // ── Guard: only process non-empty strings ──
1215
1336
  if (!markdown || typeof markdown !== 'string') {
1216
1337
  return '';
1217
1338
  }
1218
-
1219
- const { fence_plugin, inline_styles = false, bidirectional = false, lazy_linefeeds = false } = options;
1220
- const styles = QUIKDOWN_STYLES; // Use module-level styles
1221
- const getAttr = createGetAttr(inline_styles, styles); // Create getAttr once
1222
1339
 
1223
- // Escape HTML entities to prevent XSS
1340
+ // ── Unpack options ──
1341
+ const { fence_plugin, inline_styles = false, bidirectional = false, lazy_linefeeds = false, allow_unsafe_html = false } = options;
1342
+ const styles = QUIKDOWN_STYLES;
1343
+ const getAttr = createGetAttr(inline_styles, styles);
1344
+
1345
+ // ── Helpers (closed over options) ──
1346
+
1347
+ /** Escape the five HTML-special characters. */
1224
1348
  function escapeHtml(text) {
1225
1349
  return text.replace(/[&<>"']/g, m => ESC_MAP[m]);
1226
1350
  }
1227
-
1228
- // Helper to add data-qd attributes for bidirectional support
1351
+
1352
+ /**
1353
+ * Bidirectional marker helper.
1354
+ * When bidirectional mode is on, returns ` data-qd="…"`.
1355
+ * The non-bidirectional branch is a trivial no-op arrow; it is
1356
+ * exercised in the core bundle but never in quikdown_bd.
1357
+ */
1358
+ /* istanbul ignore next - trivial no-op fallback */
1229
1359
  const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';
1230
-
1231
- // Sanitize URLs to prevent XSS attacks
1360
+
1361
+ /**
1362
+ * Sanitize a URL to block javascript:, vbscript:, and non-image data: URIs.
1363
+ * Returns '#' for blocked URLs.
1364
+ */
1232
1365
  function sanitizeUrl(url, allowUnsafe = false) {
1233
1366
  /* istanbul ignore next - defensive programming, regex ensures url is never empty */
1234
1367
  if (!url) return '';
1235
-
1236
- // If unsafe URLs are explicitly allowed, return as-is
1237
1368
  if (allowUnsafe) return url;
1238
-
1369
+
1239
1370
  const trimmedUrl = url.trim();
1240
1371
  const lowerUrl = trimmedUrl.toLowerCase();
1241
-
1242
- // Block dangerous protocols
1243
1372
  const dangerousProtocols = ['javascript:', 'vbscript:', 'data:'];
1244
-
1373
+
1245
1374
  for (const protocol of dangerousProtocols) {
1246
1375
  if (lowerUrl.startsWith(protocol)) {
1247
- // Exception: Allow data:image/* for images
1248
1376
  if (protocol === 'data:' && lowerUrl.startsWith('data:image/')) {
1249
1377
  return trimmedUrl;
1250
1378
  }
1251
- // Return safe empty link for dangerous protocols
1252
1379
  return '#';
1253
1380
  }
1254
1381
  }
1255
-
1256
1382
  return trimmedUrl;
1257
1383
  }
1258
1384
 
1259
- // Process the markdown in phases
1385
+ // ────────────────────────────────────────────────────────────────
1386
+ // Phase 1 — Code Extraction
1387
+ // ────────────────────────────────────────────────────────────────
1388
+ // Why extract code first? Fenced blocks and inline code spans can
1389
+ // contain markdown-like characters (*, _, #, |, etc.) that must NOT
1390
+ // be interpreted as formatting. By pulling them out and replacing
1391
+ // with unique placeholders, the rest of the pipeline never sees them.
1392
+
1260
1393
  let html = markdown;
1261
-
1262
- // Phase 1: Extract and protect code blocks and inline code
1263
- const codeBlocks = [];
1264
- const inlineCodes = [];
1265
-
1266
- // Extract fenced code blocks first (supports both ``` and ~~~)
1267
- // Match paired fences - ``` with ``` and ~~~ with ~~~
1268
- // Fence must be at start of line
1394
+ const codeBlocks = []; // Array of {lang, code, custom, fence, hasReverse}
1395
+ const inlineCodes = []; // Array of escaped-HTML strings
1396
+
1397
+ // ── Fenced code blocks ──
1398
+ // Matches paired fences: ``` with ``` and ~~~ with ~~~.
1399
+ // The fence must start at column 0 of a line (^ with /m flag).
1400
+ // Group 1 = fence marker, Group 2 = language hint, Group 3 = code body.
1269
1401
  html = html.replace(/^(```|~~~)([^\n]*)\n([\s\S]*?)^\1$/gm, (match, fence, lang, code) => {
1270
1402
  const placeholder = `${PLACEHOLDER_CB}${codeBlocks.length}§`;
1271
-
1272
- // Trim the language specification
1273
1403
  const langTrimmed = lang ? lang.trim() : '';
1274
-
1275
- // If custom fence plugin is provided, use it (v1.1.0: object format required)
1404
+
1276
1405
  if (fence_plugin && fence_plugin.render && typeof fence_plugin.render === 'function') {
1406
+ // Custom plugin — store raw code (un-escaped) so the plugin
1407
+ // receives the original source.
1277
1408
  codeBlocks.push({
1278
1409
  lang: langTrimmed,
1279
1410
  code: code.trimEnd(),
@@ -1282,6 +1413,7 @@
1282
1413
  hasReverse: !!fence_plugin.reverse
1283
1414
  });
1284
1415
  } else {
1416
+ // Default — pre-escape the code for safe HTML output.
1285
1417
  codeBlocks.push({
1286
1418
  lang: langTrimmed,
1287
1419
  code: escapeHtml(code.trimEnd()),
@@ -1291,66 +1423,97 @@
1291
1423
  }
1292
1424
  return placeholder;
1293
1425
  });
1294
-
1295
- // Extract inline code
1426
+
1427
+ // ── Inline code spans ──
1428
+ // Matches a single backtick pair: `content`.
1429
+ // Content is captured and HTML-escaped immediately.
1296
1430
  html = html.replace(/`([^`]+)`/g, (match, code) => {
1297
1431
  const placeholder = `${PLACEHOLDER_IC}${inlineCodes.length}§`;
1298
1432
  inlineCodes.push(escapeHtml(code));
1299
1433
  return placeholder;
1300
1434
  });
1301
-
1302
- // Now escape HTML in the rest of the content
1303
- html = escapeHtml(html);
1304
-
1305
- // Phase 2: Process block elements
1306
-
1307
- // Process tables
1435
+
1436
+ // ────────────────────────────────────────────────────────────────
1437
+ // Phase 2 — HTML Escaping
1438
+ // ────────────────────────────────────────────────────────────────
1439
+ // All remaining text (everything except code placeholders) is escaped
1440
+ // to prevent XSS. The `allow_unsafe_html` option skips this for
1441
+ // trusted pipelines that intentionally embed raw HTML.
1442
+
1443
+ if (!allow_unsafe_html) {
1444
+ html = escapeHtml(html);
1445
+ }
1446
+
1447
+ // ────────────────────────────────────────────────────────────────
1448
+ // Phase 3 — Block Scanning + Inline Formatting + Paragraphs
1449
+ // ────────────────────────────────────────────────────────────────
1450
+ // This is the heart of the lexer rewrite. Instead of applying
1451
+ // 10+ global regex passes, we:
1452
+ // 1. Process tables (line walker — tables need multi-line lookahead)
1453
+ // 2. Scan remaining lines for headings, HR, blockquotes
1454
+ // 3. Process lists (line walker — lists need indent tracking)
1455
+ // 4. Apply inline formatting to all text content
1456
+ // 5. Wrap remaining text in <p> tags
1457
+ //
1458
+ // Steps 1 and 3 are line-walkers that process the full text in a
1459
+ // single pass each. Step 2 replaces global regex with a per-line
1460
+ // scanner. Steps 4-5 are applied to the result.
1461
+ //
1462
+ // Total: 3 structured passes instead of 10+ regex passes.
1463
+
1464
+ // ── Step 1: Tables ──
1465
+ // Tables need multi-line lookahead (header → separator → body rows)
1466
+ // so they're handled by a dedicated line-walker first.
1308
1467
  html = processTable(html, getAttr);
1309
-
1310
- // Process headings (supports optional trailing #'s)
1311
- html = html.replace(/^(#{1,6})\s+(.+?)\s*#*$/gm, (match, hashes, content) => {
1312
- const level = hashes.length;
1313
- return `<h${level}${getAttr('h' + level)}${dataQd(hashes)}>${content}</h${level}>`;
1314
- });
1315
-
1316
- // Process blockquotes (must handle escaped > since we already escaped HTML)
1317
- html = html.replace(/^&gt;\s+(.+)$/gm, `<blockquote${getAttr('blockquote')}>$1</blockquote>`);
1318
- // Merge consecutive blockquotes
1319
- html = html.replace(/<\/blockquote>\n<blockquote>/g, '\n');
1320
-
1321
- // Process horizontal rules (allow trailing spaces)
1322
- html = html.replace(/^---+\s*$/gm, `<hr${getAttr('hr')}>`);
1323
-
1324
- // Process lists
1468
+
1469
+ // ── Step 2: Headings, HR, Blockquotes ──
1470
+ // These are simple line-level constructs. We scan each line once
1471
+ // and replace matching lines with their HTML representation.
1472
+ html = scanLineBlocks(html, getAttr, dataQd);
1473
+
1474
+ // ── Step 3: Lists ──
1475
+ // Lists need indent-level tracking across lines, so they get their
1476
+ // own line-walker.
1325
1477
  html = processLists(html, getAttr, inline_styles, bidirectional);
1326
-
1327
- // Phase 3: Process inline elements
1328
-
1329
- // Images (must come before links, with URL sanitization)
1478
+
1479
+ // ── Step 4: Inline formatting ──
1480
+ // Apply bold, italic, strikethrough, images, links, and autolinks
1481
+ // to all text content. This runs on the output of steps 1-3, so
1482
+ // it sees text inside headings, blockquotes, table cells, list
1483
+ // items, and paragraph text.
1484
+
1485
+ // Images (must come before links — ![alt](src) vs [text](url))
1330
1486
  html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
1331
1487
  const sanitizedSrc = sanitizeUrl(src, options.allow_unsafe_urls);
1488
+ /* istanbul ignore next - bd-only branch */
1332
1489
  const altAttr = bidirectional && alt ? ` data-qd-alt="${escapeHtml(alt)}"` : '';
1490
+ /* istanbul ignore next - bd-only branch */
1333
1491
  const srcAttr = bidirectional ? ` data-qd-src="${escapeHtml(src)}"` : '';
1334
1492
  return `<img${getAttr('img')} src="${sanitizedSrc}" alt="${alt}"${altAttr}${srcAttr}${dataQd('!')}>`;
1335
1493
  });
1336
-
1337
- // Links (with URL sanitization)
1494
+
1495
+ // Links
1338
1496
  html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, href) => {
1339
- // Sanitize URL to prevent XSS
1340
1497
  const sanitizedHref = sanitizeUrl(href, options.allow_unsafe_urls);
1341
1498
  const isExternal = /^https?:\/\//i.test(sanitizedHref);
1342
1499
  const rel = isExternal ? ' rel="noopener noreferrer"' : '';
1500
+ /* istanbul ignore next - bd-only branch */
1343
1501
  const textAttr = bidirectional ? ` data-qd-text="${escapeHtml(text)}"` : '';
1344
1502
  return `<a${getAttr('a')} href="${sanitizedHref}"${rel}${textAttr}${dataQd('[')}>${text}</a>`;
1345
1503
  });
1346
-
1347
- // Autolinks - convert bare URLs to clickable links
1504
+
1505
+ // Autolinks bare https?:// URLs become clickable <a> tags
1348
1506
  html = html.replace(/(^|\s)(https?:\/\/[^\s<]+)/g, (match, prefix, url) => {
1349
1507
  const sanitizedUrl = sanitizeUrl(url, options.allow_unsafe_urls);
1350
1508
  return `${prefix}<a${getAttr('a')} href="${sanitizedUrl}" rel="noopener noreferrer">${url}</a>`;
1351
1509
  });
1352
-
1353
- // Process inline formatting (bold, italic, strikethrough)
1510
+
1511
+ // Protect rendered tags so emphasis regexes don't see attribute
1512
+ // values — fixes #3 (underscores in URLs interpreted as emphasis).
1513
+ const savedTags = [];
1514
+ html = html.replace(/<[^>]+>/g, m => { savedTags.push(m); return `%%T${savedTags.length - 1}%%`; });
1515
+
1516
+ // Bold, italic, strikethrough
1354
1517
  const inlinePatterns = [
1355
1518
  [/\*\*(.+?)\*\*/g, 'strong', '**'],
1356
1519
  [/__(.+?)__/g, 'strong', '__'],
@@ -1358,60 +1521,66 @@
1358
1521
  [/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, 'em', '_'],
1359
1522
  [/~~(.+?)~~/g, 'del', '~~']
1360
1523
  ];
1361
-
1362
1524
  inlinePatterns.forEach(([pattern, tag, marker]) => {
1363
1525
  html = html.replace(pattern, `<${tag}${getAttr(tag)}${dataQd(marker)}>$1</${tag}>`);
1364
1526
  });
1365
-
1366
- // Line breaks
1527
+
1528
+ // Restore protected tags
1529
+ html = html.replace(/%%T(\d+)%%/g, (_, i) => savedTags[i]);
1530
+
1531
+ // ── Step 5: Line breaks + paragraph wrapping ──
1367
1532
  if (lazy_linefeeds) {
1368
- // Lazy linefeeds: single newline becomes <br> (except between paragraphs and after/before block elements)
1533
+ // Lazy linefeeds mode: every single \n becomes <br> EXCEPT:
1534
+ // • Double newlines → paragraph break
1535
+ // • Newlines adjacent to block elements (h, blockquote, pre, hr, table, list)
1536
+ //
1537
+ // Strategy: protect block-adjacent newlines with §N§, convert
1538
+ // the rest, then restore.
1539
+
1369
1540
  const blocks = [];
1370
1541
  let bi = 0;
1371
-
1372
- // Protect tables and lists
1542
+
1543
+ // Protect tables and lists from <br> injection
1373
1544
  html = html.replace(/<(table|[uo]l)[^>]*>[\s\S]*?<\/\1>/g, m => {
1374
1545
  blocks[bi] = m;
1375
1546
  return `§B${bi++}§`;
1376
1547
  });
1377
-
1378
- // Handle paragraphs and block elements
1548
+
1379
1549
  html = html.replace(/\n\n+/g, '§P§')
1380
- // After block elements
1550
+ // After block-level closing tags
1381
1551
  .replace(/(<\/(?:h[1-6]|blockquote|pre)>)\n/g, '$1§N§')
1382
1552
  .replace(/(<(?:h[1-6]|blockquote|pre|hr)[^>]*>)\n/g, '$1§N§')
1383
- // Before block elements
1553
+ // Before block-level opening tags
1384
1554
  .replace(/\n(<(?:h[1-6]|blockquote|pre|hr)[^>]*>)/g, '§N§$1')
1385
1555
  .replace(/\n(§B\d+§)/g, '§N§$1')
1386
1556
  .replace(/(§B\d+§)\n/g, '$1§N§')
1387
- // Convert remaining newlines
1557
+ // Convert surviving newlines to <br>
1388
1558
  .replace(/\n/g, `<br${getAttr('br')}>`)
1389
1559
  // Restore
1390
1560
  .replace(/§N§/g, '\n')
1391
1561
  .replace(/§P§/g, '</p><p>');
1392
-
1562
+
1393
1563
  // Restore protected blocks
1394
1564
  blocks.forEach((b, i) => html = html.replace(`§B${i}§`, b));
1395
-
1565
+
1396
1566
  html = '<p>' + html + '</p>';
1397
1567
  } else {
1398
- // Standard: two spaces at end of line for line breaks
1399
- html = html.replace(/ $/gm, `<br${getAttr('br')}>`);
1400
-
1401
- // Paragraphs (double newlines)
1402
- // Don't add </p> after block elements (they're not in paragraphs)
1568
+ // Standard mode: two trailing spaces <br>, double newline new paragraph
1569
+ html = html.replace(/ {2}$/gm, `<br${getAttr('br')}>`);
1570
+
1403
1571
  html = html.replace(/\n\n+/g, (match, offset) => {
1404
- // Check if we're after a block element closing tag
1405
1572
  const before = html.substring(0, offset);
1406
1573
  if (before.match(/<\/(h[1-6]|blockquote|ul|ol|table|pre|hr)>$/)) {
1407
- return '<p>'; // Just open a new paragraph
1574
+ return '<p>';
1408
1575
  }
1409
- return '</p><p>'; // Normal paragraph break
1576
+ return '</p><p>';
1410
1577
  });
1411
1578
  html = '<p>' + html + '</p>';
1412
1579
  }
1413
-
1414
- // Clean up empty paragraphs and unwrap block elements
1580
+
1581
+ // ── Step 6: Cleanup ──
1582
+ // Remove <p> wrappers that accidentally enclose block elements.
1583
+ // This is simpler than trying to prevent them during wrapping.
1415
1584
  const cleanupPatterns = [
1416
1585
  [/<p><\/p>/g, ''],
1417
1586
  [/<p>(<h[1-6][^>]*>)/g, '$1'],
@@ -1425,67 +1594,154 @@
1425
1594
  [/(<\/table>)<\/p>/g, '$1'],
1426
1595
  [/<p>(<pre[^>]*>)/g, '$1'],
1427
1596
  [/(<\/pre>)<\/p>/g, '$1'],
1428
- [new RegExp(`<p>(${PLACEHOLDER_CB}\\d+§)<\/p>`, 'g'), '$1']
1597
+ [new RegExp(`<p>(${PLACEHOLDER_CB}\\d+§)</p>`, 'g'), '$1']
1429
1598
  ];
1430
-
1431
1599
  cleanupPatterns.forEach(([pattern, replacement]) => {
1432
1600
  html = html.replace(pattern, replacement);
1433
1601
  });
1434
-
1435
- // Fix orphaned closing </p> tags after block elements
1436
- // When a paragraph follows a block element, ensure it has opening <p>
1602
+
1603
+ // When a block element is followed by a newline and then text, open a <p>.
1437
1604
  html = html.replace(/(<\/(?:h[1-6]|blockquote|ul|ol|table|pre|hr)>)\n([^<])/g, '$1\n<p>$2');
1438
-
1439
- // Phase 4: Restore code blocks and inline code
1440
-
1441
- // Restore code blocks
1605
+
1606
+ // ────────────────────────────────────────────────────────────────
1607
+ // Phase 4 — Code Restoration
1608
+ // ────────────────────────────────────────────────────────────────
1609
+ // Replace placeholders with rendered HTML. For fenced blocks this
1610
+ // means wrapping in <pre><code>…</code></pre> (or calling the
1611
+ // fence_plugin). For inline code it means <code>…</code>.
1612
+
1442
1613
  codeBlocks.forEach((block, i) => {
1443
1614
  let replacement;
1444
-
1615
+
1445
1616
  if (block.custom && fence_plugin && fence_plugin.render) {
1446
- // Use custom fence plugin (v1.1.0: object format with render function)
1617
+ // Delegate to the user-provided fence plugin.
1447
1618
  replacement = fence_plugin.render(block.code, block.lang);
1448
-
1449
- // If plugin returns undefined, fall back to default rendering
1619
+
1450
1620
  if (replacement === undefined) {
1621
+ // Plugin declined — fall back to default rendering.
1451
1622
  const langClass = !inline_styles && block.lang ? ` class="language-${block.lang}"` : '';
1452
1623
  const codeAttr = inline_styles ? getAttr('code') : langClass;
1624
+ /* istanbul ignore next - bd-only branch */
1453
1625
  const langAttr = bidirectional && block.lang ? ` data-qd-lang="${escapeHtml(block.lang)}"` : '';
1626
+ /* istanbul ignore next - bd-only branch */
1454
1627
  const fenceAttr = bidirectional ? ` data-qd-fence="${escapeHtml(block.fence)}"` : '';
1455
1628
  replacement = `<pre${getAttr('pre')}${fenceAttr}${langAttr}><code${codeAttr}>${escapeHtml(block.code)}</code></pre>`;
1456
- } else if (bidirectional) {
1457
- // If bidirectional and plugin provided HTML, add data attributes for roundtrip
1458
- replacement = replacement.replace(/^<(\w+)/,
1629
+ } else /* istanbul ignore next - bd-only branch */ if (bidirectional) {
1630
+ // Plugin returned HTML inject data attributes for roundtrip.
1631
+ replacement = replacement.replace(/^<(\w+)/,
1459
1632
  `<$1 data-qd-fence="${escapeHtml(block.fence)}" data-qd-lang="${escapeHtml(block.lang)}" data-qd-source="${escapeHtml(block.code)}"`);
1460
1633
  }
1461
1634
  } else {
1462
- // Default rendering
1635
+ // Default rendering — wrap in <pre><code>.
1463
1636
  const langClass = !inline_styles && block.lang ? ` class="language-${block.lang}"` : '';
1464
1637
  const codeAttr = inline_styles ? getAttr('code') : langClass;
1638
+ /* istanbul ignore next - bd-only branch */
1465
1639
  const langAttr = bidirectional && block.lang ? ` data-qd-lang="${escapeHtml(block.lang)}"` : '';
1640
+ /* istanbul ignore next - bd-only branch */
1466
1641
  const fenceAttr = bidirectional ? ` data-qd-fence="${escapeHtml(block.fence)}"` : '';
1467
1642
  replacement = `<pre${getAttr('pre')}${fenceAttr}${langAttr}><code${codeAttr}>${block.code}</code></pre>`;
1468
1643
  }
1469
-
1644
+
1470
1645
  const placeholder = `${PLACEHOLDER_CB}${i}§`;
1471
1646
  html = html.replace(placeholder, replacement);
1472
1647
  });
1473
-
1474
- // Restore inline code
1648
+
1649
+ // Restore inline code spans
1475
1650
  inlineCodes.forEach((code, i) => {
1476
1651
  const placeholder = `${PLACEHOLDER_IC}${i}§`;
1477
1652
  html = html.replace(placeholder, `<code${getAttr('code')}${dataQd('`')}>${code}</code>`);
1478
1653
  });
1479
-
1654
+
1480
1655
  return html.trim();
1481
1656
  }
1482
1657
 
1658
+ // ════════════════════════════════════════════════════════════════════
1659
+ // Block-level line scanner
1660
+ // ════════════════════════════════════════════════════════════════════
1661
+
1662
+ /**
1663
+ * scanLineBlocks — single-pass line scanner for headings, HR, blockquotes
1664
+ *
1665
+ * Walks the text line by line. For each line it checks (in order):
1666
+ * 1. Heading — starts with 1-6 '#' followed by a space
1667
+ * 2. HR — line is entirely '---…' (3+ dashes, optional trailing space)
1668
+ * 3. Blockquote — starts with '&gt; ' (the > was already HTML-escaped)
1669
+ *
1670
+ * Lines that don't match any block pattern are passed through unchanged.
1671
+ *
1672
+ * This replaces three separate global regex passes from the pre-1.2.8
1673
+ * architecture with one structured scan.
1674
+ *
1675
+ * @param {string} text The document text (HTML-escaped, code extracted)
1676
+ * @param {Function} getAttr Attribute factory (class or style)
1677
+ * @param {Function} dataQd Bidirectional marker factory
1678
+ * @returns {string} Text with block-level elements rendered
1679
+ */
1680
+ function scanLineBlocks(text, getAttr, dataQd) {
1681
+ const lines = text.split('\n');
1682
+ const result = [];
1683
+ let i = 0;
1684
+
1685
+ while (i < lines.length) {
1686
+ const line = lines[i];
1687
+
1688
+ // ── Heading ──
1689
+ // Count leading '#' characters. Valid heading: 1-6 hashes then a space.
1690
+ // Example: "## Hello World ##" → <h2>Hello World</h2>
1691
+ let hashCount = 0;
1692
+ while (hashCount < line.length && hashCount < 7 && line[hashCount] === '#') {
1693
+ hashCount++;
1694
+ }
1695
+ if (hashCount >= 1 && hashCount <= 6 && line[hashCount] === ' ') {
1696
+ // Extract content after "# " and strip trailing hashes
1697
+ const content = line.slice(hashCount + 1).replace(/\s*#+\s*$/, '');
1698
+ const tag = 'h' + hashCount;
1699
+ result.push(`<${tag}${getAttr(tag)}${dataQd('#'.repeat(hashCount))}>${content}</${tag}>`);
1700
+ i++;
1701
+ continue;
1702
+ }
1703
+
1704
+ // ── Horizontal Rule ──
1705
+ // Three or more dashes, optional trailing whitespace, nothing else.
1706
+ if (isDashHRLine(line)) {
1707
+ result.push(`<hr${getAttr('hr')}>`);
1708
+ i++;
1709
+ continue;
1710
+ }
1711
+
1712
+ // ── Blockquote ──
1713
+ // After Phase 2, the '>' character has been escaped to '&gt;'.
1714
+ // Pattern: "&gt; content" or merged consecutive blockquotes.
1715
+ if (/^&gt;\s+/.test(line)) {
1716
+ result.push(`<blockquote${getAttr('blockquote')}>${line.replace(/^&gt;\s+/, '')}</blockquote>`);
1717
+ i++;
1718
+ continue;
1719
+ }
1720
+
1721
+ // ── Pass-through ──
1722
+ result.push(line);
1723
+ i++;
1724
+ }
1725
+
1726
+ // Merge consecutive blockquotes into a single element.
1727
+ // <blockquote>A</blockquote>\n<blockquote>B</blockquote>
1728
+ // → <blockquote>A\nB</blockquote>
1729
+ let joined = result.join('\n');
1730
+ joined = joined.replace(/<\/blockquote>\n<blockquote>/g, '\n');
1731
+ return joined;
1732
+ }
1733
+
1734
+ // ════════════════════════════════════════════════════════════════════
1735
+ // Table processing (line walker)
1736
+ // ════════════════════════════════════════════════════════════════════
1737
+
1483
1738
  /**
1484
- * Process inline markdown formatting
1739
+ * Inline markdown formatter for table cells.
1740
+ * Handles bold, italic, strikethrough, and code within cell text.
1741
+ * Links / images / autolinks are handled by the global inline pass
1742
+ * (Phase 3 Step 4) which runs after table processing.
1485
1743
  */
1486
1744
  function processInlineMarkdown(text, getAttr) {
1487
-
1488
- // Process inline formatting patterns
1489
1745
  const patterns = [
1490
1746
  [/\*\*(.+?)\*\*/g, 'strong'],
1491
1747
  [/__(.+?)__/g, 'strong'],
@@ -1494,27 +1750,32 @@
1494
1750
  [/~~(.+?)~~/g, 'del'],
1495
1751
  [/`([^`]+)`/g, 'code']
1496
1752
  ];
1497
-
1498
1753
  patterns.forEach(([pattern, tag]) => {
1499
1754
  text = text.replace(pattern, `<${tag}${getAttr(tag)}>$1</${tag}>`);
1500
1755
  });
1501
-
1502
1756
  return text;
1503
1757
  }
1504
1758
 
1505
1759
  /**
1506
- * Process markdown tables
1760
+ * processTable — line walker for markdown tables
1761
+ *
1762
+ * Walks through lines looking for runs of pipe-containing lines.
1763
+ * Each run is validated (must contain a separator row: |---|---|)
1764
+ * and rendered as an HTML <table>. Invalid runs are restored as-is.
1765
+ *
1766
+ * @param {string} text Full document text
1767
+ * @param {Function} getAttr Attribute factory
1768
+ * @returns {string} Text with tables rendered
1507
1769
  */
1508
1770
  function processTable(text, getAttr) {
1509
1771
  const lines = text.split('\n');
1510
1772
  const result = [];
1511
1773
  let inTable = false;
1512
1774
  let tableLines = [];
1513
-
1775
+
1514
1776
  for (let i = 0; i < lines.length; i++) {
1515
1777
  const line = lines[i].trim();
1516
-
1517
- // Check if this line looks like a table row (with or without trailing |)
1778
+
1518
1779
  if (line.includes('|') && (line.startsWith('|') || /[^\\|]/.test(line))) {
1519
1780
  if (!inTable) {
1520
1781
  inTable = true;
@@ -1522,14 +1783,11 @@
1522
1783
  }
1523
1784
  tableLines.push(line);
1524
1785
  } else {
1525
- // Not a table line
1526
1786
  if (inTable) {
1527
- // Process the accumulated table
1528
1787
  const tableHtml = buildTable(tableLines, getAttr);
1529
1788
  if (tableHtml) {
1530
1789
  result.push(tableHtml);
1531
1790
  } else {
1532
- // Not a valid table, restore original lines
1533
1791
  result.push(...tableLines);
1534
1792
  }
1535
1793
  inTable = false;
@@ -1538,8 +1796,8 @@
1538
1796
  result.push(lines[i]);
1539
1797
  }
1540
1798
  }
1541
-
1542
- // Handle table at end of text
1799
+
1800
+ // Handle table at end of document
1543
1801
  if (inTable && tableLines.length > 0) {
1544
1802
  const tableHtml = buildTable(tableLines, getAttr);
1545
1803
  if (tableHtml) {
@@ -1548,35 +1806,35 @@
1548
1806
  result.push(...tableLines);
1549
1807
  }
1550
1808
  }
1551
-
1809
+
1552
1810
  return result.join('\n');
1553
1811
  }
1554
1812
 
1555
1813
  /**
1556
- * Build an HTML table from markdown table lines
1814
+ * buildTable validate and render a table from accumulated lines
1815
+ *
1816
+ * @param {string[]} lines Array of pipe-containing lines
1817
+ * @param {Function} getAttr Attribute factory
1818
+ * @returns {string|null} HTML table string, or null if invalid
1557
1819
  */
1558
1820
  function buildTable(lines, getAttr) {
1559
-
1560
1821
  if (lines.length < 2) return null;
1561
-
1562
- // Check for separator line (second line should be the separator)
1822
+
1823
+ // Find the separator row (---|---|)
1563
1824
  let separatorIndex = -1;
1564
1825
  for (let i = 1; i < lines.length; i++) {
1565
- // Support separator with or without leading/trailing pipes
1566
1826
  if (/^\|?[\s\-:|]+\|?$/.test(lines[i]) && lines[i].includes('-')) {
1567
1827
  separatorIndex = i;
1568
1828
  break;
1569
1829
  }
1570
1830
  }
1571
-
1572
1831
  if (separatorIndex === -1) return null;
1573
-
1832
+
1574
1833
  const headerLines = lines.slice(0, separatorIndex);
1575
1834
  const bodyLines = lines.slice(separatorIndex + 1);
1576
-
1577
- // Parse alignment from separator
1835
+
1836
+ // Parse alignment from separator cells (:--- = left, :---: = center, ---: = right)
1578
1837
  const separator = lines[separatorIndex];
1579
- // Handle pipes at start/end or not
1580
1838
  const separatorCells = separator.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
1581
1839
  const alignments = separatorCells.map(cell => {
1582
1840
  const trimmed = cell.trim();
@@ -1584,31 +1842,28 @@
1584
1842
  if (trimmed.endsWith(':')) return 'right';
1585
1843
  return 'left';
1586
1844
  });
1587
-
1845
+
1588
1846
  let html = `<table${getAttr('table')}>\n`;
1589
-
1590
- // Build header
1591
- // Note: headerLines will always have length > 0 since separatorIndex starts from 1
1847
+
1848
+ // Header
1592
1849
  html += `<thead${getAttr('thead')}>\n`;
1593
1850
  headerLines.forEach(line => {
1594
- html += `<tr${getAttr('tr')}>\n`;
1595
- // Handle pipes at start/end or not
1596
- const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
1597
- cells.forEach((cell, i) => {
1598
- const alignStyle = alignments[i] && alignments[i] !== 'left' ? `text-align:${alignments[i]}` : '';
1599
- const processedCell = processInlineMarkdown(cell.trim(), getAttr);
1600
- html += `<th${getAttr('th', alignStyle)}>${processedCell}</th>\n`;
1601
- });
1602
- html += '</tr>\n';
1851
+ html += `<tr${getAttr('tr')}>\n`;
1852
+ const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
1853
+ cells.forEach((cell, i) => {
1854
+ const alignStyle = alignments[i] && alignments[i] !== 'left' ? `text-align:${alignments[i]}` : '';
1855
+ const processedCell = processInlineMarkdown(cell.trim(), getAttr);
1856
+ html += `<th${getAttr('th', alignStyle)}>${processedCell}</th>\n`;
1857
+ });
1858
+ html += '</tr>\n';
1603
1859
  });
1604
1860
  html += '</thead>\n';
1605
-
1606
- // Build body
1861
+
1862
+ // Body
1607
1863
  if (bodyLines.length > 0) {
1608
1864
  html += `<tbody${getAttr('tbody')}>\n`;
1609
1865
  bodyLines.forEach(line => {
1610
1866
  html += `<tr${getAttr('tr')}>\n`;
1611
- // Handle pipes at start/end or not
1612
1867
  const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
1613
1868
  cells.forEach((cell, i) => {
1614
1869
  const alignStyle = alignments[i] && alignments[i] !== 'left' ? `text-align:${alignments[i]}` : '';
@@ -1619,61 +1874,81 @@
1619
1874
  });
1620
1875
  html += '</tbody>\n';
1621
1876
  }
1622
-
1877
+
1623
1878
  html += '</table>';
1624
1879
  return html;
1625
1880
  }
1626
1881
 
1882
+ // ════════════════════════════════════════════════════════════════════
1883
+ // List processing (line walker)
1884
+ // ════════════════════════════════════════════════════════════════════
1885
+
1627
1886
  /**
1628
- * Process markdown lists (ordered and unordered)
1887
+ * processLists line walker for ordered, unordered, and task lists
1888
+ *
1889
+ * Scans each line for list markers (-, *, +, 1., 2., etc.) with
1890
+ * optional leading indentation for nesting. Non-list lines close
1891
+ * any open lists and pass through unchanged.
1892
+ *
1893
+ * Task lists (- [ ] / - [x]) are detected and rendered with
1894
+ * checkbox inputs.
1895
+ *
1896
+ * @param {string} text Full document text
1897
+ * @param {Function} getAttr Attribute factory
1898
+ * @param {boolean} inline_styles Whether to use inline styles
1899
+ * @param {boolean} bidirectional Whether to add data-qd markers
1900
+ * @returns {string} Text with lists rendered
1629
1901
  */
1630
1902
  function processLists(text, getAttr, inline_styles, bidirectional) {
1631
-
1632
1903
  const lines = text.split('\n');
1633
1904
  const result = [];
1634
- let listStack = []; // Track nested lists
1635
-
1636
- // Helper to escape HTML for data-qd attributes
1637
- const escapeHtml = (text) => text.replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[m]);
1905
+ const listStack = []; // tracks nesting: [{type:'ul', level:0}, …]
1906
+
1907
+ // Helper to escape HTML for data-qd attributes. List markers (`-`, `*`,
1908
+ // `+`, `1.`, etc.) never contain HTML-special chars, so the replace
1909
+ // callback is defensive-only and never actually fires in practice.
1910
+ /* istanbul ignore next - defensive: list markers never trigger escaping */
1911
+ const escapeHtml = (text) => text.replace(/[&<>"']/g,
1912
+ /* istanbul ignore next - defensive: list markers never contain HTML specials */
1913
+ m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[m]);
1914
+ /* istanbul ignore next - trivial no-op fallback; not exercised via bd bundle */
1638
1915
  const dataQd = bidirectional ? (marker) => ` data-qd="${escapeHtml(marker)}"` : () => '';
1639
-
1916
+
1640
1917
  for (let i = 0; i < lines.length; i++) {
1641
1918
  const line = lines[i];
1642
1919
  const match = line.match(/^(\s*)([*\-+]|\d+\.)\s+(.+)$/);
1643
-
1920
+
1644
1921
  if (match) {
1645
1922
  const [, indent, marker, content] = match;
1646
1923
  const level = Math.floor(indent.length / 2);
1647
1924
  const isOrdered = /^\d+\./.test(marker);
1648
1925
  const listType = isOrdered ? 'ol' : 'ul';
1649
-
1650
- // Check for task list items
1926
+
1927
+ // Task list detection (only in unordered lists)
1651
1928
  let listItemContent = content;
1652
1929
  let taskListClass = '';
1653
1930
  const taskMatch = content.match(/^\[([x ])\]\s+(.*)$/i);
1654
1931
  if (taskMatch && !isOrdered) {
1655
1932
  const [, checked, taskContent] = taskMatch;
1656
1933
  const isChecked = checked.toLowerCase() === 'x';
1657
- const checkboxAttr = inline_styles
1658
- ? ' style="margin-right:.5em"'
1934
+ const checkboxAttr = inline_styles
1935
+ ? ' style="margin-right:.5em"'
1659
1936
  : ` class="${CLASS_PREFIX}task-checkbox"`;
1660
1937
  listItemContent = `<input type="checkbox"${checkboxAttr}${isChecked ? ' checked' : ''} disabled> ${taskContent}`;
1661
1938
  taskListClass = inline_styles ? ' style="list-style:none"' : ` class="${CLASS_PREFIX}task-item"`;
1662
1939
  }
1663
-
1664
- // Close deeper levels
1940
+
1941
+ // Close deeper nesting levels
1665
1942
  while (listStack.length > level + 1) {
1666
1943
  const list = listStack.pop();
1667
1944
  result.push(`</${list.type}>`);
1668
1945
  }
1669
-
1670
- // Open new level if needed
1946
+
1947
+ // Open new list or switch type at current level
1671
1948
  if (listStack.length === level) {
1672
- // Need to open a new list
1673
1949
  listStack.push({ type: listType, level });
1674
1950
  result.push(`<${listType}${getAttr(listType)}>`);
1675
1951
  } else if (listStack.length === level + 1) {
1676
- // Check if we need to switch list type
1677
1952
  const currentList = listStack[listStack.length - 1];
1678
1953
  if (currentList.type !== listType) {
1679
1954
  result.push(`</${currentList.type}>`);
@@ -1682,11 +1957,11 @@
1682
1957
  result.push(`<${listType}${getAttr(listType)}>`);
1683
1958
  }
1684
1959
  }
1685
-
1960
+
1686
1961
  const liAttr = taskListClass || getAttr('li');
1687
1962
  result.push(`<li${liAttr}${dataQd(marker)}>${listItemContent}</li>`);
1688
1963
  } else {
1689
- // Not a list item, close all lists
1964
+ // Not a list item close all open lists
1690
1965
  while (listStack.length > 0) {
1691
1966
  const list = listStack.pop();
1692
1967
  result.push(`</${list.type}>`);
@@ -1694,76 +1969,76 @@
1694
1969
  result.push(line);
1695
1970
  }
1696
1971
  }
1697
-
1698
- // Close any remaining lists
1972
+
1973
+ // Close any remaining open lists
1699
1974
  while (listStack.length > 0) {
1700
1975
  const list = listStack.pop();
1701
1976
  result.push(`</${list.type}>`);
1702
1977
  }
1703
-
1978
+
1704
1979
  return result.join('\n');
1705
1980
  }
1706
1981
 
1982
+ // ════════════════════════════════════════════════════════════════════
1983
+ // Static API
1984
+ // ════════════════════════════════════════════════════════════════════
1985
+
1707
1986
  /**
1708
- * Emit CSS styles for quikdown elements
1709
- * @param {string} prefix - Optional class prefix (default: 'quikdown-')
1710
- * @param {string} theme - Optional theme: 'light' (default) or 'dark'
1711
- * @returns {string} CSS string with quikdown styles
1987
+ * Emit CSS rules for all quikdown elements.
1988
+ *
1989
+ * @param {string} prefix Class prefix (default: 'quikdown-')
1990
+ * @param {string} theme 'light' (default) or 'dark'
1991
+ * @returns {string} CSS text
1712
1992
  */
1713
1993
  quikdown.emitStyles = function(prefix = 'quikdown-', theme = 'light') {
1714
1994
  const styles = QUIKDOWN_STYLES;
1715
-
1716
- // Define theme color overrides
1995
+
1717
1996
  const themeOverrides = {
1718
1997
  dark: {
1719
- '#f4f4f4': '#2a2a2a', // pre background
1720
- '#f0f0f0': '#2a2a2a', // code background
1721
- '#f2f2f2': '#2a2a2a', // th background
1722
- '#ddd': '#3a3a3a', // borders
1723
- '#06c': '#6db3f2', // links
1998
+ '#f4f4f4': '#2a2a2a', // pre background
1999
+ '#f0f0f0': '#2a2a2a', // code background
2000
+ '#f2f2f2': '#2a2a2a', // th background
2001
+ '#ddd': '#3a3a3a', // borders
2002
+ '#06c': '#6db3f2', // links
1724
2003
  _textColor: '#e0e0e0'
1725
2004
  },
1726
2005
  light: {
1727
- _textColor: '#333' // Explicit text color for light theme
2006
+ _textColor: '#333'
1728
2007
  }
1729
2008
  };
1730
-
2009
+
1731
2010
  let css = '';
1732
2011
  for (const [tag, style] of Object.entries(styles)) {
1733
2012
  let themedStyle = style;
1734
-
1735
- // Apply theme overrides if dark theme
1736
- if (theme === 'dark' && themeOverrides.dark) {
1737
- // Replace colors
1738
- for (const [oldColor, newColor] of Object.entries(themeOverrides.dark)) {
1739
- if (!oldColor.startsWith('_')) {
1740
- themedStyle = themedStyle.replace(new RegExp(oldColor, 'g'), newColor);
1741
- }
1742
- }
1743
-
1744
- // Add text color for certain elements in dark theme
1745
- const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote'];
1746
- if (needsTextColor.includes(tag)) {
1747
- themedStyle += `;color:${themeOverrides.dark._textColor}`;
1748
- }
1749
- } else if (theme === 'light' && themeOverrides.light) {
1750
- // Add explicit text color for light theme elements too
1751
- const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote'];
1752
- if (needsTextColor.includes(tag)) {
1753
- themedStyle += `;color:${themeOverrides.light._textColor}`;
2013
+
2014
+ if (theme === 'dark' && themeOverrides.dark) {
2015
+ for (const [oldColor, newColor] of Object.entries(themeOverrides.dark)) {
2016
+ if (!oldColor.startsWith('_')) {
2017
+ themedStyle = themedStyle.replaceAll(oldColor, newColor);
1754
2018
  }
1755
2019
  }
1756
-
2020
+ const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote'];
2021
+ if (needsTextColor.includes(tag)) {
2022
+ themedStyle += `;color:${themeOverrides.dark._textColor}`;
2023
+ }
2024
+ } else if (theme === 'light' && themeOverrides.light) {
2025
+ const needsTextColor = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'li', 'blockquote'];
2026
+ if (needsTextColor.includes(tag)) {
2027
+ themedStyle += `;color:${themeOverrides.light._textColor}`;
2028
+ }
2029
+ }
2030
+
1757
2031
  css += `.${prefix}${tag} { ${themedStyle} }\n`;
1758
2032
  }
1759
-
2033
+
1760
2034
  return css;
1761
2035
  };
1762
2036
 
1763
2037
  /**
1764
- * Configure quikdown with options and return a function
1765
- * @param {Object} options - Configuration options
1766
- * @returns {Function} Configured quikdown function
2038
+ * Create a pre-configured parser with baked-in options.
2039
+ *
2040
+ * @param {Object} options Options to bake in
2041
+ * @returns {Function} Configured quikdown(markdown) function
1767
2042
  */
1768
2043
  quikdown.configure = function(options) {
1769
2044
  return function(markdown) {
@@ -1771,18 +2046,18 @@
1771
2046
  };
1772
2047
  };
1773
2048
 
1774
- /**
1775
- * Version information
1776
- */
2049
+ /** Semantic version (injected at build time) */
1777
2050
  quikdown.version = quikdownVersion;
1778
2051
 
1779
- // Export for both CommonJS and ES6
2052
+ // ════════════════════════════════════════════════════════════════════
2053
+ // Exports
2054
+ // ════════════════════════════════════════════════════════════════════
2055
+
1780
2056
  /* istanbul ignore next */
1781
2057
  if (typeof module !== 'undefined' && module.exports) {
1782
2058
  module.exports = quikdown;
1783
2059
  }
1784
2060
 
1785
- // For browser global
1786
2061
  /* istanbul ignore next */
1787
2062
  if (typeof window !== 'undefined') {
1788
2063
  window.quikdown = quikdown;
@@ -1806,8 +2081,11 @@
1806
2081
  return quikdown(markdown, { ...options, bidirectional: true });
1807
2082
  }
1808
2083
 
1809
- // Copy all properties and methods from quikdown (including version)
2084
+ // Copy all properties and methods from quikdown (including version).
2085
+ // Skip `configure` — quikdown_bd provides its own override below, so the
2086
+ // inner quikdown.configure is dead code in this bundle.
1810
2087
  Object.keys(quikdown).forEach(key => {
2088
+ if (key === 'configure') return;
1811
2089
  quikdown_bd[key] = quikdown[key];
1812
2090
  });
1813
2091
 
@@ -1841,7 +2119,7 @@
1841
2119
 
1842
2120
  // Process children with context
1843
2121
  let childContent = '';
1844
- for (let child of node.childNodes) {
2122
+ for (const child of node.childNodes) {
1845
2123
  childContent += walkNode(child, { parentTag: tag, ...parentContext });
1846
2124
  }
1847
2125
 
@@ -2075,7 +2353,7 @@
2075
2353
  let index = 1;
2076
2354
  const indent = ' '.repeat(depth);
2077
2355
 
2078
- for (let child of listNode.children) {
2356
+ for (const child of listNode.children) {
2079
2357
  if (child.tagName !== 'LI') continue;
2080
2358
 
2081
2359
  const dataQd = child.getAttribute('data-qd');
@@ -2088,7 +2366,7 @@
2088
2366
  marker = '-';
2089
2367
  // Get text without the checkbox
2090
2368
  let text = '';
2091
- for (let node of child.childNodes) {
2369
+ for (const node of child.childNodes) {
2092
2370
  if (node.nodeType === Node.TEXT_NODE) {
2093
2371
  text += node.textContent;
2094
2372
  } else if (node.tagName && node.tagName !== 'INPUT') {
@@ -2099,7 +2377,7 @@
2099
2377
  } else {
2100
2378
  let itemContent = '';
2101
2379
 
2102
- for (let node of child.childNodes) {
2380
+ for (const node of child.childNodes) {
2103
2381
  if (node.tagName === 'UL' || node.tagName === 'OL') {
2104
2382
  itemContent += walkList(node, node.tagName === 'OL', depth + 1);
2105
2383
  } else {
@@ -2128,7 +2406,7 @@
2128
2406
  const headerRow = thead.querySelector('tr');
2129
2407
  if (headerRow) {
2130
2408
  const headers = [];
2131
- for (let th of headerRow.querySelectorAll('th')) {
2409
+ for (const th of headerRow.querySelectorAll('th')) {
2132
2410
  headers.push(th.textContent.trim());
2133
2411
  }
2134
2412
  result += '| ' + headers.join(' | ') + ' |\n';
@@ -2147,9 +2425,9 @@
2147
2425
  // Process body
2148
2426
  const tbody = table.querySelector('tbody');
2149
2427
  if (tbody) {
2150
- for (let row of tbody.querySelectorAll('tr')) {
2428
+ for (const row of tbody.querySelectorAll('tr')) {
2151
2429
  const cells = [];
2152
- for (let td of row.querySelectorAll('td')) {
2430
+ for (const td of row.querySelectorAll('td')) {
2153
2431
  cells.push(td.textContent.trim());
2154
2432
  }
2155
2433
  if (cells.length > 0) {
@@ -2171,10 +2449,13 @@
2171
2449
  return markdown;
2172
2450
  };
2173
2451
 
2174
- // Override the configure method to return a bidirectional version
2452
+ // Override the configure method to return a bidirectional version.
2453
+ // We delegate to the inner quikdown.configure so the shared closure
2454
+ // machinery is exercised in both bundles (no dead code).
2175
2455
  quikdown_bd.configure = function(options) {
2456
+ const innerParser = quikdown.configure({ ...options, bidirectional: true });
2176
2457
  return function(markdown) {
2177
- return quikdown_bd(markdown, options);
2458
+ return innerParser(markdown);
2178
2459
  };
2179
2460
  };
2180
2461