@dr-ishaan/rehype-perfect-code-blocks 1.3.3 → 2.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dr-ishaan/rehype-perfect-code-blocks",
3
- "version": "1.3.3",
3
+ "version": "2.1.0",
4
4
  "description": "Beautiful, configurable code blocks for Astro / MDX / any rehype pipeline. Built on Shiki, inspired by rehype-pretty-code, VitePress, Docusaurus, and Expressive Code.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -51,7 +51,7 @@
51
51
  "scripts": {
52
52
  "build": "tsc -p tsconfig.json && cp src/styles.css dist/styles.css",
53
53
  "dev": "tsc -p tsconfig.json --watch",
54
- "test": "node test-meta-parser.mjs && node test-dom-structure.mjs && node test-options.mjs && node test-notations.mjs && node test-security.mjs && node test-integration.mjs && node test-regression.mjs && node test-css.mjs && node test-edge-cases.mjs && node stress-tests.mjs && node new-feature-tests.mjs && node test-issue-12.mjs && node test-issue-11.mjs && node test-architecture-patterns.mjs && node test-copy-button-fix.mjs && node test-tailwind-compat.mjs",
54
+ "test": "node test-meta-parser.mjs && node test-dom-structure.mjs && node test-options.mjs && node test-notations.mjs && node test-security.mjs && node test-integration.mjs && node test-regression.mjs && node test-css.mjs && node test-edge-cases.mjs && node stress-tests.mjs && node new-feature-tests.mjs && node test-issue-12.mjs && node test-issue-11.mjs && node test-architecture-patterns.mjs && node test-copy-button-fix.mjs && node test-tailwind-compat.mjs && node test-v2-css-architecture.mjs && node test-v2-phase2.mjs",
55
55
  "prepublishOnly": "npm run build && npm test",
56
56
  "prepack": "npm run build"
57
57
  },
package/src/astro.ts CHANGED
@@ -22,6 +22,7 @@ import { rehypePerfectCodeBlocks } from './index.js';
22
22
  import { remarkPreserveCodeMeta } from './remark.js';
23
23
  import { COPY_SCRIPT } from './copy-script.js';
24
24
  import type { PerfectCodeOptions } from './types.js';
25
+ import { generateTokenStyles, applyScopeToCss } from './tokens.js';
25
26
  import { readFileSync } from 'node:fs';
26
27
  import { readdirSync, writeFileSync, readFileSync as readFile } from 'node:fs';
27
28
  import { fileURLToPath } from 'node:url';
