@dr-ishaan/rehype-perfect-code-blocks 1.2.2 → 1.3.2

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 (57) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/LICENSE +0 -0
  3. package/README.md +225 -13
  4. package/dist/astro.d.ts +0 -0
  5. package/dist/astro.d.ts.map +1 -1
  6. package/dist/astro.js +9 -2
  7. package/dist/astro.js.map +1 -1
  8. package/dist/color-utils.d.ts +77 -0
  9. package/dist/color-utils.d.ts.map +1 -0
  10. package/dist/color-utils.js +189 -0
  11. package/dist/color-utils.js.map +1 -0
  12. package/dist/copy-script.d.ts +10 -3
  13. package/dist/copy-script.d.ts.map +1 -1
  14. package/dist/copy-script.js +108 -16
  15. package/dist/copy-script.js.map +1 -1
  16. package/dist/index.d.ts +6 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +5 -1
  19. package/dist/index.js.map +1 -1
  20. package/dist/meta.d.ts +0 -0
  21. package/dist/meta.d.ts.map +0 -0
  22. package/dist/meta.js +0 -0
  23. package/dist/meta.js.map +0 -0
  24. package/dist/remark.d.ts +0 -0
  25. package/dist/remark.d.ts.map +0 -0
  26. package/dist/remark.js +0 -0
  27. package/dist/remark.js.map +0 -0
  28. package/dist/shiki.d.ts +20 -0
  29. package/dist/shiki.d.ts.map +1 -1
  30. package/dist/shiki.js +116 -4
  31. package/dist/shiki.js.map +1 -1
  32. package/dist/styles.css +0 -0
  33. package/dist/transformer.d.ts +0 -0
  34. package/dist/transformer.d.ts.map +1 -1
  35. package/dist/transformer.js +108 -1
  36. package/dist/transformer.js.map +1 -1
  37. package/dist/types.d.ts +12 -0
  38. package/dist/types.d.ts.map +1 -1
  39. package/dist/types.js +0 -0
  40. package/dist/types.js.map +0 -0
  41. package/dist/word-diff.d.ts +47 -0
  42. package/dist/word-diff.d.ts.map +1 -0
  43. package/dist/word-diff.js +138 -0
  44. package/dist/word-diff.js.map +1 -0
  45. package/package.json +2 -2
  46. package/src/astro.ts +9 -2
  47. package/src/color-utils.ts +214 -0
  48. package/src/copy-script.ts +108 -16
  49. package/src/index.ts +7 -1
  50. package/src/meta.ts +0 -0
  51. package/src/remark.ts +0 -0
  52. package/src/shiki.ts +157 -10
  53. package/src/styles.css +0 -0
  54. package/src/transformer.ts +109 -1
  55. package/src/types.ts +12 -0
  56. package/src/vite-raw.d.ts +0 -0
  57. package/src/word-diff.ts +143 -0
package/src/index.ts CHANGED
@@ -20,11 +20,16 @@
20
20
  import type { Plugin } from 'unified';
21
21
  import type { Root } from 'hast';
22
22
  import { rehypePerfectCodeBlocks as transformer } from './transformer.js';
23
- import { runShikiOnRawBlocks } from './shiki.js';
23
+ import { runShikiOnRawBlocks, disposeHighlighter, runHighlighterTask } from './shiki.js';
24
24
  import { remarkPreserveCodeMeta } from './remark.js';
25
+ import { wordDiff, hasChanges } from './word-diff.js';
26
+ import type { DiffToken } from './word-diff.js';
25
27
  import type { PerfectCodeOptions } from './types.js';
26
28
 
27
29
  export { remarkPreserveCodeMeta };
30
+ export { disposeHighlighter, runHighlighterTask };
31
+ export { wordDiff, hasChanges };
32
+ export type { DiffToken };
28
33
 
29
34
  export const rehypePerfectCodeBlocks: Plugin<[PerfectCodeOptions?], Root> =
