@dr-ishaan/rehype-perfect-code-blocks 1.1.7 → 1.2.1
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 +106 -40
- package/LICENSE +0 -0
- package/README.md +0 -0
- package/dist/astro.d.ts +0 -0
- package/dist/astro.d.ts.map +1 -1
- package/dist/astro.js +13 -4
- package/dist/astro.js.map +1 -1
- package/dist/copy-script.d.ts +2 -1
- package/dist/copy-script.d.ts.map +1 -1
- package/dist/copy-script.js +16 -2
- package/dist/copy-script.js.map +1 -1
- package/dist/index.d.ts +0 -0
- package/dist/index.d.ts.map +0 -0
- package/dist/index.js +15 -1
- package/dist/index.js.map +1 -1
- package/dist/meta.d.ts +0 -0
- package/dist/meta.d.ts.map +1 -1
- package/dist/meta.js +35 -2
- package/dist/meta.js.map +1 -1
- package/dist/remark.d.ts +0 -0
- package/dist/remark.d.ts.map +0 -0
- package/dist/remark.js +0 -0
- package/dist/remark.js.map +0 -0
- package/dist/shiki.d.ts +0 -0
- package/dist/shiki.d.ts.map +1 -1
- package/dist/shiki.js +301 -33
- package/dist/shiki.js.map +1 -1
- package/dist/styles.css +0 -0
- package/dist/transformer.d.ts +0 -0
- package/dist/transformer.d.ts.map +1 -1
- package/dist/transformer.js +230 -16
- package/dist/transformer.js.map +1 -1
- package/dist/types.d.ts +109 -4
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +0 -0
- package/dist/types.js.map +0 -0
- package/package.json +2 -2
- package/src/astro.ts +14 -4
- package/src/copy-script.ts +16 -2
- package/src/index.ts +15 -1
- package/src/meta.ts +35 -2
- package/src/remark.ts +0 -0
- package/src/shiki.ts +306 -34
- package/src/styles.css +0 -0
- package/src/transformer.ts +243 -17
- package/src/types.ts +105 -4
- package/src/vite-raw.d.ts +0 -0
package/src/transformer.ts
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import type { Element, ElementContent, Properties, Root, Text } from 'hast';
|
|
22
22
|
import { visit } from 'unist-util-visit';
|
|
23
23
|
import { parseMeta } from './meta.js';
|
|
24
|
-
import type { PerfectCodeOptions, ResolvedBlock, MagicComment } from './types.js';
|
|
24
|
+
import type { PerfectCodeOptions, ResolvedBlock, MagicComment, ParsedMeta } from './types.js';
|
|
25
25
|
|
|
26
26
|
/** Default inline SVG copy icon (16x16 GitHub octicon copy). */
|
|
27
27
|
const DEFAULT_COPY_ICON = `<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"/><path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"/></svg>`;
|
|
@@ -170,7 +170,7 @@ const DEFAULT_MAGIC_COMMENTS: MagicComment[] = [
|
|
|
170
170
|
},
|
|
171
171
|
];
|
|
172
172
|
|
|
173
|
-
const DEFAULT_TERMINAL_LANGS = ['sh', 'bash', 'zsh', 'shell', 'console', 'powershell', 'bat', 'cmd', 'fish'];
|
|
173
|
+
const DEFAULT_TERMINAL_LANGS = ['sh', 'bash', 'zsh', 'shell', 'console', 'powershell', 'bat', 'cmd', 'fish', 'ansi'];
|
|
174
174
|
|
|
175
175
|
export function rehypePerfectCodeBlocks(userOptions: PerfectCodeOptions = {}) {
|
|
176
176
|
const { shiki: userShiki, ...rest } = userOptions;
|
|
@@ -189,6 +189,8 @@ export function rehypePerfectCodeBlocks(userOptions: PerfectCodeOptions = {}) {
|
|
|
189
189
|
errorLevels: true,
|
|
190
190
|
wrap: false,
|
|
191
191
|
collapseAfter: null,
|
|
192
|
+
collapseRanges: null,
|
|
193
|
+
collapseStyle: 'github',
|
|
192
194
|
showWhitespace: false,
|
|
193
195
|
indentGuides: false,
|
|
194
196
|
caption: true,
|
|
@@ -197,11 +199,17 @@ export function rehypePerfectCodeBlocks(userOptions: PerfectCodeOptions = {}) {
|
|
|
197
199
|
theme: { light: 'github-light', dark: 'github-dark' },
|
|
198
200
|
langs: [],
|
|
199
201
|
transformers: [],
|
|
202
|
+
transformerOrder: 'after',
|
|
200
203
|
...(userShiki ?? {}),
|
|
201
204
|
},
|
|
202
205
|
keepBackground: false,
|
|
203
206
|
styleToClass: false,
|
|
204
207
|
useHastApi: true,
|
|
208
|
+
disableAutoTransformers: false,
|
|
209
|
+
removeComments: false,
|
|
210
|
+
removeLineBreaks: false,
|
|
211
|
+
zeroIndexed: false,
|
|
212
|
+
lineOptions: [],
|
|
205
213
|
customNotations: {},
|
|
206
214
|
magicComments: DEFAULT_MAGIC_COMMENTS,
|
|
207
215
|
inlineCode: false,
|
|
@@ -213,9 +221,12 @@ export function rehypePerfectCodeBlocks(userOptions: PerfectCodeOptions = {}) {
|
|
|
213
221
|
languageLabels: {},
|
|
214
222
|
languageAliases: {},
|
|
215
223
|
defaultBlockLang: '',
|
|
224
|
+
tabWidth: 0,
|
|
225
|
+
copyStripComments: true,
|
|
216
226
|
accessibleScroll: true,
|
|
217
227
|
announceCopy: true,
|
|
218
228
|
hideCopyWithoutJs: true,
|
|
229
|
+
terminalSrOnlyTitle: true,
|
|
219
230
|
rehypePlugins: [],
|
|
220
231
|
filterMetaString: (s) => s,
|
|
221
232
|
onVisitLine: () => {},
|
|
@@ -223,6 +234,9 @@ export function rehypePerfectCodeBlocks(userOptions: PerfectCodeOptions = {}) {
|
|
|
223
234
|
onVisitHighlightedChars: () => {},
|
|
224
235
|
onVisitTitle: () => {},
|
|
225
236
|
onVisitCaption: () => {},
|
|
237
|
+
texts: {},
|
|
238
|
+
logger: console,
|
|
239
|
+
cspNonce: '',
|
|
226
240
|
preset: 'default',
|
|
227
241
|
injectStyles: true,
|
|
228
242
|
theme: 'auto',
|
|
@@ -232,6 +246,7 @@ export function rehypePerfectCodeBlocks(userOptions: PerfectCodeOptions = {}) {
|
|
|
232
246
|
|
|
233
247
|
return async (tree: Root) => {
|
|
234
248
|
const visits: Element[] = [];
|
|
249
|
+
const inlineCodes: Element[] = [];
|
|
235
250
|
|
|
236
251
|
visit(tree, 'element', (node) => {
|
|
237
252
|
if (node.tagName !== 'pre') return;
|
|
@@ -243,15 +258,88 @@ export function rehypePerfectCodeBlocks(userOptions: PerfectCodeOptions = {}) {
|
|
|
243
258
|
visits.push(node);
|
|
244
259
|
});
|
|
245
260
|
|
|
261
|
+
// Inline code highlighting: visit <code> elements whose parent is NOT <pre>.
|
|
262
|
+
// These are inline code spans like `code{:lang}` or `code{:.token}`.
|
|
263
|
+
if (options.inlineCode) {
|
|
264
|
+
visit(tree, 'element', (node, index, parent) => {
|
|
265
|
+
if (node.tagName !== 'code') return;
|
|
266
|
+
if (!parent || parent.type !== 'element' || parent.tagName === 'pre') return;
|
|
267
|
+
if (node.properties?.dataPcbDone) return;
|
|
268
|
+
inlineCodes.push(node);
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
246
272
|
for (const pre of visits) {
|
|
247
273
|
const replacement = await transformPre(pre, options);
|
|
248
274
|
if (replacement) {
|
|
249
275
|
Object.assign(pre, replacement);
|
|
250
276
|
}
|
|
251
277
|
}
|
|
278
|
+
|
|
279
|
+
// Process inline code blocks (after the block-level transform).
|
|
280
|
+
for (const code of inlineCodes) {
|
|
281
|
+
transformInlineCode(code, options);
|
|
282
|
+
}
|
|
252
283
|
};
|
|
253
284
|
}
|
|
254
285
|
|
|
286
|
+
/**
|
|
287
|
+
* Transform an inline `<code>` element (not inside a `<pre>`).
|
|
288
|
+
*
|
|
289
|
+
* Supports two modes (matching rehype-pretty-code's syntax):
|
|
290
|
+
* - `inlineCode: 'lang'` or `true` — parse `code{:lang}` suffix and tokenize via Shiki
|
|
291
|
+
* - `inlineCode: 'token'` — parse `code{:.token}` suffix and color by VS Code token
|
|
292
|
+
*
|
|
293
|
+
* The suffix is stripped from the displayed text. The resulting tokenized
|
|
294
|
+
* spans are wrapped in a `<code class="pcb__inline">` element.
|
|
295
|
+
*
|
|
296
|
+
* NOTE: This function only modifies the inline `<code>` element's children
|
|
297
|
+
* and adds a class. Shiki tokenization happens at render time via the
|
|
298
|
+
* `engine: 'shiki'` path (runShikiOnRawBlocks handles `<pre><code>` only —
|
|
299
|
+
* inline code is styled by the CSS based on the `pcb__inline` class).
|
|
300
|
+
*/
|
|
301
|
+
function transformInlineCode(
|
|
302
|
+
code: Element,
|
|
303
|
+
opts: Required<PerfectCodeOptions>
|
|
304
|
+
): void {
|
|
305
|
+
// Extract the text content.
|
|
306
|
+
const text = extractText(code);
|
|
307
|
+
if (!text) return;
|
|
308
|
+
|
|
309
|
+
// Parse the `{:lang}` or `{:.token}` suffix.
|
|
310
|
+
// rehype-pretty-code uses `{:lang}` for language and `{:.token}` for token.
|
|
311
|
+
const langMatch = text.match(/\{:([a-zA-Z][\w.-]*)\}$/);
|
|
312
|
+
const tokenMatch = text.match(/\{:\.([\w-]+)\}$/);
|
|
313
|
+
|
|
314
|
+
let displayText = text;
|
|
315
|
+
let lang: string | null = null;
|
|
316
|
+
let token: string | null = null;
|
|
317
|
+
|
|
318
|
+
if (opts.inlineCode === 'token' && tokenMatch) {
|
|
319
|
+
token = tokenMatch[1];
|
|
320
|
+
displayText = text.slice(0, tokenMatch.index);
|
|
321
|
+
} else if (opts.inlineCode === true || opts.inlineCode === 'lang') {
|
|
322
|
+
if (langMatch) {
|
|
323
|
+
lang = langMatch[1];
|
|
324
|
+
displayText = text.slice(0, langMatch.index);
|
|
325
|
+
} else if (opts.inlineDefaultLang || opts.defaultInlineLang) {
|
|
326
|
+
lang = opts.inlineDefaultLang || opts.defaultInlineLang || null;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Replace the text content with the stripped display text.
|
|
331
|
+
code.children = [{ type: 'text', value: displayText } as Text];
|
|
332
|
+
|
|
333
|
+
// Add the `pcb__inline` class plus optional lang/token classes for CSS.
|
|
334
|
+
const cls = ['pcb__inline'];
|
|
335
|
+
if (lang) cls.push(`pcb__inline--${lang}`);
|
|
336
|
+
if (token) cls.push(`pcb__inline--token-${token}`);
|
|
337
|
+
code.properties = code.properties ?? {};
|
|
338
|
+
code.properties.className = cls;
|
|
339
|
+
// Mark as done so we don't re-process it.
|
|
340
|
+
code.properties.dataPcbDone = true;
|
|
341
|
+
}
|
|
342
|
+
|
|
255
343
|
async function transformPre(
|
|
256
344
|
pre: Element,
|
|
257
345
|
opts: Required<PerfectCodeOptions>
|
|
@@ -309,6 +397,13 @@ async function transformPre(
|
|
|
309
397
|
// buildCopyButton().
|
|
310
398
|
const copyButtonEnabled = typeof opts.copyButton === 'object' ? true : !!opts.copyButton;
|
|
311
399
|
const autoTitleBar = !!(title || effectiveLang || copyButtonEnabled);
|
|
400
|
+
// Distinguish whole-block collapse (collapseAfter threshold or `collapse` flag)
|
|
401
|
+
// from per-line collapse (collapseRanges from `collapse="N-M"` meta).
|
|
402
|
+
// Per-line collapse uses <details> sections INSIDE the <pre>, not around it.
|
|
403
|
+
const hasCollapseRanges = !!(meta.collapseRanges && meta.collapseRanges.length > 0);
|
|
404
|
+
const wholeBlockCollapse = !hasCollapseRanges && (
|
|
405
|
+
meta.flags.collapse ?? (opts.collapseAfter != null ? shouldCollapse(pre, opts.collapseAfter) : false)
|
|
406
|
+
);
|
|
312
407
|
const resolved: ResolvedBlock = {
|
|
313
408
|
language: effectiveLang,
|
|
314
409
|
title,
|
|
@@ -321,7 +416,7 @@ async function transformPre(
|
|
|
321
416
|
decorations: meta.flags.decorations ?? opts.decorations,
|
|
322
417
|
showLanguage: meta.flags.showLanguage ?? opts.showLanguage,
|
|
323
418
|
copyButton: meta.flags.copyButton ?? copyButtonEnabled,
|
|
324
|
-
collapse:
|
|
419
|
+
collapse: wholeBlockCollapse,
|
|
325
420
|
};
|
|
326
421
|
|
|
327
422
|
// Auto-detect terminal preset based on language.
|
|
@@ -348,6 +443,10 @@ async function transformPre(
|
|
|
348
443
|
// Filter out trailing empty line (from trailing newline in source).
|
|
349
444
|
const filteredLines = filterTrailingEmpty(lineSpans);
|
|
350
445
|
|
|
446
|
+
// Apply per-line collapsible sections (meta `collapse="5-12,20-30"`).
|
|
447
|
+
// Wraps matching line ranges in <details><summary>N collapsed lines</summary>...</details>.
|
|
448
|
+
const collapsedLines = wrapCollapsedSections(filteredLines, meta, opts, resolved.lineNumbersStart);
|
|
449
|
+
|
|
351
450
|
// Call onVisitLine / onVisitHighlightedLine hooks.
|
|
352
451
|
filteredLines.forEach((line, i) => {
|
|
353
452
|
const lineNumber = i + resolved.lineNumbersStart;
|
|
@@ -359,6 +458,9 @@ async function transformPre(
|
|
|
359
458
|
});
|
|
360
459
|
|
|
361
460
|
// data-line-numbers-max-digits attribute on <code> for CSS-driven gutter sizing.
|
|
461
|
+
// Uses filteredLines.length (not collapsedLines) because collapsed sections
|
|
462
|
+
// contain the same total number of logical lines — we just want the max
|
|
463
|
+
// gutter number's digit count.
|
|
362
464
|
const maxDigits = String(filteredLines.length + resolved.lineNumbersStart - 1).length;
|
|
363
465
|
const codeDataProps: Record<string, unknown> = {
|
|
364
466
|
dataLineNumbersMaxDigits: String(maxDigits),
|
|
@@ -371,11 +473,36 @@ async function transformPre(
|
|
|
371
473
|
codeDataProps.dataLineNumbers = '';
|
|
372
474
|
}
|
|
373
475
|
|
|
476
|
+
// Collect body-level `has-*` classes (e.g. `has-diff`, `has-focused`,
|
|
477
|
+
// `has-highlighted`) from the line spans. These were previously stripped —
|
|
478
|
+
// restoring them lets CSS target the whole <pre> when any line has a state.
|
|
479
|
+
const preLevelClasses = new Set<string>();
|
|
480
|
+
for (const line of collapsedLines) {
|
|
481
|
+
// Skip <details> wrapper elements — only inspect actual line spans.
|
|
482
|
+
if (line.tagName !== 'span') continue;
|
|
483
|
+
const lineClasses = (line.properties?.className as string[] | undefined) ?? [];
|
|
484
|
+
if (lineClasses.includes('pcb__line--add') || lineClasses.includes('pcb__line--del')) {
|
|
485
|
+
preLevelClasses.add('has-diff');
|
|
486
|
+
}
|
|
487
|
+
if (lineClasses.includes('pcb__line--focus')) {
|
|
488
|
+
preLevelClasses.add('has-focused');
|
|
489
|
+
}
|
|
490
|
+
if (lineClasses.includes('pcb__line--hl')) {
|
|
491
|
+
preLevelClasses.add('has-highlighted');
|
|
492
|
+
}
|
|
493
|
+
if (lineClasses.includes('pcb__line--error') || lineClasses.includes('pcb__line--warning') || lineClasses.includes('pcb__line--info')) {
|
|
494
|
+
preLevelClasses.add('has-error-level');
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
374
498
|
// Build code <pre><code> with line spans.
|
|
375
499
|
// When keepBackground is true, preserve Shiki's inline `style` (which includes
|
|
376
500
|
// background-color + color from the theme) on the new <pre>.
|
|
377
|
-
const newCode = h('code', codeDataProps,
|
|
501
|
+
const newCode = h('code', codeDataProps, collapsedLines);
|
|
378
502
|
const newPreProps: Record<string, unknown> = {};
|
|
503
|
+
if (preLevelClasses.size > 0) {
|
|
504
|
+
newPreProps.className = [...preLevelClasses];
|
|
505
|
+
}
|
|
379
506
|
if (opts.keepBackground && pre.properties?.style) {
|
|
380
507
|
newPreProps.style = pre.properties.style;
|
|
381
508
|
}
|
|
@@ -387,25 +514,40 @@ async function transformPre(
|
|
|
387
514
|
const bodyClasses = ['pcb__body'];
|
|
388
515
|
if (resolved.highlight.length > 0) bodyClasses.push('pcb__body--has-hl');
|
|
389
516
|
// Accessibility: mark scrollable region so screen readers announce it (WCAG 4.1.2).
|
|
517
|
+
// Also add tabindex=0 for keyboard scrolling (WCAG 2.1.1).
|
|
390
518
|
const bodyProps: Record<string, unknown> = { className: bodyClasses };
|
|
391
519
|
if (opts.accessibleScroll) {
|
|
392
520
|
bodyProps.role = 'region';
|
|
521
|
+
bodyProps.tabIndex = 0;
|
|
522
|
+
// i18n: allow user to override the aria-label prefix.
|
|
523
|
+
const ariaPrefix = opts.texts?.codeBlockAriaPrefix ?? 'Code block';
|
|
393
524
|
// Escape the title/lang for use in an aria-label attribute (defense in depth:
|
|
394
525
|
// prevents any <script> or other HTML from leaking into the attribute).
|
|
395
526
|
const safeLabel = title
|
|
396
|
-
?
|
|
527
|
+
? `${ariaPrefix}: ${title.replace(/[<>"'&]/g, (c) => ({ '<': '<', '>': '>', '"': '"', "'": ''', '&': '&' }[c] ?? c))}`
|
|
397
528
|
: effectiveLang
|
|
398
|
-
?
|
|
399
|
-
:
|
|
529
|
+
? `${ariaPrefix}: ${effectiveLang}`
|
|
530
|
+
: ariaPrefix;
|
|
400
531
|
bodyProps.ariaLabel = safeLabel;
|
|
401
532
|
}
|
|
402
533
|
const body = h('div', bodyProps, [newPre]);
|
|
403
534
|
|
|
535
|
+
// i18n: screen-reader-only title for terminal preset (improves context).
|
|
536
|
+
const terminalSrText = (presetClass === 'terminal' && !title && opts.terminalSrOnlyTitle !== false)
|
|
537
|
+
? (opts.texts?.terminalSrOnlyTitle ?? 'Terminal window')
|
|
538
|
+
: null;
|
|
539
|
+
|
|
404
540
|
// Header bar.
|
|
541
|
+
// Use <figcaption> when there's no separate caption (more semantic).
|
|
542
|
+
// When both title-bar and caption are present, use <div> for bar and
|
|
543
|
+
// <figcaption> for the caption (matches rehype-pretty-code).
|
|
405
544
|
let bar: Element | null = null;
|
|
406
545
|
if (resolved.titleBar) {
|
|
407
546
|
const barChildren: ElementContent[] = [];
|
|
408
547
|
if (resolved.decorations) barChildren.push(dotsElement());
|
|
548
|
+
if (terminalSrText) {
|
|
549
|
+
barChildren.push(h('span', { className: ['pcb__sr-only'] }, [hText(terminalSrText)]));
|
|
550
|
+
}
|
|
409
551
|
barChildren.push(
|
|
410
552
|
h('div', { className: ['pcb__title'] }, title ? [hText(title)] : [])
|
|
411
553
|
);
|
|
@@ -413,9 +555,18 @@ async function transformPre(
|
|
|
413
555
|
barChildren.push(h('div', { className: ['pcb__lang'] }, [hText(languageLabel ?? resolved.language)]));
|
|
414
556
|
}
|
|
415
557
|
if (resolved.copyButton) {
|
|
416
|
-
|
|
558
|
+
const btn = buildCopyButton(opts);
|
|
559
|
+
// For terminal preset + copyStripComments, mark the button so the
|
|
560
|
+
// client script knows to strip # comments from the copied text.
|
|
561
|
+
if (presetClass === 'terminal' && opts.copyStripComments !== false) {
|
|
562
|
+
(btn.properties as Record<string, unknown>).dataStripComments = '';
|
|
563
|
+
}
|
|
564
|
+
barChildren.push(btn);
|
|
417
565
|
}
|
|
418
|
-
bar
|
|
566
|
+
// Use figcaption for the bar when there's no separate caption below —
|
|
567
|
+
// this gives the figure an accessible name from the title.
|
|
568
|
+
const barTag = (!opts.caption || !meta.caption) && title ? 'figcaption' : 'div';
|
|
569
|
+
bar = h(barTag, { className: ['pcb__bar'] }, barChildren);
|
|
419
570
|
if (title) opts.onVisitTitle(bar);
|
|
420
571
|
}
|
|
421
572
|
|
|
@@ -433,6 +584,9 @@ async function transformPre(
|
|
|
433
584
|
if (resolved.collapse) {
|
|
434
585
|
const summaryChildren: ElementContent[] = [];
|
|
435
586
|
if (resolved.decorations) summaryChildren.push(dotsElement());
|
|
587
|
+
if (terminalSrText) {
|
|
588
|
+
summaryChildren.push(h('span', { className: ['pcb__sr-only'] }, [hText(terminalSrText)]));
|
|
589
|
+
}
|
|
436
590
|
summaryChildren.push(
|
|
437
591
|
h('span', { className: ['pcb__title'] }, [hText(title ?? 'code')])
|
|
438
592
|
);
|
|
@@ -459,8 +613,14 @@ async function transformPre(
|
|
|
459
613
|
|
|
460
614
|
/** Build the copy button based on options (legacy boolean or new object form). */
|
|
461
615
|
function buildCopyButton(opts: Required<PerfectCodeOptions>): Element {
|
|
462
|
-
|
|
463
|
-
|
|
616
|
+
// i18n: allow user to override default UI strings via `texts` option.
|
|
617
|
+
const texts = opts.texts ?? {};
|
|
618
|
+
const defaultLabel = texts.copyLabel ?? 'copy';
|
|
619
|
+
const defaultDoneLabel = texts.doneLabel ?? 'copied!';
|
|
620
|
+
const defaultAriaLabel = texts.copyAriaLabel ?? 'Copy code';
|
|
621
|
+
|
|
622
|
+
let label: string | null = defaultLabel;
|
|
623
|
+
let doneLabel: string = defaultDoneLabel;
|
|
464
624
|
let copyIcon: string | undefined;
|
|
465
625
|
let successIcon: string | undefined;
|
|
466
626
|
let feedbackDuration: number | undefined;
|
|
@@ -471,16 +631,25 @@ function buildCopyButton(opts: Required<PerfectCodeOptions>): Element {
|
|
|
471
631
|
label = opts.copyButton.label ?? null;
|
|
472
632
|
}
|
|
473
633
|
if ('doneLabel' in opts.copyButton) {
|
|
474
|
-
doneLabel = opts.copyButton.doneLabel ??
|
|
634
|
+
doneLabel = opts.copyButton.doneLabel ?? defaultDoneLabel;
|
|
475
635
|
}
|
|
476
636
|
copyIcon = opts.copyButton.copyIcon;
|
|
477
637
|
successIcon = opts.copyButton.successIcon;
|
|
478
638
|
feedbackDuration = opts.copyButton.feedbackDuration;
|
|
479
|
-
} else {
|
|
480
|
-
// Legacy
|
|
481
|
-
|
|
482
|
-
|
|
639
|
+
} else if (opts.copyButton === true) {
|
|
640
|
+
// Legacy boolean `copyButton: true`.
|
|
641
|
+
// If user explicitly set copyButtonLabel/copyButtonDoneLabel (deprecated),
|
|
642
|
+
// honor them. Otherwise use the i18n defaults from `texts`.
|
|
643
|
+
// We detect "explicitly set" by checking against the defaults: 'copy' and 'copied!'.
|
|
644
|
+
// (This is a bit of a hack but maintains backward compat.)
|
|
645
|
+
if (opts.copyButtonLabel !== 'copy') {
|
|
646
|
+
label = opts.copyButtonLabel;
|
|
647
|
+
}
|
|
648
|
+
if (opts.copyButtonDoneLabel !== 'copied!') {
|
|
649
|
+
doneLabel = opts.copyButtonDoneLabel;
|
|
650
|
+
}
|
|
483
651
|
}
|
|
652
|
+
// Note: when copyButton=false, buildCopyButton is never called.
|
|
484
653
|
|
|
485
654
|
const btnChildren: ElementContent[] = [copyIconElement(copyIcon)];
|
|
486
655
|
if (label) {
|
|
@@ -490,7 +659,7 @@ function buildCopyButton(opts: Required<PerfectCodeOptions>): Element {
|
|
|
490
659
|
const btnProps: Record<string, unknown> = {
|
|
491
660
|
className: ['pcb__copy'],
|
|
492
661
|
type: 'button',
|
|
493
|
-
ariaLabel:
|
|
662
|
+
ariaLabel: defaultAriaLabel,
|
|
494
663
|
dataDoneLabel: doneLabel,
|
|
495
664
|
};
|
|
496
665
|
// SECURITY: successIcon is stored verbatim as a data-* attribute and later
|
|
@@ -787,6 +956,63 @@ function filterTrailingEmpty(lines: Element[]): Element[] {
|
|
|
787
956
|
return hasText ? lines : lines.slice(0, -1);
|
|
788
957
|
}
|
|
789
958
|
|
|
959
|
+
/**
|
|
960
|
+
* Wrap per-line collapsible sections in <details><summary>…</summary>…</details>.
|
|
961
|
+
*
|
|
962
|
+
* Reads `meta.collapseRanges` (parsed from `collapse="5-12,20-30"` meta) and
|
|
963
|
+
* wraps matching line ranges. Lines are 1-indexed (matching the lineNumbersStart
|
|
964
|
+
* offset passed in).
|
|
965
|
+
*
|
|
966
|
+
* The summary element shows "N collapsed lines" (or the i18n variant).
|
|
967
|
+
* The details element gets class `pcb__collapse` plus a style class based on
|
|
968
|
+
* `opts.collapseStyle` (e.g. `pcb__collapse--github`).
|
|
969
|
+
*
|
|
970
|
+
* Lines outside any range are passed through unchanged.
|
|
971
|
+
*/
|
|
972
|
+
function wrapCollapsedSections(
|
|
973
|
+
lines: Element[],
|
|
974
|
+
meta: ParsedMeta,
|
|
975
|
+
opts: Required<PerfectCodeOptions>,
|
|
976
|
+
lineNumbersStart: number
|
|
977
|
+
): Element[] {
|
|
978
|
+
if (!meta.collapseRanges || meta.collapseRanges.length === 0) {
|
|
979
|
+
return lines;
|
|
980
|
+
}
|
|
981
|
+
// Build a map of lineNumber → index in `lines`.
|
|
982
|
+
// Line numbers are 1-indexed starting at `lineNumbersStart`.
|
|
983
|
+
// Index in `lines` is 0-indexed, so line N corresponds to lines[N - lineNumbersStart].
|
|
984
|
+
const result: Element[] = [];
|
|
985
|
+
let i = 0;
|
|
986
|
+
while (i < lines.length) {
|
|
987
|
+
const lineNumber = i + lineNumbersStart;
|
|
988
|
+
// Find a collapse range that starts at this line.
|
|
989
|
+
const range = meta.collapseRanges.find((r) => r.from === lineNumber);
|
|
990
|
+
if (range) {
|
|
991
|
+
const rangeSize = range.to - range.from + 1;
|
|
992
|
+
const sectionLines = lines.slice(i, i + rangeSize);
|
|
993
|
+
// i18n: customize the summary label.
|
|
994
|
+
const labelFn = opts.texts?.collapsedLinesLabel;
|
|
995
|
+
const summaryText = labelFn
|
|
996
|
+
? labelFn(rangeSize)
|
|
997
|
+
: `${rangeSize} collapsed line${rangeSize === 1 ? '' : 's'}`;
|
|
998
|
+
const summary = h('summary', { className: ['pcb__collapse-summary'] }, [hText(summaryText)]);
|
|
999
|
+
const detailsClasses = ['pcb__collapse'];
|
|
1000
|
+
if (opts.collapseStyle && opts.collapseStyle !== 'github') {
|
|
1001
|
+
detailsClasses.push(`pcb__collapse--${opts.collapseStyle}`);
|
|
1002
|
+
} else {
|
|
1003
|
+
detailsClasses.push('pcb__collapse--github');
|
|
1004
|
+
}
|
|
1005
|
+
const details = h('details', { className: detailsClasses }, [summary, ...sectionLines]);
|
|
1006
|
+
result.push(details);
|
|
1007
|
+
i += rangeSize;
|
|
1008
|
+
} else {
|
|
1009
|
+
result.push(lines[i]);
|
|
1010
|
+
i++;
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
return result;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
790
1016
|
/** Walk line children and remap "highlighted-word" → "pcb__word" class. */
|
|
791
1017
|
function mapWordHighlights(children: ElementContent[]): ElementContent[] {
|
|
792
1018
|
return children.map((child) => {
|
package/src/types.ts
CHANGED
|
@@ -59,6 +59,16 @@ export interface PerfectCodeOptions {
|
|
|
59
59
|
wrap?: boolean;
|
|
60
60
|
/** Auto-collapse blocks longer than N lines. null = never. Default: null */
|
|
61
61
|
collapseAfter?: number | null;
|
|
62
|
+
/**
|
|
63
|
+
* Per-line collapsible sections.
|
|
64
|
+
* Pass a meta string like `collapse="5-12,20-30"` to wrap matching line ranges
|
|
65
|
+
* in `<details><summary>N collapsed lines</summary>...</details>`.
|
|
66
|
+
* Style options: 'github' (default), 'collapsible-start', 'collapsible-end', 'collapsible-auto'.
|
|
67
|
+
* Default: null (disabled).
|
|
68
|
+
*/
|
|
69
|
+
collapseRanges?: string | null;
|
|
70
|
+
/** Style for collapsible sections. Default: 'github'. */
|
|
71
|
+
collapseStyle?: 'github' | 'collapsible-start' | 'collapsible-end' | 'collapsible-auto';
|
|
62
72
|
/** Show visible whitespace (tabs/spaces). Default: false */
|
|
63
73
|
showWhitespace?: false | 'all' | 'boundary' | 'trailing' | 'leading';
|
|
64
74
|
/** Render vertical indent guides. false | true (default 2) | number (indent width). Default: false */
|
|
@@ -76,8 +86,12 @@ export interface PerfectCodeOptions {
|
|
|
76
86
|
engine?: 'auto' | 'shiki' | 'passthrough';
|
|
77
87
|
/** Shiki options passed through when the plugin calls Shiki itself. */
|
|
78
88
|
shiki?: {
|
|
79
|
-
/**
|
|
80
|
-
|
|
89
|
+
/**
|
|
90
|
+
* Theme — string for single theme, { light, dark } for dual-theme via CSS vars,
|
|
91
|
+
* or a Record<string, string> for multi-theme (3+ themes) support.
|
|
92
|
+
* Multi-theme example: `{ light: 'github-light', dark: 'github-dark', dim: 'github-dark-dimmed' }`.
|
|
93
|
+
*/
|
|
94
|
+
theme?: string | { light: string; dark: string } | Record<string, string>;
|
|
81
95
|
/** Pre-loaded languages. Defaults to a sensible set; missing langs are lazy-loaded. */
|
|
82
96
|
langs?: string[];
|
|
83
97
|
/**
|
|
@@ -88,6 +102,12 @@ export interface PerfectCodeOptions {
|
|
|
88
102
|
regexEngine?: 'oniguruma' | 'javascript';
|
|
89
103
|
/** Additional Shiki transformers to apply (see @shikijs/transformers). */
|
|
90
104
|
transformers?: ShikiTransformer[];
|
|
105
|
+
/**
|
|
106
|
+
* Controls whether user-provided transformers run 'before' or 'after' the
|
|
107
|
+
* auto-registered ones (default: 'after'). Use 'before' to give user
|
|
108
|
+
* transformers first access to the code text.
|
|
109
|
+
*/
|
|
110
|
+
transformerOrder?: 'before' | 'after';
|
|
91
111
|
/** Override the highlighter factory (e.g. for custom TextMate grammars). */
|
|
92
112
|
getHighlighter?: (opts: { themes: string[]; langs: string[] }) => Promise<unknown>;
|
|
93
113
|
[key: string]: unknown;
|
|
@@ -108,6 +128,34 @@ export interface PerfectCodeOptions {
|
|
|
108
128
|
* round-trip. Faster + safer. Default: true.
|
|
109
129
|
*/
|
|
110
130
|
useHastApi?: boolean;
|
|
131
|
+
/**
|
|
132
|
+
* Disable auto-registration of @shikijs/transformers. When true, ONLY the
|
|
133
|
+
* transformers in `shiki.transformers` are applied. Default: false.
|
|
134
|
+
* Useful for advanced users who want full manual control.
|
|
135
|
+
*/
|
|
136
|
+
disableAutoTransformers?: boolean;
|
|
137
|
+
/**
|
|
138
|
+
* Strip all comments from the rendered code (// ..., # ..., /* ... *\/, <!-- ... -->).
|
|
139
|
+
* Powered by @shikijs/transformers `transformerRemoveComments`. Default: false.
|
|
140
|
+
*/
|
|
141
|
+
removeComments?: boolean;
|
|
142
|
+
/**
|
|
143
|
+
* Remove line breaks from the rendered code (joins all lines into one).
|
|
144
|
+
* Powered by @shikijs/transformers `transformerRemoveLineBreaks`. Default: false.
|
|
145
|
+
* Useful for compact inline-style code blocks.
|
|
146
|
+
*/
|
|
147
|
+
removeLineBreaks?: boolean;
|
|
148
|
+
/**
|
|
149
|
+
* When `true`, treat {1,3-5} meta ranges as zero-indexed (line 0 is the first
|
|
150
|
+
* line). When `false` (default), line numbers start at 1.
|
|
151
|
+
*/
|
|
152
|
+
zeroIndexed?: boolean;
|
|
153
|
+
/**
|
|
154
|
+
* Programmatic per-line class assignment (Shiki's `transformerCompactLineOptions`).
|
|
155
|
+
* Example: `[{ line: 1, classes: ['highlight'] }, { line: 3, classes: ['add'] }]`.
|
|
156
|
+
* Default: [] (disabled).
|
|
157
|
+
*/
|
|
158
|
+
lineOptions?: { line: number; classes?: string[]; attrs?: Record<string, string> }[];
|
|
111
159
|
|
|
112
160
|
/* ---------- Inline comment notations (VitePress-style) ---------- */
|
|
113
161
|
/**
|
|
@@ -141,7 +189,7 @@ export interface PerfectCodeOptions {
|
|
|
141
189
|
/* ---------- Auto frame detection (Expressive Code style) ---------- */
|
|
142
190
|
/**
|
|
143
191
|
* Auto-switch to terminal preset for these languages. Default:
|
|
144
|
-
* ['sh', 'bash', 'zsh', 'shell', 'console', 'powershell', 'bat', 'cmd', 'fish']
|
|
192
|
+
* ['sh', 'bash', 'zsh', 'shell', 'console', 'powershell', 'bat', 'cmd', 'fish', 'ansi']
|
|
145
193
|
*/
|
|
146
194
|
terminalLangs?: string[];
|
|
147
195
|
/**
|
|
@@ -172,10 +220,22 @@ export interface PerfectCodeOptions {
|
|
|
172
220
|
* (Renamed from `inlineDefaultLang` for clarity; old name still works.)
|
|
173
221
|
*/
|
|
174
222
|
defaultInlineLang?: string;
|
|
223
|
+
/**
|
|
224
|
+
* Replace tabs with N spaces before tokenization. 0 disables (default).
|
|
225
|
+
* Useful for languages where Shiki's tab rendering doesn't match the
|
|
226
|
+
* surrounding code style.
|
|
227
|
+
*/
|
|
228
|
+
tabWidth?: number;
|
|
229
|
+
/**
|
|
230
|
+
* Strip leading `#` comment lines from terminal code when copying to clipboard.
|
|
231
|
+
* Default: true (only effective when preset === 'terminal').
|
|
232
|
+
*/
|
|
233
|
+
copyStripComments?: boolean;
|
|
175
234
|
|
|
176
235
|
/* ---------- Accessibility ---------- */
|
|
177
236
|
/**
|
|
178
|
-
* Add `role="region"
|
|
237
|
+
* Add `role="region"`, `aria-label`, and `tabindex="0"` to scrollable code
|
|
238
|
+
* blocks (WCAG 2.1.1 keyboard accessible, 4.1.2 name-role-value).
|
|
179
239
|
* Default: true.
|
|
180
240
|
*/
|
|
181
241
|
accessibleScroll?: boolean;
|
|
@@ -190,6 +250,12 @@ export interface PerfectCodeOptions {
|
|
|
190
250
|
* Default: true.
|
|
191
251
|
*/
|
|
192
252
|
hideCopyWithoutJs?: boolean;
|
|
253
|
+
/**
|
|
254
|
+
* Add a screen-reader-only `<span class="pcb__sr-only">Terminal window</span>`
|
|
255
|
+
* to terminal-preset blocks that have no title. Improves screen reader context.
|
|
256
|
+
* Default: true.
|
|
257
|
+
*/
|
|
258
|
+
terminalSrOnlyTitle?: boolean;
|
|
193
259
|
|
|
194
260
|
/**
|
|
195
261
|
* Additional rehype plugins to run BEFORE rehype-perfect-code-blocks.
|
|
@@ -214,6 +280,40 @@ export interface PerfectCodeOptions {
|
|
|
214
280
|
/** Called for the caption element (if present). */
|
|
215
281
|
onVisitCaption?: (element: unknown) => void;
|
|
216
282
|
|
|
283
|
+
/* ---------- i18n (internationalization) ---------- */
|
|
284
|
+
/**
|
|
285
|
+
* Localized UI strings. Defaults are English. Override per-locale by
|
|
286
|
+
* passing a different `texts` object based on the current language.
|
|
287
|
+
*/
|
|
288
|
+
texts?: {
|
|
289
|
+
/** Copy button label (default: 'copy'). */
|
|
290
|
+
copyLabel?: string;
|
|
291
|
+
/** Label shown after successful copy (default: 'copied!'). */
|
|
292
|
+
doneLabel?: string;
|
|
293
|
+
/** Aria-label for the copy button (default: 'Copy code'). */
|
|
294
|
+
copyAriaLabel?: string;
|
|
295
|
+
/** Screen-reader-only title for terminal-preset blocks (default: 'Terminal window'). */
|
|
296
|
+
terminalSrOnlyTitle?: string;
|
|
297
|
+
/** aria-label prefix for scrollable body (default: 'Code block'). */
|
|
298
|
+
codeBlockAriaPrefix?: string;
|
|
299
|
+
/** Summary text for collapsed sections, with `{n}` placeholder (default: '{n} collapsed lines'). */
|
|
300
|
+
collapsedLinesLabel?: (n: number) => string;
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
/* ---------- Logging ---------- */
|
|
304
|
+
/**
|
|
305
|
+
* Custom logger. Defaults to `console`. Useful for silencing warnings in
|
|
306
|
+
* production or routing them to a structured logger.
|
|
307
|
+
*/
|
|
308
|
+
logger?: { warn: (msg: string) => void; error: (msg: string) => void };
|
|
309
|
+
|
|
310
|
+
/* ---------- CSP (Content Security Policy) ---------- */
|
|
311
|
+
/**
|
|
312
|
+
* Nonce to add to injected `<script>` and `<style>` tags. Enables strict CSP
|
|
313
|
+
* (`script-src 'self' 'nonce-...'`). Default: undefined (no nonce).
|
|
314
|
+
*/
|
|
315
|
+
cspNonce?: string;
|
|
316
|
+
|
|
217
317
|
/* ---------- Styling ---------- */
|
|
218
318
|
/** Visual preset. Default: 'default' */
|
|
219
319
|
preset?: 'default' | 'terminal' | 'minimal';
|
|
@@ -245,6 +345,7 @@ export interface ParsedMeta {
|
|
|
245
345
|
highlightGroups: { lines: number[]; id?: string }[]; // {1,2}#a {3,4}#b
|
|
246
346
|
wordHighlights: { text: string; range?: [number, number]; id?: string }[];
|
|
247
347
|
lineNumbersStart: number | null; // from ln{N} or showLineNumbers{N}
|
|
348
|
+
collapseRanges: { from: number; to: number }[]; // from collapse="5-12,20-30"
|
|
248
349
|
flags: {
|
|
249
350
|
wrap: boolean | null;
|
|
250
351
|
lineNumbers: boolean | null;
|
package/src/vite-raw.d.ts
CHANGED
|
File without changes
|