@dr-ishaan/rehype-perfect-code-blocks 1.2.1 → 1.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/LICENSE +0 -0
  3. package/README.md +0 -0
  4. package/dist/astro.d.ts +0 -0
  5. package/dist/astro.d.ts.map +0 -0
  6. package/dist/astro.js +0 -0
  7. package/dist/astro.js.map +0 -0
  8. package/dist/color-utils.d.ts +77 -0
  9. package/dist/color-utils.d.ts.map +1 -0
  10. package/dist/color-utils.js +189 -0
  11. package/dist/color-utils.js.map +1 -0
  12. package/dist/copy-script.d.ts +10 -3
  13. package/dist/copy-script.d.ts.map +1 -1
  14. package/dist/copy-script.js +75 -16
  15. package/dist/copy-script.js.map +1 -1
  16. package/dist/index.d.ts +6 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +5 -1
  19. package/dist/index.js.map +1 -1
  20. package/dist/meta.d.ts +0 -0
  21. package/dist/meta.d.ts.map +1 -1
  22. package/dist/meta.js +31 -1
  23. package/dist/meta.js.map +1 -1
  24. package/dist/remark.d.ts +0 -0
  25. package/dist/remark.d.ts.map +0 -0
  26. package/dist/remark.js +0 -0
  27. package/dist/remark.js.map +0 -0
  28. package/dist/shiki.d.ts +20 -0
  29. package/dist/shiki.d.ts.map +1 -1
  30. package/dist/shiki.js +116 -4
  31. package/dist/shiki.js.map +1 -1
  32. package/dist/styles.css +0 -0
  33. package/dist/transformer.d.ts +0 -0
  34. package/dist/transformer.d.ts.map +1 -1
  35. package/dist/transformer.js +108 -1
  36. package/dist/transformer.js.map +1 -1
  37. package/dist/types.d.ts +12 -0
  38. package/dist/types.d.ts.map +1 -1
  39. package/dist/types.js +0 -0
  40. package/dist/types.js.map +0 -0
  41. package/dist/word-diff.d.ts +47 -0
  42. package/dist/word-diff.d.ts.map +1 -0
  43. package/dist/word-diff.js +138 -0
  44. package/dist/word-diff.js.map +1 -0
  45. package/package.json +2 -2
  46. package/src/astro.ts +0 -0
  47. package/src/color-utils.ts +214 -0
  48. package/src/copy-script.ts +75 -16
  49. package/src/index.ts +7 -1
  50. package/src/meta.ts +33 -1
  51. package/src/remark.ts +0 -0
  52. package/src/shiki.ts +157 -10
  53. package/src/styles.css +0 -0
  54. package/src/transformer.ts +109 -1
  55. package/src/types.ts +12 -0
  56. package/src/vite-raw.d.ts +0 -0
  57. package/src/word-diff.ts +143 -0
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Color manipulation utilities for theme-aware CSS variable defaults.
3
+ *
4
+ * Pattern 2 (adopted from expressive-code's `helpers/color-transforms.ts`):
5
+ * CSS variable defaults are derived from the loaded Shiki theme and adjusted
6
+ * to meet WCAG contrast ratios, so code blocks look good with ANY Shiki theme
7
+ * out of the box — line numbers, diff backgrounds, and focus highlights are
8
+ * automatically legible against the theme's background color.
9
+ *
10
+ * The functions here are intentionally minimal — we only implement what we
11
+ * need to compute a few default `--pcb-*` values. For full color manipulation
12
+ * (lighten/darken/mix), see expressive-code's implementation.
13
+ */
14
+
15
+ /** A color in RGB format (0–255 per channel). */
16
+ export interface RGB {
17
+ r: number;
18
+ g: number;
19
+ b: number;
20
+ a?: number; // 0–1, optional alpha
21
+ }
22
+
23
+ /** Relative luminance per WCAG 2.1: https://www.w3.org/TR/WCAG21/#dfn-relative-luminance */
24
+ function relativeLuminance({ r, g, b }: RGB): number {
25
+ const channel = (c: number): number => {
26
+ const s = c / 255;
27
+ return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
28
+ };
29
+ return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
30
+ }
31
+
32
+ /** Contrast ratio per WCAG 2.1: https://www.w3.org/TR/WCAG21/#dfn-contrast-ratio (1–21). */
33
+ export function contrastRatio(fg: RGB, bg: RGB): number {
34
+ const l1 = relativeLuminance(fg);
35
+ const l2 = relativeLuminance(bg);
36
+ const lighter = Math.max(l1, l2);
37
+ const darker = Math.min(l1, l2);
38
+ return (lighter + 0.05) / (darker + 0.05);
39
+ }
40
+
41
+ /**
42
+ * Parse a hex color (#RGB, #RGBA, #RRGGBB, #RRGGBBAA) or rgb()/rgba() string
43
+ * into an RGB object. Returns null if the input can't be parsed.
44
+ */
45
+ export function parseColor(input: string | undefined | null): RGB | null {
46
+ if (!input) return null;
47
+ const s = input.trim().toLowerCase();
48
+ // Hex: #RGB, #RGBA, #RRGGBB, #RRGGBBAA
49
+ const hexMatch = s.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/);
50
+ if (hexMatch) {
51
+ let hex = hexMatch[1];
52
+ if (hex.length === 3 || hex.length === 4) {
53
+ hex = hex
54
+ .split('')
55
+ .map((c) => c + c)
56
+ .join('');
57
+ }
58
+ const r = parseInt(hex.slice(0, 2), 16);
59
+ const g = parseInt(hex.slice(2, 4), 16);
60
+ const b = parseInt(hex.slice(4, 6), 16);
61
+ const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : undefined;
62
+ return { r, g, b, a };
63
+ }
64
+ // rgb() / rgba()
65
+ const rgbMatch = s.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)$/);
66
+ if (rgbMatch) {
67
+ return {
68
+ r: parseInt(rgbMatch[1], 10),
69
+ g: parseInt(rgbMatch[2], 10),
70
+ b: parseInt(rgbMatch[3], 10),
71
+ a: rgbMatch[4] !== undefined ? parseFloat(rgbMatch[4]) : undefined,
72
+ };
73
+ }
74
+ return null;
75
+ }
76
+
77
+ /** Convert an RGB color back to a hex string (#RRGGBB or #RRGGBBAA). */
78
+ export function toHex({ r, g, b, a }: RGB): string {
79
+ const toHex2 = (n: number): string => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0');
80
+ const base = `#${toHex2(r)}${toHex2(g)}${toHex2(b)}`;
81
+ if (a !== undefined && a < 1) return base + toHex2(a * 255);
82
+ return base;
83
+ }
84
+
85
+ /** Mix two colors by a ratio (0 = pure a, 1 = pure b). */
86
+ export function mix(a: RGB, b: RGB, ratio: number): RGB {
87
+ const t = Math.max(0, Math.min(1, ratio));
88
+ return {
89
+ r: a.r * (1 - t) + b.r * t,
90
+ g: a.g * (1 - t) + b.g * t,
91
+ b: a.b * (1 - t) + b.b * t,
92
+ a: a.a !== undefined || b.a !== undefined ? (a.a ?? 1) * (1 - t) + (b.a ?? 1) * t : undefined,
93
+ };
94
+ }
95
+
96
+ /** Lighten a color toward white by a ratio (0–1). */
97
+ export function lighten(color: RGB, ratio: number): RGB {
98
+ return mix(color, { r: 255, g: 255, b: 255 }, ratio);
99
+ }
100
+
101
+ /** Darken a color toward black by a ratio (0–1). */
102
+ export function darken(color: RGB, ratio: number): RGB {
103
+ return mix(color, { r: 0, g: 0, b: 0 }, ratio);
104
+ }
105
+
106
+ /**
107
+ * Adjust a foreground color to meet a target contrast ratio against a
108
+ * background. If the contrast is already sufficient, returns the foreground
109
+ * unchanged. Otherwise, lightens (if bg is dark) or darkens (if bg is light)
110
+ * the foreground until the target ratio is met.
111
+ *
112
+ * @param fg Foreground color to adjust
113
+ * @param bg Background color to adjust against
114
+ * @param minRatio Minimum WCAG contrast ratio (default 4.5 = AA for normal text)
115
+ * @param maxRatio Maximum ratio to aim for if adjusting (default 7.0 = AAA)
116
+ * @returns The adjusted foreground color (or the original if already sufficient)
117
+ */
118
+ export function ensureColorContrastOnBackground(
119
+ fg: RGB,
120
+ bg: RGB,
121
+ minRatio = 4.5,
122
+ maxRatio = 7.0
123
+ ): RGB {
124
+ const current = contrastRatio(fg, bg);
125
+ if (current >= minRatio) return fg;
126
+
127
+ // Decide direction: if bg is dark (luminance < 0.5), lighten fg; else darken.
128
+ const bgLum = relativeLuminance(bg);
129
+ const direction = bgLum < 0.5 ? 'lighten' : 'darken';
130
+ // Binary search between current and pure white/black for the target ratio.
131
+ let lo = 0;
132
+ let hi = 1;
133
+ let best = fg;
134
+ for (let i = 0; i < 16; i++) {
135
+ const mid = (lo + hi) / 2;
136
+ const candidate = direction === 'lighten' ? lighten(fg, mid) : darken(fg, mid);
137
+ const ratio = contrastRatio(candidate, bg);
138
+ if (ratio >= minRatio && ratio <= maxRatio) {
139
+ return candidate;
140
+ }
141
+ if (ratio < minRatio) {
142
+ lo = mid;
143
+ } else {
144
+ best = candidate;
145
+ hi = mid;
146
+ }
147
+ }
148
+ return best;
149
+ }
150
+
151
+ /**
152
+ * Extract the background and foreground colors from a Shiki theme object.
153
+ * Shiki themes have a `bg` and `fg` property at the top level (hex strings).
154
+ * Returns nulls if the theme shape doesn't match.
155
+ */
156
+ export function extractThemeColors(theme: unknown): { bg: RGB | null; fg: RGB | null } {
157
+ const t = theme as { bg?: string; fg?: string; name?: string };
158
+ return {
159
+ bg: parseColor(t?.bg),
160
+ fg: parseColor(t?.fg),
161
+ };
162
+ }
163
+
164
+ /**
165
+ * Compute theme-aware `--pcb-*` defaults for a code block based on the
166
+ * loaded Shiki theme. These are applied as inline styles on the `<figure>`
167
+ * element, so the static `dist/styles.css` can ship its own defaults while
168
+ * the runtime overrides them with theme-aware values.
169
+ *
170
+ * Currently computes:
171
+ * - --pcb-bg: theme background (or 'inherit' if unknown)
172
+ * - --pcb-fg: theme foreground (or 'inherit' if unknown)
173
+ * - --pcb-line-numbers-fg: theme fg, contrast-adjusted against theme bg
174
+ * - --pcb-line-highlight-bg: theme fg at ~12% alpha (subtle highlight)
175
+ * - --pcb-line-diff-add-bg: green at ~18% alpha
176
+ * - --pcb-line-diff-del-bg: red at ~18% alpha
177
+ * - --pcb-line-focus-bg: theme fg at ~6% alpha (subtle dim)
178
+ *
179
+ * @param theme The Shiki theme object (must have `bg` and `fg` hex strings)
180
+ * @returns A CSS style string (e.g. `--pcb-bg:#fff;--pcb-fg:#000;...`) or empty string
181
+ */
182
+ export function computeThemeAwareDefaults(theme: unknown): string {
183
+ const { bg, fg } = extractThemeColors(theme);
184
+ if (!bg || !fg) return '';
185
+
186
+ const parts: string[] = [];
187
+ parts.push(`--pcb-bg:${toHex(bg)}`);
188
+ parts.push(`--pcb-fg:${toHex(fg)}`);
189
+
190
+ // Line numbers: use fg, but adjust contrast to >= 3.0 (WCAG AA for large text)
191
+ // against the background. Shiki themes often have low-contrast line-number
192
+ // colors baked in; we override them with a guaranteed-legible value.
193
+ const lineNumFg = ensureColorContrastOnBackground(fg, bg, 3.0, 4.5);
194
+ parts.push(`--pcb-ln-fg:${toHex(lineNumFg)}`);
195
+
196
+ // Line highlight background: subtle tint of the foreground at ~12% alpha.
197
+ // Use mix() with the background to get a slightly-lighter/darker shade.
198
+ const hlBg = mix(bg, fg, 0.12);
199
+ parts.push(`--pcb-line-highlight-bg:${toHex(hlBg)}`);
200
+
201
+ // Diff add: green (#22863a in github) at 18% alpha over bg.
202
+ const diffAdd = mix(bg, { r: 34, g: 134, b: 58 }, 0.18);
203
+ parts.push(`--pcb-line-add-bg:${toHex(diffAdd)}`);
204
+
205
+ // Diff del: red (#cb2431 in github) at 18% alpha over bg.
206
+ const diffDel = mix(bg, { r: 203, g: 36, b: 49 }, 0.18);
207
+ parts.push(`--pcb-line-del-bg:${toHex(diffDel)}`);
208
+
209
+ // Focus background: dim the non-focused lines by mixing bg with fg at low alpha.
210
+ const focusBg = mix(bg, fg, 0.04);
211
+ parts.push(`--pcb-line-focus-bg:${toHex(focusBg)}`);
212
+
213
+ return parts.join(';');
214
+ }
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * The copy-button client script. Inlined once per page (deduped by the
3
- * Astro integration via `injectScript`). ~600 bytes gzipped.
3
+ * Astro integration via `injectScript`). ~1.2 KB gzipped.
4
4
  *
