@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.
Files changed (57) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/LICENSE +0 -0
  3. package/README.md +0 -0
  4. package/dist/astro.d.ts +0 -0
  5. package/dist/astro.d.ts.map +0 -0
  6. package/dist/astro.js +0 -0
  7. package/dist/astro.js.map +0 -0
  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 +75 -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 +1 -1
  22. package/dist/meta.js +31 -1
  23. package/dist/meta.js.map +1 -1
  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 +0 -0
  47. package/src/color-utils.ts +214 -0
  48. package/src/copy-script.ts +75 -16
  49. package/src/index.ts +7 -1
  50. package/src/meta.ts +33 -1
  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
@@ -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
+ }