@dr-ishaan/rehype-perfect-code-blocks 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/katex.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Minimal type declaration for katex (optional peer dependency).
3
+ * When katex is installed, its own types take precedence.
4
+ */
5
+ declare module 'katex' {
6
+ export interface KatexOptions {
7
+ displayMode?: boolean;
8
+ throwOnError?: boolean;
9
+ strict?: boolean | 'ignore' | 'error' | 'warn';
10
+ output?: 'html' | 'mathml' | 'htmlAndMathml';
11
+ [key: string]: unknown;
12
+ }
13
+ export function renderToString(latex: string, options?: KatexOptions): string;
14
+ const _default: { renderToString: typeof renderToString };
15
+ export default _default;
16
+ }
package/src/math.ts ADDED
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Math/LaTeX rendering via KaTeX (v2.1.0).
3
+ *
4
+ * Renders LaTeX at build time (server-side) — no client-side JS needed.
5
+ * `katex` is an optional peer dependency: if not installed, the plugin
6
+ * falls back to rendering the LaTeX source as plain text in a styled
7
+ * container.
8
+ *
9
+ * Supports:
10
+ * - Inline math: `$...$` in text nodes (via a remark plugin)
11
+ * - Block math: `$$...$$` blocks
12
+ * - Fenced code blocks with language `math`, `latex`, or `tex`
13
+ */
14
+
15
+ export interface MathOptions {
16
+ engine?: 'katex' | 'none';
17
+ inline?: boolean;
18
+ block?: boolean;
19
+ injectCss?: boolean;
20
+ throwOnError?: boolean;
21
+ strict?: boolean | 'ignore' | 'error' | 'warn';
22
+ }
23
+
24
+ export interface ResolvedMathOptions {
25
+ engine: 'katex' | 'none';
26
+ inline: boolean;
27
+ block: boolean;
28
+ injectCss: boolean;
29
+ throwOnError: boolean;
30
+ strict: boolean | 'ignore' | 'error' | 'warn';
31
+ }
32
+
33
+ export function resolveMathOptions(math?: MathOptions): ResolvedMathOptions {
34
+ return {
35
+ engine: math?.engine ?? 'none',
36
+ inline: math?.inline ?? true,
37
+ block: math?.block ?? true,
38
+ injectCss: math?.injectCss ?? true,
39
+ throwOnError: math?.throwOnError ?? true,
40
+ strict: math?.strict ?? false,
41
+ };
42
+ }
43
+
44
+ /** Languages that should be rendered as math instead of syntax-highlighted. */
45
+ export const MATH_LANGS = new Set(['math', 'latex', 'tex']);
46
+
47
+ /** Cache for the dynamically-imported katex module. */
48
+ let _katexModule: typeof import('katex') | null | undefined;
49
+
50
+ /**
51
+ * Try to load the katex module. Returns null if katex is not installed.
52
+ * Caches the result so subsequent calls don't re-import.
53
+ */
54
+ async function getKatex(): Promise<typeof import('katex') | null> {
55
+ if (_katexModule !== undefined) return _katexModule;
56
+ try {
57
+ _katexModule = await import('katex');
58
+ return _katexModule;
59
+ } catch {
60
+ _katexModule = null;
61
+ return null;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Check if a language identifier should be treated as math.
67
+ */
68
+ export function isMathLanguage(lang: string): boolean {
69
+ return MATH_LANGS.has(lang.toLowerCase());
70
+ }
71
+
72
+ /**
73
+ * Render a LaTeX string to HTML via KaTeX.
74
+ *
75
+ * @param latex The LaTeX source string
76
+ * @param displayMode true for block math ($$...$$), false for inline ($...$)
77
+ * @param options Resolved math options
78
+ * @returns { html: string, isKatex: boolean } — if katex is available,
79
+ * html is the KaTeX-rendered HTML; otherwise it's the LaTeX source in a
80
+ * `<code>` element, and isKatex is false.
81
+ */
82
+ export async function renderMath(
83
+ latex: string,
84
+ displayMode: boolean,
85
+ options: ResolvedMathOptions
86
+ ): Promise<{ html: string; isKatex: boolean }> {
87
+ if (options.engine === 'none') {
88
+ return { html: escapeHtml(latex), isKatex: false };
89
+ }
90
+
91
+ const katex = await getKatex();
92
+ if (!katex) {
93
+ // KaTeX not installed — fall back to plain text
94
+ return { html: escapeHtml(latex), isKatex: false };
95
+ }
96
+
97
+ try {
98
+ const html = katex.renderToString(latex, {
99
+ displayMode,
100
+ throwOnError: options.throwOnError,
101
+ strict: options.strict,
102
+ output: 'html',
103
+ });
104
+ return { html, isKatex: true };
105
+ } catch {
106
+ // KaTeX rendering failed — fall back to plain text
107
+ return { html: escapeHtml(latex), isKatex: false };
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Escape HTML special characters in a string (for the fallback path).
113
+ */
114
+ function escapeHtml(s: string): string {
115
+ return s
116
+ .replace(/&/g, '&amp;')
117
+ .replace(/</g, '&lt;')
118
+ .replace(/>/g, '&gt;')
119
+ .replace(/"/g, '&quot;');
120
+ }
121
+
122
+ /**
123
+ * Regex to find inline `$...$` math in text.
124
+ * Matches `$` followed by non-$ content followed by `$`.
125
+ * Does NOT match `$$` (which is block math) or escaped `\$`.
126
+ *
127
+ * Examples:
128
+ * "$x^2$" → match (inline math)
129
+ * "$$x^2$$" → NO match (block math)
130
+ * "cost is \$5" → NO match (escaped dollar sign)
131
+ * "a $ b $ c" → match "$ b $" (ambiguous, but we match it)
132
+ */
133
+ export const INLINE_MATH_REGEX = /(^|[^\\$])\$(?!\$)([^$]+?)\$(?!\$)/g;
134
+
135
+ /**
136
+ * Regex to find block `$$...$$` math in text.
137
+ * Matches `$$` followed by content followed by `$$`.
138
+ */
139
+ export const BLOCK_MATH_REGEX = /\$\$([\s\S]+?)\$\$/g;
140
+
141
+ /**
142
+ * Process a text string, replacing inline `$...$` and block `$$...$$`
143
+ * with rendered math HTML.
144
+ *
145
+ * @param text The input text
146
+ * @param options Resolved math options
147
+ * @returns Array of { type: 'text' | 'inline-math' | 'block-math', content: string }
148
+ * segments. The caller is responsible for converting these to HAST nodes.
149
+ */
150
+ export async function processMathInText(
151
+ text: string,
152
+ options: ResolvedMathOptions
153
+ ): Promise<Array<{ type: 'text' | 'inline-math' | 'block-math'; content: string; html?: string }>> {
154
+ if (options.engine === 'none' || (!options.inline && !options.block)) {
155
+ return [{ type: 'text', content: text }];
156
+ }
157
+
158
+ const segments: Array<{ type: 'text' | 'inline-math' | 'block-math'; content: string; html?: string }> = [];
159
+ let remaining = text;
160
+
161
+ // First, extract block math ($$...$$)
162
+ if (options.block) {
163
+ let lastIndex = 0;
164
+ let match: RegExpExecArray | null;
165
+ const blockRegex = new RegExp(BLOCK_MATH_REGEX);
166
+ while ((match = blockRegex.exec(remaining)) !== null) {
167
+ // Text before the block math
168
+ if (match.index > lastIndex) {
169
+ const beforeText = remaining.slice(lastIndex, match.index);
170
+ // Process inline math in the text before
171
+ if (options.inline) {
172
+ const inlineSegments = await processInlineMath(beforeText, options);
173
+ segments.push(...inlineSegments);
174
+ } else {
175
+ segments.push({ type: 'text', content: beforeText });
176
+ }
177
+ }
178
+ // The block math
179
+ const latex = match[1].trim();
180
+ const { html, isKatex } = await renderMath(latex, true, options);
181
+ segments.push({ type: 'block-math', content: latex, html });
182
+ lastIndex = match.index + match[0].length;
183
+ }
184
+ // Remaining text after the last block math
185
+ if (lastIndex < remaining.length) {
186
+ const afterText = remaining.slice(lastIndex);
187
+ if (options.inline) {
188
+ const inlineSegments = await processInlineMath(afterText, options);
189
+ segments.push(...inlineSegments);
190
+ } else {
191
+ segments.push({ type: 'text', content: afterText });
192
+ }
193
+ }
194
+ } else if (options.inline) {
195
+ // Only inline math
196
+ const inlineSegments = await processInlineMath(remaining, options);
197
+ segments.push(...inlineSegments);
198
+ } else {
199
+ segments.push({ type: 'text', content: remaining });
200
+ }
201
+
202
+ return segments;
203
+ }
204
+
205
+ /**
206
+ * Process inline `$...$` math in a text string.
207
+ * Returns segments of text and inline-math.
208
+ */
209
+ async function processInlineMath(
210
+ text: string,
211
+ options: ResolvedMathOptions
212
+ ): Promise<Array<{ type: 'text' | 'inline-math'; content: string; html?: string }>> {
213
+ const segments: Array<{ type: 'text' | 'inline-math'; content: string; html?: string }> = [];
214
+ const regex = new RegExp(INLINE_MATH_REGEX);
215
+ let lastIndex = 0;
216
+ let match: RegExpExecArray | null;
217
+
218
+ while ((match = regex.exec(text)) !== null) {
219
+ // Text before the match (including the prefix character)
220
+ const prefix = match[1] || '';
221
+ const matchStart = match.index + prefix.length;
222
+ if (matchStart > lastIndex) {
223
+ segments.push({ type: 'text', content: text.slice(lastIndex, match.index) + prefix });
224
+ } else if (prefix) {
225
+ segments.push({ type: 'text', content: prefix });
226
+ }
227
+
228
+ const latex = match[2].trim();
229
+ const { html } = await renderMath(latex, false, options);
230
+ segments.push({ type: 'inline-math', content: latex, html });
231
+ lastIndex = regex.lastIndex;
232
+ }
233
+
234
+ if (lastIndex < text.length) {
235
+ segments.push({ type: 'text', content: text.slice(lastIndex) });
236
+ }
237
+
238
+ return segments.length > 0 ? segments : [{ type: 'text', content: text }];
239
+ }
240
+
241
+ /**
242
+ * Get the KaTeX CSS path for injection.
243
+ * Returns the path to `katex/dist/katex.min.css` relative to the project.
244
+ */
245
+ export function getKatexCssPath(): string {
246
+ return 'katex/dist/katex.min.css';
247
+ }
248
+
249
+ /**
250
+ * Try to read the KaTeX CSS content from the filesystem.
251
+ * Returns null if katex is not installed or the CSS file can't be read.
252
+ */
253
+ export async function tryReadKatexCss(): Promise<string | null> {
254
+ try {
255
+ const katex = await getKatex();
256
+ if (!katex) return null;
257
+ // katex module path — try to read the CSS
258
+ const { readFileSync } = await import('node:fs');
259
+ const { createRequire } = await import('node:module');
260
+ const require = createRequire(import.meta.url);
261
+ const katexPath = require.resolve('katex');
262
+ const katexDir = katexPath.replace(/[/\\]katex\.(mjs|js|cjs)$/, '');
263
+ const cssPath = katexDir + '/katex.min.css';
264
+ return readFileSync(cssPath, 'utf8');
265
+ } catch {
266
+ return null;
267
+ }
268
+ }
package/src/meta.ts CHANGED
@@ -57,6 +57,9 @@ export function parseMeta(meta: string | undefined): ParsedMeta {
57
57
  wordHighlights: [],
58
58
  lineNumbersStart: null,
59
59
  collapseRanges: [],
60
+ author: null,
61
+ year: null,
62
+ source: null,
60
63
  flags: {
61
64
  wrap: null,
62
65
  lineNumbers: null,
@@ -85,6 +88,20 @@ export function parseMeta(meta: string | undefined): ParsedMeta {
85
88
  continue;
86
89
  }
87
90
 
91
+ // v2.2.0: Attribution metadata — author="...", year="...", source="..."
92
+ if (tok.startsWith('author=')) {
93
+ result.author = unquote(tok.slice('author='.length));
94
+ continue;
95
+ }
96
+ if (tok.startsWith('year=')) {
97
+ result.year = unquote(tok.slice('year='.length));
98
+ continue;
99
+ }
100
+ if (tok.startsWith('source=')) {
101
+ result.source = unquote(tok.slice('source='.length));
102
+ continue;
103
+ }
104
+
88
105
  // collapse="5-12,20-30" — per-line collapsible sections
89
106
  if (tok.startsWith('collapse=')) {
90
107
  const val = unquote(tok.slice('collapse='.length));
@@ -182,8 +199,8 @@ function tokenize(input: string): string[] {
182
199
  while (i < input.length && /\s/.test(input[i])) i++;
183
200
  if (i >= input.length) break;
184
201
 
185
- // Quoted key="value" or key='value' (title=, caption=, collapse=)
186
- if (/^(?:title|caption|collapse)=$/.test(input.slice(i).match(/^[a-z]+=/i)?.[0] ?? '')) {
202
+ // Quoted key="value" or key='value' (title=, caption=, collapse=, author=, year=, source=)
203
+ if (/^(?:title|caption|collapse|author|year|source)=$/.test(input.slice(i).match(/^[a-z]+=/i)?.[0] ?? '')) {
187
204
  const eq = input.indexOf('=', i);
188
205
  let j = eq + 1;
189
206
  const quote = input[j];
package/src/shiki.ts CHANGED
@@ -18,6 +18,7 @@ import { fromHtml } from 'hast-util-from-html';
18
18
  import { visit } from 'unist-util-visit';
19
19
  import type { PerfectCodeOptions } from './types.js';
20
20
  import { computeThemeAwareDefaults } from './color-utils.js';
21
+ import { isMathLanguage, renderMath, resolveMathOptions, MATH_LANGS } from './math.js';
21
22
  import {
22
23
  transformerNotationDiff,
23
24
  transformerNotationFocus,
@@ -447,6 +448,50 @@ export async function runShikiOnRawBlocks(
447
448
 
448
449
  if (targets.length === 0) return;
449
450
 
451
+ // v2.1.0: Handle math language blocks — render via KaTeX instead of Shiki.
452
+ const mathOpts = resolveMathOptions(opts.math as Record<string, unknown> | undefined);
453
+ if (mathOpts.engine === 'katex' && mathOpts.block) {
454
+ const mathTargets: Element[] = [];
455
+ const codeTargets: Element[] = [];
456
+ for (const pre of targets) {
457
+ const code = pre.children.find(
458
+ (c): c is Element => c.type === 'element' && c.tagName === 'code'
459
+ );
460
+ if (!code) { codeTargets.push(pre); continue; }
461
+ const cls = (code.properties?.className as string[] | undefined) ?? [];
462
+ const langClass = cls.find((c) => c.startsWith('language-'));
463
+ const lang = langClass ? langClass.replace('language-', '') : '';
464
+ if (isMathLanguage(lang)) {
465
+ mathTargets.push(pre);
466
+ } else {
467
+ codeTargets.push(pre);
468
+ }
469
+ }
470
+
471
+ // Render math blocks via KaTeX
472
+ for (const pre of mathTargets) {
473
+ const code = pre.children.find(
474
+ (c): c is Element => c.type === 'element' && c.tagName === 'code'
475
+ );
476
+ if (!code) continue;
477
+ const text = extractText(code).replace(/\r\n?/g, '\n').trim();
478
+ const { html, isKatex } = await renderMath(text, true, mathOpts);
479
+ // Replace the <pre> with rendered math
480
+ const mathDiv: Element = {
481
+ type: 'element',
482
+ tagName: 'div',
483
+ properties: { className: ['pcb__math', isKatex ? 'pcb__math--katex' : 'pcb__math--fallback'] },
484
+ children: [{ type: 'text', value: html }],
485
+ };
486
+ Object.assign(pre, mathDiv);
487
+ }
488
+
489
+ // Only process non-math targets with Shiki
490
+ targets.splice(0, targets.length, ...codeTargets);
491
+ }
492
+
493
+ if (targets.length === 0) return;
494
+
450
495
  // Build theme keys — supports single (string), dual ({light,dark}), and
451
496
  // multi-theme (Record<string,string> with 3+ entries) for advanced use cases.
452
497
  const themeSpec = opts.shiki.theme;
@@ -473,8 +518,16 @@ export async function runShikiOnRawBlocks(
473
518
  // (javascript, typescript, python). This matches Shiki's own case-
474
519
  // insensitive behavior in codeToHast/codeToHtml, and matches what every
475
520
  // other CommonMark renderer accepts. See issue #12.
521
+ //
522
+ // v2.1.0: When shiki.lazy is true, don't preload the user's `langs` list —
523
+ // only load languages that are actually in this document. This avoids
524
+ // loading grammars for pages that only use 1-2 languages out of a
525
+ // configured set of 20+. The lazy-load path below will load them on demand.
526
+ const isLazy = (opts.shiki as { lazy?: boolean }).lazy === true;
476
527
  const langSet = new Set<string>(
477
- (opts.shiki.langs ?? []).map((l) => l.toLowerCase())
528
+ isLazy
529
+ ? [] // Lazy: don't preload anything — document-specific langs added below
530
+ : (opts.shiki.langs ?? []).map((l) => l.toLowerCase())
478
531
  );
479
532
  for (const pre of targets) {
480
533
  const code = pre.children.find(
package/src/styles.css CHANGED
@@ -654,3 +654,71 @@
654
654
  html.no-js .pcb__copy {
655
655
  display: none !important;
656
656
  }
657
+
658
+ /* ============================================================
659
+ v2.2.0: Phase 3 — Split diff, annotations, attribution
660
+ ============================================================ */
661
+
662
+ /* ---------- Split diff view ---------- */
663
+ :where(.pcb--split-diff) .pcb__body {
664
+ display: grid;
665
+ grid-template-columns: 1fr 1fr;
666
+ }
667
+ :where(.pcb--split-diff) .pcb__body > pre {
668
+ border-right: 1px solid var(--pcb-border);
669
+ }
670
+ :where(.pcb--split-diff) .pcb__body > pre:last-child) {
671
+ border-right: none;
672
+ }
673
+ @media (max-width: 768px) {
674
+ :where(.pcb--split-diff) .pcb__body {
675
+ grid-template-columns: 1fr;
676
+ }
677
+ :where(.pcb--split-diff) .pcb__body > pre {
678
+ border-right: none;
679
+ border-bottom: 1px solid var(--pcb-border);
680
+ }
681
+ }
682
+
683
+ /* ---------- Line annotations ---------- */
684
+ :where(.pcb__ann) {
685
+ display: none; /* hidden by default, shown when .pcb--annotations */
686
+ }
687
+ :where(.pcb--annotations) .pcb__line[data-ann] {
688
+ display: grid;
689
+ grid-template-columns: auto 1fr auto;
690
+ align-items: baseline;
691
+ }
692
+ :where(.pcb--annotations) .pcb__ann) {
693
+ display: block;
694
+ padding-left: 1rem;
695
+ color: var(--pcb-text-muted);
696
+ font-size: 0.8125em;
697
+ font-style: italic;
698
+ white-space: normal;
699
+ border-left: 2px solid var(--pcb-border);
700
+ user-select: none;
701
+ }
702
+ @media (max-width: 768px) {
703
+ :where(.pcb--annotations) .pcb__line[data-ann]) {
704
+ grid-template-columns: auto 1fr;
705
+ }
706
+ :where(.pcb--annotations) .pcb__ann) {
707
+ grid-column: 1 / -1;
708
+ padding-left: 0;
709
+ border-left: none;
710
+ border-top: 1px dashed var(--pcb-border);
711
+ margin-top: 0.25rem;
712
+ }
713
+ }
714
+
715
+ /* ---------- Attribution footer ---------- */
716
+ :where(.pcb__attribution) {
717
+ padding: 0.5rem 1rem;
718
+ font-family: var(--pcb-bar-font);
719
+ font-size: var(--pcb-bar-font-size);
720
+ color: var(--pcb-caption-color);
721
+ background: var(--pcb-caption-bg);
722
+ border-top: 1px solid var(--pcb-border);
723
+ font-style: italic;
724
+ }
@@ -247,6 +247,12 @@ export function rehypePerfectCodeBlocks(userOptions: PerfectCodeOptions = {}) {
247
247
  tokens: undefined as unknown as NonNullable<PerfectCodeOptions['tokens']>,
248
248
  darkMode: undefined as unknown as NonNullable<PerfectCodeOptions['darkMode']>,
249
249
  scope: undefined as unknown as string,
250
+ math: undefined as unknown as NonNullable<PerfectCodeOptions['math']>,
251
+ devWarnings: process.env.NODE_ENV !== 'production',
252
+ // v2.2.0: Phase 3
253
+ diffMode: 'unified' as const,
254
+ annotations: false,
255
+ attribution: false,
250
256
  inline: false,
251
257
  ...rest,
252
258
  };
@@ -637,6 +643,31 @@ async function transformPre(
637
643
  figureChildren.push(cap);
638
644
  }
639
645
 
646
+ // v2.2.0: Attribution footer — render author/year/source as a footer below the code block.
647
+ if ((opts as { attribution?: boolean }).attribution && (meta.author || meta.year || meta.source)) {
648
+ const parts: string[] = [];
649
+ if (meta.author) parts.push(meta.author);
650
+ if (meta.year) parts.push(`(${meta.year})`);
651
+ if (meta.source) parts.push(`. ${meta.source}.`);
652
+ else if (meta.author || meta.year) parts.push('.');
653
+ const attrText = parts.join(' ').trim();
654
+ if (attrText) {
655
+ figureChildren.push(
656
+ h('figcaption', { className: ['pcb__attribution'] }, [hText(attrText)])
657
+ );
658
+ }
659
+ }
660
+
661
+ // v2.2.0: Add pcb--split-diff class when diffMode is 'split'
662
+ if ((opts as { diffMode?: string }).diffMode === 'split') {
663
+ figClasses.push('pcb--split-diff');
664
+ }
665
+
666
+ // v2.2.0: Add pcb--annotations class when annotations are enabled
667
+ if ((opts as { annotations?: boolean }).annotations) {
668
+ figClasses.push('pcb--annotations');
669
+ }
670
+
640
671
  return h('figure', { className: figClasses }, figureChildren);
641
672
  }
642
673
 
@@ -939,6 +970,19 @@ function toLineSpans(
939
970
  // Map word-highlight spans inside this line.
940
971
  const mappedChildren = mapWordHighlights(line.children);
941
972
 
973
+ // v2.2.0: Parse and strip // [!ann: "text"] annotation notation.
974
+ let annotationText: string | null = null;
975
+ if ((opts as { annotations?: boolean }).annotations) {
976
+ const lineText = extractLineText(line);
977
+ const annMatch = lineText.match(/\[!ann:\s*"([^"]*)"\s*\]/);
978
+ if (annMatch) {
979
+ annotationText = annMatch[1];
980
+ // Strip the annotation from the line's text content
981
+ // (replace in all text nodes within the line)
982
+ stripAnnotationFromLine(line, annMatch[0]);
983
+ }
984
+ }
985
+
942
986
  // The line wrapper itself (the Shiki <span class="line ...">) becomes the
943
987
  // content of .pcb__code. Strip its classes — we've already mapped them
944
988
  // onto the outer .pcb__line wrapper, so they shouldn't also appear here.
@@ -951,7 +995,7 @@ function toLineSpans(
951
995
  children: mappedChildren,
952
996
  };
953
997
 
954
- // Build the row: [gutter-cell?, code-cell]
998
+ // Build the row: [gutter-cell?, code-cell, annotation?]
955
999
  const lineChildren: ElementContent[] = [];
956
1000
  if (resolved.lineNumbers) {
957
1001
  lineChildren.push(
@@ -962,7 +1006,18 @@ function toLineSpans(
962
1006
  h('span', { className: ['pcb__code'] }, [innerWrapper])
963
1007
  );
964
1008
 
965
- return h('span', { className: [...classes] }, lineChildren);
1009
+ // v2.2.0: Add annotation cell if this line has an annotation
1010
+ if (annotationText !== null) {
1011
+ lineChildren.push(
1012
+ h('span', { className: ['pcb__ann'], 'dataAnn': annotationText }, [hText(annotationText)])
1013
+ );
1014
+ }
1015
+
1016
+ const lineProps: Record<string, unknown> = { className: [...classes] };
1017
+ if (annotationText !== null) {
1018
+ lineProps['dataAnn'] = annotationText;
1019
+ }
1020
+ return h('span', lineProps, lineChildren);
966
1021
  });
967
1022
  }
968
1023
 
@@ -1151,6 +1206,18 @@ function hText(value: string): Text {
1151
1206
  return { type: 'text', value };
1152
1207
  }
1153
1208
 
1209
+ /** v2.2.0: Strip an annotation notation from all text nodes in a line element. */
1210
+ function stripAnnotationFromLine(line: Element, annotation: string): void {
1211
+ const walk = (node: ElementContent): void => {
1212
+ if (node.type === 'text') {
1213
+ node.value = node.value.replace(annotation, '');
1214
+ } else if (node.type === 'element') {
1215
+ for (const child of node.children) walk(child);
1216
+ }
1217
+ };
1218
+ for (const child of line.children) walk(child);
1219
+ }
1220
+
1154
1221
  /* ---------- Pattern 5: word-level diff (selective adoption from expressive-code) ---------- */
1155
1222
 
1156
1223
  /**