5
5
  * Behavior:
6
- * - Listens for clicks on `.pcb__copy`
6
+ * - Listens for clicks on `.pcb__copy` (event delegation on document)
7
7
  * - Copies the textContent of the nearest `pre code`
8
8
  * - Toggles `.pcb__copy--done` and swaps the icon + label
9
9
  * - Resets after `data-feedback-duration` ms (default 1600)
@@ -13,6 +13,13 @@
13
13
  * - Strips leading `#` comment lines when `data-strip-comments` is set (terminal preset)
14
14
  * - Announces "Copied" to screen readers via an aria-live region (WCAG 4.1.2)
15
15
  * - Hides copy button when JS is disabled (via .no-js class on <html>)
16
+ *
17
+ * Pattern 4 (adopted from VitePress + expressive-code):
18
+ * - Event delegation via `document.addEventListener('click', ...)` — works
19
+ * regardless of how buttons were rendered (SSR, CSR, view transitions).
20
+ * - MutationObserver re-initializes the aria-live region and the .no-js → .js
21
+ * class swap when new code blocks are added to the DOM (SPA support).
22
+ * - `astro:page-load` event listener for Astro view transitions.
16
23
  */
17
24
  export const COPY_SCRIPT = `
18
25
  (function () {
@@ -20,27 +27,37 @@ export const COPY_SCRIPT = `
20
27
  window.__pcbCopyReady = true;
