@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.
- package/CHANGELOG.md +112 -0
- package/dist/astro.d.ts.map +1 -1
- package/dist/astro.js +34 -7
- 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.js +15 -1
- package/dist/index.js.map +1 -1
- package/dist/meta.d.ts.map +1 -1
- package/dist/meta.js +51 -7
- package/dist/meta.js.map +1 -1
- package/dist/shiki.d.ts.map +1 -1
- package/dist/shiki.js +254 -25
- package/dist/shiki.js.map +1 -1
- package/dist/transformer.d.ts +15 -0
- package/dist/transformer.d.ts.map +1 -1
- package/dist/transformer.js +273 -26
- package/dist/transformer.js.map +1 -1
- package/dist/types.d.ts +109 -4
- package/dist/types.d.ts.map +1 -1
- package/package.json +23 -4
- package/src/astro.ts +36 -9
- package/src/copy-script.ts +16 -2
- package/src/index.ts +15 -1
- package/src/meta.ts +51 -7
- package/src/shiki.ts +258 -26
- package/src/transformer.ts +286 -24
- package/src/types.ts +105 -5
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>`;
|
|
@@ -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:
|
|
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,
|
|
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
|
-
?
|
|
527
|
+
? `${ariaPrefix}: ${title.replace(/[<>"'&]/g, (c) => ({ '<': '<', '>': '>', '"': '"', "'": ''', '&': '&' }[c] ?? c))}`
|
|
375
528
|
: effectiveLang
|
|
376
|
-
?
|
|
377
|
-
:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
441
|
-
|
|
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 ??
|
|
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
|
|
459
|
-
|
|
460
|
-
|
|
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:
|
|
662
|
+
ariaLabel: defaultAriaLabel,
|
|
472
663
|
dataDoneLabel: doneLabel,
|
|
473
664
|
};
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
|
|
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
|
|