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