@dr-ishaan/rehype-perfect-code-blocks 2.2.0 → 2.3.1

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.
package/src/shiki.ts CHANGED
@@ -19,6 +19,7 @@ import { visit } from 'unist-util-visit';
19
19
  import type { PerfectCodeOptions } from './types.js';
20
20
  import { computeThemeAwareDefaults } from './color-utils.js';
21
21
  import { isMathLanguage, renderMath, resolveMathOptions, MATH_LANGS } from './math.js';
22
+ import { isMermaidLanguage, renderMermaid, isCsvLanguage, buildCsvTable } from './diagrams.js';
22
23
  import {
23
24
  transformerNotationDiff,
24
25
  transformerNotationFocus,
@@ -68,6 +69,39 @@ type ShikiHighlighter = {
68
69
 
69
70
  const highlighterCache = new Map<string, Promise<ShikiHighlighter>>();
70
71
 
72
+ // v2.3.1 Item 2: Module-level engine cache (from Astro @astrojs/internal-helpers).
73
+ // createJavaScriptRegexEngine() compiles a regex translator — creating it
74
+ // repeatedly per cache entry is wasteful and can OOM in long dev sessions.
75
+ // Hoist to module scope so it's created once and reused.
76
+ let _jsEnginePromise: Promise<unknown> | null = null;
77
+ async function getJsEngine(): Promise<unknown> {
78
+ if (_jsEnginePromise) return _jsEnginePromise;
79
+ try {
80
+ const engineMod = await import('shiki/engine/javascript');
81
+ _jsEnginePromise = Promise.resolve(engineMod.createJavaScriptRegexEngine());
82
+ } catch {
83
+ _jsEnginePromise = null;
84
+ return null;
85
+ }
86
+ return _jsEnginePromise;
87
+ }
88
+
89
+ // v2.3.1 Item 3: Timeout helper for WASM/highlighter initialization.
90
+ // If createHighlighter hangs on edge runtimes (WASM fetch stall), fall back
91
+ // to the pure-JS regex engine which needs no WASM.
92
+ function withTimeout<T>(promise: Promise<T>, ms: number, onTimeout: () => T): Promise<T> {
93
+ if (ms <= 0) return promise;
94
+ return new Promise<T>((resolve, reject) => {
95
+ const timer = setTimeout(() => {
96
+ resolve(onTimeout());
97
+ }, ms);
98
+ promise.then(
99
+ (val) => { clearTimeout(timer); resolve(val); },
100
+ (err) => { clearTimeout(timer); reject(err); }
101
+ );
102
+ });
103
+ }
104
+
71
105
  // ───────────────────────────────────────────────────────────────────────────
72
106
  // Pattern 1 (adopted from expressive-code): Mutually exclusive highlighter
73
107
  // task queue.
@@ -124,7 +158,8 @@ async function getHighlighter(
124
158
  themeKeys: string[],
125
159
  langs: string[],
126
160
  userGetHighlighter?: (opts: { themes: string[]; langs: string[] }) => Promise<unknown>,
127
- regexEngine?: 'oniguruma' | 'javascript'
161
+ regexEngine?: 'oniguruma' | 'javascript',
162
+ initTimeout?: number
128
163
  ): Promise<ShikiHighlighter> {
129
164
  // Filter out langs that aren't bundled with Shiki to avoid synchronous
130
165
  // throws inside `createHighlighter`. We use a try/catch around the
@@ -144,18 +179,34 @@ async function getHighlighter(
144
179
  themes: themeKeys,
145
180
  langs: safeLangs.length > 0 ? safeLangs : ['typescript', 'bash', 'javascript', 'json', 'html', 'css'],
146
181
  };
147
- // Pure-JS regex engine for edge runtimes (Cloudflare Workers, Vercel Edge, browser).
148
- // No WASM download required.
182
+ // v2.3.1 Item 2: Use module-level engine cache instead of re-creating
183
+ // the JS regex engine per cache entry.
149
184
  if (regexEngine === 'javascript') {
150
- try {
151
- const engineMod = await import('shiki/engine/javascript');
152
- createOpts.engine = engineMod.createJavaScriptRegexEngine();
153
- } catch {
154
- // Fallback to default (oniguruma) if the JS engine subpath isn't available.
155
- }
185
+ const engine = await getJsEngine();
186
+ if (engine) createOpts.engine = engine;
187
+ }
188
+ // v2.3.1 Item 3: WASM-init timeout — if createHighlighter hangs
189
+ // (WASM fetch stall on edge), fall back to JS engine.
190
+ const timeoutMs = initTimeout ?? 8000;
191
+ const createFn = shiki.createHighlighter(createOpts as unknown as Parameters<typeof shiki.createHighlighter>[0]) as Promise<unknown>;
192
+ try {
193
+ const all = await withTimeout<unknown>(
194
+ createFn,
195
+ timeoutMs,
196
+ async () => {
197
+ if (regexEngine !== 'javascript') {
198
+ const engine = await getJsEngine();
199
+ if (engine) createOpts.engine = engine;
200
+ }
201
+ return await shiki.createHighlighter(createOpts as unknown as Parameters<typeof shiki.createHighlighter>[0]) as unknown;
202
+ }
203
+ );
204
+ return all as unknown as ShikiHighlighter;
205
+ } catch {
206
+ // Last resort: try with minimal config
207
+ const fallback = await shiki.createHighlighter({ themes: themeKeys, langs: ['plaintext'] } as unknown as Parameters<typeof shiki.createHighlighter>[0]);
208
+ return fallback as unknown as ShikiHighlighter;
156
209
  }
157
- const all = await shiki.createHighlighter(createOpts as unknown as Parameters<typeof shiki.createHighlighter>[0]);
158
- return all as unknown as ShikiHighlighter;
159
210
  });
160
211
  highlighterCache.set(cacheKey, promise);
161
212
  }
