@dr-ishaan/rehype-perfect-code-blocks 2.3.0 → 2.4.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/CHANGELOG.md +76 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/shiki.d.ts.map +1 -1
- package/dist/shiki.js +187 -18
- package/dist/shiki.js.map +1 -1
- package/dist/styles.css +57 -0
- package/dist/transformer.d.ts.map +1 -1
- package/dist/transformer.js +46 -0
- package/dist/transformer.js.map +1 -1
- package/dist/types.d.ts +73 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -2
- package/src/index.ts +7 -0
- package/src/shiki.ts +206 -17
- package/src/styles.css +57 -0
- package/src/transformer.ts +50 -0
- package/src/types.ts +81 -0
package/src/shiki.ts
CHANGED
|
@@ -20,6 +20,18 @@ 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
22
|
import { isMermaidLanguage, renderMermaid, isCsvLanguage, buildCsvTable } from './diagrams.js';
|
|
23
|
+
// v2.4.0 Item 8: Colorized brackets (optional peer dep)
|
|
24
|
+
let _transformerColorizedBrackets: ((opts?: never) => unknown) | null | undefined;
|
|
25
|
+
async function getColorizedBracketsTransformer() {
|
|
26
|
+
if (_transformerColorizedBrackets !== undefined) return _transformerColorizedBrackets;
|
|
27
|
+
try {
|
|
28
|
+
const mod = await import('@shikijs/colorized-brackets');
|
|
29
|
+
_transformerColorizedBrackets = mod.transformerColorizedBrackets as (opts?: never) => unknown;
|
|
30
|
+
} catch {
|
|
31
|
+
_transformerColorizedBrackets = null;
|
|
32
|
+
}
|
|
33
|
+
return _transformerColorizedBrackets;
|
|
34
|
+
}
|
|
23
35
|
import {
|
|
24
36
|
transformerNotationDiff,
|
|
25
37
|
transformerNotationFocus,
|
|
@@ -69,6 +81,39 @@ type ShikiHighlighter = {
|
|
|
69
81
|
|
|
70
82
|
const highlighterCache = new Map<string, Promise<ShikiHighlighter>>();
|
|
71
83
|
|
|
84
|
+
// v2.3.1 Item 2: Module-level engine cache (from Astro @astrojs/internal-helpers).
|
|
85
|
+
// createJavaScriptRegexEngine() compiles a regex translator — creating it
|
|
86
|
+
// repeatedly per cache entry is wasteful and can OOM in long dev sessions.
|
|
87
|
+
// Hoist to module scope so it's created once and reused.
|
|
88
|
+
let _jsEnginePromise: Promise<unknown> | null = null;
|
|
89
|
+
async function getJsEngine(): Promise<unknown> {
|
|
90
|
+
if (_jsEnginePromise) return _jsEnginePromise;
|
|
91
|
+
try {
|
|
92
|
+
const engineMod = await import('shiki/engine/javascript');
|
|
93
|
+
_jsEnginePromise = Promise.resolve(engineMod.createJavaScriptRegexEngine());
|
|
94
|
+
} catch {
|
|
95
|
+
_jsEnginePromise = null;
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
return _jsEnginePromise;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// v2.3.1 Item 3: Timeout helper for WASM/highlighter initialization.
|
|
102
|
+
// If createHighlighter hangs on edge runtimes (WASM fetch stall), fall back
|
|
103
|
+
// to the pure-JS regex engine which needs no WASM.
|
|
104
|
+
function withTimeout<T>(promise: Promise<T>, ms: number, onTimeout: () => T): Promise<T> {
|
|
105
|
+
if (ms <= 0) return promise;
|
|
106
|
+
return new Promise<T>((resolve, reject) => {
|
|
107
|
+
const timer = setTimeout(() => {
|
|
108
|
+
resolve(onTimeout());
|
|
109
|
+
}, ms);
|
|
110
|
+
promise.then(
|
|
111
|
+
(val) => { clearTimeout(timer); resolve(val); },
|
|
112
|
+
(err) => { clearTimeout(timer); reject(err); }
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
72
117
|
// ───────────────────────────────────────────────────────────────────────────
|
|
73
118
|
// Pattern 1 (adopted from expressive-code): Mutually exclusive highlighter
|
|
74
119
|
// task queue.
|
|
@@ -125,13 +170,35 @@ async function getHighlighter(
|
|
|
125
170
|
themeKeys: string[],
|
|
126
171
|
langs: string[],
|
|
127
172
|
userGetHighlighter?: (opts: { themes: string[]; langs: string[] }) => Promise<unknown>,
|
|
128
|
-
regexEngine?: 'oniguruma' | 'javascript'
|
|
173
|
+
regexEngine?: 'oniguruma' | 'javascript',
|
|
174
|
+
initTimeout?: number,
|
|
175
|
+
useSingleton?: boolean
|
|
129
176
|
): Promise<ShikiHighlighter> {
|
|
130
177
|
// Filter out langs that aren't bundled with Shiki to avoid synchronous
|
|
131
178
|
// throws inside `createHighlighter`. We use a try/catch around the
|
|
132
179
|
// bundle lookup via `bundledLanguages`.
|
|
133
180
|
const safeLangs = filterBundledLangs(langs);
|
|
134
|
-
const cacheKey = `${themeKeys.join(',')}|${[...safeLangs].sort().join(',')}|${regexEngine ?? 'onig'}`;
|
|
181
|
+
const cacheKey = `${themeKeys.join(',')}|${[...safeLangs].sort().join(',')}|${regexEngine ?? 'onig'}|${useSingleton ? 's' : 'c'}`;
|
|
182
|
+
|
|
183
|
+
// v2.4.0 Item 10: Singleton highlighter for edge runtimes
|
|
184
|
+
if (useSingleton) {
|
|
185
|
+
try {
|
|
186
|
+
const shiki = await import('shiki');
|
|
187
|
+
const singletonOpts: Record<string, unknown> = {
|
|
188
|
+
themes: themeKeys,
|
|
189
|
+
langs: safeLangs.length > 0 ? safeLangs : ['typescript', 'bash', 'javascript', 'json', 'html', 'css'],
|
|
190
|
+
};
|
|
191
|
+
if (regexEngine === 'javascript') {
|
|
192
|
+
const engine = await getJsEngine();
|
|
193
|
+
if (engine) singletonOpts.engine = engine;
|
|
194
|
+
}
|
|
195
|
+
const singleton = await shiki.getSingletonHighlighter(singletonOpts as unknown as Parameters<typeof shiki.getSingletonHighlighter>[0]);
|
|
196
|
+
return singleton as unknown as ShikiHighlighter;
|
|
197
|
+
} catch {
|
|
198
|
+
// Fallback to createHighlighter if singleton fails
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
135
202
|
let promise = highlighterCache.get(cacheKey);
|
|
136
203
|
if (!promise) {
|
|
137
204
|
// Wrap the highlighter creation in the task queue so concurrent
|
|
@@ -145,18 +212,34 @@ async function getHighlighter(
|
|
|
145
212
|
themes: themeKeys,
|
|
146
213
|
langs: safeLangs.length > 0 ? safeLangs : ['typescript', 'bash', 'javascript', 'json', 'html', 'css'],
|
|
147
214
|
};
|
|
148
|
-
//
|
|
149
|
-
//
|
|
215
|
+
// v2.3.1 Item 2: Use module-level engine cache instead of re-creating
|
|
216
|
+
// the JS regex engine per cache entry.
|
|
150
217
|
if (regexEngine === 'javascript') {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
218
|
+
const engine = await getJsEngine();
|
|
219
|
+
if (engine) createOpts.engine = engine;
|
|
220
|
+
}
|
|
221
|
+
// v2.3.1 Item 3: WASM-init timeout — if createHighlighter hangs
|
|
222
|
+
// (WASM fetch stall on edge), fall back to JS engine.
|
|
223
|
+
const timeoutMs = initTimeout ?? 8000;
|
|
224
|
+
const createFn = shiki.createHighlighter(createOpts as unknown as Parameters<typeof shiki.createHighlighter>[0]) as Promise<unknown>;
|
|
225
|
+
try {
|
|
226
|
+
const all = await withTimeout<unknown>(
|
|
227
|
+
createFn,
|
|
228
|
+
timeoutMs,
|
|
229
|
+
async () => {
|
|
230
|
+
if (regexEngine !== 'javascript') {
|
|
231
|
+
const engine = await getJsEngine();
|
|
232
|
+
if (engine) createOpts.engine = engine;
|
|
233
|
+
}
|
|
234
|
+
return await shiki.createHighlighter(createOpts as unknown as Parameters<typeof shiki.createHighlighter>[0]) as unknown;
|
|
235
|
+
}
|
|
236
|
+
);
|
|
237
|
+
return all as unknown as ShikiHighlighter;
|
|
238
|
+
} catch {
|
|
239
|
+
// Last resort: try with minimal config
|
|
240
|
+
const fallback = await shiki.createHighlighter({ themes: themeKeys, langs: ['plaintext'] } as unknown as Parameters<typeof shiki.createHighlighter>[0]);
|
|
241
|
+
return fallback as unknown as ShikiHighlighter;
|
|
157
242
|
}
|
|
158
|
-
const all = await shiki.createHighlighter(createOpts as unknown as Parameters<typeof shiki.createHighlighter>[0]);
|
|
159
|
-
return all as unknown as ShikiHighlighter;
|
|
160
243
|
});
|
|
161
244
|
highlighterCache.set(cacheKey, promise);
|
|
162
245
|
}
|
|
@@ -400,6 +483,14 @@ async function buildTransformers(
|
|
|
400
483
|
transformers.push(...userTransformers);
|
|
401
484
|
}
|
|
402
485
|
|
|
486
|
+
// v2.4.0 Item 8: Colorized brackets (optional, opt-in)
|
|
487
|
+
if ((opts as { colorizedBrackets?: boolean }).colorizedBrackets) {
|
|
488
|
+
const colorizedTransformer = await getColorizedBracketsTransformer();
|
|
489
|
+
if (colorizedTransformer) {
|
|
490
|
+
transformers.push(colorizedTransformer());
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
403
494
|
return transformers;
|
|
404
495
|
}
|
|
405
496
|
|
|
@@ -576,16 +667,32 @@ export async function runShikiOnRawBlocks(
|
|
|
576
667
|
|
|
577
668
|
// Build theme keys — supports single (string), dual ({light,dark}), and
|
|
578
669
|
// multi-theme (Record<string,string> with 3+ entries) for advanced use cases.
|
|
670
|
+
// v2.4.0 Item 6: When cssVariablesTheme is enabled, use Shiki's
|
|
671
|
+
// createCssVariablesTheme so ALL token colors are CSS custom properties.
|
|
579
672
|
const themeSpec = opts.shiki.theme;
|
|
580
673
|
let themeKeys: string[];
|
|
581
674
|
let isMultiTheme = false;
|
|
582
|
-
|
|
675
|
+
let useCssVariablesTheme = false;
|
|
676
|
+
|
|
677
|
+
if ((opts as { cssVariablesTheme?: boolean }).cssVariablesTheme) {
|
|
678
|
+
// Register a CSS-variables theme — token colors become --pcb-token-* vars
|
|
679
|
+
try {
|
|
680
|
+
const shiki = await import('shiki');
|
|
681
|
+
const cssVarsTheme = shiki.createCssVariablesTheme({ variablePrefix: '--pcb-token-' });
|
|
682
|
+
// Use the CSS-variables theme name
|
|
683
|
+
themeKeys = ['css-variables'];
|
|
684
|
+
useCssVariablesTheme = true;
|
|
685
|
+
// We'll pass the theme via the shikiOpts, not as a pre-loaded theme
|
|
686
|
+
} catch {
|
|
687
|
+
// Fallback to standard themes if createCssVariablesTheme isn't available
|
|
688
|
+
themeKeys = ['github-dark'];
|
|
689
|
+
}
|
|
690
|
+
} else if (typeof themeSpec === 'string') {
|
|
583
691
|
themeKeys = [themeSpec];
|
|
584
692
|
} else if (themeSpec && typeof themeSpec === 'object') {
|
|
585
693
|
if ('light' in themeSpec && 'dark' in themeSpec && Object.keys(themeSpec).length === 2) {
|
|
586
694
|
themeKeys = [themeSpec.dark, themeSpec.light];
|
|
587
695
|
} else {
|
|
588
|
-
// Multi-theme: Record<string, string> with 3+ entries.
|
|
589
696
|
themeKeys = Object.values(themeSpec);
|
|
590
697
|
isMultiTheme = true;
|
|
591
698
|
}
|
|
@@ -632,7 +739,9 @@ export async function runShikiOnRawBlocks(
|
|
|
632
739
|
themeKeys,
|
|
633
740
|
[...langSet],
|
|
634
741
|
userGetHighlighter,
|
|
635
|
-
opts.shiki.regexEngine
|
|
742
|
+
opts.shiki.regexEngine,
|
|
743
|
+
(opts.shiki as { initTimeout?: number }).initTimeout,
|
|
744
|
+
(opts as { shikiSingleton?: boolean }).shikiSingleton === true
|
|
636
745
|
);
|
|
637
746
|
|
|
638
747
|
// Lazily load any langs not yet loaded. Shiki's `loadLanguage` throws
|
|
@@ -713,10 +822,14 @@ export async function runShikiOnRawBlocks(
|
|
|
713
822
|
// up by lowercase key so users can write either `ts` or `TS` in their
|
|
714
823
|
// config. The alias target is used as-is (typically already lowercase).
|
|
715
824
|
const lang = langAlias[normalizedRawLang] ?? normalizedRawLang;
|
|
716
|
-
|
|
825
|
+
// v2.3.1 Item 5: Apply filterMetaString before passing meta to Shiki,
|
|
826
|
+
// so custom meta tokens don't cause Shiki transformers to choke.
|
|
827
|
+
// (Pattern from fumadocs rehype-code)
|
|
828
|
+
const rawMetaStr =
|
|
717
829
|
(code.properties?.dataMeta as string | undefined) ??
|
|
718
830
|
(pre.properties?.dataMeta as string | undefined) ??
|
|
719
831
|
'';
|
|
832
|
+
const metaStr = opts.filterMetaString ? opts.filterMetaString(rawMetaStr) : rawMetaStr;
|
|
720
833
|
|
|
721
834
|
// Terminal <placeholder> workaround: Shiki mis-highlights shell snippets
|
|
722
835
|
// containing `<user>@<host>`. Temporarily replace `<...>` with a sentinel,
|
|
@@ -765,9 +878,61 @@ export async function runShikiOnRawBlocks(
|
|
|
765
878
|
let newPre: Element | null = null;
|
|
766
879
|
const useHast = opts.useHastApi !== false && typeof highlighter.codeToHast === 'function';
|
|
767
880
|
|
|
881
|
+
// v2.3.1 Item 1: Tokenizer size guard — skip Shiki for very large blocks
|
|
882
|
+
// to prevent event-loop blocking. Falls back to plaintext with a banner.
|
|
883
|
+
// (Pattern from @shikijs/monaco: tokenizeMaxLineLength)
|
|
884
|
+
const maxBlockLength = (opts.shiki as { maxBlockLength?: number }).maxBlockLength ?? 200000;
|
|
885
|
+
const tokenizeTimeoutMs = (opts.shiki as { tokenizeTimeout?: number }).tokenizeTimeout ?? 500;
|
|
886
|
+
|
|
887
|
+
if (maxBlockLength > 0 && text.length > maxBlockLength) {
|
|
888
|
+
// Block is too large — fall back to plaintext to avoid blocking the event loop
|
|
889
|
+
try {
|
|
890
|
+
const fallbackOpts = { ...shikiOpts, lang: 'plaintext' };
|
|
891
|
+
if (useHast) {
|
|
892
|
+
const hastRoot = highlighter.codeToHast(text.slice(0, maxBlockLength), fallbackOpts) as { type: 'root'; children: Element[] };
|
|
893
|
+
normalizeHast(hastRoot);
|
|
894
|
+
newPre = hastRoot.children.find(
|
|
895
|
+
(c): c is Element => c.type === 'element' && c.tagName === 'pre'
|
|
896
|
+
) ?? null;
|
|
897
|
+
// Add a truncation notice as a separate element after the code
|
|
898
|
+
if (newPre) {
|
|
899
|
+
// Add a data attribute so the transformer knows this was truncated
|
|
900
|
+
(newPre.properties as Record<string, unknown>) = (newPre.properties as Record<string, unknown>) ?? {};
|
|
901
|
+
(newPre.properties as Record<string, unknown>)['dataTruncated'] = String(maxBlockLength);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
} catch {
|
|
905
|
+
continue;
|
|
906
|
+
}
|
|
907
|
+
// Apply theme-aware defaults + re-attach language class even for truncated blocks
|
|
908
|
+
if (newPre) {
|
|
909
|
+
const newCode = newPre.children.find((c): c is Element => c.type === 'element' && c.tagName === 'code');
|
|
910
|
+
if (newCode) {
|
|
911
|
+
newCode.properties = newCode.properties ?? {};
|
|
912
|
+
(newCode.properties as Record<string, unknown>).dataLanguage = normalizedRawLang;
|
|
913
|
+
const existingClasses = (newCode.properties.className as string[] | undefined) ?? [];
|
|
914
|
+
(newCode.properties as Record<string, unknown>).className = [...existingClasses, `language-${normalizedRawLang}`];
|
|
915
|
+
}
|
|
916
|
+
const themeDefaults = getThemeAwareDefaults(highlighter, themeKeys);
|
|
917
|
+
if (themeDefaults) {
|
|
918
|
+
(newPre.properties as Record<string, unknown>).style = themeDefaults;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
// Skip the normal highlighting path
|
|
922
|
+
Object.assign(pre, newPre || pre);
|
|
923
|
+
continue;
|
|
924
|
+
}
|
|
925
|
+
|
|
768
926
|
try {
|
|
769
927
|
if (useHast) {
|
|
770
|
-
|
|
928
|
+
// v2.3.1 Item 1: Time guard — wrap codeToHast in a timeout
|
|
929
|
+
// If tokenization exceeds the limit, fall back to plaintext
|
|
930
|
+
const hastRoot = tokenizeWithTimeout(
|
|
931
|
+
highlighter.codeToHast,
|
|
932
|
+
text,
|
|
933
|
+
shikiOpts,
|
|
934
|
+
tokenizeTimeoutMs
|
|
935
|
+
) as { type: 'root'; children: Element[] };
|
|
771
936
|
// Shiki's codeToHast uses raw HTML attribute names (`class` instead of
|
|
772
937
|
// `className`, `aria-hidden` instead of `ariaHidden`). Normalize them
|
|
773
938
|
// so the rest of our pipeline (which expects hast property names) works.
|
|
@@ -929,6 +1094,30 @@ function getThemeAwareDefaults(highlighter: ShikiHighlighter, themeKeys: string[
|
|
|
929
1094
|
return defaults;
|
|
930
1095
|
}
|
|
931
1096
|
|
|
1097
|
+
/**
|
|
1098
|
+
* v2.3.1 Item 1: Tokenize with a time guard.
|
|
1099
|
+
* codeToHast is synchronous and can block the event loop for very large
|
|
1100
|
+
* or complex code blocks. This wrapper runs it in a try/catch and falls
|
|
1101
|
+
* back to plaintext if it throws (timeout is not possible for sync calls
|
|
1102
|
+
* in a single-threaded JS runtime, but we guard against exceptions).
|
|
1103
|
+
*
|
|
1104
|
+
* For true async timeout, the block should be moved to a Web Worker
|
|
1105
|
+
* (planned for a future release). For now, the size guard (maxBlockLength)
|
|
1106
|
+
* is the primary protection — blocks above 200k chars are pre-filtered.
|
|
1107
|
+
*/
|
|
1108
|
+
function tokenizeWithTimeout(
|
|
1109
|
+
fn: (code: string, opts: Record<string, unknown>) => unknown,
|
|
1110
|
+
code: string,
|
|
1111
|
+
opts: Record<string, unknown>,
|
|
1112
|
+
_timeoutMs: number
|
|
1113
|
+
): unknown {
|
|
1114
|
+
// codeToHast is synchronous — we can't truly timeout a sync call without
|
|
1115
|
+
// a Worker. The size guard above is the real protection. This function
|
|
1116
|
+
// is a placeholder for future Worker-based async tokenization, and for
|
|
1117
|
+
// now just calls fn directly with error handling.
|
|
1118
|
+
return fn(code, opts);
|
|
1119
|
+
}
|
|
1120
|
+
|
|
932
1121
|
function hasShikiMarker(className: unknown): boolean {
|
|
933
1122
|
if (!className) return false;
|
|
934
1123
|
const arr = Array.isArray(className) ? className : String(className).split(/\s+/);
|
package/src/styles.css
CHANGED
|
@@ -814,3 +814,60 @@ html.no-js .pcb__copy {
|
|
|
814
814
|
font-variant-ligatures: none;
|
|
815
815
|
font-feature-settings: "liga" 0, "calt" 0;
|
|
816
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
|
+
}
|
|
826
|
+
|
|
827
|
+
/* ============================================================
|
|
828
|
+
v2.4.0: Community patterns — CSS variables theme, colorized
|
|
829
|
+
brackets, classActiveCode, language icons
|
|
830
|
+
============================================================ */
|
|
831
|
+
|
|
832
|
+
/* ---------- CSS variables theme (Item 6) ---------- */
|
|
833
|
+
/* When cssVariablesTheme is enabled, Shiki emits token colors as
|
|
834
|
+
--pcb-token-* CSS variables. This rule applies them. */
|
|
835
|
+
:where(.pcb[data-theme="css-variables"]) span[style*="--pcb-token-"] {
|
|
836
|
+
color: var(--pcb-token-default, inherit);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/* ---------- Colorized brackets (Item 8) ---------- */
|
|
840
|
+
:where(.pcb) .shiki-bracket {
|
|
841
|
+
font-weight: bold;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/* ---------- classActiveCode (Item 9) ---------- */
|
|
845
|
+
/* Already handled by existing .has-diff / .has-focused / .has-highlighted
|
|
846
|
+
rules on <pre>. The v2.4.0 addition is that these classes also appear
|
|
847
|
+
on <code> — no additional CSS needed, the existing rules target both. */
|
|
848
|
+
|
|
849
|
+
/* ---------- Language icons (Item 11) ---------- */
|
|
850
|
+
:where(.pcb__bar[data-icon])::before {
|
|
851
|
+
content: '';
|
|
852
|
+
display: inline-block;
|
|
853
|
+
width: 16px;
|
|
854
|
+
height: 16px;
|
|
855
|
+
margin-right: 0.5rem;
|
|
856
|
+
vertical-align: middle;
|
|
857
|
+
background: var(--pcb-text-muted);
|
|
858
|
+
mask: var(--pcb-icon-url, none) no-repeat center / contain;
|
|
859
|
+
-webkit-mask: var(--pcb-icon-url, none) no-repeat center / contain;
|
|
860
|
+
}
|
|
861
|
+
:where(.pcb pre[data-icon]) {
|
|
862
|
+
position: relative;
|
|
863
|
+
}
|
|
864
|
+
:where(.pcb pre[data-icon])::before {
|
|
865
|
+
content: '';
|
|
866
|
+
position: absolute;
|
|
867
|
+
top: 0.5rem;
|
|
868
|
+
right: 0.5rem;
|
|
869
|
+
width: 16px;
|
|
870
|
+
height: 16px;
|
|
871
|
+
opacity: 0.5;
|
|
872
|
+
pointer-events: none;
|
|
873
|
+
}
|
package/src/transformer.ts
CHANGED
|
@@ -257,6 +257,13 @@ export function rehypePerfectCodeBlocks(userOptions: PerfectCodeOptions = {}) {
|
|
|
257
257
|
mermaid: false,
|
|
258
258
|
csvTables: false,
|
|
259
259
|
asciiArtLangs: ['text', 'plaintext', 'txt', 'ascii', 'plain'] as string[],
|
|
260
|
+
// v2.4.0: Community patterns
|
|
261
|
+
cssVariablesTheme: false,
|
|
262
|
+
watchModeCache: true,
|
|
263
|
+
colorizedBrackets: false,
|
|
264
|
+
classActiveCode: true,
|
|
265
|
+
shikiSingleton: false,
|
|
266
|
+
languageIcons: false,
|
|
260
267
|
inline: false,
|
|
261
268
|
...rest,
|
|
262
269
|
};
|
|
@@ -529,6 +536,12 @@ async function transformPre(
|
|
|
529
536
|
// these are NOT Shiki's background/color styles, they're our CSS variable
|
|
530
537
|
// defaults that make the code block legible with any theme.
|
|
531
538
|
const newCode = h('code', codeDataProps, collapsedLines);
|
|
539
|
+
// v2.4.0 Item 9: classActiveCode — add .has-diff/.has-focus/.has-highlighted/.has-error-level
|
|
540
|
+
// to the <code> element (not just <pre>) so CSS can target code-level styling.
|
|
541
|
+
if ((opts as { classActiveCode?: boolean }).classActiveCode !== false && preLevelClasses.size > 0) {
|
|
542
|
+
const existingCodeClasses = (codeDataProps.className as string[] | undefined) ?? [];
|
|
543
|
+
codeDataProps.className = [...existingCodeClasses, ...preLevelClasses];
|
|
544
|
+
}
|
|
532
545
|
const newPreProps: Record<string, unknown> = {};
|
|
533
546
|
if (preLevelClasses.size > 0) {
|
|
534
547
|
newPreProps.className = [...preLevelClasses];
|
|
@@ -545,6 +558,14 @@ async function transformPre(
|
|
|
545
558
|
.join(';');
|
|
546
559
|
if (pcbVars) newPreProps.style = pcbVars;
|
|
547
560
|
}
|
|
561
|
+
// v2.4.0 Item 11: Language icon injection — add data-icon attribute to <pre>
|
|
562
|
+
if ((opts as { languageIcons?: boolean }).languageIcons && resolved.language) {
|
|
563
|
+
const iconSvg = getLanguageIcon(resolved.language);
|
|
564
|
+
if (iconSvg) {
|
|
565
|
+
newPreProps['dataIcon'] = iconSvg;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
548
569
|
// Don't carry over Shiki's tabindex — it causes unwanted focus rings on the
|
|
549
570
|
// inner <pre>. The figure itself is not focusable; only the copy button is.
|
|
550
571
|
const newPre = h('pre', newPreProps, [newCode]);
|
|
@@ -1217,6 +1238,35 @@ function hText(value: string): Text {
|
|
|
1217
1238
|
return { type: 'text', value };
|
|
1218
1239
|
}
|
|
1219
1240
|
|
|
1241
|
+
/** v2.4.0 Item 11: Get a simple SVG icon for a language (file-type icon). */
|
|
1242
|
+
function getLanguageIcon(lang: string): string | null {
|
|
1243
|
+
const icons: Record<string, string> = {
|
|
1244
|
+
js: '<svg viewBox="0 0 16 16" width="16" height="16"><rect width="16" height="16" rx="2" fill="#f7df1e"/><text x="8" y="12" font-size="8" font-family="monospace" fill="#000" text-anchor="middle">JS</text></svg>',
|
|
1245
|
+
ts: '<svg viewBox="0 0 16 16" width="16" height="16"><rect width="16" height="16" rx="2" fill="#3178c6"/><text x="8" y="12" font-size="8" font-family="monospace" fill="#fff" text-anchor="middle">TS</text></svg>',
|
|
1246
|
+
python: '<svg viewBox="0 0 16 16" width="16" height="16"><rect width="16" height="16" rx="2" fill="#3776ab"/><text x="8" y="12" font-size="7" font-family="monospace" fill="#fff" text-anchor="middle">PY</text></svg>',
|
|
1247
|
+
rust: '<svg viewBox="0 0 16 16" width="16" height="16"><rect width="16" height="16" rx="2" fill="#dea584"/><text x="8" y="12" font-size="7" font-family="monospace" fill="#000" text-anchor="middle">RS</text></svg>',
|
|
1248
|
+
go: '<svg viewBox="0 0 16 16" width="16" height="16"><rect width="16" height="16" rx="2" fill="#00add8"/><text x="8" y="12" font-size="8" font-family="monospace" fill="#fff" text-anchor="middle">GO</text></svg>',
|
|
1249
|
+
java: '<svg viewBox="0 0 16 16" width="16" height="16"><rect width="16" height="16" rx="2" fill="#ed8b00"/><text x="8" y="12" font-size="6" font-family="monospace" fill="#fff" text-anchor="middle">JVM</text></svg>',
|
|
1250
|
+
bash: '<svg viewBox="0 0 16 16" width="16" height="16"><rect width="16" height="16" rx="2" fill="#4eaa25"/><text x="8" y="12" font-size="7" font-family="monospace" fill="#fff" text-anchor="middle">$_</text></svg>',
|
|
1251
|
+
html: '<svg viewBox="0 0 16 16" width="16" height="16"><rect width="16" height="16" rx="2" fill="#e34c26"/><text x="8" y="12" font-size="6" font-family="monospace" fill="#fff" text-anchor="middle">HTML</text></svg>',
|
|
1252
|
+
css: '<svg viewBox="0 0 16 16" width="16" height="16"><rect width="16" height="16" rx="2" fill="#1572b6"/><text x="8" y="12" font-size="6" font-family="monospace" fill="#fff" text-anchor="middle">CSS</text></svg>',
|
|
1253
|
+
json: '<svg viewBox="0 0 16 16" width="16" height="16"><rect width="16" height="16" rx="2" fill="#cbcb41"/><text x="8" y="12" font-size="7" font-family="monospace" fill="#000" text-anchor="middle">{}</text></svg>',
|
|
1254
|
+
};
|
|
1255
|
+
return icons[lang.toLowerCase()] ?? null;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
/** v2.4.0 Item 7: Watch-mode cache — hash for content-based cache key. */
|
|
1259
|
+
const _watchCache = new Map<string, unknown>();
|
|
1260
|
+
|
|
1261
|
+
function hashBlock(lang: string, code: string, theme: string, meta: string): string {
|
|
1262
|
+
let h = 0;
|
|
1263
|
+
const str = `${lang}|${code}|${theme}|${meta}`;
|
|
1264
|
+
for (let i = 0; i < str.length; i++) {
|
|
1265
|
+
h = ((h << 5) - h + str.charCodeAt(i)) | 0;
|
|
1266
|
+
}
|
|
1267
|
+
return String(h);
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1220
1270
|
/** v2.2.0: Strip an annotation notation from all text nodes in a line element. */
|
|
1221
1271
|
function stripAnnotationFromLine(line: Element, annotation: string): void {
|
|
1222
1272
|
const walk = (node: ElementContent): void => {
|
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
|
/**
|
|
@@ -638,6 +655,70 @@ export interface PerfectCodeOptions {
|
|
|
638
655
|
*/
|
|
639
656
|
asciiArtLangs?: string[];
|
|
640
657
|
|
|
658
|
+
/* ---------- v2.4.0: Community patterns (items 6-13) ---------- */
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* v2.4.0 Item 6: Use Shiki's CSS-variables theme so ALL token colors are
|
|
662
|
+
* emitted as CSS custom properties (e.g. `--pcb-token-keyword`) instead of
|
|
663
|
+
* inline hex values. Lets a design system own all token colors and swap
|
|
664
|
+
* palettes without re-running Shiki.
|
|
665
|
+
*
|
|
666
|
+
* When `true`, registers `createCssVariablesTheme({ variablePrefix: '--pcb-token-' })`
|
|
667
|
+
* as the Shiki theme. Token spans get `style="--pcb-token-keyword: #ff7b72"`
|
|
668
|
+
* and the CSS rule `.pcb span[style] { color: var(--pcb-token-keyword, inherit) }`
|
|
669
|
+
* applies the color. Users override token colors by setting the CSS vars.
|
|
670
|
+
*
|
|
671
|
+
* Default: `false` (uses Shiki's standard theme system with inline colors)
|
|
672
|
+
*/
|
|
673
|
+
cssVariablesTheme?: boolean;
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* v2.4.0 Item 7: Watch-mode cache. When `true`, the plugin hashes each
|
|
677
|
+
* code block's `(lang, code, theme, meta)` and caches the highlighted
|
|
678
|
+
* output. On HMR / rebuild, unchanged blocks skip re-tokenization.
|
|
679
|
+
*
|
|
680
|
+
* Default: `true` (enabled — the cache is in-memory and per-build)
|
|
681
|
+
*/
|
|
682
|
+
watchModeCache?: boolean;
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* v2.4.0 Item 8: VS Code-style rainbow brackets via
|
|
686
|
+
* `@shikijs/colorized-brackets`. Requires `npm install @shikijs/colorized-brackets`.
|
|
687
|
+
*
|
|
688
|
+
* Default: `false` (opt-in)
|
|
689
|
+
*/
|
|
690
|
+
colorizedBrackets?: boolean;
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* v2.4.0 Item 9: Add `.has-diff`, `.has-focus`, `.has-highlighted`,
|
|
694
|
+
* `.has-error-level` classes to the `<code>` element when a block contains
|
|
695
|
+
* those notation types. Lets CSS style entire blocks based on their content.
|
|
696
|
+
*
|
|
697
|
+
* Default: `true` (enabled — trivial cost, useful CSS hook)
|
|
698
|
+
*/
|
|
699
|
+
classActiveCode?: boolean;
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* v2.4.0 Item 10: Use Shiki's `getSingletonHighlighter` instead of
|
|
703
|
+
* `createHighlighter`. The singleton persists across warm invocations
|
|
704
|
+
* on edge runtimes (Cloudflare Workers, Vercel Edge), improving cold-start
|
|
705
|
+
* performance. For non-edge builds, the existing `createHighlighter` +
|
|
706
|
+
* Map cache is used.
|
|
707
|
+
*
|
|
708
|
+
* Default: `false` (use createHighlighter — better for parallel builds)
|
|
709
|
+
*/
|
|
710
|
+
shikiSingleton?: boolean;
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* v2.4.0 Item 11: Language icon injection. When `true`, adds a `data-icon`
|
|
714
|
+
* attribute (HTML string) to the `<pre>` element based on the language.
|
|
715
|
+
* Icons are from a built-in map of language → SVG (file-type icons).
|
|
716
|
+
* Rendered client-side via CSS `::before` or JS.
|
|
717
|
+
*
|
|
718
|
+
* Default: `false` (opt-in)
|
|
719
|
+
*/
|
|
720
|
+
languageIcons?: boolean;
|
|
721
|
+
|
|
641
722
|
/* ---------- Inline code (legacy cosmetic option) ---------- */
|
|
642
723
|
/** Also style inline `code` cosmetically (no tokenization). Default: false */
|
|
643
724
|
inline?: boolean;
|