@dr-ishaan/rehype-perfect-code-blocks 1.2.1 → 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 +83 -0
- package/LICENSE +0 -0
- package/README.md +0 -0
- package/dist/astro.d.ts +0 -0
- package/dist/astro.d.ts.map +0 -0
- package/dist/astro.js +0 -0
- package/dist/astro.js.map +0 -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/meta.d.ts +0 -0
- package/dist/meta.d.ts.map +1 -1
- package/dist/meta.js +31 -1
- package/dist/meta.js.map +1 -1
- package/dist/remark.d.ts +0 -0
- package/dist/remark.d.ts.map +0 -0
- package/dist/remark.js +0 -0
- package/dist/remark.js.map +0 -0
- 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/styles.css +0 -0
- package/dist/transformer.d.ts +0 -0
- 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/types.js +0 -0
- package/dist/types.js.map +0 -0
- 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/astro.ts +0 -0
- package/src/color-utils.ts +214 -0
- package/src/copy-script.ts +75 -16
- package/src/index.ts +7 -1
- package/src/meta.ts +33 -1
- package/src/remark.ts +0 -0
- package/src/shiki.ts +157 -10
- package/src/styles.css +0 -0
- package/src/transformer.ts +109 -1
- package/src/types.ts +12 -0
- package/src/vite-raw.d.ts +0 -0
- package/src/word-diff.ts +143 -0
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/vite-raw.d.ts
CHANGED
|
File without changes
|
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
|
+
}
|