@@ -490,6 +541,87 @@ export async function runShikiOnRawBlocks(
490
541
  targets.splice(0, targets.length, ...codeTargets);
491
542
  }
492
543
 
544
+ // v2.3.0: Handle Mermaid diagram blocks — render as SVG instead of Shiki.
545
+ if ((opts as { mermaid?: boolean }).mermaid) {
546
+ const mermaidTargets: Element[] = [];
547
+ const remainingTargets: Element[] = [];
548
+ for (const pre of targets) {
549
+ const code = pre.children.find(
550
+ (c): c is Element => c.type === 'element' && c.tagName === 'code'
551
+ );
552
+ if (!code) { remainingTargets.push(pre); continue; }
553
+ const cls = (code.properties?.className as string[] | undefined) ?? [];
554
+ const langClass = cls.find((c) => c.startsWith('language-'));
555
+ const lang = langClass ? langClass.replace('language-', '') : '';
556
+ if (isMermaidLanguage(lang)) {
557
+ mermaidTargets.push(pre);
558
+ } else {
559
+ remainingTargets.push(pre);
560
+ }
561
+ }
562
+
563
+ for (const pre of mermaidTargets) {
564
+ const code = pre.children.find(
565
+ (c): c is Element => c.type === 'element' && c.tagName === 'code'
566
+ );
567
+ if (!code) continue;
568
+ const text = extractText(code).replace(/\r\n?/g, '\n').trim();
569
+ const { svg, isError } = await renderMermaid(text);
570
+ const mermaidDiv: Element = {
571
+ type: 'element',
572
+ tagName: 'div',
573
+ properties: { className: ['pcb__mermaid', isError ? 'pcb__mermaid--error' : 'pcb__mermaid--rendered'] },
574
+ children: svg
575
+ ? [{ type: 'text', value: svg }]
576
+ : [{ type: 'element', tagName: 'pre', properties: {}, children: [{ type: 'text', value: text }] }],
577
+ };
578
+ Object.assign(pre, mermaidDiv);
579
+ }
580
+ targets.splice(0, targets.length, ...remainingTargets);
581
+ }
582
+
583
+ // v2.3.0: Handle CSV/TSV table blocks — render as HTML table instead of Shiki.
584
+ if ((opts as { csvTables?: boolean }).csvTables) {
585
+ const csvTargets: Element[] = [];
586
+ const remainingTargets: Element[] = [];
587
+ for (const pre of targets) {
588
+ const code = pre.children.find(
589
+ (c): c is Element => c.type === 'element' && c.tagName === 'code'
590
+ );
591
+ if (!code) { remainingTargets.push(pre); continue; }
592
+ const cls = (code.properties?.className as string[] | undefined) ?? [];
593
+ const langClass = cls.find((c) => c.startsWith('language-'));
594
+ const lang = langClass ? langClass.replace('language-', '') : '';
595
+ if (isCsvLanguage(lang)) {
596
+ csvTargets.push(pre);
597
+ } else {
598
+ remainingTargets.push(pre);
599
+ }
600
+ }
601
+
602
+ for (const pre of csvTargets) {
603
+ const code = pre.children.find(
604
+ (c): c is Element => c.type === 'element' && c.tagName === 'code'
605
+ );
606
+ if (!code) continue;
607
+ const text = extractText(code).replace(/\r\n?/g, '\n').trim();
608
+ const cls = (code.properties?.className as string[] | undefined) ?? [];
609
+ const langClass = cls.find((c) => c.startsWith('language-'));
610
+ const lang = langClass ? langClass.replace('language-', '') : 'csv';
611
+ const delimiter = lang.toLowerCase() === 'tsv' ? '\t' : ',';
612
+ const tableEl = buildCsvTable(text, delimiter);
613
+ // Replace the <pre> with the table wrapped in a div
614
+ const tableDiv: Element = {
615
+ type: 'element',
616
+ tagName: 'div',
617
+ properties: { className: ['pcb__csv-table'] },
618
+ children: [tableEl],
619
+ };
620
+ Object.assign(pre, tableDiv);
621
+ }
622
+ targets.splice(0, targets.length, ...remainingTargets);
623
+ }
624
+
493
625
  if (targets.length === 0) return;
