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

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.
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Diagram & table rendering (v2.3.0).
3
+ *
4
+ * - Mermaid: render ```mermaid blocks as SVG diagrams
5
+ * - CSV/TSV: render ```csv / ```tsv blocks as HTML tables
6
+ *
7
+ * Both are build-time (server-side) — no client JS needed.
8
+ * `mermaid` is an optional peer dependency.
9
+ */
10
+
11
+ import type { Element, ElementContent } from 'hast';
12
+
13
+ /** Languages that trigger Mermaid rendering. */
14
+ export const MERMAID_LANGS = new Set(['mermaid', 'mmd']);
15
+
16
+ /** Languages that trigger CSV/TSV table rendering. */
17
+ export const CSV_LANGS = new Set(['csv']);
18
+ export const TSV_LANGS = new Set(['tsv']);
19
+
20
+ export function isMermaidLanguage(lang: string): boolean {
21
+ return MERMAID_LANGS.has(lang.toLowerCase());
22
+ }
23
+
24
+ export function isCsvLanguage(lang: string): boolean {
25
+ return CSV_LANGS.has(lang.toLowerCase()) || TSV_LANGS.has(lang.toLowerCase());
26
+ }
27
+
28
+ /**
29
+ * Render a Mermaid diagram to SVG.
30
+ * Falls back to a `<pre>` with the source if mermaid is not installed.
31
+ */
32
+ export async function renderMermaid(source: string): Promise<{ svg: string | null; isError: boolean }> {
33
+ try {
34
+ const mermaid = await import('mermaid');
35
+ // mermaid v10+ uses mermaid.default.render()
36
+ const m = (mermaid as unknown as { default?: { render: (id: string, text: string) => Promise<string> } }).default ?? mermaid;
37
+ if (typeof m.render === 'function') {
38
+ const id = 'pcb-mermaid-' + Math.random().toString(36).slice(2, 10);
39
+ const svg = await m.render(id, source.trim());
40
+ return { svg, isError: false };
41
+ }
42
+ } catch {
43
+ // mermaid not installed or rendering failed
44
+ }
45
+ return { svg: null, isError: true };
46
+ }
47
+
48
+ /**
49
+ * Parse CSV/TSV text into rows of cells.
50
+ */
51
+ export function parseCsv(text: string, delimiter: ',' | '\t' = ','): string[][] {
52
+ const rows: string[][] = [];
53
+ for (const line of text.split('\n')) {
54
+ if (line.trim() === '') continue;
55
+ rows.push(line.split(delimiter).map((cell) => cell.trim()));
56
+ }
57
+ return rows;
58
+ }
59
+
60
+ /**
61
+ * Build a HAST element tree for a CSV/TSV table.
62
+ * First row is the header (in `<thead>`), remaining rows in `<tbody>`.
63
+ */
64
+ export function buildCsvTable(text: string, delimiter: ',' | '\t' = ','): Element {
65
+ const rows = parseCsv(text, delimiter);
66
+ const children: ElementContent[] = [];
67
+
68
+ if (rows.length > 0) {
69
+ // Header
70
+ const headerCells: ElementContent[] = rows[0].map((cell) => ({
71
+ type: 'element',
72
+ tagName: 'th',
73
+ properties: { className: ['pcb__th'] },
74
+ children: [{ type: 'text', value: cell }],
75
+ }));
76
+ children.push({
77
+ type: 'element',
78
+ tagName: 'thead',
79
+ properties: {},
80
+ children: [{
81
+ type: 'element',
82
+ tagName: 'tr',
83
+ properties: { className: ['pcb__tr'] },
84
+ children: headerCells,
85
+ }],
86
+ });
87
+
88
+ // Body
89
+ const bodyRows: ElementContent[] = rows.slice(1).map((row) => ({
90
+ type: 'element',
91
+ tagName: 'tr',
92
+ properties: { className: ['pcb__tr'] },
93
+ children: row.map((cell) => ({
94
+ type: 'element',
95
+ tagName: 'td',
96
+ properties: { className: ['pcb__td'] },
97
+ children: [{ type: 'text', value: cell }],
98
+ })),
99
+ }));
100
+ children.push({
101
+ type: 'element',
102
+ tagName: 'tbody',
103
+ properties: {},
104
+ children: bodyRows,
105
+ });
106
+ }
107
+
108
+ return {
109
+ type: 'element',
110
+ tagName: 'table',
111
+ properties: { className: ['pcb__table'] },
112
+ children,
113
+ };
114
+ }
115
+
116
+ /** ASCII art language defaults — disable ligatures for alignment. */
117
+ export const DEFAULT_ASCII_ART_LANGS = ['text', 'plaintext', 'txt', 'ascii', 'plain'];
118
+
119
+ /**
120
+ * Check if a language should be treated as ASCII art (ligatures disabled).
121
+ */
122
+ export function isAsciiArtLang(lang: string, asciiArtLangs: string[]): boolean {
123
+ const set = new Set(asciiArtLangs.map((l) => l.toLowerCase()));
124
+ return set.has(lang.toLowerCase());
125
+ }
package/src/index.ts CHANGED
@@ -29,6 +29,8 @@ import type { DesignTokens } from './tokens.js';
29
29
  import { resolveMathOptions, isMathLanguage, renderMath } from './math.js';
30
30
  import type { MathOptions, ResolvedMathOptions } from './math.js';
31
31
  import { runDevWarnings, warnUnknownLanguage } from './dev-warnings.js';
32
+ import { isMermaidLanguage, isCsvLanguage, buildCsvTable, parseCsv, renderMermaid } from './diagrams.js';
33
+ import { CLASSES } from './classes.js';
32
34
  import type { PerfectCodeOptions } from './types.js';
33
35
 
34
36
  export { remarkPreserveCodeMeta };
@@ -37,6 +39,8 @@ export { wordDiff, hasChanges };
37
39
  export { generateTokenStyles, applyScopeToCss, generateDarkModeSelector, generateLightModeSelector };
38
40
  export { resolveMathOptions, isMathLanguage, renderMath };
39
41
  export { runDevWarnings, warnUnknownLanguage };
42
+ export { isMermaidLanguage, isCsvLanguage, buildCsvTable, parseCsv, renderMermaid };
43
+ export { CLASSES };
40
44
  export type { DiffToken, DesignTokens, MathOptions, ResolvedMathOptions };
41
45
 
42
46
  export const rehypePerfectCodeBlocks: Plugin<[PerfectCodeOptions?], Root> =
@@ -178,6 +182,10 @@ function resolveDefaults(opts: PerfectCodeOptions): Required<PerfectCodeOptions>
178
182
  diffMode: opts.diffMode ?? 'unified',
179
183
  annotations: opts.annotations ?? false,
180
184
  attribution: opts.attribution ?? false,
185
+ // v2.3.0: P2
186
+ mermaid: opts.mermaid ?? false,
187
+ csvTables: opts.csvTables ?? false,
188
+ asciiArtLangs: opts.asciiArtLangs ?? ['text', 'plaintext', 'txt', 'ascii', 'plain'],
181
189
  inline: opts.inline ?? false,
182
190
  };
183
191
  }
@@ -0,0 +1,11 @@
1
+ /** Minimal type declaration for mermaid (optional peer dependency). */
2
+ declare module 'mermaid' {
3
+ export interface MermaidConfig { [key: string]: unknown; }
4
+ export function render(id: string, text: string): Promise<string>;
5
+ export function initialize(config?: MermaidConfig): void;
6
+ const _default: {
7
+ render: typeof render;
8
+ initialize: typeof initialize;
9
+ };
10
+ export default _default;
11
+ }
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,
@@ -490,6 +491,87 @@ export async function runShikiOnRawBlocks(
490
491
  targets.splice(0, targets.length, ...codeTargets);
491
492
  }
492
493
 
494
+ // v2.3.0: Handle Mermaid diagram blocks — render as SVG instead of Shiki.
495
+ if ((opts as { mermaid?: boolean }).mermaid) {
496
+ const mermaidTargets: Element[] = [];
497
+ const remainingTargets: Element[] = [];
498
+ for (const pre of targets) {
499
+ const code = pre.children.find(
500
+ (c): c is Element => c.type === 'element' && c.tagName === 'code'
501
+ );
502
+ if (!code) { remainingTargets.push(pre); continue; }
503
+ const cls = (code.properties?.className as string[] | undefined) ?? [];
504
+ const langClass = cls.find((c) => c.startsWith('language-'));
505
+ const lang = langClass ? langClass.replace('language-', '') : '';
506
+ if (isMermaidLanguage(lang)) {
507
+ mermaidTargets.push(pre);
508
+ } else {
509
+ remainingTargets.push(pre);
510
+ }
511
+ }
512
+
513
+ for (const pre of mermaidTargets) {
514
+ const code = pre.children.find(
515
+ (c): c is Element => c.type === 'element' && c.tagName === 'code'
516
+ );
517
+ if (!code) continue;
518
+ const text = extractText(code).replace(/\r\n?/g, '\n').trim();
519
+ const { svg, isError } = await renderMermaid(text);
520
+ const mermaidDiv: Element = {
521
+ type: 'element',
522
+ tagName: 'div',
523
+ properties: { className: ['pcb__mermaid', isError ? 'pcb__mermaid--error' : 'pcb__mermaid--rendered'] },
524
+ children: svg
525
+ ? [{ type: 'text', value: svg }]
526
+ : [{ type: 'element', tagName: 'pre', properties: {}, children: [{ type: 'text', value: text }] }],
527
+ };
528
+ Object.assign(pre, mermaidDiv);
529
+ }
530
+ targets.splice(0, targets.length, ...remainingTargets);
531
+ }
532
+
533
+ // v2.3.0: Handle CSV/TSV table blocks — render as HTML table instead of Shiki.
534
+ if ((opts as { csvTables?: boolean }).csvTables) {
535
+ const csvTargets: Element[] = [];
536
+ const remainingTargets: Element[] = [];
537
+ for (const pre of targets) {
538
+ const code = pre.children.find(
539
+ (c): c is Element => c.type === 'element' && c.tagName === 'code'
540
+ );
541
+ if (!code) { remainingTargets.push(pre); continue; }
542
+ const cls = (code.properties?.className as string[] | undefined) ?? [];
543
+ const langClass = cls.find((c) => c.startsWith('language-'));
544
+ const lang = langClass ? langClass.replace('language-', '') : '';
545
+ if (isCsvLanguage(lang)) {
546
+ csvTargets.push(pre);
547
+ } else {
548
+ remainingTargets.push(pre);
549
+ }
550
+ }
551
+
552
+ for (const pre of csvTargets) {
553
+ const code = pre.children.find(
554
+ (c): c is Element => c.type === 'element' && c.tagName === 'code'
555
+ );
556
+ if (!code) continue;
557
+ const text = extractText(code).replace(/\r\n?/g, '\n').trim();
558
+ const cls = (code.properties?.className as string[] | undefined) ?? [];
559
+ const langClass = cls.find((c) => c.startsWith('language-'));
560
+ const lang = langClass ? langClass.replace('language-', '') : 'csv';
561
+ const delimiter = lang.toLowerCase() === 'tsv' ? '\t' : ',';
562
+ const tableEl = buildCsvTable(text, delimiter);
563
+ // Replace the <pre> with the table wrapped in a div
564
+ const tableDiv: Element = {
565
+ type: 'element',
566
+ tagName: 'div',
567
+ properties: { className: ['pcb__csv-table'] },
568
+ children: [tableEl],
569
+ };
570
+ Object.assign(pre, tableDiv);
571
+ }
572
+ targets.splice(0, targets.length, ...remainingTargets);
573
+ }
574
+
493
575
  if (targets.length === 0) return;
494
576
 
495
577
  // Build theme keys — supports single (string), dual ({light,dark}), and
package/src/styles.css CHANGED
@@ -722,3 +722,95 @@ 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
+ }
@@ -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
@@ -343,7 +343,7 @@ export interface PerfectCodeOptions {
343
343
 
344
344
  /* ---------- Styling ---------- */
345
345
  /** Visual preset. Default: 'default' */
346
- preset?: 'default' | 'terminal' | 'minimal';
346
+ preset?: 'default' | 'terminal' | 'minimal' | 'retro';
347
347
  /** Inject the bundled CSS automatically. Set false to ship your own. Default: true */
348
348
  injectStyles?: boolean;
349
349
  /** Manual theme override. Default: 'auto' (prefers-color-scheme) */
@@ -609,6 +609,35 @@ export interface PerfectCodeOptions {
609
609
  */
610
610
  attribution?: boolean;
611
611
 
612
+ /* ---------- v2.3.0: P2 — Diagrams, Tables, ASCII, Frames, A11y ---------- */
613
+
614
+ /**
615
+ * Mermaid diagram rendering. When a fenced code block has language `mermaid`,
616
+ * render it as an SVG diagram instead of syntax-highlighted code.
617
+ *
618
+ * `mermaid` must be installed: `npm install mermaid`
619
+ *
620
+ * Default: `false` (mermaid blocks render as plain code)
621
+ */
622
+ mermaid?: boolean;
623
+
624
+ /**
625
+ * CSV/TSV table rendering. When a fenced code block has language `csv` or
626
+ * `tsv`, render it as a styled HTML table instead of code.
627
+ *
628
+ * Default: `false` (CSV/TSV blocks render as plain code)
629
+ */
630
+ csvTables?: boolean;
631
+
632
+ /**
633
+ * ASCII art preservation. For languages in the list, disable ligatures,
634
+ * preserve trailing whitespace, and set `font-variant-ligatures: none` to
635
+ * ensure ASCII art alignment is maintained.
636
+ *
637
+ * Default: `['text', 'plaintext', 'txt', 'ascii', 'plain']`
638
+ */
639
+ asciiArtLangs?: string[];
640
+
612
641
  /* ---------- Inline code (legacy cosmetic option) ---------- */
613
642
  /** Also style inline `code` cosmetically (no tokenization). Default: false */
614
643
  inline?: boolean;