21
28
 
22
29
  // Remove the .no-js class so the copy buttons become visible (graceful degradation).
23
- if (document.documentElement.classList.contains('no-js')) {
24
- document.documentElement.classList.remove('no-js');
25
- document.documentElement.classList.add('js');
30
+ function swapNoJs() {
31
+ if (document.documentElement.classList.contains('no-js')) {
32
+ document.documentElement.classList.remove('no-js');
33
+ document.documentElement.classList.add('js');
34
+ }
26
35
  }
36
+ swapNoJs();
27
37
 
28
38
  // Reuse a single aria-live region for all copy announcements.
29
- var liveRegion = document.querySelector('.pcb__sr-live');
30
- if (!liveRegion) {
31
- liveRegion = document.createElement('span');
32
- liveRegion.className = 'pcb__sr-live';
33
- liveRegion.setAttribute('aria-live', 'polite');
34
- liveRegion.setAttribute('aria-atomic', 'true');
35
- liveRegion.setAttribute('role', 'status');
36
- document.body.appendChild(liveRegion);
39
+ var liveRegion = null;
40
+ function ensureLiveRegion() {
41
+ if (liveRegion && document.body.contains(liveRegion)) return liveRegion;
42
+ liveRegion = document.querySelector('.pcb__sr-live');
43
+ if (!liveRegion) {
44
+ liveRegion = document.createElement('span');
45
+ liveRegion.className = 'pcb__sr-live';
46
+ liveRegion.setAttribute('aria-live', 'polite');
47
+ liveRegion.setAttribute('aria-atomic', 'true');
48
+ liveRegion.setAttribute('role', 'status');
49
+ document.body.appendChild(liveRegion);
50
+ }
51
+ return liveRegion;
37
52
  }
53
+ ensureLiveRegion();
38
54
 
39
55
  function announce(msg) {
40
- if (!liveRegion) return;
41
- liveRegion.textContent = '';
56
+ var lr = ensureLiveRegion();
57
+ if (!lr) return;
58
+ lr.textContent = '';
42
59
  // Force re-announcement by clearing then setting on next tick.
43
- setTimeout(function () { liveRegion.textContent = msg; }, 50);
60
+ setTimeout(function () { lr.textContent = msg; }, 50);
44
61
  }
45
62
 
46
63
  function findLabel(btn) {
@@ -60,6 +77,9 @@ export const COPY_SCRIPT = `
60
77
  return text.replace(/^[ \\t]*(?:#|\\/\\/|REM\\b).*$/gm, '').replace(/\\n{3,}/g, '\\n\\n').trim();
61
78
  }
62
79
 
80
+ // Event-delegated click handler. Works for buttons added after initial
81
+ // render (e.g. via React/Vue re-render or Astro view transitions) because
82
+ // the listener is on document, not on each button.
63
83
  document.addEventListener('click', function (e) {
64
84
  var btn = e.target && e.target.closest && e.target.closest('.pcb__copy');
65
85
  if (!btn) return;
@@ -123,5 +143,44 @@ export const COPY_SCRIPT = `
123
143
  document.body.removeChild(ta);
124
144
  }
125
145
  });