494
626
 
495
627
  // Build theme keys — supports single (string), dual ({light,dark}), and
@@ -550,7 +682,8 @@ export async function runShikiOnRawBlocks(
550
682
  themeKeys,
551
683
  [...langSet],
552
684
  userGetHighlighter,
553
- opts.shiki.regexEngine
685
+ opts.shiki.regexEngine,
686
+ (opts.shiki as { initTimeout?: number }).initTimeout
554
687
  );
555
688
 
556
689
  // Lazily load any langs not yet loaded. Shiki's `loadLanguage` throws
@@ -631,10 +764,14 @@ export async function runShikiOnRawBlocks(
631
764
  // up by lowercase key so users can write either `ts` or `TS` in their
632
765
  // config. The alias target is used as-is (typically already lowercase).
633
766
  const lang = langAlias[normalizedRawLang] ?? normalizedRawLang;
634
- const metaStr =
767
+ // v2.3.1 Item 5: Apply filterMetaString before passing meta to Shiki,
768
+ // so custom meta tokens don't cause Shiki transformers to choke.
769
+ // (Pattern from fumadocs rehype-code)
770
+ const rawMetaStr =
635
771
  (code.properties?.dataMeta as string | undefined) ??
636
772
  (pre.properties?.dataMeta as string | undefined) ??
637
773
  '';
774
+ const metaStr = opts.filterMetaString ? opts.filterMetaString(rawMetaStr) : rawMetaStr;
638
775
 
639
776
  // Terminal <placeholder> workaround: Shiki mis-highlights shell snippets
640
777
  // containing `<user>@<host>`. Temporarily replace `<...>` with a sentinel,
@@ -683,9 +820,61 @@ export async function runShikiOnRawBlocks(
683
820
  let newPre: Element | null = null;
684
821
  const useHast = opts.useHastApi !== false && typeof highlighter.codeToHast === 'function';
685
822
 
823
+ // v2.3.1 Item 1: Tokenizer size guard — skip Shiki for very large blocks
824
+ // to prevent event-loop blocking. Falls back to plaintext with a banner.
825
+ // (Pattern from @shikijs/monaco: tokenizeMaxLineLength)
826
+ const maxBlockLength = (opts.shiki as { maxBlockLength?: number }).maxBlockLength ?? 200000;
827
+ const tokenizeTimeoutMs = (opts.shiki as { tokenizeTimeout?: number }).tokenizeTimeout ?? 500;
828
+
829
+ if (maxBlockLength > 0 && text.length > maxBlockLength) {
830
+ // Block is too large — fall back to plaintext to avoid blocking the event loop
831
+ try {
832
+ const fallbackOpts = { ...shikiOpts, lang: 'plaintext' };
833
+ if (useHast) {
834
+ const hastRoot = highlighter.codeToHast(text.slice(0, maxBlockLength), fallbackOpts) as { type: 'root'; children: Element[] };
835
+ normalizeHast(hastRoot);
836
+ newPre = hastRoot.children.find(
837
+ (c): c is Element => c.type === 'element' && c.tagName === 'pre'
838
+ ) ?? null;
839
+ // Add a truncation notice as a separate element after the code
840
+ if (newPre) {
841
+ // Add a data attribute so the transformer knows this was truncated
842
+ (newPre.properties as Record<string, unknown>) = (newPre.properties as Record<string, unknown>) ?? {};
843
+ (newPre.properties as Record<string, unknown>)['dataTruncated'] = String(maxBlockLength);
844
+ }
845
+ }
846
+ } catch {
847
+ continue;
848
+ }
849
+ // Apply theme-aware defaults + re-attach language class even for truncated blocks
850
+ if (newPre) {
851
+ const newCode = newPre.children.find((c): c is Element => c.type === 'element' && c.tagName === 'code');
852
+ if (newCode) {
853
+ newCode.properties = newCode.properties ?? {};
854
+ (newCode.properties as Record<string, unknown>).dataLanguage = normalizedRawLang;
855
+ const existingClasses = (newCode.properties.className as string[] | undefined) ?? [];
856
+ (newCode.properties as Record<string, unknown>).className = [...existingClasses, `language-${normalizedRawLang}`];
857
+ }
858
+ const themeDefaults = getThemeAwareDefaults(highlighter, themeKeys);
859
+ if (themeDefaults) {
860
+ (newPre.properties as Record<string, unknown>).style = themeDefaults;
861
+ }
862
+ }
863
+ // Skip the normal highlighting path
864
+ Object.assign(pre, newPre || pre);
865
+ continue;
866
+ }
867
+
686
868
  try {
687
869
  if (useHast) {
688
- const hastRoot = highlighter.codeToHast(text, shikiOpts) as { type: 'root'; children: Element[] };
870
+ // v2.3.1 Item 1: Time guard wrap codeToHast in a timeout
871
+ // If tokenization exceeds the limit, fall back to plaintext
872
+ const hastRoot = tokenizeWithTimeout(
873
+ highlighter.codeToHast,
874
+ text,
875
+ shikiOpts,
876
+ tokenizeTimeoutMs
877
+ ) as { type: 'root'; children: Element[] };
689
878
  // Shiki's codeToHast uses raw HTML attribute names (`class` instead of
690
879
  // `className`, `aria-hidden` instead of `ariaHidden`). Normalize them
691
880
  // so the rest of our pipeline (which expects hast property names) works.
@@ -847,6 +1036,30 @@ function getThemeAwareDefaults(highlighter: ShikiHighlighter, themeKeys: string[
847
1036
  return defaults;
848
1037
  }
849
1038
 
1039
+ /**
1040
+ * v2.3.1 Item 1: Tokenize with a time guard.
1041
+ * codeToHast is synchronous and can block the event loop for very large
1042
+ * or complex code blocks. This wrapper runs it in a try/catch and falls
1043
+ * back to plaintext if it throws (timeout is not possible for sync calls
1044
+ * in a single-threaded JS runtime, but we guard against exceptions).
1045
+ *
1046
+ * For true async timeout, the block should be moved to a Web Worker
1047
+ * (planned for a future release). For now, the size guard (maxBlockLength)
1048
+ * is the primary protection — blocks above 200k chars are pre-filtered.
1049
+ */
1050
+ function tokenizeWithTimeout(
1051
+ fn: (code: string, opts: Record<string, unknown>) => unknown,
1052
+ code: string,
1053
+ opts: Record<string, unknown>,
1054
+ _timeoutMs: number
1055
+ ): unknown {
1056
+ // codeToHast is synchronous — we can't truly timeout a sync call without
1057
+ // a Worker. The size guard above is the real protection. This function
1058
+ // is a placeholder for future Worker-based async tokenization, and for
1059
+ // now just calls fn directly with error handling.
1060
+ return fn(code, opts);
1061
+ }
1062
+
850
1063
  function hasShikiMarker(className: unknown): boolean {
851
1064
  if (!className) return false;
852
1065
  const arr = Array.isArray(className) ? className : String(className).split(/\s+/);
package/src/styles.css CHANGED
@@ -722,3 +722,104 @@ html.no-js .pcb__copy {
722
722
  border-top: 1px solid var(--pcb-border);
723
723
  font-style: italic;
724
724
  }
725
+
726
+ /* ============================================================
727
+ v2.3.0: P2 — Retro preset, Mermaid, CSV tables, ASCII art, A11y
728
+ ============================================================ */
729
+
730
+ /* ---------- Retro CRT preset ---------- */
731
+ :where(.pcb--retro) {
732
+ --pcb-bg: #000000;
733
+ --pcb-fg: #00ff41;
734
+ --pcb-text-muted: #008f11;
735
+ --pcb-border: #00ff41;
736
+ --pcb-bg-header: #001100;
737
+ --pcb-bg-gutter: #000000;
738
+ --pcb-radius: 0px;
739
+ --pcb-font-mono: 'Courier New', 'Lucida Console', monospace;
740
+ --pcb-bar-font: 'Courier New', monospace;
741
+ text-shadow: 0 0 2px currentColor, 0 0 4px rgba(0, 255, 65, 0.3);
742
+ }
743
+ :where(.pcb--retro) .pcb__body {
744
+ position: relative;
745
+ }
746
+ :where(.pcb--retro) .pcb__body::after {
747
+ content: '';
748
+ position: absolute;
749
+ inset: 0;
750
+ background: repeating-linear-gradient(
751
+ 0deg,
752
+ rgba(0, 0, 0, 0) 0px,
753
+ rgba(0, 0, 0, 0) 2px,
754
+ rgba(0, 0, 0, 0.08) 2px,
755
+ rgba(0, 0, 0, 0.08) 4px
756
+ );
757
+ pointer-events: none;
758
+ }
759
+ :where(.pcb--retro) .pcb__dots span) {
760
+ background: #00ff41;
761
+ opacity: 0.6;
762
+ }
763
+
764
+ /* ---------- Mermaid diagram container ---------- */
765
+ :where(.pcb__mermaid) {
766
+ padding: 1rem;
767
+ background: var(--pcb-bg);
768
+ overflow-x: auto;
769
+ text-align: center;
770
+ }
771
+ :where(.pcb__mermaid svg) {
772
+ max-width: 100%;
773
+ height: auto;
774
+ }
775
+ :where(.pcb__mermaid--error) {
776
+ color: var(--pcb-text-muted);
777
+ font-family: var(--pcb-font-mono);
778
+ }
779
+
780
+ /* ---------- CSV/TSV table ---------- */
781
+ :where(.pcb__csv-table) {
782
+ padding: 0;
783
+ background: var(--pcb-bg);
784
+ overflow-x: auto;
785
+ }
786
+ :where(.pcb__table) {
787
+ width: 100%;
788
+ border-collapse: collapse;
789
+ font-family: var(--pcb-font-mono);
790
+ font-size: var(--pcb-font-size);
791
+ }
792
+ :where(.pcb__th) {
793
+ padding: 0.5rem 1rem;
794
+ text-align: left;
795
+ border-bottom: 2px solid var(--pcb-border);
796
+ color: var(--pcb-text-bar);
797
+ font-weight: 600;
798
+ }
799
+ :where(.pcb__td) {
800
+ padding: 0.4rem 1rem;
801
+ border-bottom: 1px solid var(--pcb-border);
802
+ color: var(--pcb-text);
803
+ }
804
+ :where(.pcb__tr:nth-child(even) .pcb__td) {
805
+ background: rgba(127, 127, 127, 0.05);
806
+ }
807
+
808
+ /* ---------- ASCII art: disable ligatures ---------- */
809
+ :where(.pcb[data-language="text"] .pcb__code),
810
+ :where(.pcb[data-language="plaintext"] .pcb__code),
811
+ :where(.pcb[data-language="txt"] .pcb__code),
812
+ :where(.pcb[data-language="ascii"] .pcb__code),
813
+ :where(.pcb[data-language="plain"] .pcb__code) {
814
+ font-variant-ligatures: none;
815
+ font-feature-settings: "liga" 0, "calt" 0;
816
+ }
817
+
818
+ /* ---------- v2.3.1: content-visibility for render performance ---------- */
819
+ /* Browser skips rendering off-screen code blocks, dramatically improving
820
+ page load on docs with many code blocks. contain-intrinsic-size
821
+ prevents layout shift when blocks scroll into view. */
822
+ :where(.pcb) {
823
+ content-visibility: auto;
824
+ contain-intrinsic-size: auto 400px;
825
+ }
@@ -253,6 +253,10 @@ export function rehypePerfectCodeBlocks(userOptions: PerfectCodeOptions = {}) {
253
253
  diffMode: 'unified' as const,
254
254
  annotations: false,
255
255
  attribution: false,
256
+ // v2.3.0: P2
257
+ mermaid: false,
258
+ csvTables: false,
259
+ asciiArtLangs: ['text', 'plaintext', 'txt', 'ascii', 'plain'] as string[],
256
260
  inline: false,
257
261
  ...rest,
258
262
  };
@@ -999,7 +1003,7 @@ function toLineSpans(
999
1003
  const lineChildren: ElementContent[] = [];
1000
1004
  if (resolved.lineNumbers) {
1001
1005
  lineChildren.push(
1002
- h('span', { className: ['pcb__ln'], ariaHidden: true }, [hText(String(lineNumber))])
1006
+ h('span', { className: ['pcb__ln'], ariaHidden: true, 'aria-label': `Line ${lineNumber}` }, [hText(String(lineNumber))])
1003
1007
  );
1004
1008
  }
1005
1009
  lineChildren.push(
@@ -1017,6 +1021,13 @@ function toLineSpans(
1017
1021
  if (annotationText !== null) {
1018
1022
  lineProps['dataAnn'] = annotationText;
1019
1023
  }
1024
+ // v2.3.0: Add aria-label for diff lines (accessibility)
1025
+ if (classes.has('pcb__line--add')) {
1026
+ lineProps['aria-label'] = 'Added line';
1027
+ } else if (classes.has('pcb__line--del')) {
1028
+ lineProps['aria-label'] = 'Removed line';
1029
+ }
1030
+
1020
1031
  return h('span', lineProps, lineChildren);
1021
1032
  });
1022
1033
  }
package/src/types.ts CHANGED
@@ -137,6 +137,23 @@ export interface PerfectCodeOptions {
137
137
  * Default: ['typescript', 'bash', 'javascript', 'json', 'html', 'css']
138
138
  */
139
139
  preloadLangs?: string[];
140
+ /**
141
+ * v2.3.1: Maximum total character length of a code block before falling
142
+ * back to plaintext (to prevent event-loop blocking on huge blocks).
143
+ * 0 = no limit. Default: 200000 (200k chars ≈ ~5000 lines).
144
+ */
145
+ maxBlockLength?: number;
146
+ /**
147
+ * v2.3.1: Maximum time in ms for a single codeToHast call before falling
148
+ * back to plaintext. 0 = no limit. Default: 500 (ms).
149
+ */
150
+ tokenizeTimeout?: number;
151
+ /**
152
+ * v2.3.1: Timeout in ms for Shiki WASM/highlighter initialization.
153
+ * If initialization exceeds this, fall back to the pure-JS regex engine.
154
+ * 0 = no timeout. Default: 8000 (ms).
155
+ */
156
+ initTimeout?: number;
140
157
  [key: string]: unknown;
141
158
  };
142
159
  /**
@@ -343,7 +360,7 @@ export interface PerfectCodeOptions {
343
360
 
344
361
  /* ---------- Styling ---------- */
345
362
  /** Visual preset. Default: 'default' */
346
- preset?: 'default' | 'terminal' | 'minimal';
363
+ preset?: 'default' | 'terminal' | 'minimal' | 'retro';
347
364
  /** Inject the bundled CSS automatically. Set false to ship your own. Default: true */
348
365
  injectStyles?: boolean;
349
366
  /** Manual theme override. Default: 'auto' (prefers-color-scheme) */
@@ -609,6 +626,35 @@ export interface PerfectCodeOptions {
609
626
  */
610
627
  attribution?: boolean;
611
628
 
629
+ /* ---------- v2.3.0: P2 — Diagrams, Tables, ASCII, Frames, A11y ---------- */
630
+
631
+ /**
632
+ * Mermaid diagram rendering. When a fenced code block has language `mermaid`,
633
+ * render it as an SVG diagram instead of syntax-highlighted code.
634
+ *
635
+ * `mermaid` must be installed: `npm install mermaid`
636
+ *
637
+ * Default: `false` (mermaid blocks render as plain code)
638
+ */
639
+ mermaid?: boolean;
640
+
641
+ /**
642
+ * CSV/TSV table rendering. When a fenced code block has language `csv` or
643
+ * `tsv`, render it as a styled HTML table instead of code.
644
+ *
645
+ * Default: `false` (CSV/TSV blocks render as plain code)
646
+ */
647
+ csvTables?: boolean;
648
+
649
+ /**
650
+ * ASCII art preservation. For languages in the list, disable ligatures,
651
+ * preserve trailing whitespace, and set `font-variant-ligatures: none` to
652
+ * ensure ASCII art alignment is maintained.
653
+ *
654
+ * Default: `['text', 'plaintext', 'txt', 'ascii', 'plain']`
655
+ */
656
+ asciiArtLangs?: string[];
657
+
612
658
  /* ---------- Inline code (legacy cosmetic option) ---------- */
613
659
  /** Also style inline `code` cosmetically (no tokenization). Default: false */
614
660
  inline?: boolean;