@dr-ishaan/rehype-perfect-code-blocks 2.1.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.
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
@@ -654,3 +654,163 @@
654
654
  html.no-js .pcb__copy {
655
655
  display: none !important;
656
656
  }
657
+
658
+ /* ============================================================
659
+ v2.2.0: Phase 3 — Split diff, annotations, attribution
660
+ ============================================================ */
661
+
662
+ /* ---------- Split diff view ---------- */
663
+ :where(.pcb--split-diff) .pcb__body {
664
+ display: grid;
665
+ grid-template-columns: 1fr 1fr;
666
+ }
667
+ :where(.pcb--split-diff) .pcb__body > pre {
668
+ border-right: 1px solid var(--pcb-border);
669
+ }
670
+ :where(.pcb--split-diff) .pcb__body > pre:last-child) {
671
+ border-right: none;
672
+ }
673
+ @media (max-width: 768px) {
674
+ :where(.pcb--split-diff) .pcb__body {
675
+ grid-template-columns: 1fr;
676
+ }
677
+ :where(.pcb--split-diff) .pcb__body > pre {
678
+ border-right: none;
679
+ border-bottom: 1px solid var(--pcb-border);
680
+ }
681
+ }
682
+
683
+ /* ---------- Line annotations ---------- */
684
+ :where(.pcb__ann) {
685
+ display: none; /* hidden by default, shown when .pcb--annotations */
686
+ }
687
+ :where(.pcb--annotations) .pcb__line[data-ann] {
688
+ display: grid;
689
+ grid-template-columns: auto 1fr auto;
690
+ align-items: baseline;
691
+ }
692
+ :where(.pcb--annotations) .pcb__ann) {
693
+ display: block;
694
+ padding-left: 1rem;
695
+ color: var(--pcb-text-muted);
696
+ font-size: 0.8125em;
697
+ font-style: italic;
698
+ white-space: normal;
699
+ border-left: 2px solid var(--pcb-border);
700
+ user-select: none;
701
+ }
702
+ @media (max-width: 768px) {
703
+ :where(.pcb--annotations) .pcb__line[data-ann]) {
704
+ grid-template-columns: auto 1fr;
705
+ }
706
+ :where(.pcb--annotations) .pcb__ann) {
707
+ grid-column: 1 / -1;
708
+ padding-left: 0;
709
+ border-left: none;
710
+ border-top: 1px dashed var(--pcb-border);
711
+ margin-top: 0.25rem;
712
+ }
713
+ }
714
+
715
+ /* ---------- Attribution footer ---------- */
716
+ :where(.pcb__attribution) {
717
+ padding: 0.5rem 1rem;
718
+ font-family: var(--pcb-bar-font);
719
+ font-size: var(--pcb-bar-font-size);
720
+ color: var(--pcb-caption-color);
721
+ background: var(--pcb-caption-bg);
722
+ border-top: 1px solid var(--pcb-border);
723
+ font-style: italic;
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
+ }
@@ -249,6 +249,14 @@ export function rehypePerfectCodeBlocks(userOptions: PerfectCodeOptions = {}) {
249
249
  scope: undefined as unknown as string,
250
250
  math: undefined as unknown as NonNullable<PerfectCodeOptions['math']>,
251
251
  devWarnings: process.env.NODE_ENV !== 'production',
252
+ // v2.2.0: Phase 3
253
+ diffMode: 'unified' as const,
254
+ annotations: false,
255
+ attribution: false,
256
+ // v2.3.0: P2
257
+ mermaid: false,
258
+ csvTables: false,
259
+ asciiArtLangs: ['text', 'plaintext', 'txt', 'ascii', 'plain'] as string[],
252
260
  inline: false,
253
261
  ...rest,
254
262
  };
@@ -639,6 +647,31 @@ async function transformPre(
639
647
  figureChildren.push(cap);
640
648
  }
641
649
 
