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