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