@dr-ishaan/rehype-perfect-code-blocks 1.3.3 → 2.0.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/tokens.ts ADDED
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Design-token bridge — derives 20+ --pcb-* variables from 5 core values.
3
+ *
4
+ * v2.0.0 feature. Uses `color-mix(in oklch, ...)` for automatic color
5
+ * derivation, which is supported in all modern browsers (Chrome 111+,
6
+ * Safari 16.4+, Firefox 113+). For older browsers, the fallback values
7
+ * from the Shiki theme (Pattern 2, v1.3.0) still apply on the <pre> element.
8
+ */
9
+
10
+ export interface DesignTokens {
11
+ bg?: string;
12
+ fg?: string;
13
+ border?: string;
14
+ radius?: string;
15
+ monoFont?: string;
16
+ }
17
+
18
+ /**
19
+ * Generate a CSS style string containing all derived --pcb-* variables
20
+ * from the 5 core token values. Returns an empty string if no tokens
21
+ * are provided.
22
+ *
23
+ * The generated CSS is applied to `:where(.pcb)` so it has zero
24
+ * specificity — user CSS always wins.
25
+ */
26
+ export function generateTokenStyles(tokens: DesignTokens, scope?: string): string {
27
+ if (!tokens || Object.keys(tokens).length === 0) return '';
28
+
29
+ const { bg, fg, border, radius, monoFont } = tokens;
30
+ if (!bg && !fg && !border && !radius && !monoFont) return '';
31
+
32
+ const scopePrefix = scope ? `${scope} ` : '';
33
+ const lines: string[] = [];
34
+
35
+ // Direct mappings (1:1)
36
+ if (bg) lines.push(` --pcb-bg: ${bg};`);
37
+ if (fg) lines.push(` --pcb-fg: ${fg};`);
38
+ if (border) lines.push(` --pcb-border: ${border};`);
39
+ if (radius) lines.push(` --pcb-radius: ${radius};`);
40
+ if (monoFont) lines.push(` --pcb-font-mono: ${monoFont};`);
41
+
42
+ // Derived mappings (auto-computed from core values via color-mix)
43
+ // Only derive if BOTH bg and fg are available (needed for color-mix)
44
+ if (bg && fg) {
45
+ // Line numbers: muted (50% mix of fg over bg)
46
+ lines.push(` --pcb-text-muted: color-mix(in oklch, ${fg}, ${bg} 50%);`);
47
+ lines.push(` --pcb-ln-fg: color-mix(in oklch, ${fg}, ${bg} 50%);`);
48
+
49
+ // Header bar background: slightly different from body (5% fg over bg)
50
+ lines.push(` --pcb-bg-header: color-mix(in oklch, ${fg} 5%, ${bg});`);
51
+
52
+ // Header text: muted (30% fg mixed toward bg)
53
+ lines.push(` --pcb-text-bar: color-mix(in oklch, ${fg}, ${bg} 30%);`);
54
+
55
+ // Line highlight: subtle tint (12% fg over bg)
56
+ lines.push(` --pcb-line-highlight: color-mix(in oklch, ${fg} 12%, ${bg});`);
57
+
58
+ // Diff add: green tint (18% green over bg)
59
+ lines.push(` --pcb-line-add: color-mix(in oklch, #2ea043 18%, ${bg});`);
60
+
61
+ // Diff del: red tint (18% red over bg)
62
+ lines.push(` --pcb-line-del: color-mix(in oklch, #f85149 18%, ${bg});`);
63
+
64
+ // Focus: blue tint (18% blue over bg)
65
+ lines.push(` --pcb-line-focus: color-mix(in oklch, #58a6ff 18%, ${bg});`);
66
+
67
+ // Copy button hover background: subtle (8% fg over bg)
68
+ lines.push(` --pcb-copy-hover-bg: color-mix(in oklch, ${fg} 8%, ${bg});`);
69
+
70
+ // Gutter background: same as bg by default
71
+ lines.push(` --pcb-bg-gutter: ${bg};`);
72
+
73
+ // Caption background: slightly different (like header)
74
+ lines.push(` --pcb-caption-bg: color-mix(in oklch, ${fg} 5%, ${bg});`);
75
+
76
+ // Caption color: muted (like header text)
77
+ lines.push(` --pcb-caption-color: color-mix(in oklch, ${fg}, ${bg} 30%);`);
78
+
79
+ // Word highlight: gold tint (30% gold over bg)
80
+ lines.push(` --pcb-word-bg: color-mix(in oklch, #bb8009 30%, ${bg});`);
81
+
82
+ // Word highlight (id): blue tint (30% blue over bg)
83
+ lines.push(` --pcb-word-bg-id: color-mix(in oklch, #58a6ff 30%, ${bg});`);
84
+ }
85
+
86
+ if (lines.length === 0) return '';
87
+
88
+ return `:where(${scopePrefix}.pcb) {\n${lines.join('\n')}\n}`;
89
+ }
90
+
91
+ /**
92
+ * Generate the dark-mode CSS selector based on the user's dark mode strategy.
93
+ *
94
+ * Returns the selector prefix that should be placed before `.pcb` in CSS rules.
95
+ * For 'media' strategy, returns empty string (the rules are wrapped in @media).
96
+ */
97
+ export function generateDarkModeSelector(
98
+ darkMode?: {
99
+ strategy?: 'media' | 'attribute' | 'class' | 'custom';
100
+ attribute?: string;
101
+ attributeValue?: string;
102
+ class?: string;
103
+ customSelector?: string;
104
+ },
105
+ scope?: string
106
+ ): { selector: string; mediaQuery: string | null } {
107
+ const scopePrefix = scope ? `${scope} ` : '';
108
+
109
+ if (!darkMode || darkMode.strategy === 'media' || !darkMode.strategy) {
110
+ // Default: prefers-color-scheme media query
111
+ return { selector: '', mediaQuery: 'prefers-color-scheme: dark' };
112
+ }
113
+
114
+ if (darkMode.strategy === 'attribute') {
115
+ const attr = darkMode.attribute ?? 'data-theme';
116
+ const val = darkMode.attributeValue ?? 'dark';
117
+ return {
118
+ selector: `html[${attr}="${val}"] ${scopePrefix}`.trim(),
119
+ mediaQuery: null,
120
+ };
121
+ }
122
+
123
+ if (darkMode.strategy === 'class') {
124
+ const cls = darkMode.class ?? 'dark';
125
+ return {
126
+ selector: `html.${cls} ${scopePrefix}`.trim(),
127
+ mediaQuery: null,
128
+ };
129
+ }
130
+
131
+ if (darkMode.strategy === 'custom') {
132
+ const sel = darkMode.customSelector ?? ':root';
133
+ return {
134
+ selector: `${sel} ${scopePrefix}`.trim(),
135
+ mediaQuery: null,
136
+ };
137
+ }
138
+
139
+ return { selector: '', mediaQuery: 'prefers-color-scheme: dark' };
140
+ }
141
+
142
+ /**
143
+ * Generate the light-mode CSS selector (the inverse of dark mode).
144
+ * When dark mode is NOT active, light mode defaults apply.
145
+ */
146
+ export function generateLightModeSelector(
147
+ darkMode?: {
148
+ strategy?: 'media' | 'attribute' | 'class' | 'custom';
149
+ attribute?: string;
150
+ attributeValue?: string;
151
+ class?: string;
152
+ customSelector?: string;
153
+ },
154
+ scope?: string
155
+ ): { selector: string; mediaQuery: string | null } {
156
+ const scopePrefix = scope ? `${scope} ` : '';
157
+
158
+ if (!darkMode || darkMode.strategy === 'media' || !darkMode.strategy) {
159
+ return { selector: '', mediaQuery: 'prefers-color-scheme: light' };
160
+ }
161
+
162
+ if (darkMode.strategy === 'attribute') {
163
+ const attr = darkMode.attribute ?? 'data-theme';
164
+ const val = darkMode.attributeValue ?? 'dark';
165
+ // Light = when the attribute is NOT the dark value
166
+ return {
167
+ selector: `html:not([${attr}="${val}"]) ${scopePrefix}`.trim(),
168
+ mediaQuery: null,
169
+ };
170
+ }
171
+
172
+ if (darkMode.strategy === 'class') {
173
+ const cls = darkMode.class ?? 'dark';
174
+ return {
175
+ selector: `html:not(.${cls}) ${scopePrefix}`.trim(),
176
+ mediaQuery: null,
177
+ };
178
+ }
179
+
180
+ if (darkMode.strategy === 'custom') {
181
+ // For custom, light = when the custom selector does NOT match
182
+ // This is tricky — we can't easily negate an arbitrary selector.
183
+ // Fall back to media query for the light case.
184
+ return { selector: '', mediaQuery: 'prefers-color-scheme: light' };
185
+ }
186
+
187
+ return { selector: '', mediaQuery: 'prefers-color-scheme: light' };
188
+ }
189
+
190
+ /**
191
+ * Apply a scope prefix to all CSS selectors in a stylesheet.
192
+ * This is a simple regex-based approach that handles the plugin's CSS
193
+ * structure. It prefixes every selector that starts with `.pcb` or
194
+ * `:where(.pcb` or `html` with the scope.
195
+ */
196
+ export function applyScopeToCss(css: string, scope: string): string {
197
+ if (!scope) return css;
198
+
199
+ // Don't double-prefix if the scope is already present
200
+ if (css.includes(`${scope} .pcb`) || css.includes(`${scope}.pcb`)) return css;
201
+
202
+ // Prefix selectors that target .pcb or html.no-js
203
+ let result = css;
204
+
205
+ // :where(.pcb) → :where(scope .pcb)
206
+ result = result.replace(/:where\(\.pcb\)/g, `:where(${scope} .pcb)`);
207
+
208
+ // :where(html:not(...)) :where(.pcb:not(...)) → :where(scope html:not(...)) :where(scope .pcb:not(...))
209
+ // This is complex — for nested :where() with html, just prefix the whole compound
210
+ result = result.replace(/:where\(html/g, `:where(${scope} html`);
211
+
212
+ // .pcb pre → scope .pcb pre (the framework-reset overrides)
213
+ result = result.replace(/^(\.pcb\s)/gm, `${scope} $1`);
214
+
215
+ // .pcb__copy → scope .pcb__copy (note: .pcb__ starts with a dot, don't add another)
216
+ result = result.replace(/^(\.pcb__)/gm, `${scope} $1`);
217
+
218
+ // html.no-js .pcb__copy → scope html.no-js scope .pcb__copy
219
+ // (This is an edge case — the no-js rule needs both html and .pcb scoped)
220
+ result = result.replace(/html\.no-js\s+\.pcb/g, `${scope} html.no-js ${scope} .pcb`);
221
+
222
+ return result;
223
+ }
@@ -242,6 +242,11 @@ export function rehypePerfectCodeBlocks(userOptions: PerfectCodeOptions = {}) {
242
242
  preset: 'default',
243
243
  injectStyles: true,
244
244
  theme: 'auto',
245
+ cssInjection: 'inline' as const,
246
+ cssLayer: 'pcb',
247
+ tokens: undefined as unknown as NonNullable<PerfectCodeOptions['tokens']>,
248
+ darkMode: undefined as unknown as NonNullable<PerfectCodeOptions['darkMode']>,
249
+ scope: undefined as unknown as string,
245
250
  inline: false,
246
251
  ...rest,
247
252
  };
package/src/types.ts CHANGED
@@ -334,6 +334,163 @@ export interface PerfectCodeOptions {
334
334
  /** Manual theme override. Default: 'auto' (prefers-color-scheme) */
335
335
  theme?: 'auto' | 'dark' | 'light';
336
336
 
337
+ /* ---------- v2.0.0: CSS Architecture ---------- */
338
+
339
+ /**
340
+ * CSS injection strategy. Controls how the plugin's stylesheet is delivered.
341
+ *
342
+ * - `'inline'` (default) — injects the full CSS in a `<style>` tag on every page.
343
+ * Simple, works everywhere, but the CSS is unlayered (can be overridden by
344
+ * framework resets on cascade ties).
345
+ *
346
+ * - `'layer'` — injects the CSS wrapped in `@layer <cssLayer> { ... }`.
347
+ * On sites using cascade layers (Tailwind v3+, daisyUI, etc.), this ensures
348
+ * the plugin's CSS sits in the correct layer and framework resets don't
349
+ * win on specificity ties. Requires `cssLayer` to be set.
350
+ *
351
+ * - `'import'` — does NOT inject CSS. The user imports it manually:
352
+ * `import '@dr-ishaan/rehype-perfect-code-blocks/styles.css'`
353
+ * Useful for sites that want full control over CSS loading (e.g., bundling
354
+ * with PostCSS, or importing into a specific `@layer` in their own CSS).
355
+ *
356
+ * Default: `'inline'` (backward-compatible with v1.x).
357
+ */
358
+ cssInjection?: 'inline' | 'layer' | 'import';
359
+
360
+ /**
361
+ * The cascade layer name to use when `cssInjection: 'layer'`.
362
+ * The plugin's CSS will be wrapped in `@layer <cssLayer> { ... }`.
363
+ *
364
+ * Example: `cssLayer: 'components'` → all plugin CSS goes into
365
+ * `@layer components { ... }`, which the user can order above
366
+ * `@layer base` (framework resets) in their CSS:
367
+ *
368
+ * ```css
369
+ * @layer base, components, utilities;
370
+ * ```
371
+ *
372
+ * Default: `'pcb'`
373
+ */
374
+ cssLayer?: string;
375
+
376
+ /**
377
+ * Design-token bridge — provide 5 core values and the plugin auto-derives
378
+ * all 20+ `--pcb-*` variables. This is a shortcut for mapping the plugin
379
+ * to an external design system (Tailwind theme, daisyUI theme, CSS custom
380
+ * properties, etc.) without manually overriding every variable.
381
+ *
382
+ * Each value can be a CSS value string (e.g., `'#1a1b26'`) or a
383
+ * `var(--your-var)` reference.
384
+ *
385
+ * When set, the plugin generates a `<style>` block with the derived
386
+ * `--pcb-*` variables applied to `:where(.pcb)` (zero specificity, so
387
+ * user CSS still wins). If a specific `--pcb-*` variable is ALSO set
388
+ * in user CSS, the user's value wins (the token bridge is a default,
389
+ * not an override).
390
+ *
391
+ * Example:
392
+ * ```js
393
+ * perfectCode({
394
+ * tokens: {
395
+ * bg: 'var(--bg-subtle)',
396
+ * fg: 'var(--ink)',
397
+ * border: 'var(--rule)',
398
+ * radius: 'var(--radius-card)',
399
+ * monoFont: 'var(--font-mono)',
400
+ * },
401
+ * })
402
+ * ```
403
+ *
404
+ * The plugin auto-derives:
405
+ * - `--pcb-bg` = `bg`
406
+ * - `--pcb-fg` = `fg`
407
+ * - `--pcb-border` = `border`
408
+ * - `--pcb-radius` = `radius`
409
+ * - `--pcb-font-mono` = `monoFont`
410
+ * - `--pcb-ln-fg` = `color-mix(in oklch, fg, bg 50%)` (muted line numbers)
411
+ * - `--pcb-bg-header` = `color-mix(in oklch, fg 5%, bg)` (header slightly different)
412
+ * - `--pcb-text-bar` = `color-mix(in oklch, fg, bg 30%)` (header text muted)
413
+ * - `--pcb-line-highlight` = `color-mix(in oklch, fg 12%, bg)` (subtle highlight)
414
+ * - `--pcb-line-add` = `color-mix(in oklch, #2ea043 18%, bg)` (diff add)
415
+ * - `--pcb-line-del` = `color-mix(in oklch, #f85149 18%, bg)` (diff del)
416
+ * - `--pcb-line-focus` = `color-mix(in oklch, #58a6ff 18%, bg)` (focus)
417
+ * - `--pcb-copy-bg` (hover) = `color-mix(in oklch, fg 8%, bg)`
418
+ *
419
+ * Uses `color-mix(in oklch, ...)` which is supported in all modern browsers
420
+ * (Chrome 111+, Safari 16.4+, Firefox 113+). For older browsers, the
421
+ * fallback values from the Shiki theme (Pattern 2, v1.3.0) still apply.
422
+ *
423
+ * Default: `undefined` (no token bridge; use the plugin's built-in defaults)
424
+ */
425
+ tokens?: {
426
+ /** Background color of the code block surface. */
427
+ bg?: string;
428
+ /** Foreground (text) color of the code. */
429
+ fg?: string;
430
+ /** Border color of the code block frame. */
431
+ border?: string;
432
+ /** Border radius of the code block. */
433
+ radius?: string;
434
+ /** Monospace font family for code text. */
435
+ monoFont?: string;
436
+ };
437
+
438
+ /**
439
+ * Dark mode strategy — controls how the plugin switches between light
440
+ * and dark themes.
441
+ *
442
+ * - `'media'` (default) — uses `@media (prefers-color-scheme: dark)`.
443
+ * Automatic, no configuration needed. Same as v1.x behavior.
444
+ *
445
+ * - `'attribute'` — switches based on an attribute on `<html>`.
446
+ * Requires `darkMode.attribute` and `darkMode.attributeValue`.
447
+ * Example: `darkMode: { strategy: 'attribute', attribute: 'data-theme', attributeValue: 'dark' }`
448
+ * → CSS: `html[data-theme="dark"] .pcb { ... }`
449
+ *
450
+ * - `'class'` — switches based on a class on `<html>`.
451
+ * Requires `darkMode.class`.
452
+ * Example: `darkMode: { strategy: 'class', class: 'dark' }`
453
+ * → CSS: `html.dark .pcb { ... }`
454
+ *
455
+ * - `'custom'` — uses a custom CSS selector.
456
+ * Requires `darkMode.customSelector`.
457
+ * Example: `darkMode: { strategy: 'custom', customSelector: ':root[data-mode="night"]' }`
458
+ * → CSS: `:root[data-mode="night"] .pcb { ... }`
459
+ *
460
+ * When using Shiki dual themes (`shiki.theme: { light, dark }`), the
461
+ * Shiki token CSS variables (`--shiki-light` / `--shiki-dark`) are also
462
+ * switched using the same strategy, not just `prefers-color-scheme`.
463
+ *
464
+ * Default: `'media'` (backward-compatible with v1.x)
465
+ */
466
+ darkMode?: {
467
+ strategy: 'media' | 'attribute' | 'class' | 'custom';
468
+ /** For `strategy: 'attribute'` — the attribute name on `<html>`. Default: `'data-theme'` */
469
+ attribute?: string;
470
+ /** For `strategy: 'attribute'` — the attribute value that triggers dark mode. Default: `'dark'` */
471
+ attributeValue?: string;
472
+ /** For `strategy: 'class'` — the class name on `<html>` that triggers dark mode. Default: `'dark'` */
473
+ class?: string;
474
+ /** For `strategy: 'custom'` — the full CSS selector that triggers dark mode. */
475
+ customSelector?: string;
476
+ };
477
+
478
+ /**
479
+ * CSS containment scope — prefix all generated CSS selectors with this
480
+ * string. Useful when code blocks appear in specific contexts (e.g., only
481
+ * inside `.prose` or `article`) and you don't want the plugin's CSS to
482
+ * affect code blocks elsewhere on the page.
483
+ *
484
+ * Example: `scope: '.prose'` → all selectors become `.prose .pcb { ... }`,
485
+ * `.prose .pcb__header { ... }`, etc.
486
+ *
487
+ * The scope is applied to ALL generated CSS, including the framework-reset
488
+ * overrides and the dark-mode selectors.
489
+ *
490
+ * Default: `undefined` (no scoping; CSS applies everywhere)
491
+ */
492
+ scope?: string;
493
+
337
494
  /* ---------- Inline code (legacy cosmetic option) ---------- */
338
495
  /** Also style inline `code` cosmetically (no tokenization). Default: false */
339
496
  inline?: boolean;