@@ -121,9 +122,26 @@ export default function perfectCode(
121
122
  const nonceAttr = options.cspNonce ? ` nonce="${escapeAttr(options.cspNonce)}"` : '';
122
123
 
123
124
  // CSS
124
- if (options.injectStyles !== false) {
125
- const css = loadCss();
125
+ if (options.injectStyles !== false && options.cssInjection !== 'import') {
126
+ let css = loadCss();
126
127
  if (css) {
128
+ // v2.0.0: Apply CSS scope if configured
129
+ if (options.scope) {
130
+ css = applyScopeToCss(css, options.scope);
131
+ }
132
+
133
+ // v2.0.0: Generate token-bridge CSS (derived --pcb-* variables)
134
+ const tokenCss = generateTokenStyles(options.tokens ?? {}, options.scope);
135
+ if (tokenCss) {
136
+ css = css + '\n' + tokenCss;
137
+ }
138
+
139
+ // v2.0.0: Wrap in @layer if configured
140
+ const layerName = options.cssLayer ?? 'pcb';
141
+ if (options.cssInjection === 'layer') {
142
+ css = `@layer ${layerName} {\n${css}\n}`;
143
+ }
144
+
127
145
  injections.push(`<style data-pcb${nonceAttr}>${css}</style>`);
128
146
  }
129
147
  }
@@ -103,6 +103,9 @@ export const COPY_SCRIPT = `
103
103
  var finish = function () {
104
104
  btn.classList.add('pcb__copy--done');
105
105
  if (label) label.textContent = done;
106
+ // v2.1.0: Update aria-label for screen readers — announce "copied" state
107
+ var originalAriaLabel = btn.getAttribute('aria-label') || 'Copy code';
108
+ btn.setAttribute('aria-label', done + ' — ' + originalAriaLabel);
106
109
  if (successIconHtml && icon) {
107
110
  var tmp = document.createElement('span');
108
111
  tmp.innerHTML = successIconHtml;
@@ -116,6 +119,8 @@ export const COPY_SCRIPT = `
116
119
  setTimeout(function () {
117
120
  btn.classList.remove('pcb__copy--done');
118
121
  if (label && originalLabel != null) label.textContent = originalLabel;
122
+ // v2.1.0: Restore original aria-label after feedback duration
123
+ btn.setAttribute('aria-label', originalAriaLabel);
119
124
  if (originalIconHtml && icon) {
120
125
  var tmp2 = document.createElement('span');
121
126
  tmp2.innerHTML = originalIconHtml;
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Development warnings (v2.1.0).
3
+ *
4
+ * Emits warnings to the logger during build/dev for common misconfigurations:
5
+ * - Unknown language not loaded in Shiki
6
+ * - Invalid meta syntax (e.g., `{1,a-5}` instead of `{1,3-5}`)
7
+ * - Conflicting options (e.g., `wrap` + `collapseAfter` both enabled)
8
+ * - Code block inside raw HTML detected but rehype-raw not installed
9
+ *
10
+ * Warnings are emitted once per unique message (deduped) to avoid spam.
11
+ */
12
+
13
+ import type { Element, Root } from 'hast';
14
+ import { visit } from 'unist-util-visit';
15
+
16
+ export interface DevWarningContext {
17
+ logger: { warn: (msg: string) => void; error: (msg: string) => void };
18
+ hasRehypeRaw: boolean;
19
+ wrap: boolean;
20
+ collapseAfter: number | null;
21
+ }
22
+
23
+ const warnedMessages = new Set<string>();
24
+
25
+ function warnOnce(ctx: DevWarningContext, msg: string): void {
26
+ if (warnedMessages.has(msg)) return;
27
+ warnedMessages.add(msg);
28
+ ctx.logger.warn(msg);
29
+ }
30
+
31
+ /**
32
+ * Check for common misconfigurations and emit dev warnings.
33
+ * Call this once per document after the plugin has processed the tree.
34
+ */
35
+ export function runDevWarnings(tree: Root, ctx: DevWarningContext): void {
36
+ // 1. Check for conflicting options
37
+ if (ctx.wrap && ctx.collapseAfter !== null) {
38
+ warnOnce(
39
+ ctx,
40
+ '[rehype-perfect-code-blocks] Both `wrap` and `collapseAfter` are enabled. ' +
41
+ 'Collapsed blocks may not wrap correctly. Consider disabling one.'
42
+ );
43
+ }
44
+
45
+ // 2. Check for code blocks inside raw HTML without rehype-raw
46
+ if (!ctx.hasRehypeRaw) {
47
+ let foundRawHtmlAroundCode = false;
48
+ visit(tree, 'element', (node: Element) => {
49
+ if (foundRawHtmlAroundCode) return;
50
+ // Look for <pre> elements that are children of raw HTML elements
51
+ // like <details>, <div> with class containing "card", etc.
52
+ // This is a heuristic — we can't perfectly detect raw HTML vs markdown HTML.
53
+ if (
54
+ node.tagName === 'details' ||
55
+ (node.tagName === 'div' && node.properties?.className &&
56
+ Array.isArray(node.properties.className) &&
57
+ node.properties.className.some((c: unknown) =>
58
+ typeof c === 'string' && (c.includes('card') || c.includes('container'))
59
+ ))
60
+ ) {
61
+ const hasPre = node.children?.some(
62
+ (c) => c.type === 'element' && c.tagName === 'pre'
63
+ );
64
+ if (hasPre) foundRawHtmlAroundCode = true;
65
+ }
66
+ });
67
+ if (foundRawHtmlAroundCode) {
68
+ warnOnce(
69
+ ctx,
70
+ '[rehype-perfect-code-blocks] Code block inside raw HTML detected but rehype-raw ' +
71
+ 'does not appear to be installed. Code blocks inside <details>, <div class="card">, ' +
72
+ 'etc. may not render correctly. Add rehype-raw to your pipeline: ' +
73
+ 'npm install rehype-raw'
74
+ );
75
+ }
76
+ }
77
+
78
+ // 3. Check for unknown/invalid meta syntax
79
+ visit(tree, 'element', (node: Element) => {
80
+ if (node.tagName !== 'pre') return;
81
+ const codeEl = node.children?.find(
82
+ (c): c is Element => c.type === 'element' && c.tagName === 'code'
83
+ );
84
+ if (!codeEl) return;
85
+ const meta = (codeEl.properties?.dataMeta as string | undefined) ??
86
+ (node.properties?.dataMeta as string | undefined) ?? '';
87
+ if (!meta) return;
88
+
89
+ // Check for invalid range syntax like {1,a-5} or {1-}
90
+ const rangeMatch = meta.match(/\{([^}]*)\}/g);
91
+ if (rangeMatch) {
92
+ for (const range of rangeMatch) {
93
+ const inside = range.slice(1, -1);
94
+ // Valid: digits, commas, hyphens, spaces, #id, /word/
95
+ // Invalid: letters (except in /word/ or "phrase"), other punctuation
96
+ if (!/^[\d\s,/-]+$/.test(inside) && !inside.includes('"') && !inside.includes('/')) {
97
+ warnOnce(
98
+ ctx,
99
+ `[rehype-perfect-code-blocks] Invalid meta syntax: "${range}" in "${meta}". ` +
100
+ 'Expected format like {1,3-5} for line highlighting.'
101
+ );
102
+ }
103
+ }
104
+ }
105
+ });
106
+ }
107
+
108
+ /**
109
+ * Warn about an unknown language that Shiki couldn't load.
110
+ * Called from shiki.ts when a language fails to tokenize.
111
+ */
112
+ export function warnUnknownLanguage(lang: string, ctx: DevWarningContext): void {
113
+ warnOnce(
114
+ ctx,
115
+ `[rehype-perfect-code-blocks] Unknown language "${lang}" — not loaded in Shiki. ` +
116
+ 'Falling back to plaintext. Add it to `shiki.langs` or install the grammar.'
117
+ );
118
+ }
119
+
120
+ /**
121
+ * Reset the warning dedup set (for testing).
122
+ */
123
+ export function resetWarningDedup(): void {
124
+ warnedMessages.clear();
125
+ }
package/src/index.ts CHANGED
@@ -24,12 +24,20 @@ import { runShikiOnRawBlocks, disposeHighlighter, runHighlighterTask } from './s
24
24
  import { remarkPreserveCodeMeta } from './remark.js';
25
25
  import { wordDiff, hasChanges } from './word-diff.js';
26
26
  import type { DiffToken } from './word-diff.js';
27
+ import { generateTokenStyles, applyScopeToCss, generateDarkModeSelector, generateLightModeSelector } from './tokens.js';
28
+ import type { DesignTokens } from './tokens.js';
29
+ import { resolveMathOptions, isMathLanguage, renderMath } from './math.js';
30
+ import type { MathOptions, ResolvedMathOptions } from './math.js';
31
+ import { runDevWarnings, warnUnknownLanguage } from './dev-warnings.js';
27
32
  import type { PerfectCodeOptions } from './types.js';
28
33
 
29
34
  export { remarkPreserveCodeMeta };
30
35
  export { disposeHighlighter, runHighlighterTask };
31
36
  export { wordDiff, hasChanges };
32
- export type { DiffToken };
37
+ export { generateTokenStyles, applyScopeToCss, generateDarkModeSelector, generateLightModeSelector };
38
+ export { resolveMathOptions, isMathLanguage, renderMath };
39
+ export { runDevWarnings, warnUnknownLanguage };
40
+ export type { DiffToken, DesignTokens, MathOptions, ResolvedMathOptions };
33
41
 
34
42
  export const rehypePerfectCodeBlocks: Plugin<[PerfectCodeOptions?], Root> =
35
43
  (options = {}) => {
@@ -157,6 +165,15 @@ function resolveDefaults(opts: PerfectCodeOptions): Required<PerfectCodeOptions>
157
165
  preset: opts.preset ?? 'default',
158
166
  injectStyles: opts.injectStyles ?? true,
159
167
  theme: opts.theme ?? 'auto',
168
+ // v2.0.0: CSS Architecture options
169
+ cssInjection: opts.cssInjection ?? 'inline',
170
+ cssLayer: opts.cssLayer ?? 'pcb',
171
+ tokens: opts.tokens ?? (undefined as unknown as NonNullable<typeof opts.tokens>),
172
+ darkMode: opts.darkMode ?? (undefined as unknown as NonNullable<typeof opts.darkMode>),
173
+ scope: opts.scope ?? (undefined as unknown as string),
174
+ // v2.1.0: P1 features
175
+ math: opts.math ?? (undefined as unknown as NonNullable<typeof opts.math>),
176
+ devWarnings: opts.devWarnings ?? (process.env.NODE_ENV !== 'production'),
160
177
  inline: opts.inline ?? false,
161
178
  };
162
179
  }
package/src/katex.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Minimal type declaration for katex (optional peer dependency).
3
+ * When katex is installed, its own types take precedence.
4
+ */
5
+ declare module 'katex' {
6
+ export interface KatexOptions {
7
+ displayMode?: boolean;
8
+ throwOnError?: boolean;
9
+ strict?: boolean | 'ignore' | 'error' | 'warn';
10
+ output?: 'html' | 'mathml' | 'htmlAndMathml';
11
+ [key: string]: unknown;
12
+ }
13
+ export function renderToString(latex: string, options?: KatexOptions): string;
14
+ const _default: { renderToString: typeof renderToString };
15
+ export default _default;
16
+ }
package/src/math.ts ADDED
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Math/LaTeX rendering via KaTeX (v2.1.0).
3
+ *
4
+ * Renders LaTeX at build time (server-side) — no client-side JS needed.
5
+ * `katex` is an optional peer dependency: if not installed, the plugin
6
+ * falls back to rendering the LaTeX source as plain text in a styled
7
+ * container.
8
+ *
9
+ * Supports:
10
+ * - Inline math: `$...$` in text nodes (via a remark plugin)
11
+ * - Block math: `$$...$$` blocks
12
+ * - Fenced code blocks with language `math`, `latex`, or `tex`
13
+ */
14
+
15
+ export interface MathOptions {
16
+ engine?: 'katex' | 'none';
17
+ inline?: boolean;
18
+ block?: boolean;
19
+ injectCss?: boolean;
20
+ throwOnError?: boolean;
21
+ strict?: boolean | 'ignore' | 'error' | 'warn';
22
+ }
23
+
24
+ export interface ResolvedMathOptions {
25
+ engine: 'katex' | 'none';
26
+ inline: boolean;
27
+ block: boolean;
28
+ injectCss: boolean;
29
+ throwOnError: boolean;
30
+ strict: boolean | 'ignore' | 'error' | 'warn';
31
+ }
32
+
33
+ export function resolveMathOptions(math?: MathOptions): ResolvedMathOptions {
34
+ return {
35
+ engine: math?.engine ?? 'none',
36
+ inline: math?.inline ?? true,
37
+ block: math?.block ?? true,
38
+ injectCss: math?.injectCss ?? true,
39
+ throwOnError: math?.throwOnError ?? true,
40
+ strict: math?.strict ?? false,
41
+ };
42
+ }
43
+
44
+ /** Languages that should be rendered as math instead of syntax-highlighted. */
45
+ export const MATH_LANGS = new Set(['math', 'latex', 'tex']);
46
+
47
+ /** Cache for the dynamically-imported katex module. */
48
+ let _katexModule: typeof import('katex') | null | undefined;
49
+
50
+ /**
51
+ * Try to load the katex module. Returns null if katex is not installed.
52
+ * Caches the result so subsequent calls don't re-import.
53
+ */
54
+ async function getKatex(): Promise<typeof import('katex') | null> {
55
+ if (_katexModule !== undefined) return _katexModule;
56
+ try {
57
+ _katexModule = await import('katex');
58
+ return _katexModule;
59
+ } catch {
60
+ _katexModule = null;
61
+ return null;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Check if a language identifier should be treated as math.
67
+ */
68
+ export function isMathLanguage(lang: string): boolean {
69
+ return MATH_LANGS.has(lang.toLowerCase());
70
+ }
71
+
72
+ /**
73
+ * Render a LaTeX string to HTML via KaTeX.
74
+ *
75
+ * @param latex The LaTeX source string
76
+ * @param displayMode true for block math ($$...$$), false for inline ($...$)
77
+ * @param options Resolved math options
78
+ * @returns { html: string, isKatex: boolean } — if katex is available,
79
+ * html is the KaTeX-rendered HTML; otherwise it's the LaTeX source in a
80
+ * `<code>` element, and isKatex is false.
81
+ */
82
+ export async function renderMath(
83
+ latex: string,
84
+ displayMode: boolean,
85
+ options: ResolvedMathOptions
86
+ ): Promise<{ html: string; isKatex: boolean }> {
87
+ if (options.engine === 'none') {
88
+ return { html: escapeHtml(latex), isKatex: false };
89
+ }
90
+
91
+ const katex = await getKatex();
92
+ if (!katex) {
93
+ // KaTeX not installed — fall back to plain text
94
+ return { html: escapeHtml(latex), isKatex: false };
95
+ }
96
+
97
+ try {
98
+ const html = katex.renderToString(latex, {
99
+ displayMode,
100
+ throwOnError: options.throwOnError,
101
+ strict: options.strict,
102
+ output: 'html',
103
+ });
104
+ return { html, isKatex: true };
105
+ } catch {
106
+ // KaTeX rendering failed — fall back to plain text
107
+ return { html: escapeHtml(latex), isKatex: false };
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Escape HTML special characters in a string (for the fallback path).
113
+ */
114
+ function escapeHtml(s: string): string {
115
+ return s
116
+ .replace(/&/g, '&amp;')
117
+ .replace(/</g, '&lt;')
118
+ .replace(/>/g, '&gt;')
119
+ .replace(/"/g, '&quot;');
120
+ }
121
+
122
+ /**
123
+ * Regex to find inline `$...$` math in text.
124
+ * Matches `$` followed by non-$ content followed by `$`.
125
+ * Does NOT match `$$` (which is block math) or escaped `\$`.
126
+ *
127
+ * Examples:
128
+ * "$x^2$" → match (inline math)
129
+ * "$$x^2$$" → NO match (block math)
130
+ * "cost is \$5" → NO match (escaped dollar sign)
131
+ * "a $ b $ c" → match "$ b $" (ambiguous, but we match it)
132
+ */
133
+ export const INLINE_MATH_REGEX = /(^|[^\\$])\$(?!\$)([^$]+?)\$(?!\$)/g;
134
+
135
+ /**
136
+ * Regex to find block `$$...$$` math in text.
137
+ * Matches `$$` followed by content followed by `$$`.
138
+ */
139
+ export const BLOCK_MATH_REGEX = /\$\$([\s\S]+?)\$\$/g;
140
+
141
+ /**
142
+ * Process a text string, replacing inline `$...$` and block `$$...$$`
143
+ * with rendered math HTML.
144
+ *
145
+ * @param text The input text
146
+ * @param options Resolved math options
147
+ * @returns Array of { type: 'text' | 'inline-math' | 'block-math', content: string }
148
+ * segments. The caller is responsible for converting these to HAST nodes.
149
+ */
150
+ export async function processMathInText(
151
+ text: string,
152
+ options: ResolvedMathOptions
153
+ ): Promise<Array<{ type: 'text' | 'inline-math' | 'block-math'; content: string; html?: string }>> {
154
+ if (options.engine === 'none' || (!options.inline && !options.block)) {
155
+ return [{ type: 'text', content: text }];
156
+ }
157
+
158
+ const segments: Array<{ type: 'text' | 'inline-math' | 'block-math'; content: string; html?: string }> = [];
159
+ let remaining = text;
160
+
161
+ // First, extract block math ($$...$$)
162
+ if (options.block) {
163
+ let lastIndex = 0;
164
+ let match: RegExpExecArray | null;
165
+ const blockRegex = new RegExp(BLOCK_MATH_REGEX);
166
+ while ((match = blockRegex.exec(remaining)) !== null) {
167
+ // Text before the block math
168
+ if (match.index > lastIndex) {
169
+ const beforeText = remaining.slice(lastIndex, match.index);
170
+ // Process inline math in the text before
171
+ if (options.inline) {
172
+ const inlineSegments = await processInlineMath(beforeText, options);
173
+ segments.push(...inlineSegments);
174
+ } else {
175
+ segments.push({ type: 'text', content: beforeText });
176
+ }
177
+ }
178
+ // The block math
179
+ const latex = match[1].trim();
180
+ const { html, isKatex } = await renderMath(latex, true, options);
181
+ segments.push({ type: 'block-math', content: latex, html });
182
+ lastIndex = match.index + match[0].length;
183
+ }
184
+ // Remaining text after the last block math
185
+ if (lastIndex < remaining.length) {
186
+ const afterText = remaining.slice(lastIndex);
187
+ if (options.inline) {
188
+ const inlineSegments = await processInlineMath(afterText, options);
189
+ segments.push(...inlineSegments);
190
+ } else {
191
+ segments.push({ type: 'text', content: afterText });
192
+ }
193
+ }
194
+ } else if (options.inline) {
195
+ // Only inline math
196
+ const inlineSegments = await processInlineMath(remaining, options);
197
+ segments.push(...inlineSegments);
198
+ } else {
199
+ segments.push({ type: 'text', content: remaining });
200
+ }
201
+
202
+ return segments;
203
+ }
204
+
205
+ /**
206
+ * Process inline `$...$` math in a text string.
207
+ * Returns segments of text and inline-math.
208
+ */
209
+ async function processInlineMath(
210
+ text: string,
211
+ options: ResolvedMathOptions
212
+ ): Promise<Array<{ type: 'text' | 'inline-math'; content: string; html?: string }>> {
213
+ const segments: Array<{ type: 'text' | 'inline-math'; content: string; html?: string }> = [];
214
+ const regex = new RegExp(INLINE_MATH_REGEX);
215
+ let lastIndex = 0;
216
+ let match: RegExpExecArray | null;
217
+
218
+ while ((match = regex.exec(text)) !== null) {
219
+ // Text before the match (including the prefix character)
220
+ const prefix = match[1] || '';
221
+ const matchStart = match.index + prefix.length;
222
+ if (matchStart > lastIndex) {
223
+ segments.push({ type: 'text', content: text.slice(lastIndex, match.index) + prefix });
224
+ } else if (prefix) {
225
+ segments.push({ type: 'text', content: prefix });
226
+ }
227
+
228
+ const latex = match[2].trim();
229
+ const { html } = await renderMath(latex, false, options);
230
+ segments.push({ type: 'inline-math', content: latex, html });
231
+ lastIndex = regex.lastIndex;
232
+ }
233
+
234
+ if (lastIndex < text.length) {
235
+ segments.push({ type: 'text', content: text.slice(lastIndex) });
236
+ }
237
+
238
+ return segments.length > 0 ? segments : [{ type: 'text', content: text }];
239
+ }
240
+
241
+ /**
242
+ * Get the KaTeX CSS path for injection.
243
+ * Returns the path to `katex/dist/katex.min.css` relative to the project.
244
+ */
245
+ export function getKatexCssPath(): string {
246
+ return 'katex/dist/katex.min.css';
247
+ }
248
+
249
+ /**
250
+ * Try to read the KaTeX CSS content from the filesystem.
251
+ * Returns null if katex is not installed or the CSS file can't be read.
252
+ */
253
+ export async function tryReadKatexCss(): Promise<string | null> {
254
+ try {
255
+ const katex = await getKatex();
256
+ if (!katex) return null;
257
+ // katex module path — try to read the CSS
258
+ const { readFileSync } = await import('node:fs');
259
+ const { createRequire } = await import('node:module');
260
+ const require = createRequire(import.meta.url);
261
+ const katexPath = require.resolve('katex');
262
+ const katexDir = katexPath.replace(/[/\\]katex\.(mjs|js|cjs)$/, '');
263
+ const cssPath = katexDir + '/katex.min.css';
264
+ return readFileSync(cssPath, 'utf8');
265
+ } catch {
266
+ return null;
267
+ }
268
+ }
package/src/shiki.ts CHANGED
@@ -18,6 +18,7 @@ import { fromHtml } from 'hast-util-from-html';
18
18
  import { visit } from 'unist-util-visit';
19
19
  import type { PerfectCodeOptions } from './types.js';
20
20
  import { computeThemeAwareDefaults } from './color-utils.js';
21
+ import { isMathLanguage, renderMath, resolveMathOptions, MATH_LANGS } from './math.js';
21
22
  import {
22
23
  transformerNotationDiff,
23
24
  transformerNotationFocus,
@@ -447,6 +448,50 @@ export async function runShikiOnRawBlocks(
447
448
 
448
449
  if (targets.length === 0) return;
449
450
 
451
+ // v2.1.0: Handle math language blocks — render via KaTeX instead of Shiki.
452
+ const mathOpts = resolveMathOptions(opts.math as Record<string, unknown> | undefined);
453
+ if (mathOpts.engine === 'katex' && mathOpts.block) {
454
+ const mathTargets: Element[] = [];
455
+ const codeTargets: Element[] = [];
456
+ for (const pre of targets) {
457
+ const code = pre.children.find(
458
+ (c): c is Element => c.type === 'element' && c.tagName === 'code'
459
+ );
460
+ if (!code) { codeTargets.push(pre); continue; }
461
+ const cls = (code.properties?.className as string[] | undefined) ?? [];
462
+ const langClass = cls.find((c) => c.startsWith('language-'));
463
+ const lang = langClass ? langClass.replace('language-', '') : '';
464
+ if (isMathLanguage(lang)) {
465
+ mathTargets.push(pre);
466
+ } else {
467
+ codeTargets.push(pre);
468
+ }
469
+ }
470
+
471
+ // Render math blocks via KaTeX
472
+ for (const pre of mathTargets) {
473
+ const code = pre.children.find(
474
+ (c): c is Element => c.type === 'element' && c.tagName === 'code'
475
+ );
476
+ if (!code) continue;
477
+ const text = extractText(code).replace(/\r\n?/g, '\n').trim();
478
+ const { html, isKatex } = await renderMath(text, true, mathOpts);
479
+ // Replace the <pre> with rendered math
480
+ const mathDiv: Element = {
481
+ type: 'element',
482
+ tagName: 'div',
483
+ properties: { className: ['pcb__math', isKatex ? 'pcb__math--katex' : 'pcb__math--fallback'] },
484
+ children: [{ type: 'text', value: html }],
485
+ };
486
+ Object.assign(pre, mathDiv);
487
+ }
488
+
489
+ // Only process non-math targets with Shiki
490
+ targets.splice(0, targets.length, ...codeTargets);
491
+ }
492
+
493
+ if (targets.length === 0) return;
494
+
450
495
  // Build theme keys — supports single (string), dual ({light,dark}), and
451
496
  // multi-theme (Record<string,string> with 3+ entries) for advanced use cases.
452
497
  const themeSpec = opts.shiki.theme;
@@ -473,8 +518,16 @@ export async function runShikiOnRawBlocks(
473
518
  // (javascript, typescript, python). This matches Shiki's own case-
474
519
  // insensitive behavior in codeToHast/codeToHtml, and matches what every
475
520
  // other CommonMark renderer accepts. See issue #12.
521
+ //
522
+ // v2.1.0: When shiki.lazy is true, don't preload the user's `langs` list —
523
+ // only load languages that are actually in this document. This avoids
524
+ // loading grammars for pages that only use 1-2 languages out of a
525
+ // configured set of 20+. The lazy-load path below will load them on demand.
526
+ const isLazy = (opts.shiki as { lazy?: boolean }).lazy === true;
476
527
  const langSet = new Set<string>(
477
- (opts.shiki.langs ?? []).map((l) => l.toLowerCase())
528
+ isLazy
529
+ ? [] // Lazy: don't preload anything — document-specific langs added below
530
+ : (opts.shiki.langs ?? []).map((l) => l.toLowerCase())
478
531
  );
479
532
  for (const pre of targets) {
480
533
  const code = pre.children.find(
package/src/styles.css CHANGED
File without changes