quikdown 1.2.10 → 1.2.11

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 (60) hide show
  1. package/README.md +2 -2
  2. package/dist/quikdown.cjs +96 -3
  3. package/dist/quikdown.d.ts +12 -0
  4. package/dist/quikdown.dark.css +1 -1
  5. package/dist/quikdown.esm.js +96 -3
  6. package/dist/quikdown.esm.min.js +2 -2
  7. package/dist/quikdown.esm.min.js.gz +0 -0
  8. package/dist/quikdown.esm.min.js.map +1 -1
  9. package/dist/quikdown.light.css +1 -1
  10. package/dist/quikdown.umd.js +96 -3
  11. package/dist/quikdown.umd.min.js +2 -2
  12. package/dist/quikdown.umd.min.js.gz +0 -0
  13. package/dist/quikdown.umd.min.js.map +1 -1
  14. package/dist/quikdown_ast.cjs +2 -2
  15. package/dist/quikdown_ast.esm.js +2 -2
  16. package/dist/quikdown_ast.esm.min.js +2 -2
  17. package/dist/quikdown_ast.esm.min.js.gz +0 -0
  18. package/dist/quikdown_ast.umd.js +2 -2
  19. package/dist/quikdown_ast.umd.min.js +2 -2
  20. package/dist/quikdown_ast.umd.min.js.gz +0 -0
  21. package/dist/quikdown_ast_html.cjs +3 -3
  22. package/dist/quikdown_ast_html.esm.js +3 -3
  23. package/dist/quikdown_ast_html.esm.min.js +2 -2
  24. package/dist/quikdown_ast_html.esm.min.js.gz +0 -0
  25. package/dist/quikdown_ast_html.umd.js +3 -3
  26. package/dist/quikdown_ast_html.umd.min.js +2 -2
  27. package/dist/quikdown_ast_html.umd.min.js.gz +0 -0
  28. package/dist/quikdown_bd.cjs +96 -3
  29. package/dist/quikdown_bd.esm.js +96 -3
  30. package/dist/quikdown_bd.esm.min.js +2 -2
  31. package/dist/quikdown_bd.esm.min.js.gz +0 -0
  32. package/dist/quikdown_bd.esm.min.js.map +1 -1
  33. package/dist/quikdown_bd.umd.js +96 -3
  34. package/dist/quikdown_bd.umd.min.js +2 -2
  35. package/dist/quikdown_bd.umd.min.js.gz +0 -0
  36. package/dist/quikdown_bd.umd.min.js.map +1 -1
  37. package/dist/quikdown_edit.cjs +232 -6
  38. package/dist/quikdown_edit.esm.js +232 -6
  39. package/dist/quikdown_edit.esm.min.js +3 -3
  40. package/dist/quikdown_edit.esm.min.js.gz +0 -0
  41. package/dist/quikdown_edit.esm.min.js.map +1 -1
  42. package/dist/quikdown_edit.umd.js +232 -6
  43. package/dist/quikdown_edit.umd.min.js +3 -3
  44. package/dist/quikdown_edit.umd.min.js.gz +0 -0
  45. package/dist/quikdown_edit.umd.min.js.map +1 -1
  46. package/dist/quikdown_json.cjs +3 -3
  47. package/dist/quikdown_json.esm.js +3 -3
  48. package/dist/quikdown_json.esm.min.js +2 -2
  49. package/dist/quikdown_json.esm.min.js.gz +0 -0
  50. package/dist/quikdown_json.umd.js +3 -3
  51. package/dist/quikdown_json.umd.min.js +2 -2
  52. package/dist/quikdown_json.umd.min.js.gz +0 -0
  53. package/dist/quikdown_yaml.cjs +3 -3
  54. package/dist/quikdown_yaml.esm.js +3 -3
  55. package/dist/quikdown_yaml.esm.min.js +2 -2
  56. package/dist/quikdown_yaml.esm.min.js.gz +0 -0
  57. package/dist/quikdown_yaml.umd.js +3 -3
  58. package/dist/quikdown_yaml.umd.min.js +2 -2
  59. package/dist/quikdown_yaml.umd.min.js.gz +0 -0
  60. package/package.json +2 -2
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Quikdown Editor - Drop-in Markdown Parser
3
- * @version 1.2.10
3
+ * @version 1.2.11
4
4
  * @license BSD-2-Clause
5
5
  * @copyright DeftIO 2025
6
6
  */
