@dr-ishaan/rehype-perfect-code-blocks 2.0.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +162 -0
- package/dist/copy-script.d.ts +1 -1
- package/dist/copy-script.d.ts.map +1 -1
- package/dist/copy-script.js +5 -0
- package/dist/copy-script.js.map +1 -1
- package/dist/dev-warnings.d.ts +36 -0
- package/dist/dev-warnings.d.ts.map +1 -0
- package/dist/dev-warnings.js +95 -0
- package/dist/dev-warnings.js.map +1 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/dist/math.d.ts +92 -0
- package/dist/math.d.ts.map +1 -0
- package/dist/math.js +229 -0
- package/dist/math.js.map +1 -0
- package/dist/meta.d.ts.map +1 -1
- package/dist/meta.js +18 -2
- package/dist/meta.js.map +1 -1
- package/dist/shiki.d.ts.map +1 -1
- package/dist/shiki.js +52 -1
- package/dist/shiki.js.map +1 -1
- package/dist/styles.css +68 -0
- package/dist/transformer.d.ts.map +1 -1
- package/dist/transformer.js +66 -2
- package/dist/transformer.js.map +1 -1
- package/dist/types.d.ts +106 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/copy-script.ts +5 -0
- package/src/dev-warnings.ts +125 -0
- package/src/index.ts +13 -1
- package/src/katex.d.ts +16 -0
- package/src/math.ts +268 -0
- package/src/meta.ts +19 -2
- package/src/shiki.ts +54 -1
- package/src/styles.css +68 -0
- package/src/transformer.ts +69 -2
- package/src/types.ts +122 -0
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, '&')
|
|
117
|
+
.replace(/</g, '<')
|
|
118
|
+
.replace(/>/g, '>')
|
|
119
|
+
.replace(/"/g, '"');
|
|
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/meta.ts
CHANGED
|
@@ -57,6 +57,9 @@ export function parseMeta(meta: string | undefined): ParsedMeta {
|
|
|
57
57
|
wordHighlights: [],
|
|
58
58
|
lineNumbersStart: null,
|
|
59
59
|
collapseRanges: [],
|
|
60
|
+
author: null,
|
|
61
|
+
year: null,
|
|
62
|
+
source: null,
|
|
60
63
|
flags: {
|
|
61
64
|
wrap: null,
|
|
62
65
|
lineNumbers: null,
|
|
@@ -85,6 +88,20 @@ export function parseMeta(meta: string | undefined): ParsedMeta {
|
|
|
85
88
|
continue;
|
|
86
89
|
}
|
|
87
90
|
|
|
91
|
+
// v2.2.0: Attribution metadata — author="...", year="...", source="..."
|
|
92
|
+
if (tok.startsWith('author=')) {
|
|
93
|
+
result.author = unquote(tok.slice('author='.length));
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (tok.startsWith('year=')) {
|
|
97
|
+
result.year = unquote(tok.slice('year='.length));
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (tok.startsWith('source=')) {
|
|
101
|
+
result.source = unquote(tok.slice('source='.length));
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
88
105
|
// collapse="5-12,20-30" — per-line collapsible sections
|
|
89
106
|
if (tok.startsWith('collapse=')) {
|
|
90
107
|
const val = unquote(tok.slice('collapse='.length));
|
|
@@ -182,8 +199,8 @@ function tokenize(input: string): string[] {
|
|
|
182
199
|
while (i < input.length && /\s/.test(input[i])) i++;
|
|
183
200
|
if (i >= input.length) break;
|
|
184
201
|
|
|
185
|
-
// Quoted key="value" or key='value' (title=, caption=, collapse=)
|
|
186
|
-
if (/^(?:title|caption|collapse)=$/.test(input.slice(i).match(/^[a-z]+=/i)?.[0] ?? '')) {
|
|
202
|
+
// Quoted key="value" or key='value' (title=, caption=, collapse=, author=, year=, source=)
|
|
203
|
+
if (/^(?:title|caption|collapse|author|year|source)=$/.test(input.slice(i).match(/^[a-z]+=/i)?.[0] ?? '')) {
|
|
187
204
|
const eq = input.indexOf('=', i);
|
|
188
205
|
let j = eq + 1;
|
|
189
206
|
const quote = input[j];
|
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
|
-
|
|
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
|
@@ -654,3 +654,71 @@
|
|
|
654
654
|
html.no-js .pcb__copy {
|
|
655
655
|
display: none !important;
|
|
656
656
|
}
|
|
657
|
+
|
|
658
|
+
/* ============================================================
|
|
659
|
+
v2.2.0: Phase 3 — Split diff, annotations, attribution
|
|
660
|
+
============================================================ */
|
|
661
|
+
|
|
662
|
+
/* ---------- Split diff view ---------- */
|
|
663
|
+
:where(.pcb--split-diff) .pcb__body {
|
|
664
|
+
display: grid;
|
|
665
|
+
grid-template-columns: 1fr 1fr;
|
|
666
|
+
}
|
|
667
|
+
:where(.pcb--split-diff) .pcb__body > pre {
|
|
668
|
+
border-right: 1px solid var(--pcb-border);
|
|
669
|
+
}
|
|
670
|
+
:where(.pcb--split-diff) .pcb__body > pre:last-child) {
|
|
671
|
+
border-right: none;
|
|
672
|
+
}
|
|
673
|
+
@media (max-width: 768px) {
|
|
674
|
+
:where(.pcb--split-diff) .pcb__body {
|
|
675
|
+
grid-template-columns: 1fr;
|
|
676
|
+
}
|
|
677
|
+
:where(.pcb--split-diff) .pcb__body > pre {
|
|
678
|
+
border-right: none;
|
|
679
|
+
border-bottom: 1px solid var(--pcb-border);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/* ---------- Line annotations ---------- */
|
|
684
|
+
:where(.pcb__ann) {
|
|
685
|
+
display: none; /* hidden by default, shown when .pcb--annotations */
|
|
686
|
+
}
|
|
687
|
+
:where(.pcb--annotations) .pcb__line[data-ann] {
|
|
688
|
+
display: grid;
|
|
689
|
+
grid-template-columns: auto 1fr auto;
|
|
690
|
+
align-items: baseline;
|
|
691
|
+
}
|
|
692
|
+
:where(.pcb--annotations) .pcb__ann) {
|
|
693
|
+
display: block;
|
|
694
|
+
padding-left: 1rem;
|
|
695
|
+
color: var(--pcb-text-muted);
|
|
696
|
+
font-size: 0.8125em;
|
|
697
|
+
font-style: italic;
|
|
698
|
+
white-space: normal;
|
|
699
|
+
border-left: 2px solid var(--pcb-border);
|
|
700
|
+
user-select: none;
|
|
701
|
+
}
|
|
702
|
+
@media (max-width: 768px) {
|
|
703
|
+
:where(.pcb--annotations) .pcb__line[data-ann]) {
|
|
704
|
+
grid-template-columns: auto 1fr;
|
|
705
|
+
}
|
|
706
|
+
:where(.pcb--annotations) .pcb__ann) {
|
|
707
|
+
grid-column: 1 / -1;
|
|
708
|
+
padding-left: 0;
|
|
709
|
+
border-left: none;
|
|
710
|
+
border-top: 1px dashed var(--pcb-border);
|
|
711
|
+
margin-top: 0.25rem;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/* ---------- Attribution footer ---------- */
|
|
716
|
+
:where(.pcb__attribution) {
|
|
717
|
+
padding: 0.5rem 1rem;
|
|
718
|
+
font-family: var(--pcb-bar-font);
|
|
719
|
+
font-size: var(--pcb-bar-font-size);
|
|
720
|
+
color: var(--pcb-caption-color);
|
|
721
|
+
background: var(--pcb-caption-bg);
|
|
722
|
+
border-top: 1px solid var(--pcb-border);
|
|
723
|
+
font-style: italic;
|
|
724
|
+
}
|
package/src/transformer.ts
CHANGED
|
@@ -247,6 +247,12 @@ export function rehypePerfectCodeBlocks(userOptions: PerfectCodeOptions = {}) {
|
|
|
247
247
|
tokens: undefined as unknown as NonNullable<PerfectCodeOptions['tokens']>,
|
|
248
248
|
darkMode: undefined as unknown as NonNullable<PerfectCodeOptions['darkMode']>,
|
|
249
249
|
scope: undefined as unknown as string,
|
|
250
|
+
math: undefined as unknown as NonNullable<PerfectCodeOptions['math']>,
|
|
251
|
+
devWarnings: process.env.NODE_ENV !== 'production',
|
|
252
|
+
// v2.2.0: Phase 3
|
|
253
|
+
diffMode: 'unified' as const,
|
|
254
|
+
annotations: false,
|
|
255
|
+
attribution: false,
|
|
250
256
|
inline: false,
|
|
251
257
|
...rest,
|
|
252
258
|
};
|
|
@@ -637,6 +643,31 @@ async function transformPre(
|
|
|
637
643
|
figureChildren.push(cap);
|
|
638
644
|
}
|
|
639
645
|
|
|
646
|
+
// v2.2.0: Attribution footer — render author/year/source as a footer below the code block.
|
|
647
|
+
if ((opts as { attribution?: boolean }).attribution && (meta.author || meta.year || meta.source)) {
|
|
648
|
+
const parts: string[] = [];
|
|
649
|
+
if (meta.author) parts.push(meta.author);
|
|
650
|
+
if (meta.year) parts.push(`(${meta.year})`);
|
|
651
|
+
if (meta.source) parts.push(`. ${meta.source}.`);
|
|
652
|
+
else if (meta.author || meta.year) parts.push('.');
|
|
653
|
+
const attrText = parts.join(' ').trim();
|
|
654
|
+
if (attrText) {
|
|
655
|
+
figureChildren.push(
|
|
656
|
+
h('figcaption', { className: ['pcb__attribution'] }, [hText(attrText)])
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// v2.2.0: Add pcb--split-diff class when diffMode is 'split'
|
|
662
|
+
if ((opts as { diffMode?: string }).diffMode === 'split') {
|
|
663
|
+
figClasses.push('pcb--split-diff');
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// v2.2.0: Add pcb--annotations class when annotations are enabled
|
|
667
|
+
if ((opts as { annotations?: boolean }).annotations) {
|
|
668
|
+
figClasses.push('pcb--annotations');
|
|
669
|
+
}
|
|
670
|
+
|
|
640
671
|
return h('figure', { className: figClasses }, figureChildren);
|
|
641
672
|
}
|
|
642
673
|
|
|
@@ -939,6 +970,19 @@ function toLineSpans(
|
|
|
939
970
|
// Map word-highlight spans inside this line.
|
|
940
971
|
const mappedChildren = mapWordHighlights(line.children);
|
|
941
972
|
|
|
973
|
+
// v2.2.0: Parse and strip // [!ann: "text"] annotation notation.
|
|
974
|
+
let annotationText: string | null = null;
|
|
975
|
+
if ((opts as { annotations?: boolean }).annotations) {
|
|
976
|
+
const lineText = extractLineText(line);
|
|
977
|
+
const annMatch = lineText.match(/\[!ann:\s*"([^"]*)"\s*\]/);
|
|
978
|
+
if (annMatch) {
|
|
979
|
+
annotationText = annMatch[1];
|
|
980
|
+
// Strip the annotation from the line's text content
|
|
981
|
+
// (replace in all text nodes within the line)
|
|
982
|
+
stripAnnotationFromLine(line, annMatch[0]);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
942
986
|
// The line wrapper itself (the Shiki <span class="line ...">) becomes the
|
|
943
987
|
// content of .pcb__code. Strip its classes — we've already mapped them
|
|
944
988
|
// onto the outer .pcb__line wrapper, so they shouldn't also appear here.
|
|
@@ -951,7 +995,7 @@ function toLineSpans(
|
|
|
951
995
|
children: mappedChildren,
|
|
952
996
|
};
|
|
953
997
|
|
|
954
|
-
// Build the row: [gutter-cell?, code-cell]
|
|
998
|
+
// Build the row: [gutter-cell?, code-cell, annotation?]
|
|
955
999
|
const lineChildren: ElementContent[] = [];
|
|
956
1000
|
if (resolved.lineNumbers) {
|
|
957
1001
|
lineChildren.push(
|
|
@@ -962,7 +1006,18 @@ function toLineSpans(
|
|
|
962
1006
|
h('span', { className: ['pcb__code'] }, [innerWrapper])
|
|
963
1007
|
);
|
|
964
1008
|
|
|
965
|
-
|
|
1009
|
+
// v2.2.0: Add annotation cell if this line has an annotation
|
|
1010
|
+
if (annotationText !== null) {
|
|
1011
|
+
lineChildren.push(
|
|
1012
|
+
h('span', { className: ['pcb__ann'], 'dataAnn': annotationText }, [hText(annotationText)])
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
const lineProps: Record<string, unknown> = { className: [...classes] };
|
|
1017
|
+
if (annotationText !== null) {
|
|
1018
|
+
lineProps['dataAnn'] = annotationText;
|
|
1019
|
+
}
|
|
1020
|
+
return h('span', lineProps, lineChildren);
|
|
966
1021
|
});
|
|
967
1022
|
}
|
|
968
1023
|
|
|
@@ -1151,6 +1206,18 @@ function hText(value: string): Text {
|
|
|
1151
1206
|
return { type: 'text', value };
|
|
1152
1207
|
}
|
|
1153
1208
|
|
|
1209
|
+
/** v2.2.0: Strip an annotation notation from all text nodes in a line element. */
|
|
1210
|
+
function stripAnnotationFromLine(line: Element, annotation: string): void {
|
|
1211
|
+
const walk = (node: ElementContent): void => {
|
|
1212
|
+
if (node.type === 'text') {
|
|
1213
|
+
node.value = node.value.replace(annotation, '');
|
|
1214
|
+
} else if (node.type === 'element') {
|
|
1215
|
+
for (const child of node.children) walk(child);
|
|
1216
|
+
}
|
|
1217
|
+
};
|
|
1218
|
+
for (const child of line.children) walk(child);
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1154
1221
|
/* ---------- Pattern 5: word-level diff (selective adoption from expressive-code) ---------- */
|
|
1155
1222
|
|
|
1156
1223
|
/**
|