quikdown 1.2.10 → 1.2.12

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