@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/CHANGELOG.md +102 -0
- package/dist/classes.d.ts +58 -0
- package/dist/classes.d.ts.map +1 -0
- package/dist/classes.js +62 -0
- package/dist/classes.js.map +1 -0
- package/dist/diagrams.d.ts +41 -0
- package/dist/diagrams.d.ts.map +1 -0
- package/dist/diagrams.js +114 -0
- package/dist/diagrams.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/shiki.d.ts.map +1 -1
- package/dist/shiki.js +206 -15
- package/dist/shiki.js.map +1 -1
- package/dist/styles.css +101 -0
- package/dist/transformer.d.ts.map +1 -1
- package/dist/transformer.js +12 -1
- package/dist/transformer.js.map +1 -1
- package/dist/types.d.ts +42 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/classes.ts +64 -0
- package/src/diagrams.ts +125 -0
- package/src/index.ts +8 -0
- package/src/mermaid.d.ts +11 -0
- package/src/shiki.ts +227 -14
- package/src/styles.css +101 -0
- package/src/transformer.ts +12 -1
- package/src/types.ts +47 -1
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
|
-
//
|
|
148
|
-
//
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/transformer.ts
CHANGED
|
@@ -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;
|