@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +106 -40
  2. package/LICENSE +0 -0
  3. package/README.md +0 -0
  4. package/dist/astro.d.ts +0 -0
  5. package/dist/astro.d.ts.map +1 -1
  6. package/dist/astro.js +13 -4
  7. package/dist/astro.js.map +1 -1
  8. package/dist/copy-script.d.ts +2 -1
  9. package/dist/copy-script.d.ts.map +1 -1
  10. package/dist/copy-script.js +16 -2
  11. package/dist/copy-script.js.map +1 -1
  12. package/dist/index.d.ts +0 -0
  13. package/dist/index.d.ts.map +0 -0
  14. package/dist/index.js +15 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/meta.d.ts +0 -0
  17. package/dist/meta.d.ts.map +1 -1
  18. package/dist/meta.js +35 -2
  19. package/dist/meta.js.map +1 -1
  20. package/dist/remark.d.ts +0 -0
  21. package/dist/remark.d.ts.map +0 -0
  22. package/dist/remark.js +0 -0
  23. package/dist/remark.js.map +0 -0
  24. package/dist/shiki.d.ts +0 -0
  25. package/dist/shiki.d.ts.map +1 -1
  26. package/dist/shiki.js +301 -33
  27. package/dist/shiki.js.map +1 -1
  28. package/dist/styles.css +0 -0
  29. package/dist/transformer.d.ts +0 -0
  30. package/dist/transformer.d.ts.map +1 -1
  31. package/dist/transformer.js +230 -16
  32. package/dist/transformer.js.map +1 -1
  33. package/dist/types.d.ts +109 -4
  34. package/dist/types.d.ts.map +1 -1
  35. package/dist/types.js +0 -0
  36. package/dist/types.js.map +0 -0
  37. package/package.json +2 -2
  38. package/src/astro.ts +14 -4
  39. package/src/copy-script.ts +16 -2
  40. package/src/index.ts +15 -1
  41. package/src/meta.ts +35 -2
  42. package/src/remark.ts +0 -0
  43. package/src/shiki.ts +306 -34
  44. package/src/styles.css +0 -0
  45. package/src/transformer.ts +243 -17
  46. package/src/types.ts +105 -4
  47. package/src/vite-raw.d.ts +0 -0
@@ -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: meta.flags.collapse ?? (opts.collapseAfter != null ? shouldCollapse(pre, opts.collapseAfter) : false),
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, filteredLines);
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
- ? `Code block: ${title.replace(/[<>"'&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;', '&': '&amp;' }[c] ?? c))}`
527
+ ? `${ariaPrefix}: ${title.replace(/[<>"'&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;', '&': '&amp;' }[c] ?? c))}`
397
528
  : effectiveLang
398
- ? `Code block: ${effectiveLang}`
399
- : 'Code block';
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
- barChildren.push(buildCopyButton(opts));
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 = h('div', { className: ['pcb__bar'] }, barChildren);
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
- let label: string | null = 'copy';
463
- let doneLabel: string = 'copied!';
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 ?? 'copied!';
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: use copyButtonLabel / copyButtonDoneLabel.
481
- label = opts.copyButtonLabel;
482
- doneLabel = opts.copyButtonDoneLabel;
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: 'Copy code',
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
- /** Theme — string for single theme, { light, dark } for dual-theme via CSS vars. */
80
- theme?: string | { light: string; dark: string };
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"` and `aria-label` to scrollable code blocks (WCAG 4.1.2).
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