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