146
+
147
+ // Pattern 4: MutationObserver for SPA support.
148
+ // When new code blocks are inserted into the DOM (e.g. by React/Vue
149
+ // re-render, Astro view transitions, or Turbolinks navigation), the
150
+ // .no-js → .js class swap may need to be re-applied so newly-added
151
+ // copy buttons become visible. The observer watches for added .pcb nodes.
152
+ if (typeof MutationObserver !== 'undefined') {
153
+ var pendingObserve = false;
154
+ var observer = new MutationObserver(function (mutations) {
155
+ // Batch checks with microtask to avoid layout thrash.
156
+ if (pendingObserve) return;
157
+ pendingObserve = true;
158
+ Promise.resolve().then(function () {
159
+ pendingObserve = false;
160
+ // If any new .pcb nodes were added, ensure the .js class is set
161
+ // (in case the page was rendered server-side with .no-js and the
162
+ // client took over after initial load).
163
+ for (var i = 0; i < mutations.length; i++) {
164
+ if (mutations[i].addedNodes && mutations[i].addedNodes.length) {
165
+ swapNoJs();
166
+ ensureLiveRegion();
167
+ break;
168
+ }
169
+ }
170
+ });
171
+ });
172
+ // Observe the whole document subtree for added nodes.
173
+ observer.observe(document.documentElement, { childList: true, subtree: true });
174
+ }
175
+
176
+ // Pattern 4: astro:page-load event listener for Astro view transitions.
177
+ // Astro emits this event after a view transition completes; the new page's
178
+ // DOM may have replaced the old, so re-apply the .no-js → .js swap.
179
+ if (typeof document.addEventListener === 'function') {
180
+ document.addEventListener('astro:page-load', function () {
181
+ swapNoJs();
182
+ ensureLiveRegion();
183
+ });
184
+ }
126
185
  })();