@@ -222,7 +222,7 @@ function looksLikeTableRow(line) {
222
222
  // ────────────────────────────────────────────────────────────────────
223
223
 
224
224
  /** Build-time version stamp (injected by tools/updateVersion) */
225
- const quikdownVersion = '1.2.10';
225
+ const quikdownVersion = '1.2.11';
226
226
 
227
227
  /** CSS class prefix used for all generated elements */
228
228
  const CLASS_PREFIX = 'quikdown-';
@@ -230,6 +230,10 @@ const CLASS_PREFIX = 'quikdown-';
230
230
  /** Placeholder sigils — chosen to be extremely unlikely in real text */
231
231
  const PLACEHOLDER_CB = '§CB'; // fenced code blocks
232
232
  const PLACEHOLDER_IC = '§IC'; // inline code spans
233
+ const PLACEHOLDER_HT = '§HT'; // safe HTML tags (limited mode)
234
+
235
+ /** Attributes whose values need URL sanitization */
236
+ const URL_ATTRIBUTES = { href:1, src:1, action:1, formaction:1 };
233
237
 
234
238
  /** HTML entity escape map */
235
239
  const ESC_MAP = {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'};
@@ -364,6 +368,46 @@ function quikdown(markdown, options = {}) {
364
368
  return trimmedUrl;
365
369
  }
366
370
 
371
+ /**
372
+ * Sanitize attributes on an HTML tag string for limited mode.
373
+ * Strips on* event handlers (case-insensitive) and runs sanitizeUrl()
374
+ * on href/src/action/formaction values.
375
+ */
376
+ function sanitizeHtmlTagAttrs(tagStr) {
377
+ // Self-closing or void tag without attributes — pass through
378
+ if (!/\s/.test(tagStr.replace(/<\/?[a-zA-Z][a-zA-Z0-9]*/, '').replace(/\/?>$/, ''))) {
379
+ return tagStr;
380
+ }
381
+ // Parse: <tagname ...attrs... > or <tagname ...attrs... />
382
+ const m = tagStr.match(/^(<\/?[a-zA-Z][a-zA-Z0-9]*)([\s\S]*?)(\/?>)$/);
383
+ /* istanbul ignore next - defensive: Phase 1.5 regex guarantees valid tag shape */
384
+ if (!m) return tagStr;
385
+
386
+ const [, open, attrStr, close] = m;
387
+ // Match individual attributes: name="value", name='value', name=value, or bare name
388
+ // eslint-disable-next-line security/detect-unsafe-regex -- linear: no nested quantifiers
389
+ const attrRe = /([a-zA-Z_][\w\-.:]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/g;
390
+ const attrs = [];
391
+ let am;
392
+ while ((am = attrRe.exec(attrStr)) !== null) {
393
+ const name = am[1];
394
+ const value = am[2] !== undefined ? am[2] : am[3] !== undefined ? am[3] : am[4];
395
+ // Strip event handlers (on*)
396
+ if (/^on/i.test(name)) continue;
397
+ if (value === undefined) {
398
+ // Boolean attribute (e.g. disabled, checked)
399
+ attrs.push(name);
400
+ } else {
401
+ let sanitized = value;
402
+ if (name.toLowerCase() in URL_ATTRIBUTES) {
403
+ sanitized = sanitizeUrl(value);
404
+ }
405
+ attrs.push(`${name}="${sanitized}"`);
406
+ }
407
+ }
408
+ return open + (attrs.length ? ' ' + attrs.join(' ') : '') + close;
409
+ }
410
+
367
411
  // ────────────────────────────────────────────────────────────────
368
412
  // Phase 1 — Code Extraction
369
413
  // ────────────────────────────────────────────────────────────────
@@ -415,17 +459,57 @@ function quikdown(markdown, options = {}) {
415
459
  return placeholder;
416
460
  });
417
461
 
462
+ // ────────────────────────────────────────────────────────────────
463
+ // Phase 1.5 — Safe HTML Extraction (whitelist mode)
464
+ // ────────────────────────────────────────────────────────────────
465
+ // When allow_unsafe_html is an object or array, extract whitelisted
466
+ // HTML tags, sanitize their attributes, and replace with placeholders.
467
+ // Non-whitelisted tags stay in text so Phase 2 will escape them.
468
+
469
+ const safeTags = [];
470
+ // Normalize: array → object for O(1) lookup; object used as-is
471
+ const htmlAllow = Array.isArray(allow_unsafe_html)
472
+ ? Object.fromEntries(allow_unsafe_html.map(t => [t, 1]))
473
+ : (allow_unsafe_html && typeof allow_unsafe_html === 'object') ? allow_unsafe_html : null;
474
+
475
+ if (htmlAllow) {
476
+ // Pass through HTML comments — browsers render them as nothing
477
+ html = html.replace(/<!--[\s\S]*?-->/g, (match) => {
478
+ const idx = safeTags.length;
479
+ safeTags.push(match);
480
+ return `${PLACEHOLDER_HT}${idx}§`;
481
+ });
482
+ html = html.replace(/<\/?([a-zA-Z][a-zA-Z0-9]*)\b[^>]*\/?>/g, (match, tagName) => {
483
+ if (tagName.toLowerCase() in htmlAllow) {
484
+ const sanitized = sanitizeHtmlTagAttrs(match);
485
+ const idx = safeTags.length;
486
+ safeTags.push(sanitized);
487
+ return `${PLACEHOLDER_HT}${idx}§`;
488
+ }
489
+ // Not whitelisted — leave in text for Phase 2 to escape
490
+ return match;
491
+ });
492
+ }
493
+
418
494
  // ────────────────────────────────────────────────────────────────
419
495
  // Phase 2 — HTML Escaping
420
496
  // ────────────────────────────────────────────────────────────────
421
497
  // All remaining text (everything except code placeholders) is escaped
422
498
  // to prevent XSS. The `allow_unsafe_html` option skips this for
423
499
  // trusted pipelines that intentionally embed raw HTML.
500
+ // For whitelist mode, escaping still runs (only `true` bypasses it).
424
501
 
425
- if (!allow_unsafe_html) {
502
+ if (allow_unsafe_html !== true) {
426
503
  html = escapeHtml(html);
427
504
  }
428
505
 
506
+ // Restore safe HTML tag placeholders after escaping
507
+ if (htmlAllow) {
508
+ safeTags.forEach((tag, i) => {
509
+ html = html.replace(`${PLACEHOLDER_HT}${i}§`, tag);
510
+ });
511
+ }
512
+
429
513
  // ────────────────────────────────────────────────────────────────
430
514
  // Phase 3 — Block Scanning + Inline Formatting + Paragraphs
431
515
  // ────────────────────────────────────────────────────────────────
@@ -667,6 +751,14 @@ function scanLineBlocks(text, getAttr, dataQd) {
667
751
  while (i < lines.length) {
668
752
  const line = lines[i];
669
753
 
754
+ // ── Markdown comment (reference-link hack) ──
755
+ // [//]: # (comment) or [//]: # "comment" or [//]: #
756
+ // These produce no output — standard markdown comment convention.
757
+ if (/^\[\/\/\]: #/.test(line)) {
758
+ i++;
759
+ continue;
760
+ }
761
+
670
762
  // ── Heading ──
671
763
  // Count leading '#' characters. Valid heading: 1-6 hashes then a space.
672
764
  // Example: "## Hello World ##" → <h2>Hello World</h2>
@@ -1031,6 +1123,7 @@ quikdown.configure = function(options) {
1031
1123
  /** Semantic version (injected at build time) */
1032
1124
  quikdown.version = quikdownVersion;
1033
1125
 
1126
+
1034
1127
  // ════════════════════════════════════════════════════════════════════
1035
1128
  // Exports
1036
1129
  // ════════════════════════════════════════════════════════════════════
@@ -2928,6 +3021,37 @@ async function getRenderedContent(previewPanel) {
2928
3021
  */
2929
3022
 
2930
3023
 
3024
+ /**
3025
+ * Curated safe HTML tag whitelist.
3026
+ * Pass to quikdown's `allow_unsafe_html` option to allow these tags
3027
+ * through while escaping everything else. Callers can use this as-is,
3028
+ * pass a subset, or build their own object — any object whose keys are
3029
+ * lowercase tag names works.
3030
+ *
3031
+ * @example
3032
+ * // Use the curated list
3033
+ * quikdown(md, { allow_unsafe_html: QuikdownEditor.SAFE_HTML_TAGS });
3034
+ *
3035
+ * // Or a minimal subset
3036
+ * quikdown(md, { allow_unsafe_html: { img: 1, a: 1, br: 1 } });
3037
+ *
3038
+ * // Or an array (converted internally)
3039
+ * quikdown(md, { allow_unsafe_html: ['img', 'a', 'br'] });
3040
+ */
3041
+ const SAFE_HTML_TAGS = {
3042
+ b:1, i:1, em:1, strong:1, del:1, s:1, u:1, mark:1, sup:1, sub:1,
3043
+ kbd:1, abbr:1, var:1, samp:1, cite:1, small:1, ins:1, dfn:1,
3044
+ ruby:1, rt:1, rp:1, time:1, wbr:1,
3045
+ img:1, picture:1, source:1, video:1, audio:1, figure:1, figcaption:1,
3046
+ a:1, br:1, hr:1,
3047
+ div:1, span:1, p:1, details:1, summary:1,
3048
+ section:1, article:1, aside:1, header:1, footer:1, nav:1, main:1,
3049
+ table:1, thead:1, tbody:1, tfoot:1, tr:1, th:1, td:1, caption:1, col:1, colgroup:1,
3050
+ ul:1, ol:1, li:1, dl:1, dt:1, dd:1,
3051
+ h1:1, h2:1, h3:1, h4:1, h5:1, h6:1,
3052
+ blockquote:1, pre:1, code:1
3053
+ };
3054
+
2931
3055
  // Default options
2932
3056
  const DEFAULT_OPTIONS = {
2933
3057
  mode: 'split', // 'source' | 'preview' | 'split'
@@ -2967,7 +3091,9 @@ const DEFAULT_OPTIONS = {
2967
3091
  customFences: {}, // { 'language': (code, lang) => html }
2968
3092
  enableComplexFences: true, // Enable CSV tables, math rendering, SVG, etc.
2969
3093
  showUndoRedo: false, // Show undo/redo toolbar buttons
2970
- undoStackSize: 100 // Maximum number of undo states to keep
3094
+ undoStackSize: 100, // Maximum number of undo states to keep
3095
+ allowUnsafeHTML: false, // false | 'limited' | true — controls HTML passthrough
3096
+ showAllowUnsafeHTML: false // Show toolbar button to cycle HTML mode
2971
3097
  };
2972
3098
 
2973
3099
  // Library catalog used by preloadFences. Each entry knows how to:
@@ -3212,7 +3338,17 @@ class QuikdownEditor {
3212
3338
  lazyLFBtn.title = 'Convert single newlines to paragraph breaks (one-time transform)';
3213
3339
  toolbar.appendChild(lazyLFBtn);
3214
3340
  }
3215
-
3341
+
3342
+ // Allow unsafe HTML toggle button (if enabled)
3343
+ if (this.options.showAllowUnsafeHTML) {
3344
+ const htmlModeBtn = document.createElement('button');
3345
+ htmlModeBtn.className = 'qde-btn';
3346
+ htmlModeBtn.dataset.action = 'toggle-html-mode';
3347
+ htmlModeBtn.textContent = this._getHtmlModeLabel(this.options.allowUnsafeHTML);
3348
+ htmlModeBtn.title = this._getHtmlModeTooltip(this.options.allowUnsafeHTML);
3349
+ toolbar.appendChild(htmlModeBtn);
3350
+ }
3351
+
3216
3352
  return toolbar;
3217
3353
  }
3218
3354
 
@@ -3271,6 +3407,25 @@ class QuikdownEditor {
3271
3407
  opacity: 0.4;
3272
3408
  pointer-events: none;
3273
3409
  }
3410
+ .qde-btn[data-action="toggle-html-mode"] {
3411
+ position: relative;
3412
+ }
3413
+ .qde-btn[data-action="toggle-html-mode"]:hover::after {
3414
+ content: attr(title);
3415
+ position: absolute;
3416
+ bottom: calc(100% + 6px);
3417
+ left: 50%;
3418
+ transform: translateX(-50%);
3419
+ padding: 5px 10px;
3420
+ background: #1f2937;
3421
+ color: #fff;
3422
+ font-size: 0.75rem;
3423
+ font-weight: 400;
3424
+ white-space: nowrap;
3425
+ border-radius: 4px;
3426
+ pointer-events: none;
3427
+ z-index: 10;
3428
+ }
3274
3429
 
3275
3430
  .qde-spacer {
3276
3431
  flex: 1;
@@ -3375,6 +3530,9 @@ class QuikdownEditor {
3375
3530
  .qde-preview > svg {
3376
3531
  max-width: 100%;
3377
3532
  }
3533
+ .qde-preview img {
3534
+ display: inline;
3535
+ }
3378
3536
  .qde-preview .leaflet-container { box-sizing: border-box; }
3379
3537
 
3380
3538
  /* Standard markdown tables (the .quikdown-table class) need to
@@ -3795,10 +3953,16 @@ class QuikdownEditor {
3795
3953
  this.previewPanel.innerHTML = '<div style="color: #999; font-style: italic; padding: 16px;">Start typing markdown in the source panel...</div>';
3796
3954
  }
3797
3955
  } else {
3956
+ // Translate editor's allowUnsafeHTML to parser's allow_unsafe_html:
3957
+ // false → false, true → true, 'limited' → quikdown_bd.SAFE_HTML_TAGS
3958
+ const htmlMode = this.options.allowUnsafeHTML;
3959
+ const allowHtml = htmlMode === 'limited' ? SAFE_HTML_TAGS : htmlMode;
3960
+
3798
3961
  this._html = quikdown_bd(markdown, {
3799
3962
  fence_plugin: this.createFencePlugin(),
3800
3963
  lazy_linefeeds: this.options.lazy_linefeeds,
3801
- inline_styles: this.options.inline_styles
3964
+ inline_styles: this.options.inline_styles,
3965
+ allow_unsafe_html: allowHtml
3802
3966
  });
3803
3967
 
3804
3968
  // Update preview if visible
@@ -5032,6 +5196,9 @@ class QuikdownEditor {
5032
5196
  case 'redo':
5033
5197
  this.redo();
5034
5198
  break;
5199
+ case 'toggle-html-mode':
5200
+ this.cycleAllowUnsafeHTML();
5201
+ break;
5035
5202
  }
5036
5203
  }
5037
5204
 
@@ -5386,6 +5553,62 @@ class QuikdownEditor {
5386
5553
  }
5387
5554
  }
5388
5555
 
5556
+ /**
5557
+ * Get the label for the current HTML passthrough mode.
5558
+ * @private
5559
+ */
5560
+ _getHtmlModeLabel(mode) {
5561
+ if (mode === true) return 'HTML: Raw';
5562
+ if (mode === 'limited') return 'HTML: Safe';
5563
+ return 'HTML: Off';
5564
+ }
5565
+
5566
+ /** @private */
5567
+ _getHtmlModeTooltip(mode) {
5568
+ if (mode === true) return 'All HTML passes through — no protection';
5569
+ if (mode === 'limited') return 'Safe tags render, dangerous tags escaped';
5570
+ return 'All HTML tags shown as text';
5571
+ }
5572
+
5573
+ /**
5574
+ * Cycle allowUnsafeHTML through false → "limited" → true → false.
5575
+ */
5576
+ cycleAllowUnsafeHTML() {
5577
+ const current = this.options.allowUnsafeHTML;
5578
+ let next;
5579
+ if (current === false) next = 'limited';
5580
+ else if (current === 'limited') next = true;
5581
+ else next = false;
5582
+ this.setAllowUnsafeHTML(next);
5583
+ }
5584
+
5585
+ /**
5586
+ * Set the HTML passthrough mode.
5587
+ * @param {boolean|'limited'} mode - false, 'limited', or true
5588
+ */
5589
+ setAllowUnsafeHTML(mode) {
5590
+ if (mode !== false && mode !== true && mode !== 'limited') return;
5591
+ this.options.allowUnsafeHTML = mode;
5592
+ // Update toolbar button label
5593
+ if (this.toolbar) {
5594
+ const btn = this.toolbar.querySelector('[data-action="toggle-html-mode"]');
5595
+ if (btn) {
5596
+ btn.textContent = this._getHtmlModeLabel(mode);
5597
+ btn.title = this._getHtmlModeTooltip(mode);
5598
+ }
5599
+ }
5600
+ // Re-render
5601
+ this.updateFromMarkdown(this._markdown);
5602
+ }
5603
+
5604
+ /**
5605
+ * Get the current HTML passthrough mode.
5606
+ * @returns {boolean|'limited'}
5607
+ */
5608
+ getAllowUnsafeHTML() {
5609
+ return this.options.allowUnsafeHTML;
5610
+ }
5611
+
5389
5612
  /**
5390
5613
  * Destroy the editor
5391
5614
  */
@@ -5406,6 +5629,9 @@ class QuikdownEditor {
5406
5629
  }
5407
5630
  }
5408
5631
 
5632
+ /** Static: curated safe HTML tag whitelist for allow_unsafe_html */
5633
+ QuikdownEditor.SAFE_HTML_TAGS = SAFE_HTML_TAGS;
5634
+
5409
5635
  // Export for CommonJS (needed for bundled ESM to work with Jest)
5410
5636
  if (typeof module !== 'undefined' && module.exports) {
5411
5637
  module.exports = QuikdownEditor;