@dr-ishaan/rehype-perfect-code-blocks 1.1.6 → 1.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.
@@ -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>`;
@@ -68,6 +68,28 @@ const DANGEROUS_HTML_PATTERNS = [
68
68
  /<embed\b/i,
69
69
  ];
70
70
 
71
+ /**
72
+ * Defense-in-depth check: returns `true` if the supplied HTML string is free
73
+ * of obviously dangerous patterns (`<script>`, `on*=` handlers, `javascript:`
74
+ * URLs, `<iframe>`, `<object>`, `<embed>`).
75
+ *
76
+ * Used by `buildCopyButton()` to gate both `copyIcon` (which becomes a hast
77
+ * subtree via `parseInlineHtml`) and `successIcon` (which is stored verbatim
78
+ * as a `data-success-icon` attribute and later innerHTML'd by the client
79
+ * copy-script). Without this check, `successIcon` would be a latent XSS sink.
80
+ *
81
+ * This is NOT a full HTML sanitizer. Callers MUST still ensure the input is
82
+ * developer-trusted. The check exists to fail-closed when dangerous patterns
83
+ * are detected, not to make untrusted input safe.
84
+ */
85
+ export function isSafeInlineHtml(html: string | undefined | null): boolean {
86
+ if (!html) return true;
87
+ for (const pattern of DANGEROUS_HTML_PATTERNS) {
88
+ if (pattern.test(html)) return false;
89
+ }
90
+ return true;
91
+ }
92
+
71
93
  function parseInlineHtml(html: string): Element {
72
94
  // Defense-in-depth: reject obviously dangerous patterns.
73
95
  for (const pattern of DANGEROUS_HTML_PATTERNS) {
@@ -148,7 +170,7 @@ const DEFAULT_MAGIC_COMMENTS: MagicComment[] = [
148
170
  },
149
171
  ];
150
172
 
151
- 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'];
152
174
 
153
175
  export function rehypePerfectCodeBlocks(userOptions: PerfectCodeOptions = {}) {
154
176
  const { shiki: userShiki, ...rest } = userOptions;
@@ -167,6 +189,8 @@ export function rehypePerfectCodeBlocks(userOptions: PerfectCodeOptions = {}) {
167
189
  errorLevels: true,
168
190
  wrap: false,
169
191
  collapseAfter: null,
192
+ collapseRanges: null,
193
+ collapseStyle: 'github',
170
194
  showWhitespace: false,
171
195
  indentGuides: false,
172
196
  caption: true,
@@ -175,11 +199,17 @@ export function rehypePerfectCodeBlocks(userOptions: PerfectCodeOptions = {}) {
175
199
  theme: { light: 'github-light', dark: 'github-dark' },
176
200
  langs: [],
177
201
  transformers: [],
202
+ transformerOrder: 'after',
178
203
  ...(userShiki ?? {}),
179
204
  },
180
205
  keepBackground: false,
181
206
  styleToClass: false,
182
207
  useHastApi: true,
208
+ disableAutoTransformers: false,
209
+ removeComments: false,
210
+ removeLineBreaks: false,
211
+ zeroIndexed: false,
212
+ lineOptions: [],
183
213
  customNotations: {},
184
214
  magicComments: DEFAULT_MAGIC_COMMENTS,
185
215
  inlineCode: false,
@@ -191,9 +221,12 @@ export function rehypePerfectCodeBlocks(userOptions: PerfectCodeOptions = {}) {
191
221
  languageLabels: {},
192
222
  languageAliases: {},
193
223
  defaultBlockLang: '',
224
+ tabWidth: 0,
225
+ copyStripComments: true,
194
226
  accessibleScroll: true,
195
227
  announceCopy: true,
196
228
  hideCopyWithoutJs: true,
229
+ terminalSrOnlyTitle: true,
197
230
  rehypePlugins: [],
198
231
  filterMetaString: (s) => s,
199
232
  onVisitLine: () => {},
@@ -201,6 +234,9 @@ export function rehypePerfectCodeBlocks(userOptions: PerfectCodeOptions = {}) {
201
234
  onVisitHighlightedChars: () => {},
202
235
  onVisitTitle: () => {},
203
236
  onVisitCaption: () => {},
237
+ texts: {},
238
+ logger: console,
239
+ cspNonce: '',
204
240
  preset: 'default',
205
241
  injectStyles: true,
206
242
  theme: 'auto',
@@ -210,6 +246,7 @@ export function rehypePerfectCodeBlocks(userOptions: PerfectCodeOptions = {}) {
210
246
 
211
247
  return async (tree: Root) => {
212
248
  const visits: Element[] = [];
249
+ const inlineCodes: Element[] = [];
213
250
 
214
251
  visit(tree, 'element', (node) => {
215
252
  if (node.tagName !== 'pre') return;
@@ -221,15 +258,88 @@ export function rehypePerfectCodeBlocks(userOptions: PerfectCodeOptions = {}) {
221
258
  visits.push(node);
222
259
  });
223
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
+
224
272
  for (const pre of visits) {
225
273
  const replacement = await transformPre(pre, options);
226
274
  if (replacement) {
227
275
  Object.assign(pre, replacement);
228
276
  }
229
277
  }
278
+
279
+ // Process inline code blocks (after the block-level transform).
280
+ for (const code of inlineCodes) {
281
+ transformInlineCode(code, options);
282
+ }
230
283
  };
231
284
  }
232
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
+
233
343
  async function transformPre(
234
344
  pre: Element,
235
345
  opts: Required<PerfectCodeOptions>
@@ -287,6 +397,13 @@ async function transformPre(
287
397
  // buildCopyButton().
288
398
  const copyButtonEnabled = typeof opts.copyButton === 'object' ? true : !!opts.copyButton;
289
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
+ );
290
407
  const resolved: ResolvedBlock = {
291
408
  language: effectiveLang,
292
409
  title,
@@ -299,7 +416,7 @@ async function transformPre(
299
416
  decorations: meta.flags.decorations ?? opts.decorations,
300
417
  showLanguage: meta.flags.showLanguage ?? opts.showLanguage,
301
418
  copyButton: meta.flags.copyButton ?? copyButtonEnabled,
302
- collapse: meta.flags.collapse ?? (opts.collapseAfter != null ? shouldCollapse(pre, opts.collapseAfter) : false),
419
+ collapse: wholeBlockCollapse,
303
420
  };
304
421
 
305
422
  // Auto-detect terminal preset based on language.
@@ -326,6 +443,10 @@ async function transformPre(
326
443
  // Filter out trailing empty line (from trailing newline in source).
327
444
  const filteredLines = filterTrailingEmpty(lineSpans);
328
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
+
329
450
  // Call onVisitLine / onVisitHighlightedLine hooks.
330
451
  filteredLines.forEach((line, i) => {
331
452
  const lineNumber = i + resolved.lineNumbersStart;
@@ -337,6 +458,9 @@ async function transformPre(
337
458
  });
338
459
 
339
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.
340
464
  const maxDigits = String(filteredLines.length + resolved.lineNumbersStart - 1).length;
341
465
  const codeDataProps: Record<string, unknown> = {
342
466
  dataLineNumbersMaxDigits: String(maxDigits),
@@ -349,11 +473,36 @@ async function transformPre(
349
473
  codeDataProps.dataLineNumbers = '';
350
474
  }
351
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
+
352
498
  // Build code <pre><code> with line spans.
353
499
  // When keepBackground is true, preserve Shiki's inline `style` (which includes
354
500
  // background-color + color from the theme) on the new <pre>.
355
- const newCode = h('code', codeDataProps, filteredLines);
501
+ const newCode = h('code', codeDataProps, collapsedLines);
356
502
  const newPreProps: Record<string, unknown> = {};
503
+ if (preLevelClasses.size > 0) {
504
+ newPreProps.className = [...preLevelClasses];
505
+ }
357
506
  if (opts.keepBackground && pre.properties?.style) {
358
507
  newPreProps.style = pre.properties.style;
359
508
  }
@@ -365,25 +514,40 @@ async function transformPre(
365
514
  const bodyClasses = ['pcb__body'];
366
515
  if (resolved.highlight.length > 0) bodyClasses.push('pcb__body--has-hl');
367
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).
368
518
  const bodyProps: Record<string, unknown> = { className: bodyClasses };
369
519
  if (opts.accessibleScroll) {
370
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';
371
524
  // Escape the title/lang for use in an aria-label attribute (defense in depth:
372
525
  // prevents any <script> or other HTML from leaking into the attribute).
373
526
  const safeLabel = title
374
- ? `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))}`
375
528
  : effectiveLang
376
- ? `Code block: ${effectiveLang}`
377
- : 'Code block';
529
+ ? `${ariaPrefix}: ${effectiveLang}`
530
+ : ariaPrefix;
378
531
  bodyProps.ariaLabel = safeLabel;
379
532
  }
380
533
  const body = h('div', bodyProps, [newPre]);
381
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
+
382
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).
383
544
  let bar: Element | null = null;
384
545
  if (resolved.titleBar) {
385
546
  const barChildren: ElementContent[] = [];
386
547
  if (resolved.decorations) barChildren.push(dotsElement());
548
+ if (terminalSrText) {
549
+ barChildren.push(h('span', { className: ['pcb__sr-only'] }, [hText(terminalSrText)]));
550
+ }
387
551
  barChildren.push(
388
552
  h('div', { className: ['pcb__title'] }, title ? [hText(title)] : [])
389
553
  );
@@ -391,9 +555,18 @@ async function transformPre(
391
555
  barChildren.push(h('div', { className: ['pcb__lang'] }, [hText(languageLabel ?? resolved.language)]));
392
556
  }
393
557
  if (resolved.copyButton) {
394
- 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);
395
565
  }
396
- 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);
397
570
  if (title) opts.onVisitTitle(bar);
398
571
  }
399
572
 
@@ -411,6 +584,9 @@ async function transformPre(
411
584
  if (resolved.collapse) {
412
585
  const summaryChildren: ElementContent[] = [];
413
586
  if (resolved.decorations) summaryChildren.push(dotsElement());
587
+ if (terminalSrText) {
588
+ summaryChildren.push(h('span', { className: ['pcb__sr-only'] }, [hText(terminalSrText)]));
589
+ }
414
590
  summaryChildren.push(
415
591
  h('span', { className: ['pcb__title'] }, [hText(title ?? 'code')])
416
592
  );
@@ -437,8 +613,14 @@ async function transformPre(
437
613
 
438
614
  /** Build the copy button based on options (legacy boolean or new object form). */
439
615
  function buildCopyButton(opts: Required<PerfectCodeOptions>): Element {
440
- let label: string | null = 'copy';
441
- 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;
442
624
  let copyIcon: string | undefined;
443
625
  let successIcon: string | undefined;
444
626
  let feedbackDuration: number | undefined;
@@ -449,16 +631,25 @@ function buildCopyButton(opts: Required<PerfectCodeOptions>): Element {
449
631
  label = opts.copyButton.label ?? null;
450
632
  }
451
633
  if ('doneLabel' in opts.copyButton) {
452
- doneLabel = opts.copyButton.doneLabel ?? 'copied!';
634
+ doneLabel = opts.copyButton.doneLabel ?? defaultDoneLabel;
453
635
  }
454
636
  copyIcon = opts.copyButton.copyIcon;
455
637
  successIcon = opts.copyButton.successIcon;
456
638
  feedbackDuration = opts.copyButton.feedbackDuration;
457
- } else {
458
- // Legacy: use copyButtonLabel / copyButtonDoneLabel.
459
- label = opts.copyButtonLabel;
460
- 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
+ }
461
651
  }
652
+ // Note: when copyButton=false, buildCopyButton is never called.
462
653
 
463
654
  const btnChildren: ElementContent[] = [copyIconElement(copyIcon)];
464
655
  if (label) {
@@ -468,11 +659,20 @@ function buildCopyButton(opts: Required<PerfectCodeOptions>): Element {
468
659
  const btnProps: Record<string, unknown> = {
469
660
  className: ['pcb__copy'],
470
661
  type: 'button',
471
- ariaLabel: 'Copy code',
662
+ ariaLabel: defaultAriaLabel,
472
663
  dataDoneLabel: doneLabel,
473
664
  };
474
- if (successIcon) btnProps.dataSuccessIcon = successIcon;
475
- if (feedbackDuration != null) btnProps.dataFeedbackDuration = String(feedbackDuration);
665
+ // SECURITY: successIcon is stored verbatim as a data-* attribute and later
666
+ // innerHTML'd by the client copy-script, so it MUST pass the same
667
+ // defense-in-depth check as copyIcon. Reject dangerous patterns (issue #2).
668
+ if (successIcon && isSafeInlineHtml(successIcon)) {
669
+ btnProps.dataSuccessIcon = successIcon;
670
+ }
671
+ // Always emit data-feedback-duration so the rendered HTML matches the
672
+ // documented default of 1600ms (issue #8). Previously the attribute was
673
+ // only emitted when explicitly set, causing the rendered HTML to differ
674
+ // from the docs even though the runtime behavior was correct.
675
+ btnProps.dataFeedbackDuration = String(feedbackDuration ?? 1600);
476
676
 
477
677
  return h('button', btnProps, btnChildren);
478
678
  }
@@ -756,6 +956,63 @@ function filterTrailingEmpty(lines: Element[]): Element[] {
756
956
  return hasText ? lines : lines.slice(0, -1);
757
957
  }
758
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
+
759
1016
  /** Walk line children and remap "highlighted-word" → "pcb__word" class. */
760
1017
  function mapWordHighlights(children: ElementContent[]): ElementContent[] {
761
1018
  return children.map((child) => {
@@ -796,12 +1053,17 @@ function splitCodeIntoLines(code: Element): Element[] {
796
1053
  }
797
1054
 
798
1055
  // Case 2: Plain tokenized code — split on newlines in Text nodes.
1056
+ //
1057
+ // We split on `\n` in Text nodes; empty lines between two non-empty lines
1058
+ // must be preserved (regression: previously dropped by a `sawAnyContent`
1059
+ // guard that was meant to skip a *trailing* empty line but also skipped
1060
+ // legitimate inter-content empty lines).
1061
+ // The trailing-empty-line case is handled separately by `filterTrailingEmpty()`
1062
+ // at the end of `transformPre()`, so we don't need to special-case it here.
799
1063
  const lines: Element[] = [];
800
1064
  let current: ElementContent[] = [];
801
- let sawAnyContent = false;
802
1065
 
803
1066
  const flush = () => {
804
- if (current.length === 0 && sawAnyContent && lines.length > 0) return;
805
1067
  lines.push({
806
1068
  type: 'element',
807
1069
  tagName: 'span',
@@ -818,15 +1080,15 @@ function splitCodeIntoLines(code: Element): Element[] {
818
1080
  if (i > 0) flush();
819
1081
  if (part) {
820
1082
  current.push({ type: 'text', value: part } as Text);
821
- sawAnyContent = true;
822
1083
  }
823
1084
  });
824
1085
  } else {
825
1086
  current.push(child);
826
- sawAnyContent = true;
827
1087
  }
828
1088
  }
829
- if (current.length > 0) flush();
1089
+ // Always flush the final pending line — `filterTrailingEmpty()` will drop
1090
+ // it if it ends up empty (i.e. the source had a trailing `\n`).
1091
+ flush();
830
1092
  return lines;
831
1093
  }
832
1094