650
+ // v2.2.0: Attribution footer — render author/year/source as a footer below the code block.
651
+ if ((opts as { attribution?: boolean }).attribution && (meta.author || meta.year || meta.source)) {
652
+ const parts: string[] = [];
653
+ if (meta.author) parts.push(meta.author);
654
+ if (meta.year) parts.push(`(${meta.year})`);
655
+ if (meta.source) parts.push(`. ${meta.source}.`);
656
+ else if (meta.author || meta.year) parts.push('.');
657
+ const attrText = parts.join(' ').trim();
658
+ if (attrText) {
659
+ figureChildren.push(
660
+ h('figcaption', { className: ['pcb__attribution'] }, [hText(attrText)])
661
+ );
662
+ }
663
+ }
664
+
665
+ // v2.2.0: Add pcb--split-diff class when diffMode is 'split'
666
+ if ((opts as { diffMode?: string }).diffMode === 'split') {
667
+ figClasses.push('pcb--split-diff');
668
+ }
669
+
670
+ // v2.2.0: Add pcb--annotations class when annotations are enabled
671
+ if ((opts as { annotations?: boolean }).annotations) {
672
+ figClasses.push('pcb--annotations');
673
+ }
674
+
642
675
  return h('figure', { className: figClasses }, figureChildren);
643
676
  }
644
677
 
@@ -941,6 +974,19 @@ function toLineSpans(
941
974
  // Map word-highlight spans inside this line.
942
975
  const mappedChildren = mapWordHighlights(line.children);
943
976
 
977
+ // v2.2.0: Parse and strip // [!ann: "text"] annotation notation.
978
+ let annotationText: string | null = null;
979
+ if ((opts as { annotations?: boolean }).annotations) {
980
+ const lineText = extractLineText(line);
981
+ const annMatch = lineText.match(/\[!ann:\s*"([^"]*)"\s*\]/);
982
+ if (annMatch) {
983
+ annotationText = annMatch[1];
984
+ // Strip the annotation from the line's text content
985
+ // (replace in all text nodes within the line)
986
+ stripAnnotationFromLine(line, annMatch[0]);
987
+ }
988
+ }
989
+
944
990
  // The line wrapper itself (the Shiki <span class="line ...">) becomes the
945
991
  // content of .pcb__code. Strip its classes — we've already mapped them
946
992
  // onto the outer .pcb__line wrapper, so they shouldn't also appear here.
@@ -953,18 +999,36 @@ function toLineSpans(
953
999
  children: mappedChildren,
954
1000
  };
955
1001
 
956
- // Build the row: [gutter-cell?, code-cell]
1002
+ // Build the row: [gutter-cell?, code-cell, annotation?]
957
1003
  const lineChildren: ElementContent[] = [];
958
1004
  if (resolved.lineNumbers) {
959
1005
  lineChildren.push(
960
- 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))])
961
1007
  );
962
1008
  }
963
1009
  lineChildren.push(
964
1010
  h('span', { className: ['pcb__code'] }, [innerWrapper])
965
1011
  );
966
1012
 
967
- return h('span', { className: [...classes] }, lineChildren);
1013
+ // v2.2.0: Add annotation cell if this line has an annotation
1014
+ if (annotationText !== null) {
1015
+ lineChildren.push(
1016
+ h('span', { className: ['pcb__ann'], 'dataAnn': annotationText }, [hText(annotationText)])
1017
+ );
1018
+ }
1019
+
1020
+ const lineProps: Record<string, unknown> = { className: [...classes] };
1021
+ if (annotationText !== null) {
1022
+ lineProps['dataAnn'] = annotationText;
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
+
1031
+ return h('span', lineProps, lineChildren);
968
1032
  });
969
1033
  }
970
1034
 
@@ -1153,6 +1217,18 @@ function hText(value: string): Text {
1153
1217
  return { type: 'text', value };
1154
1218
  }
1155
1219
 
