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