@dr-ishaan/rehype-perfect-code-blocks 1.2.2 → 1.3.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 +58 -0
- package/dist/color-utils.d.ts +77 -0
- package/dist/color-utils.d.ts.map +1 -0
- package/dist/color-utils.js +189 -0
- package/dist/color-utils.js.map +1 -0
- package/dist/copy-script.d.ts +10 -3
- package/dist/copy-script.d.ts.map +1 -1
- package/dist/copy-script.js +75 -16
- package/dist/copy-script.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/shiki.d.ts +20 -0
- package/dist/shiki.d.ts.map +1 -1
- package/dist/shiki.js +116 -4
- package/dist/shiki.js.map +1 -1
- package/dist/transformer.d.ts.map +1 -1
- package/dist/transformer.js +108 -1
- package/dist/transformer.js.map +1 -1
- package/dist/types.d.ts +12 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/word-diff.d.ts +47 -0
- package/dist/word-diff.d.ts.map +1 -0
- package/dist/word-diff.js +138 -0
- package/dist/word-diff.js.map +1 -0
- package/package.json +2 -2
- package/src/color-utils.ts +214 -0
- package/src/copy-script.ts +75 -16
- package/src/index.ts +7 -1
- package/src/shiki.ts +157 -10
- package/src/transformer.ts +109 -1
- package/src/types.ts +12 -0
- package/src/word-diff.ts +143 -0
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
|
-
|
|
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
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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/transformer.ts
CHANGED
|
@@ -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(
|
|
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/word-diff.ts
ADDED
|
@@ -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
|
+
}
|