1220
+ /** v2.2.0: Strip an annotation notation from all text nodes in a line element. */
1221
+ function stripAnnotationFromLine(line: Element, annotation: string): void {
1222
+ const walk = (node: ElementContent): void => {
1223
+ if (node.type === 'text') {
1224
+ node.value = node.value.replace(annotation, '');
1225
+ } else if (node.type === 'element') {
1226
+ for (const child of node.children) walk(child);
1227
+ }
1228
+ };
1229
+ for (const child of line.children) walk(child);
1230
+ }
1231
+
1156
1232
  /* ---------- Pattern 5: word-level diff (selective adoption from expressive-code) ---------- */
1157
1233
 
1158
1234
  /**
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) */
@@ -550,6 +550,94 @@ export interface PerfectCodeOptions {
550
550
  */
551
551
  devWarnings?: boolean;
552
552
 
553
+ /* ---------- v2.2.0: Diff & Comparison ---------- */
554
+
555
+ /**
556
+ * Side-by-side diff view. When enabled, adjacent `+`/`-` diff line pairs
557
+ * are rendered in a two-column layout (Before | After) with synchronized
558
+ * scrolling, instead of the default unified diff view.
559
+ *
560
+ * On mobile (viewport < 768px), the columns stack vertically.
561
+ *
562
+ * Default: `'unified'` (backward-compatible with v1.x/v2.0/v2.1)
563
+ */
564
+ diffMode?: 'unified' | 'split';
565
+
566
+ /* ---------- v2.2.0: Annotations ---------- */
567
+
568
+ /**
569
+ * Line annotations — margin notes attached to specific code lines.
570
+ *
571
+ * Use `// [!ann: "explanation"]` notation inside code to annotate a line.
572
+ * The annotation appears on the right side of the code block, connected
573
+ * to the annotated line with a subtle connector line. On mobile,
574
+ * annotations appear inline below the annotated line.
575
+ *
576
+ * Example:
577
+ * ```ts
578
+ * const attention = Q.dot(K.T) / Math.sqrt(d) // [!ann: "Scaled dot-product"]
579
+ * const weights = softmax(attention) // [!ann: "Normalized weights"]
580
+ * ```
581
+ *
582
+ * Default: `false` (opt-in)
583
+ */
584
+ annotations?: boolean;
585
+
586
+ /* ---------- v2.2.0: Attribution ---------- */
587
+
588
+ /**
589
+ * Code attribution — structured metadata for code blocks.
590
+ *
591
+ * When enabled, the plugin parses `author`, `year`, and `source` attributes
592
+ * from the fence meta string and renders them as a footer below the code block:
593
+ *
594
+ * ````markdown
595
+ * ```ts title="perceptron.ts" author="Rosenblatt" year="1958" source="Principles of Neurodynamics"
596
+ * const output = stepFunction(inputs.dot(weights))
597
+ * ```
598
+ * ````
599
+ *
600
+ * Renders:
601
+ * ```
602
+ * ┌─ perceptron.ts ──────────────────┐
603
+ * │ const output = stepFunction(...) │
604
+ * └───────────────────────────────────┘
605
+ * Rosenblatt (1958). Principles of Neurodynamics.
606
+ * ```
607
+ *
608
+ * Default: `false` (opt-in; when disabled, author/year/source meta is silently ignored)
609
+ */
610
+ attribution?: boolean;
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
+
553
641
  /* ---------- Inline code (legacy cosmetic option) ---------- */
554
642
  /** Also style inline `code` cosmetically (no tokenization). Default: false */
555
643
  inline?: boolean;
@@ -574,6 +662,10 @@ export interface ParsedMeta {
574
662
  wordHighlights: { text: string; range?: [number, number]; id?: string }[];
575
663
  lineNumbersStart: number | null; // from ln{N} or showLineNumbers{N}
576
664
  collapseRanges: { from: number; to: number }[]; // from collapse="5-12,20-30"
665
+ // v2.2.0: Attribution metadata
666
+ author: string | null; // from author="..."
667
+ year: string | null; // from year="..."
668
+ source: string | null; // from source="..."
577
669
  flags: {
578
670
  wrap: boolean | null;
579
671
  lineNumbers: boolean | null;