@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/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
- // Pure-JS regex engine for edge runtimes (Cloudflare Workers, Vercel Edge, browser).
149
- // No WASM download required.
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
- try {
152
- const engineMod = await import('shiki/engine/javascript');
153
- createOpts.engine = engineMod.createJavaScriptRegexEngine();
154
- } catch {
155
- // Fallback to default (oniguruma) if the JS engine subpath isn't available.
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
- if (typeof themeSpec === 'string') {
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
- const metaStr =
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
- const hastRoot = highlighter.codeToHast(text, shikiOpts) as { type: 'root'; children: Element[] };
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
+ }
@@ -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;