127
186
  `;
package/src/index.ts CHANGED
@@ -20,11 +20,16 @@
20
20
  import type { Plugin } from 'unified';
21
21
  import type { Root } from 'hast';
22
22
  import { rehypePerfectCodeBlocks as transformer } from './transformer.js';
23
- import { runShikiOnRawBlocks } from './shiki.js';
23
+ import { runShikiOnRawBlocks, disposeHighlighter, runHighlighterTask } from './shiki.js';
24
24
  import { remarkPreserveCodeMeta } from './remark.js';
25
+ import { wordDiff, hasChanges } from './word-diff.js';
26
+ import type { DiffToken } from './word-diff.js';
25
27
  import type { PerfectCodeOptions } from './types.js';
26
28
 
27
29
  export { remarkPreserveCodeMeta };
30
+ export { disposeHighlighter, runHighlighterTask };
31
+ export { wordDiff, hasChanges };
32
+ export type { DiffToken };
28
33
 
29
34
  export const rehypePerfectCodeBlocks: Plugin<[PerfectCodeOptions?], Root> =
30
35
  (options = {}) => {
@@ -90,6 +95,7 @@ function resolveDefaults(opts: PerfectCodeOptions): Required<PerfectCodeOptions>
90
95
  lineNumbersStart: opts.lineNumbersStart ?? 1,
91
96
  highlight: opts.highlight ?? true,
92
97
  diff: opts.diff ?? true,
98
+ wordDiff: opts.wordDiff ?? false,
93
99
  focus: opts.focus ?? true,
94
100
  errorLevels: opts.errorLevels ?? true,
95
101
  wrap: opts.wrap ?? false,
package/src/meta.ts CHANGED
@@ -103,7 +103,13 @@ export function parseMeta(meta: string | undefined): ParsedMeta {
103
103
  const idMatch = after.match(/^#([\w-]+)/);
104
104
  const lines = parseRanges(rangePart);
105
105
  if (lines.length > 0) {
106
- result.highlight.push(...lines);
106
+ // Issue #11: use a loop instead of `push(...lines)` to avoid
107
+ // stack overflow if `lines` is somehow huge (e.g. via a future
108
+ // code path that bypasses the MAX_HIGHLIGHT_LINES cap in
109
+ // parseRanges). Spread on >~100k args exhausts the V8 call stack.
110
+ for (let i = 0; i < lines.length; i++) {
111
+ result.highlight.push(lines[i]);
112
+ }
107
113
  result.highlightGroups.push({ lines, id: idMatch?.[1] });
108
114
  }
109
115
  continue;
@@ -287,6 +293,24 @@ function parseRanges(spec: string): number[] {
287
293
  // `,` and remaining whitespace (between range tokens), so `{1, 3 - 5, 7}`
288
294
  // → `1, 3-5, 7` → splits to ['1', '3-5', '7'] → [1, 3, 4, 5, 7]. ✓
289
295
  const normalized = spec.replace(/\s*-\s*/g, '-');
296
+
297
+ // Issue #11: a range like {1-1000000} would previously expand to a
298
+ // 1,000,000-element Set, then `result.highlight.push(...lines)` at the
299
+ // call site would blow the V8 stack (spread on huge arrays exceeds the
300
+ // ~100k-arg limit). This is a DoS vector for any deployment that renders
301
+ // user-supplied markdown.
302
+ //
303
+ // Fix: short-circuit ranges whose span exceeds a sane cap. Highlighting
304
+ // more than 10,000 lines in a single code block is not a real use case —
305
+ // at that point the user almost certainly has a typo or is trying to abuse
306
+ // the renderer. We return an empty array (skip highlighting) rather than
307
+ // throwing, so the rest of the block still renders normally.
308
+ //
309
+ // The cap is intentionally much larger than any realistic code block
310
+ // (a 10k-line code block is itself pathological) to avoid false positives.
311
+ const MAX_HIGHLIGHT_LINES = 10_000;
312
+ let totalSpan = 0;
313
+
290
314
  for (const part of normalized.split(/[\s,]+/)) {
291
315
  if (!part) continue;
292
316
  const m = part.match(/^(\d+)(?:-(\d+))?$/);
@@ -295,6 +319,14 @@ function parseRanges(spec: string): number[] {
295
319
  const end = m[2] ? parseInt(m[2], 10) : start;
296
320
  const lo = Math.min(start, end);
297
321
  const hi = Math.max(start, end);
322
+ const span = hi - lo + 1;
323
+ totalSpan += span;
324
+ if (totalSpan > MAX_HIGHLIGHT_LINES) {
325
+ // Range is implausibly large — bail out and skip highlighting
326
+ // for this entire meta spec. The block still renders; it just
327
+ // won't have any line-highlighting applied.
328
+ return [];
329
+ }
298
330
  for (let n = lo; n <= hi; n++) out.add(n);
299
331
  }
300
332
  return [...out].sort((a, b) => a - b);
package/src/remark.ts CHANGED
File without changes
package/src/shiki.ts CHANGED
@@ -17,6 +17,7 @@ import type { Element, Root } from 'hast';
17
17
  import { fromHtml } from 'hast-util-from-html';
18
18
  import { visit } from 'unist-util-visit';
19
19
  import type { PerfectCodeOptions } from './types.js';
20
+ import { computeThemeAwareDefaults } from './color-utils.js';
20
21
  import {
21
22
  transformerNotationDiff,
22
23
  transformerNotationFocus,
@@ -66,6 +67,58 @@ type ShikiHighlighter = {
66
67
 
67
68
  const highlighterCache = new Map<string, Promise<ShikiHighlighter>>();
68
69
 
70
+ // ───────────────────────────────────────────────────────────────────────────
71
+ // Pattern 1 (adopted from expressive-code): Mutually exclusive highlighter
72
+ // task queue.
73
+ //
74
+ // All highlighter operations (createHighlighter, loadLanguage, loadTheme,
75
+ // codeToHast, codeToHtml) are wrapped in `runHighlighterTask(() => ...)`.
76
+ // This serializes them globally, preventing race conditions in parallel
77
+ // static-site builds where multiple unified pipelines share the same
78
+ // module-level highlighter cache.
79
+ //
80
+ // Without this queue, if pipeline A calls `loadLanguage('ts')` and pipeline
81
+ // B calls `codeToHast(code, { lang: 'ts' })` on the same tick, B may run
82
+ // before A's load completes and fall back to plaintext — the "issue #13"
83
+ // class of bug. The queue makes all operations globally sequential.
84
+ //
85
+ // Tradeoff: slight throughput reduction in parallel builds; correctness >
86
+ // throughput for syntax highlighting.
87
+ // ───────────────────────────────────────────────────────────────────────────
88
+
89
+ type QueueTask = { taskFn: () => Promise<unknown>; resolve: (v: unknown) => void; reject: (e: unknown) => void };
90
+ const taskQueue: QueueTask[] = [];
91
+ let processingQueue = false;
92
+
93
+ function processQueue(): void {
94
+ const next = taskQueue.shift();
95
+ if (!next) {
96
+ processingQueue = false;
97
+ return;
98
+ }
99
+ Promise.resolve()
100
+ .then(() => next.taskFn())
101
+ .then(
102
+ (result) => { next.resolve(result); processQueue(); },
103
+ (err) => { next.reject(err); processQueue(); }
104
+ );
105
+ }
106
+
107
+ /**
108
+ * Run a task function inside the mutually exclusive highlighter queue.
109
+ * All calls are serialized globally — the next task starts only after the
110
+ * current one resolves or rejects.
111
+ */
112
+ export function runHighlighterTask<T>(taskFn: () => Promise<T>): Promise<T> {
113
+ return new Promise<T>((resolve, reject) => {
114
+ taskQueue.push({ taskFn: taskFn as () => Promise<unknown>, resolve: resolve as (v: unknown) => void, reject });
115
+ if (!processingQueue) {
116
+ processingQueue = true;
117
+ processQueue();
118
+ }
119
+ });
120
+ }
121
+
69
122
  async function getHighlighter(
70
123
  themeKeys: string[],
71
124
  langs: string[],
@@ -79,7 +132,9 @@ async function getHighlighter(
79
132
  const cacheKey = `${themeKeys.join(',')}|${[...safeLangs].sort().join(',')}|${regexEngine ?? 'onig'}`;
80
133
  let promise = highlighterCache.get(cacheKey);
81
134
  if (!promise) {
82
- promise = (async () => {
135
+ // Wrap the highlighter creation in the task queue so concurrent
136
+ // pipeline instances don't race on Shiki's internal singleton state.
137
+ promise = runHighlighterTask(async () => {
83
138
  if (userGetHighlighter) {
84
139
  return (await userGetHighlighter({ themes: themeKeys, langs: safeLangs })) as ShikiHighlighter;
85
140
  }
@@ -100,12 +155,39 @@ async function getHighlighter(
100
155
  }
101
156
  const all = await shiki.createHighlighter(createOpts as unknown as Parameters<typeof shiki.createHighlighter>[0]);
102
157
  return all as unknown as ShikiHighlighter;
103
- })();
158
+ });
104
159
  highlighterCache.set(cacheKey, promise);
105
160
  }
106
161
  return promise;
107
162
  }
108
163
 
164
+ /**
165
+ * Pattern 3 (adopted from VitePress): Dispose all cached highlighters and
166
+ * clear the cache. Call this in long-running dev servers when the theme
167
+ * changes, or during cleanup of a build pipeline, to release the WASM
168
+ * engine + loaded grammars + theme cache held by Shiki.
169
+ *
170
+ * After calling this, the next render will create a fresh highlighter.
171
+ *
172
+ * @example
173
+ * // In a Vite dev server shutdown hook:
174
+ * import { disposeHighlighter } from '@dr-ishaan/rehype-perfect-code-blocks';
175
+ * server.http2.close(() => disposeHighlighter());
176
+ */
177
+ export function disposeHighlighter(): void {
178
+ for (const promise of highlighterCache.values()) {
179
+ // The promise may still be pending; if so, attach a dispose-on-resolve.
180
+ promise.then(
181
+ (h) => {
182
+ const maybeDisposable = h as unknown as { dispose?: () => void };
183
+ if (typeof maybeDisposable.dispose === 'function') maybeDisposable.dispose();
184
+ },
185
+ () => { /* ignore — failed highlighters are already gone */ }
186
+ );
187
+ }
188
+ highlighterCache.clear();
189
+ }
190
+
109
191
  /** Filter out languages that aren't bundled with Shiki (avoids sync throws). */
110
192
  function filterBundledLangs(langs: string[]): string[] {
111
193
  // Always keep plaintext variants (special — don't require a bundle).
@@ -421,17 +503,22 @@ export async function runShikiOnRawBlocks(
421
503
  // Lazily load any langs not yet loaded. Shiki's `loadLanguage` throws
422
504
  // synchronously for bundled-but-unknown langs (e.g. typos), so wrap each
423
505
  // call in its own try/catch and use Promise.allSettled to swallow rejects.
506
+ //
507
+ // Wrapped in `runHighlighterTask` so concurrent pipeline instances don't
508
+ // race on Shiki's internal language registry. (Pattern 1)
424
509
  const loaded = new Set(highlighter.getLoadedLanguages());
425
510
  const missing = [...langSet].filter((l) => !loaded.has(l));
426
511
  if (missing.length > 0) {
427
- const results = await Promise.allSettled(
428
- missing.map((l) => {
429
- try {
430
- return Promise.resolve(highlighter.loadLanguage(l));
431
- } catch {
432
- return Promise.resolve();
433
- }
434
- })
512
+ const results = await runHighlighterTask(() =>
513
+ Promise.allSettled(
514
+ missing.map((l) => {
515
+ try {
516
+ return Promise.resolve(highlighter.loadLanguage(l));
517
+ } catch {
518
+ return Promise.resolve();
519
+ }
520
+ })
521
+ )
435
522
  );
436
523
  // Log failed language loads (competitor analysis: EC does this, improves DX).
437
524
  const failed: string[] = [];
@@ -642,11 +729,71 @@ export async function runShikiOnRawBlocks(
642
729
  // language-* class and the Shiki lang we actually used.
643
730
  (newCode.properties as Record<string, unknown>).dataLanguage = normalizedRawLang;
644
731
  }
732
+
733
+ // Pattern 2: Apply theme-aware --pcb-* defaults as inline styles on the
734
+ // <pre> element. The static dist/styles.css ships its own defaults, but
735
+ // those are generic; the runtime overrides them here based on the loaded
736
+ // Shiki theme so colors look good with ANY theme out of the box.
737
+ //
738
+ // We compute the defaults once per (theme,lang) combination and cache
739
+ // them on a WeakMap keyed by the highlighter to avoid recomputing per block.
740
+ if (typeof newPre.properties === 'object' && newPre.properties !== null) {
741
+ const themeDefaults = getThemeAwareDefaults(highlighter, themeKeys);
742
+ if (themeDefaults) {
743
+ const existingStyle = (newPre.properties as { style?: string }).style;
744
+ // Prepend our defaults so user-provided inline styles (if any) win.
745
+ (newPre.properties as { style?: string }).style = themeDefaults + (existingStyle ? `;${existingStyle}` : '');
746
+ }
747
+ }
748
+
645
749
  Object.assign(pre, newPre);
646
750
  }
647
751
  }
648
752
  }
649
753
 
754
+ // Cache theme-aware defaults per highlighter instance + theme keys, so we
755
+ // don't recompute them for every code block on the page.
756
+ const themeDefaultsCache = new WeakMap<object, Map<string, string>>();
757
+
758
+ function getThemeAwareDefaults(highlighter: ShikiHighlighter, themeKeys: string[]): string {
759
+ // Use the highlighter object as the WeakMap key.
760
+ const hlKey = highlighter as unknown as object;
761
+ let perHl = themeDefaultsCache.get(hlKey);
762
+ if (!perHl) {
763
+ perHl = new Map();
764
+ themeDefaultsCache.set(hlKey, perHl);
765
+ }
766
+ const cacheKey = themeKeys.slice().sort().join(',');
767
+ let cached = perHl.get(cacheKey);
768
+ if (cached !== undefined) return cached;
769
+
770
+ // Get the theme object from the highlighter.
771
+ // Use the first theme key (typically the dark theme in dual-theme config).
772
+ let theme: unknown = null;
773
+ try {
774
+ // highlighter.getTheme() returns the resolved theme registration.
775
+ const themeName = themeKeys[0];
776
+ const hlAny = highlighter as unknown as { getTheme?: (name: string) => unknown };
777
+ if (themeName && typeof hlAny.getTheme === 'function') {
778
+ theme = hlAny.getTheme(themeName);
779
+ }
780
+ } catch {
781
+ theme = null;
782
+ }
783
+
784
+ let defaults = '';
785
+ if (theme) {
786
+ try {
787
+ defaults = computeThemeAwareDefaults(theme);
788
+ } catch {
789
+ defaults = '';
790
+ }
791
+ }
792
+
793
+ perHl.set(cacheKey, defaults);
794
+ return defaults;
795
+ }
796
+
650
797
  function hasShikiMarker(className: unknown): boolean {
651
798
  if (!className) return false;
652
799
  const arr = Array.isArray(className) ? className : String(className).split(/\s+/);
package/src/styles.css CHANGED
File without changes