30
35
  (options = {}) => {
@@ -90,6 +95,7 @@ function resolveDefaults(opts: PerfectCodeOptions): Required<PerfectCodeOptions>
90
95
  lineNumbersStart: opts.lineNumbersStart ?? 1,
91
96
  highlight: opts.highlight ?? true,
92
97
  diff: opts.diff ?? true,
98
+ wordDiff: opts.wordDiff ?? false,
93
99
  focus: opts.focus ?? true,
94
100
  errorLevels: opts.errorLevels ?? true,
95
101
  wrap: opts.wrap ?? false,
package/src/meta.ts CHANGED
File without changes
package/src/remark.ts CHANGED
File without changes
package/src/shiki.ts CHANGED
@@ -17,6 +17,7 @@ import type { Element, Root } from 'hast';
17
17
  import { fromHtml } from 'hast-util-from-html';
18
18
  import { visit } from 'unist-util-visit';
19
19
  import type { PerfectCodeOptions } from './types.js';
20
+ import { computeThemeAwareDefaults } from './color-utils.js';
20
21
  import {
21
22
  transformerNotationDiff,
22
23
  transformerNotationFocus,
@@ -66,6 +67,58 @@ type ShikiHighlighter = {
66
67
 
67
68
  const highlighterCache = new Map<string, Promise<ShikiHighlighter>>();
68
69
 
70
+ // ───────────────────────────────────────────────────────────────────────────
71
+ // Pattern 1 (adopted from expressive-code): Mutually exclusive highlighter
72
+ // task queue.
73
+ //
74
+ // All highlighter operations (createHighlighter, loadLanguage, loadTheme,
75
+ // codeToHast, codeToHtml) are wrapped in `runHighlighterTask(() => ...)`.
76
+ // This serializes them globally, preventing race conditions in parallel
77
+ // static-site builds where multiple unified pipelines share the same
78
+ // module-level highlighter cache.
79
+ //
80
+ // Without this queue, if pipeline A calls `loadLanguage('ts')` and pipeline
81
+ // B calls `codeToHast(code, { lang: 'ts' })` on the same tick, B may run
82
+ // before A's load completes and fall back to plaintext — the "issue #13"
83
+ // class of bug. The queue makes all operations globally sequential.
84
+ //
85
+ // Tradeoff: slight throughput reduction in parallel builds; correctness >
86
+ // throughput for syntax highlighting.
87
+ // ───────────────────────────────────────────────────────────────────────────
88
+
89
+ type QueueTask = { taskFn: () => Promise<unknown>; resolve: (v: unknown) => void; reject: (e: unknown) => void };
90
+ const taskQueue: QueueTask[] = [];
91
+ let processingQueue = false;
92
+
93
+ function processQueue(): void {
94
+ const next = taskQueue.shift();
95
+ if (!next) {
96
+ processingQueue = false;
97
+ return;
98
+ }
99
+ Promise.resolve()
100
+ .then(() => next.taskFn())
101
+ .then(
102
+ (result) => { next.resolve(result); processQueue(); },
103
+ (err) => { next.reject(err); processQueue(); }
104
+ );
105
+ }
106
+
107
+ /**
108
+ * Run a task function inside the mutually exclusive highlighter queue.
109
+ * All calls are serialized globally — the next task starts only after the
110
+ * current one resolves or rejects.
111
+ */
112
+ export function runHighlighterTask<T>(taskFn: () => Promise<T>): Promise<T> {
113
+ return new Promise<T>((resolve, reject) => {
114
+ taskQueue.push({ taskFn: taskFn as () => Promise<unknown>, resolve: resolve as (v: unknown) => void, reject });
115
+ if (!processingQueue) {
116
+ processingQueue = true;
117
+ processQueue();
118
+ }
119
+ });
120
+ }
121
+
69
122
  async function getHighlighter(
70
123
  themeKeys: string[],
71
124
  langs: string[],
@@ -79,7 +132,9 @@ async function getHighlighter(
79
132
  const cacheKey = `${themeKeys.join(',')}|${[...safeLangs].sort().join(',')}|${regexEngine ?? 'onig'}`;
80
133
  let promise = highlighterCache.get(cacheKey);
81
134
  if (!promise) {
82
- promise = (async () => {
135
+ // Wrap the highlighter creation in the task queue so concurrent
136
+ // pipeline instances don't race on Shiki's internal singleton state.
137
+ promise = runHighlighterTask(async () => {
83
138
  if (userGetHighlighter) {
84
139
  return (await userGetHighlighter({ themes: themeKeys, langs: safeLangs })) as ShikiHighlighter;
85
140
  }
@@ -100,12 +155,39 @@ async function getHighlighter(
100
155
  }
101
156
  const all = await shiki.createHighlighter(createOpts as unknown as Parameters<typeof shiki.createHighlighter>[0]);
102
157
  return all as unknown as ShikiHighlighter;
103
- })();
158
+ });
104
159
  highlighterCache.set(cacheKey, promise);
105
160
  }
106
161
  return promise;
107
162
  }
108
163
 
164
+ /**
165
+ * Pattern 3 (adopted from VitePress): Dispose all cached highlighters and
166
+ * clear the cache. Call this in long-running dev servers when the theme
167
+ * changes, or during cleanup of a build pipeline, to release the WASM
168
+ * engine + loaded grammars + theme cache held by Shiki.
169
+ *
170
+ * After calling this, the next render will create a fresh highlighter.
171
+ *
172
+ * @example
173
+ * // In a Vite dev server shutdown hook:
174
+ * import { disposeHighlighter } from '@dr-ishaan/rehype-perfect-code-blocks';
175
+ * server.http2.close(() => disposeHighlighter());
176
+ */
177
+ export function disposeHighlighter(): void {
178
+ for (const promise of highlighterCache.values()) {
179
+ // The promise may still be pending; if so, attach a dispose-on-resolve.
180
+ promise.then(
181
+ (h) => {
182
+ const maybeDisposable = h as unknown as { dispose?: () => void };
183
+ if (typeof maybeDisposable.dispose === 'function') maybeDisposable.dispose();
184
+ },
185
+ () => { /* ignore — failed highlighters are already gone */ }
186
+ );
187
+ }
188
+ highlighterCache.clear();
189
+ }
190
+
109
191
  /** Filter out languages that aren't bundled with Shiki (avoids sync throws). */
110
192
  function filterBundledLangs(langs: string[]): string[] {
111
193
  // Always keep plaintext variants (special — don't require a bundle).
@@ -421,17 +503,22 @@ export async function runShikiOnRawBlocks(
421
503
  // Lazily load any langs not yet loaded. Shiki's `loadLanguage` throws
422
504
  // synchronously for bundled-but-unknown langs (e.g. typos), so wrap each
423
505
  // call in its own try/catch and use Promise.allSettled to swallow rejects.
506
+ //
507
+ // Wrapped in `runHighlighterTask` so concurrent pipeline instances don't
508
+ // race on Shiki's internal language registry. (Pattern 1)
424
509
  const loaded = new Set(highlighter.getLoadedLanguages());
425
510
  const missing = [...langSet].filter((l) => !loaded.has(l));
426
511
  if (missing.length > 0) {
427
- const results = await Promise.allSettled(
428
- missing.map((l) => {
429
- try {
430
- return Promise.resolve(highlighter.loadLanguage(l));
431
- } catch {
432
- return Promise.resolve();
433
- }
434
- })
512
+ const results = await runHighlighterTask(() =>
513
+ Promise.allSettled(
514
+ missing.map((l) => {
515
+ try {
516
+ return Promise.resolve(highlighter.loadLanguage(l));
517
+ } catch {
518
+ return Promise.resolve();
519
+ }
520
+ })
521
+ )
435
522
  );
436
523
  // Log failed language loads (competitor analysis: EC does this, improves DX).
437
524
  const failed: string[] = [];
@@ -642,11 +729,71 @@ export async function runShikiOnRawBlocks(
642
729
  // language-* class and the Shiki lang we actually used.
643
730
  (newCode.properties as Record<string, unknown>).dataLanguage = normalizedRawLang;
644
731
  }
732
+
733
+ // Pattern 2: Apply theme-aware --pcb-* defaults as inline styles on the
734
+ // <pre> element. The static dist/styles.css ships its own defaults, but
735
+ // those are generic; the runtime overrides them here based on the loaded
736
+ // Shiki theme so colors look good with ANY theme out of the box.
737
+ //
738
+ // We compute the defaults once per (theme,lang) combination and cache
739
+ // them on a WeakMap keyed by the highlighter to avoid recomputing per block.
740
+ if (typeof newPre.properties === 'object' && newPre.properties !== null) {
741
+ const themeDefaults = getThemeAwareDefaults(highlighter, themeKeys);
742
+ if (themeDefaults) {
743
+ const existingStyle = (newPre.properties as { style?: string }).style;
744
+ // Prepend our defaults so user-provided inline styles (if any) win.
745
+ (newPre.properties as { style?: string }).style = themeDefaults + (existingStyle ? `;${existingStyle}` : '');
746
+ }
747
+ }
748
+
645
749
  Object.assign(pre, newPre);
646
750
  }
647
751
  }
648
752
  }
649
753
 
754
+ // Cache theme-aware defaults per highlighter instance + theme keys, so we
755
+ // don't recompute them for every code block on the page.
756
+ const themeDefaultsCache = new WeakMap<object, Map<string, string>>();
757
+
758
+ function getThemeAwareDefaults(highlighter: ShikiHighlighter, themeKeys: string[]): string {
759
+ // Use the highlighter object as the WeakMap key.
760
+ const hlKey = highlighter as unknown as object;
761
+ let perHl = themeDefaultsCache.get(hlKey);
762
+ if (!perHl) {
763
+ perHl = new Map();
764
+ themeDefaultsCache.set(hlKey, perHl);
765
+ }
766
+ const cacheKey = themeKeys.slice().sort().join(',');
767
+ let cached = perHl.get(cacheKey);
768
+ if (cached !== undefined) return cached;
769
+
770
+ // Get the theme object from the highlighter.
771
+ // Use the first theme key (typically the dark theme in dual-theme config).
772
+ let theme: unknown = null;
773
+ try {
774
+ // highlighter.getTheme() returns the resolved theme registration.
775
+ const themeName = themeKeys[0];
776
+ const hlAny = highlighter as unknown as { getTheme?: (name: string) => unknown };
777
+ if (themeName && typeof hlAny.getTheme === 'function') {
778
+ theme = hlAny.getTheme(themeName);
779
+ }
780
+ } catch {
781
+ theme = null;
782
+ }
783
+
784
+ let defaults = '';
785
+ if (theme) {
786
+ try {
787
+ defaults = computeThemeAwareDefaults(theme);
788
+ } catch {
789
+ defaults = '';
790
+ }
791
+ }
792
+
793
+ perHl.set(cacheKey, defaults);
794
+ return defaults;
795
+ }
796
+
650
797
  function hasShikiMarker(className: unknown): boolean {
651
798
  if (!className) return false;
652
799
  const arr = Array.isArray(className) ? className : String(className).split(/\s+/);
package/src/styles.css CHANGED
File without changes
@@ -21,6 +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 { wordDiff, hasChanges } from './word-diff.js';
24
25
  import type { PerfectCodeOptions, ResolvedBlock, MagicComment, ParsedMeta } from './types.js';
25
26
 
26
27
  /** Default inline SVG copy icon (16x16 GitHub octicon copy). */
@@ -185,6 +186,7 @@ export function rehypePerfectCodeBlocks(userOptions: PerfectCodeOptions = {}) {
185
186
  lineNumbersStart: 1,
186
187
  highlight: true,
187
188
  diff: true,
189
+ wordDiff: false,
188
190
  focus: true,
189
191
  errorLevels: true,
190
192
  wrap: false,
@@ -443,9 +445,18 @@ async function transformPre(
443
445
  // Filter out trailing empty line (from trailing newline in source).
444
446
  const filteredLines = filterTrailingEmpty(lineSpans);
445
447
 
448
+ // Pattern 5 (selective adoption from expressive-code): word-level diff.
449
+ // When `wordDiff` is enabled and `diff` is also true, scan for adjacent
450
+ // `pcb__line--del` / `pcb__line--add` pairs and wrap the changed words
451
+ // in `<mark class="pcb__word-diff--del">` / `<mark class="pcb__word-diff--add">`
452
+ // elements so readers can see exactly what changed within each diff line.
453
+ const linesForCollapse = opts.wordDiff && opts.diff
454
+ ? applyWordDiff(filteredLines)
455
+ : filteredLines;
456
+
446
457
  // Apply per-line collapsible sections (meta `collapse="5-12,20-30"`).
447
458
  // Wraps matching line ranges in <details><summary>N collapsed lines</summary>...</details>.
448
- const collapsedLines = wrapCollapsedSections(filteredLines, meta, opts, resolved.lineNumbersStart);
459
+ const collapsedLines = wrapCollapsedSections(linesForCollapse, meta, opts, resolved.lineNumbersStart);
449
460
 
450
461
  // Call onVisitLine / onVisitHighlightedLine hooks.
451
462
  filteredLines.forEach((line, i) => {
@@ -498,6 +509,10 @@ async function transformPre(
498
509
  // Build code <pre><code> with line spans.
499
510
  // When keepBackground is true, preserve Shiki's inline `style` (which includes
500
511
  // background-color + color from the theme) on the new <pre>.
512
+ // Pattern 2: Always preserve our theme-aware --pcb-* defaults (set by
513
+ // shiki.ts:getThemeAwareDefaults) even when keepBackground is false —
514
+ // these are NOT Shiki's background/color styles, they're our CSS variable
515
+ // defaults that make the code block legible with any theme.
501
516
  const newCode = h('code', codeDataProps, collapsedLines);
502
517
  const newPreProps: Record<string, unknown> = {};
503
518
  if (preLevelClasses.size > 0) {
@@ -505,6 +520,15 @@ async function transformPre(
505
520
  }
506
521
  if (opts.keepBackground && pre.properties?.style) {
507
522
  newPreProps.style = pre.properties.style;
523
+ } else if (pre.properties?.style) {
524
+ // keepBackground is false — strip Shiki's bg/color inline styles but
525
+ // preserve our --pcb-* defaults (Pattern 2).
526
+ const originalStyle = pre.properties.style as string;
527
+ const pcbVars = originalStyle
528
+ .split(';')
529
+ .filter((part: string) => part.trim().startsWith('--pcb-'))
530
+ .join(';');
531
+ if (pcbVars) newPreProps.style = pcbVars;
508
532
  }
509
533
  // Don't carry over Shiki's tabindex — it causes unwanted focus rings on the
510
534
  // inner <pre>. The figure itself is not focusable; only the copy button is.
@@ -1121,3 +1145,87 @@ function h(tag: string, props: Record<string, unknown> = {}, children: ElementCo
1121
1145
  function hText(value: string): Text {
1122
1146
  return { type: 'text', value };
1123
1147
  }
1148
+
1149
+ /* ---------- Pattern 5: word-level diff (selective adoption from expressive-code) ---------- */
1150
+
1151
+ /**
1152
+ * Extract the plain text content of a line span (for diff comparison).
1153
+ * Walks the line's children and concatenates all text values.
1154
+ */
1155
+ function extractLineText(line: Element): string {
1156
+ const out: string[] = [];
1157
+ const walk = (node: ElementContent): void => {
1158
+ if (node.type === 'text') {
1159
+ out.push(node.value);
1160
+ } else if (node.type === 'element') {
1161
+ for (const child of node.children) walk(child);
1162
+ }
1163
+ };
1164
+ for (const child of line.children) walk(child);
1165
+ return out.join('');
1166
+ }
1167
+
1168
+ /**
1169
+ * Find the `.pcb__code` child of a line span and replace its children
1170
+ * with the given replacement nodes (preserving the `.pcb__code` wrapper).
1171
+ */
1172
+ function replaceCodeChildren(line: Element, newChildren: ElementContent[]): void {
1173
+ const codeChild = line.children.find(
1174
+ (c): c is Element =>
1175
+ c.type === 'element' &&
1176
+ c.tagName === 'span' &&
1177
+ ((c.properties?.className as string[] | undefined) ?? []).includes('pcb__code')
1178
+ );
1179
+ if (codeChild) {
1180
+ codeChild.children = newChildren;
1181
+ }
1182
+ }
1183
+
1184
+ /**
1185
+ * Apply word-level diff highlighting to adjacent `pcb__line--del` / `pcb__line--add`
1186
+ * pairs. For each pair, compute the per-word diff between the del line's text and
1187
+ * the add line's text, then wrap changed words in `<mark>` elements.
1188
+ *
1189
+ * Only adjacent del→add pairs are processed (the common case for unified diffs).
1190
+ * Standalone del or add lines (no adjacent counterpart) are left unchanged.
1191
+ *
1192
+ * This is a post-processing step that runs after `toLineSpans` and before
1193
+ * `wrapCollapsedSections`. It mutates the line spans in place.
1194
+ */
1195
+ function applyWordDiff(lines: Element[]): Element[] {
1196
+ for (let i = 0; i < lines.length - 1; i++) {
1197
+ const cur = lines[i];
1198
+ const next = lines[i + 1];
1199
+ const curClasses = (cur.properties?.className as string[] | undefined) ?? [];
1200
+ const nextClasses = (next.properties?.className as string[] | undefined) ?? [];
1201
+ const curIsDel = curClasses.includes('pcb__line--del');
1202
+ const nextIsAdd = nextClasses.includes('pcb__line--add');
1203
+ if (!curIsDel || !nextIsAdd) continue;
1204
+
1205
+ const oldText = extractLineText(cur);
1206
+ const newText = extractLineText(next);
1207
+ const tokens = wordDiff(oldText, newText);
1208
+ if (!hasChanges(tokens)) continue;
1209
+
1210
+ // Build replacement children for the del line: wrap 'del' tokens in <mark>,
1211
+ // pass 'equal' and 'add' tokens through as plain text (add tokens don't
1212
+ // belong in the del line).
1213
+ const delChildren: ElementContent[] = [];
1214
+ const addChildren: ElementContent[] = [];
1215
+ for (const token of tokens) {
1216
+ if (token.type === 'equal') {
1217
+ delChildren.push(hText(token.text));
1218
+ addChildren.push(hText(token.text));
1219
+ } else if (token.type === 'del') {
1220
+ delChildren.push(h('mark', { className: ['pcb__word-diff', 'pcb__word-diff--del'] }, [hText(token.text)]));
1221
+ // del tokens don't appear in the add line
1222
+ } else if (token.type === 'add') {
1223
+ addChildren.push(h('mark', { className: ['pcb__word-diff', 'pcb__word-diff--add'] }, [hText(token.text)]));
1224
+ // add tokens don't appear in the del line
1225
+ }
1226
+ }
1227
+ replaceCodeChildren(cur, delChildren);
1228
+ replaceCodeChildren(next, addChildren);
1229
+ }
1230
+ return lines;
1231
+ }
package/src/types.ts CHANGED
@@ -51,6 +51,18 @@ export interface PerfectCodeOptions {
51
51
  highlight?: boolean;
52
52
  /** Enable +/- diff line coloring AND // [!code ++] / [!code --] notation. Default: true */
53
53
  diff?: boolean;
54
+ /**
55
+ * Pattern 5 (selective adoption from expressive-code): Enable word-level diff
56
+ * highlighting. When `diff` is also true and a code block contains adjacent
57
+ * `+`/`-` diff lines, the plugin computes the per-word diff between the
58
+ * removed and added lines and wraps changed words in `<mark class="pcb__word-diff--add">`
59
+ * / `<mark class="pcb__word-diff--del">` elements. This makes it easy for
60
+ * readers to see exactly what changed, not just which lines changed.
61
+ *
62
+ * Uses a simple LCS-based word diff algorithm (no external deps).
63
+ * Default: false (opt-in; adds a small per-block cost when diff lines are present).
64
+ */
65
+ wordDiff?: boolean;
54
66
  /** Enable // [!code focus] notation. Default: true */
55
67
  focus?: boolean;
56
68
  /** Enable // [!code error] / [!code warning] notations. Default: true */
package/src/vite-raw.d.ts CHANGED
File without changes
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Word-level diff utility for Pattern 5 (selective adoption from expressive-code).
3
+ *
4
+ * Computes a per-word diff between two lines of code using a simple LCS-based
5
+ * algorithm. Used to highlight the specific words that changed within `+`/`-`
6
+ * diff lines, so readers can see exactly what was added/removed rather than
7
+ * just which lines changed.
8
+ *
9
+ * Algorithm: split each line into tokens (words + whitespace + punctuation),
10
+ * compute the LCS (Longest Common Subsequence) between the two token arrays,
11
+ * then walk both arrays emitting add/remove/equal markers.
12
+ *
13
+ * No external dependencies — this is ~80 lines of self-contained code.
14
+ */
15
+
16
+ /** A diff token: the text content + whether it was added, removed, or unchanged. */
17
+ export interface DiffToken {
18
+ text: string;
19
+ type: 'add' | 'del' | 'equal';
20
+ }
21
+
22
+ /**
23
+ * Split a code line into tokens for diffing. Each token is either:
24
+ * - a run of whitespace
25
+ * - a run of word characters (alphanumeric + underscore)
26
+ * - a single punctuation character
27
+ *
28
+ * This produces reasonable word-level diffs for most code without being
29
+ * overly granular (character-level) or too coarse (line-level).
30
+ */
31
+ function tokenize(line: string): string[] {
32
+ const tokens: string[] = [];
33
+ let i = 0;
34
+ while (i < line.length) {
35
+ const ch = line[i];
36
+ // Whitespace run
37
+ if (/\s/.test(ch)) {
38
+ let j = i + 1;
39
+ while (j < line.length && /\s/.test(line[j])) j++;
40
+ tokens.push(line.slice(i, j));
41
+ i = j;
42
+ continue;
43
+ }
44
+ // Word character run (alphanumeric + underscore + dot for method chains)
45
+ if (/[\w.]/.test(ch)) {
46
+ let j = i + 1;
47
+ while (j < line.length && /[\w.]/.test(line[j])) j++;
48
+ tokens.push(line.slice(i, j));
49
+ i = j;
50
+ continue;
51
+ }
52
+ // Single punctuation character
53
+ tokens.push(ch);
54
+ i++;
55
+ }
56
+ return tokens;
57
+ }
58
+
59
+ /**
60
+ * Compute the LCS table between two token arrays.
61
+ * Returns a 2D array where table[i][j] = length of LCS of a[0..i) and b[0..j).
62
+ */
63
+ function lcsTable(a: string[], b: string[]): number[][] {
64
+ const m = a.length;
65
+ const n = b.length;
66
+ const table: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
67
+ for (let i = 1; i <= m; i++) {
68
+ for (let j = 1; j <= n; j++) {
69
+ if (a[i - 1] === b[j - 1]) {
70
+ table[i][j] = table[i - 1][j - 1] + 1;
71
+ } else {
72
+ table[i][j] = Math.max(table[i - 1][j], table[i][j - 1]);
73
+ }
74
+ }
75
+ }
76
+ return table;
77
+ }
78
+
79
+ /**
80
+ * Compute a word-level diff between two strings.
81
+ * Returns an array of DiffToken entries; concatenating all `.text` values
82
+ * reconstructs the union of both inputs. The `.type` field indicates whether
83
+ * each token was added, removed, or unchanged relative to the other string.
84
+ *
85
+ * @param oldStr The "before" line (typically the `-` line, without the prefix)
86
+ * @param newStr The "after" line (typically the `+` line, without the prefix)
87
+ * @returns Array of diff tokens
88
+ *
89
+ * @example
90
+ * wordDiff('const x = 1', 'const y = 2')
91
+ * // → [
92
+ * // { text: 'const ', type: 'equal' },
93
+ * // { text: 'x', type: 'del' },
94
+ * // { text: 'y', type: 'add' },
95
+ * // { text: ' = ', type: 'equal' },
96
+ * // { text: '1', type: 'del' },
97
+ * // { text: '2', type: 'add' },
98
+ * // ]
99
+ */
100
+ export function wordDiff(oldStr: string, newStr: string): DiffToken[] {
101
+ const a = tokenize(oldStr);
102
+ const b = tokenize(newStr);
103
+ const table = lcsTable(a, b);
104
+
105
+ // Backtrack through the LCS table to emit the diff.
106
+ const result: DiffToken[] = [];
107
+ let i = a.length;
108
+ let j = b.length;
109
+ while (i > 0 || j > 0) {
110
+ if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
111
+ result.push({ text: a[i - 1], type: 'equal' });
112
+ i--;
113
+ j--;
114
+ } else if (j > 0 && (i === 0 || table[i][j - 1] >= table[i - 1][j])) {
115
+ result.push({ text: b[j - 1], type: 'add' });
116
+ j--;
117
+ } else {
118
+ result.push({ text: a[i - 1], type: 'del' });
119
+ i--;
120
+ }
121
+ }
122
+ result.reverse();
123
+
124
+ // Merge consecutive tokens of the same type to reduce output size.
125
+ const merged: DiffToken[] = [];
126
+ for (const token of result) {
127
+ const last = merged[merged.length - 1];
128
+ if (last && last.type === token.type) {
129
+ last.text += token.text;
130
+ } else {
131
+ merged.push({ ...token });
132
+ }
133
+ }
134
+ return merged;
135
+ }
136
+
137
+ /**
138
+ * Check if a diff result has any changes (i.e., at least one add or del token).
139
+ * Used to skip wrapping when the lines are identical.
140
+ */
141
+ export function hasChanges(tokens: DiffToken[]): boolean {
142
+ return tokens.some((t) => t.type !== 'equal');
143
+ }