quikchat 1.2.4 → 1.2.7

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