Diffs is in early active development—APIs are subject to change.
Diffs is a library for rendering code and diffs on the web. This includes both high-level, easy-to-use components, as well as exposing many of the internals if you want to selectively use specific pieces. We've built syntax highlighting on top of Shiki which provides a lot of great theme and language support.
1const std = @import("std");23pub fn main() !void {4 const stdout = std.io.getStdOut().writer();5 try stdout.print("Hi you, {s}!\n", .{"world"});6}1const std = @import("std");23pub fn main() !void {4 const stdout = std.io.getStdOut().writer();5 try stdout.print("Hello there, {s}!\n", .{"zig"});6}
We have an opinionated stance in our architecture: browsers are rather efficient at rendering raw HTML. We lean into this by having all the lower level APIs purely rendering strings (the raw HTML) that are then consumed by higher-order components and utilities. This gives us great performance and flexibility to support popular libraries like React as well as provide great tools if you want to stick to vanilla JavaScript and HTML. The higher-order components render all this out into Shadow DOM and CSS grid layout.
Generally speaking, you're probably going to want to use the higher level components since they provide an easy-to-use API that you can get started with rather quickly. We currently only have components for vanilla JavaScript and React, but will add more if there's demand.
For this overview, we'll talk about the vanilla JavaScript components for now but there are React equivalents for all of these.
Our goal with visualizing diffs was to provide some flexible and approachable
APIs for how you may want to render diffs. For this, we provide a component
called FileDiff.
There are two ways to render diffs with FileDiff:
You can see examples of these approaches below, in both JavaScript and React.
1import {2 type FileContents,3 FileDiff,4} from '@pierre/diffs';5
6// Store file objects in variables rather than inlining them.7// FileDiff uses reference equality to detect changes and skip8// unnecessary re-renders, so keep these references stable.9const oldFile: FileContents = {10 name: 'main.zig',11 contents: `const std = @import("std");12
13pub fn main() !void {14 const stdout = std.io.getStdOut().writer();15 try stdout.print("Hi you, {s}!\\\\n", .{"world"});16}17`,18};19
20const newFile: FileContents = {21 name: 'main.zig',22 contents: `const std = @import("std");23
24pub fn main() !void {25 const stdout = std.io.getStdOut().writer();26 try stdout.print("Hello there, {s}!\\\\n", .{"zig"});27}28`,29};30
31// We automatically detect the language based on the filename32// You can also provide a lang property when instantiating FileDiff.33const fileDiffInstance = new FileDiff({ theme: 'pierre-dark' });34
35// render() is synchronous. Syntax highlighting happens async in the36// background and the diff updates automatically when complete.37fileDiffInstance.render({38 oldFile,39 newFile,40 // where to render the diff into41 containerWrapper: document.body,42});Diffs is published as an npm package. Install Diffs with the package manager of your choice:
1npm install @pierre/diffsThe package provides several entry points for different use cases:
| Package | Description |
|---|---|
@pierre/diffs | Vanilla JS components and utility functions for parsing and rendering diffs |
@pierre/diffs/react | React components for rendering diffs with full interactivity |
@pierre/diffs/ssr | Server-side rendering utilities for pre-rendering diffs with syntax highlighting |
@pierre/diffs/worker | Worker pool utilities for offloading syntax highlighting to background threads |
Before diving into the components, it's helpful to understand the two core data structures used throughout the library.
FileContents represents a single file. Use it when rendering a file with the
<File> component, or pass two of them as oldFile and newFile to diff
components.
1import type { FileContents } from '@pierre/diffs';2
3// FileContents represents a single file4interface FileContents {5 // The filename (used for display and language detection)6 name: string;7
8 // The file's text content9 contents: string;10
11 // Optional: Override the detected language for syntax highlighting12 // See: https://shiki.style/languages13 lang?: SupportedLanguages;14
15 // Optional: Cache key for AST caching in Worker Pool.16 // When provided, rendered AST results are cached and reused.17 // IMPORTANT: The key must change whenever the content, filename18 // or lang changes!19 cacheKey?: string;20}21
22// Example usage23const file: FileContents = {24 name: 'example.tsx',25 contents: 'export function Hello() { return <div>Hello</div>; }',26 cacheKey: 'example-file-v1', // Must change if contents change27};28
29// With explicit language override30const jsonFile: FileContents = {31 name: 'config', // No extension, so we specify lang32 contents: '{ "key": "value" }',33 lang: 'json',34 cacheKey: 'config-file',35};FileDiffMetadata represents the differences between two files. It contains the
hunks (changed regions), line counts, and optionally the full file contents for
expansion.
Tip: You can generate FileDiffMetadata using
parseDiffFromFile (from two file versions) or
parsePatchFiles (from a patch string).
1import type { FileDiffMetadata, Hunk } from '@pierre/diffs';2
3// FileDiffMetadata represents the differences between two files4interface FileDiffMetadata {5 // Current filename6 name: string;7
8 // Previous filename (for renames)9 prevName: string | undefined;10
11 // Optional: Override language for syntax highlighting12 lang?: SupportedLanguages;13
14 // Type of change: 'change' | 'rename-pure' | 'rename-changed' | 'new' | 'deleted'15 type: ChangeTypes;16
17 // Array of diff hunks containing the actual changes18 hunks: Hunk[];19
20 // Line counts for split and unified views21 splitLineCount: number;22 unifiedLineCount: number;23
24 // Full file contents (when generated using parseDiffFromFile,25 // enables expansion around hunks)26 oldLines?: string[];27 newLines?: string[];28
29 // Optional: Cache key for AST caching in Worker Pool.30 // When provided, rendered diff AST results are cached and reused.31 // IMPORTANT: The key must change whenever the diff changes!32 cacheKey?: string;33}34
35// Hunk represents a single changed region in the diff36// Think of it like the sections defined by the '@@' lines in patches37interface Hunk {38 // Addition/deletion counts, parsed out from patch data39 additionCount: number;40 additionStart: number;41 additionLines: number;42 deletionCount: number;43 deletionStart: number;44 deletionLines: number;45
46 // The actual content of the hunk (context and changes)47 hunkContent: (ContextContent | ChangeContent)[];48
49 // Optional context shown in hunk headers (e.g., function name)50 hunkContext: string | undefined;51
52 // Line position information, mostly used internally for 53 // rendering optimizations54 splitLineStart: number;55 splitLineCount: number;56 unifiedLineStart: number;57 unifiedLineCount: number;58}59
60// ContextContent represents unchanged lines surrounding changes61interface ContextContent {62 type: 'context';63 lines: string[];64 // 'true' if the file does not have a blank newline at the end65 noEOFCR: boolean;66}67
68// ChangeContent represents a group of additions and deletions69interface ChangeContent {70 type: 'change';71 deletions: string[];72 additions: string[];73 // 'true' if the file does not have a blank newline at the end74 noEOFCRDeletions: boolean;75 noEOFCRAdditions: boolean;76}There are two ways to create a FileDiffMetadata.
Use parseDiffFromFile when you have both file versions. This approach includes
the full file contents, enabling the "expand unchanged" feature.
1import {2 parseDiffFromFile,3 type FileContents,4 type FileDiffMetadata,5} from '@pierre/diffs';6
7// Define your two file versions8const oldFile: FileContents = {9 name: 'greeting.ts',10 contents: 'export const greeting = "Hello";',11 cacheKey: 'greeting-old', // Optional: enables AST caching12};13
14const newFile: FileContents = {15 name: 'greeting.ts',16 contents: 'export const greeting = "Hello, World!";',17 cacheKey: 'greeting-new',18};19
20// Generate the diff metadata21const diff: FileDiffMetadata = parseDiffFromFile(oldFile, newFile);22
23// The resulting diff includes oldLines and newLines,24// which enables "expand unchanged" functionality in the UI.25// If both files have cacheKey, the diff will have a combined26// cacheKey of "greeting-old:greeting-new" for AST caching.Use parsePatchFiles when you have a unified diff or patch file. This is useful
when working with git output or patch files from APIs.
1import {2 parsePatchFiles,3 type ParsedPatch,4 type FileDiffMetadata,5} from '@pierre/diffs';6
7// Parse a unified diff / patch string8const patchString = `--- a/file.ts9+++ b/file.ts10@@ -1,3 +1,3 @@11 const x = 1;12-const y = 2;13+const y = 3;14 const z = 4;`;15
16// Returns an array of ParsedPatch objects (one per commit in the patch)17// Pass an optional cacheKeyPrefix to enable AST caching with Worker Pool18const patches: ParsedPatch[] = parsePatchFiles(patchString, 'my-patch-key');19
20// Each ParsedPatch contains an array of FileDiffMetadata21const files: FileDiffMetadata[] = patches[0].files;22
23// With cacheKeyPrefix, each diff gets a cacheKey like "my-patch-0",24// "my-patch-1", etc.25// This enables AST caching in Worker Pool for parsed patches.26
27// Note: Diffs from patch files don't include oldLines/newLines,28// so "expand unchanged" won't work unless you add them manuallyTip: If you need to change the language after creating a FileContents or
FileDiffMetadata, use the
setLanguageOverride utility function.
Import React components from @pierre/diffs/react.
We offer a variety of components to render diffs and files. Many of them share similar types of props, which you can find documented in Shared Props.
The React API exposes four main components:
MultiFileDiff compares two file versionsPatchDiff renders from a patch stringFileDiff renders a pre-parsed FileDiffMetadataFile renders a single code file without a diff1import {2 type FileContents,3 MultiFileDiff,4} from '@pierre/diffs/react';5
6// MultiFileDiff compares two file versions directly.7// Use this when you have the old and new file contents.8
9// Keep file objects stable (useState/useMemo) to avoid re-renders.10// The component uses reference equality for change detection.11const oldFile: FileContents = {12 name: 'example.ts',13 contents: 'console.log("Hello world")',14};15
16const newFile: FileContents = {17 name: 'example.ts',18 contents: 'console.warn("Updated message")',19};20
21export function MyDiff() {22 return (23 <MultiFileDiff24 // Required: the two file versions to compare25 oldFile={oldFile}26 newFile={newFile}27
28 options={{29 theme: { dark: 'pierre-dark', light: 'pierre-light' },30 diffStyle: 'split',31 }}32
33 // See "Shared Props" tabs for all available props:34 // lineAnnotations, renderAnnotation, renderHeaderMetadata,35 // renderHoverUtility, selectedLines, className, style, etc.36 />37 );38}The three diff components (MultiFileDiff, PatchDiff, and FileDiff) share a
common set of props for configuration, annotations, and styling. The File
component has similar props, but uses LineAnnotation instead of
DiffLineAnnotation (no side property).
1// ============================================================2// SHARED OPTIONS FOR DIFF COMPONENTS3// ============================================================4// These options are shared by MultiFileDiff, PatchDiff, and FileDiff.5// Pass them via the `options` prop.6
7import { MultiFileDiff } from '@pierre/diffs/react';8
9<MultiFileDiff10 {...}11 options={{12 theme: { dark: 'pierre-dark', light: 'pierre-light' },13 diffStyle: 'split',14 // ... see below for all available options15 }}16/>17
18interface DiffOptions {19 // ─────────────────────────────────────────────────────────────20 // THEMING21 // ─────────────────────────────────────────────────────────────22
23 // Theme for syntax highlighting. Can be a single theme name or an24 // object with 'dark' and 'light' keys for automatic switching.25 // Built-in options: 'pierre-dark', 'pierre-light', or any Shiki theme.26 // See: https://shiki.style/themes27 theme: { dark: 'pierre-dark', light: 'pierre-light' },28
29 // When using dark/light theme object, this controls which is used:30 // 'system' (default) - follows OS preference31 // 'dark' or 'light' - forces specific theme32 themeType: 'system',33
34 // ─────────────────────────────────────────────────────────────35 // DIFF DISPLAY36 // ─────────────────────────────────────────────────────────────37
38 // 'split' (default) - side-by-side view39 // 'unified' - single column view40 diffStyle: 'split',41
42 // Line change indicators:43 // 'bars' (default) - colored bars on left edge44 // 'classic' - '+' and '-' characters45 // 'none' - no indicators46 diffIndicators: 'bars',47
48 // Show colored backgrounds on changed lines (default: true)49 disableBackground: false,50
51 // ─────────────────────────────────────────────────────────────52 // HUNK SEPARATORS53 // ─────────────────────────────────────────────────────────────54
55 // What to show between diff hunks:56 // 'line-info' (default) - shows collapsed line count, clickable to expand57 // 'metadata' - shows patch format like '@@ -60,6 +60,22 @@'58 // 'simple' - subtle bar separator59 hunkSeparators: 'line-info',60
61 // Force unchanged context to always render (default: false)62 // Requires oldFile/newFile API or FileDiffMetadata with newLines63 expandUnchanged: false,64
65 // Lines revealed per click when expanding collapsed regions66 expansionLineCount: 100,67
68 // ─────────────────────────────────────────────────────────────69 // INLINE CHANGE HIGHLIGHTING70 // ─────────────────────────────────────────────────────────────71
72 // Highlight changed portions within modified lines:73 // 'word-alt' (default) - word boundaries, minimizes single-char gaps74 // 'word' - word boundaries75 // 'char' - character-level granularity76 // 'none' - disable inline highlighting77 lineDiffType: 'word-alt',78
79 // Skip inline diff for lines exceeding this length80 maxLineDiffLength: 1000,81
82 // ─────────────────────────────────────────────────────────────83 // LAYOUT & DISPLAY84 // ─────────────────────────────────────────────────────────────85
86 // Show line numbers (default: true)87 disableLineNumbers: false,88
89 // Long line handling: 'scroll' (default) or 'wrap'90 overflow: 'scroll',91
92 // Hide the file header with filename and stats93 disableFileHeader: false,94
95 // Skip syntax highlighting for lines exceeding this length96 tokenizeMaxLineLength: 1000,97
98 // ─────────────────────────────────────────────────────────────99 // LINE SELECTION100 // ─────────────────────────────────────────────────────────────101
102 // Enable click-to-select on line numbers103 enableLineSelection: false,104
105 // Callbacks for selection events106 onLineSelected(range: SelectedLineRange | null) {107 // Fires continuously during drag108 },109 onLineSelectionStart(range: SelectedLineRange | null) {110 // Fires on mouse down111 },112 onLineSelectionEnd(range: SelectedLineRange | null) {113 // Fires on mouse up - good for saving selection114 },115
116 // ─────────────────────────────────────────────────────────────117 // MOUSE EVENTS118 // ─────────────────────────────────────────────────────────────119
120 // Must be true to enable renderHoverUtility prop121 enableHoverUtility: false,122
123 // Callbacks for mouse events on diff lines124 onLineClick({ lineNumber, side, event }) {125 // Fires when clicking anywhere on a line126 },127 onLineNumberClick({ lineNumber, side, event }) {128 // Fires when clicking anywhere in the line number column129 },130 onLineEnter({ lineNumber, side }) {131 // Fires when mouse enters a line132 },133 onLineLeave({ lineNumber, side }) {134 // Fires when mouse leaves a line135 },136}Import vanilla JavaScript classes, components, and methods from
@pierre/diffs.
The Vanilla JS API exposes two core components: FileDiff (compare two file
versions or render a pre-parsed FileDiffMetadata) and File (render a single
code file without diff). Typically you'll want to interface with these as
they'll handle all the complicated aspects of syntax highlighting, theming, and
full interactivity for you.
1import { FileDiff, type FileContents } from '@pierre/diffs';2
3// Create the instance with options4const instance = new FileDiff({5 theme: { dark: 'pierre-dark', light: 'pierre-light' },6 diffStyle: 'split',7});8
9// Define your files (keep references stable to avoid re-renders)10const oldFile: FileContents = {11 name: 'example.ts',12 contents: 'console.log("Hello world")',13};14
15const newFile: FileContents = {16 name: 'example.ts',17 contents: 'console.warn("Updated message")',18};19
20// Render the diff into a container21instance.render({22 oldFile,23 newFile,24 containerWrapper: document.getElementById('diff-container'),25});26
27// Update options later if needed (full replacement, not merge)28instance.setOptions({ ...instance.options, diffStyle: 'unified' });29instance.rerender(); // Must call rerender() after updating options30
31// Clean up when done32instance.cleanUp();Both FileDiff and File accept an options object in their constructor. The
File component has similar options, but excludes diff-specific settings and
uses LineAnnotation instead of DiffLineAnnotation (no side property).
1import { FileDiff } from '@pierre/diffs';2
3// All available options for the FileDiff class4const instance = new FileDiff({5
6 // ─────────────────────────────────────────────────────────────7 // THEMING8 // ─────────────────────────────────────────────────────────────9
10 // Theme for syntax highlighting. Can be a single theme name or an11 // object with 'dark' and 'light' keys for automatic switching.12 // Built-in options: 'pierre-dark', 'pierre-light', or any Shiki theme.13 // See: https://shiki.style/themes14 theme: { dark: 'pierre-dark', light: 'pierre-light' },15
16 // When using dark/light theme object, this controls which is used:17 // 'system' (default) - follows OS preference18 // 'dark' or 'light' - forces specific theme19 themeType: 'system',20
21 // ─────────────────────────────────────────────────────────────22 // DIFF DISPLAY23 // ─────────────────────────────────────────────────────────────24
25 // 'split' (default) - side-by-side view26 // 'unified' - single column view27 diffStyle: 'split',28
29 // Line change indicators:30 // 'bars' (default) - colored bars on left edge31 // 'classic' - '+' and '-' characters32 // 'none' - no indicators33 diffIndicators: 'bars',34
35 // Show colored backgrounds on changed lines (default: true)36 disableBackground: false,37
38 // ─────────────────────────────────────────────────────────────39 // HUNK SEPARATORS40 // ─────────────────────────────────────────────────────────────41
42 // What to show between diff hunks:43 // 'line-info' (default) - shows collapsed line count, clickable to expand44 // 'metadata' - shows patch format like '@@ -60,6 +60,22 @@'45 // 'simple' - subtle bar separator46 // Or pass a function for custom rendering (see Hunk Separators section)47 hunkSeparators: 'line-info',48
49 // Force unchanged context to always render (default: false)50 // Requires oldFile/newFile API or FileDiffMetadata with newLines51 expandUnchanged: false,52
53 // Lines revealed per click when expanding collapsed regions54 expansionLineCount: 100,55
56 // ─────────────────────────────────────────────────────────────57 // INLINE CHANGE HIGHLIGHTING58 // ─────────────────────────────────────────────────────────────59
60 // Highlight changed portions within modified lines:61 // 'word-alt' (default) - word boundaries, minimizes single-char gaps62 // 'word' - word boundaries63 // 'char' - character-level granularity64 // 'none' - disable inline highlighting65 lineDiffType: 'word-alt',66
67 // Skip inline diff for lines exceeding this length68 maxLineDiffLength: 1000,69
70 // ─────────────────────────────────────────────────────────────71 // LAYOUT & DISPLAY72 // ─────────────────────────────────────────────────────────────73
74 // Show line numbers (default: true)75 disableLineNumbers: false,76
77 // Long line handling: 'scroll' (default) or 'wrap'78 overflow: 'scroll',79
80 // Hide the file header with filename and stats81 disableFileHeader: false,82
83 // Skip syntax highlighting for lines exceeding this length84 tokenizeMaxLineLength: 1000,85
86 // ─────────────────────────────────────────────────────────────87 // LINE SELECTION88 // ─────────────────────────────────────────────────────────────89
90 // Enable click-to-select on line numbers91 enableLineSelection: false,92
93 // Callbacks for selection events94 onLineSelected(range) {95 // Fires continuously during drag96 },97 onLineSelectionStart(range) {98 // Fires on mouse down99 },100 onLineSelectionEnd(range) {101 // Fires on mouse up - good for saving selection102 },103
104 // ─────────────────────────────────────────────────────────────105 // MOUSE EVENTS106 // ─────────────────────────────────────────────────────────────107
108 // Must be true to enable renderHoverUtility109 enableHoverUtility: false,110
111 // Fires when clicking anywhere on a line112 onLineClick({ lineNumber, side, event }) {},113
114 // Fires when clicking anywhere in the line number column115 onLineNumberClick({ lineNumber, side, event }) {},116
117 // Fires when mouse enters a line118 onLineEnter({ lineNumber, side }) {},119
120 // Fires when mouse leaves a line121 onLineLeave({ lineNumber, side }) {},122
123 // ─────────────────────────────────────────────────────────────124 // RENDER CALLBACKS125 // ─────────────────────────────────────────────────────────────126
127 // Render custom content in the file header (after +/- stats)128 renderHeaderMetadata({ oldFile, newFile, fileDiff }) {129 const span = document.createElement('span');130 span.textContent = fileDiff?.newName ?? '';131 return span;132 },133
134 // Render annotations on specific lines135 renderAnnotation(annotation) {136 const element = document.createElement('div');137 element.textContent = annotation.metadata.threadId;138 return element;139 },140
141 // Render UI in the line number column on hover142 // Requires enableHoverUtility: true143 renderHoverUtility(getHoveredLine) {144 const button = document.createElement('button');145 button.textContent = '+';146 button.addEventListener('click', () => {147 const { lineNumber, side } = getHoveredLine();148 console.log('Clicked line', lineNumber, 'on', side);149 });150 return button;151 },152
153});154
155// ─────────────────────────────────────────────────────────────156// INSTANCE METHODS157// ─────────────────────────────────────────────────────────────158
159// Render the diff160instance.render({161 oldFile: { name: 'file.ts', contents: '...' },162 newFile: { name: 'file.ts', contents: '...' },163 lineAnnotations: [{ side: 'additions', lineNumber: 5, metadata: {} }],164 containerWrapper: document.body,165});166
167// Update options (full replacement, not merge)168instance.setOptions({ ...instance.options, diffStyle: 'unified' });169
170// Update line annotations after initial render171instance.setLineAnnotations([172 { side: 'additions', lineNumber: 5, metadata: { threadId: 'abc' } }173]);174
175// Programmatically control selected lines176instance.setSelectedLines({177 start: 12,178 end: 22,179 side: 'additions',180 endSide: 'deletions',181});182
183// Force re-render (useful after changing options)184instance.rerender();185
186// Programmatically expand a collapsed hunk187instance.expandHunk(0, 'down'); // hunkIndex, direction: 'up' | 'down' | 'all'188
189// Change the active theme type190instance.setThemeType('dark'); // 'dark' | 'light' | 'system'191
192// Clean up (removes DOM, event listeners, clears state)193instance.cleanUp();If you want to render custom hunk separators that won't scroll with the content, there are a few tricks you will need to employ. See the following code snippet:
1import { FileDiff } from '@pierre/diffs';2
3// A hunk separator that utilizes the existing grid to have4// a number column and a content column where neither will5// scroll with the code6const instance = new FileDiff({7 hunkSeparators(hunkData: HunkData) {8 const fragment = document.createDocumentFragment();9 const numCol = document.createElement('div');10 numCol.textContent = `${hunkData.lines}`;11 numCol.style.position = 'sticky';12 numCol.style.left = '0';13 numCol.style.backgroundColor = 'var(--diffs-bg)';14 numCol.style.zIndex = '2';15 fragment.appendChild(numCol);16 const contentCol = document.createElement('div');17 contentCol.textContent = 'unmodified lines';18 contentCol.style.position = 'sticky';19 contentCol.style.width = 'var(--diffs-column-content-width)';20 contentCol.style.left = 'var(--diffs-column-number-width)';21 fragment.appendChild(contentCol);22 return fragment;23 },24})25
26// If you want to create a single column that spans both colums27// and doesn't scroll, you can do something like this:28const instance2 = new FileDiff({29 hunkSeparators(hunkData: HunkData) {30 const wrapper = document.createElement('div');31 wrapper.style.gridColumn = 'span 2';32 const contentCol = document.createElement('div');33 contentCol.textContent = `${hunkData.lines} unmodified lines`;34 contentCol.style.position = 'sticky';35 contentCol.style.width = 'var(--diffs-column-width)';36 contentCol.style.left = '0';37 wrapper.appendChild(contentCol);38 return wrapper;39 },40})41
42// If you want to create a single column that's aligned with the content43// column and doesn't scroll, you can do something like this:44const instance3 = new FileDiff({45 hunkSeparators(hunkData: HunkData) {46 const wrapper = document.createElement('div');47 wrapper.style.gridColumn = '2 / 3';48 wrapper.textContent = `${hunkData.lines} unmodified lines`;49 wrapper.style.position = 'sticky';50 wrapper.style.width = 'var(--diffs-column-content-width)';51 wrapper.style.left = 'var(--diffs-column-number-width)';52 return wrapper;53 },54})For most use cases, you should use the higher-level components like FileDiff
and File (vanilla JS) or the React components (MultiFileDiff, FileDiff,
PatchDiff, File). These renderers are low-level building blocks intended
for advanced use cases.
These renderer classes handle the low-level work of parsing and rendering code with syntax highlighting. Useful when you need direct access to the rendered output as HAST nodes or HTML strings for custom rendering pipelines.
Takes a FileDiffMetadata data structure and renders out the raw HAST
(Hypertext Abstract Syntax Tree) elements for diff hunks. You can generate
FileDiffMetadata via parseDiffFromFile or parsePatchFiles utility
functions.
1import {2 DiffHunksRenderer,3 type FileDiffMetadata,4 type HunksRenderResult,5 parseDiffFromFile,6} from '@pierre/diffs';7
8const instance = new DiffHunksRenderer();9
10// Set options (this is a full replacement, not a merge)11instance.setOptions({ theme: 'github-dark', diffStyle: 'split' });12
13// Parse diff content from 2 versions of a file14const fileDiff: FileDiffMetadata = parseDiffFromFile(15 { name: 'file.ts', contents: 'const greeting = "Hello";' },16 { name: 'file.ts', contents: 'const greeting = "Hello, World!";' }17);18
19// Render hunks (async - waits for highlighter initialization)20const result: HunksRenderResult = await instance.asyncRender(fileDiff);21
22// result contains hast nodes for each column based on diffStyle:23// - 'split' mode: additionsAST and deletionsAST (side-by-side)24// - 'unified' mode: unifiedAST only (single column)25// - preNode: the wrapper <pre> element as a hast node26// - headerNode: the file header element27// - hunkData: metadata about each hunk (for custom separators)28
29// Render to a complete HTML string (includes <pre> and <code> wrappers)30const fullHTML: string = instance.renderFullHTML(result);31
32// Or render just a specific column to HTML33const additionsHTML: string = instance.renderPartialHTML(34 result.additionsAST,35 'additions' // wraps in <code data-additions>36);37
38// Or render without the <code> wrapper39const rawHTML: string = instance.renderPartialHTML(result.additionsAST);40
41// Or get the full AST for further transformation42const fullAST = instance.renderFullAST(result);Takes a FileContents object (just a filename and contents string) and renders
syntax-highlighted code as HAST elements. Useful for rendering single files
without any diff context.
1import {2 FileRenderer,3 type FileContents,4 type FileRenderResult,5} from '@pierre/diffs';6
7const instance = new FileRenderer();8
9// Set options (this is a full replacement, not a merge)10instance.setOptions({11 theme: 'pierre-dark',12 overflow: 'scroll',13 disableLineNumbers: false,14 disableFileHeader: false,15 // Starting line number (useful for showing snippets)16 startingLineNumber: 1,17 // Skip syntax highlighting for very long lines18 tokenizeMaxLineLength: 1000,19});20
21const file: FileContents = {22 name: 'example.ts',23 contents: `function greet(name: string) {24 console.log(\`Hello, \${name}!\`);25}26
27export { greet };`,28};29
30// Render file (async - waits for highlighter initialization)31const result: FileRenderResult = await instance.asyncRender(file);32
33// result contains:34// - codeAST: array of hast ElementContent nodes for each line35// - preAST: the wrapper <pre> element as a hast node36// - headerAST: the file header element (if not disabled)37// - totalLines: number of lines in the file38// - themeStyles: CSS custom properties for theming39
40// Render to a complete HTML string (includes <pre> wrapper)41const fullHTML: string = instance.renderFullHTML(result);42
43// Or render just the code lines to HTML44const partialHTML: string = instance.renderPartialHTML(result.codeAST);45
46// Or get the full AST for further transformation47const fullAST = instance.renderFullAST(result);Import utility functions from @pierre/diffs. These can be used with any
framework or rendering approach.
Programmatically accept or reject individual hunks in a diff. This is useful for building interactive code review interfaces, AI-assisted coding tools, or any workflow where users need to selectively apply changes.
When you accept a hunk, the new (additions) version is kept and the hunk is
converted to context lines. When you reject a hunk, the old (deletions)
version is restored. The function returns a new FileDiffMetadata object with
all line numbers properly adjusted for subsequent hunks.
1import {2 diffAcceptRejectHunk,3 FileDiff,4 parseDiffFromFile,5 type FileDiffMetadata,6} from '@pierre/diffs';7
8// Parse a diff from two file versions9let fileDiff: FileDiffMetadata = parseDiffFromFile(10 { name: 'file.ts', contents: 'const x = 1;\nconst y = 2;' },11 { name: 'file.ts', contents: 'const x = 1;\nconst y = 3;\nconst z = 4;' }12);13
14// Create a FileDiff instance15const instance = new FileDiff({ theme: 'pierre-dark' });16
17// Render the initial diff showing the changes18instance.render({19 fileDiff,20 containerWrapper: document.getElementById('diff-container')!,21});22
23// Accept a hunk - keeps the new (additions) version.24// The hunk is converted to context lines (no longer shows as a change).25// Note: If the diff has a cacheKey, it's automatically updated by 26// this function.27fileDiff = diffAcceptRejectHunk(fileDiff, 0, 'accept');28
29// Or reject a hunk - reverts to the old (deletions) version.30// fileDiff = diffAcceptRejectHunk(fileDiff, 0, 'reject');31
32// Re-render with the updated fileDiff - the accepted hunk33// now appears as context lines instead of additions/deletions34instance.render({35 fileDiff,36 containerWrapper: document.getElementById('diff-container')!,37});Dispose the shared Shiki highlighter instance to free memory. Useful when cleaning up resources in single-page applications.
1import { disposeHighlighter } from '@pierre/diffs';2
3// Dispose the shared highlighter instance to free memory.4// This is useful when you're done rendering diffs and want5// to clean up resources (e.g., in a single-page app when6// navigating away from a diff view).7//8// Note: After calling this, all themes and languages will9// need to be reloaded on the next render.10disposeHighlighter();Get direct access to the shared Shiki highlighter instance used internally by all components. Useful for custom highlighting operations.
1import { getSharedHighlighter } from '@pierre/diffs';2
3// Get the shared Shiki highlighter instance.4// This is the same instance used internally by all FileDiff5// and File components. Useful if you need direct access to6// Shiki for custom highlighting operations.7//8// The highlighter is initialized lazily - themes and languages9// are loaded on demand as you render different files.10const highlighter = await getSharedHighlighter();11
12// You can use it directly for custom highlighting13const tokens = highlighter.codeToTokens('const x = 1;', {14 lang: 'typescript',15 theme: 'pierre-dark',16});Compare two versions of a file and generate a FileDiffMetadata structure. Use
this when you have the full contents of both file versions rather than a patch
string.
If both oldFile and newFile have a cacheKey, the resulting
FileDiffMetadata will automatically receive a combined cache key (format:
oldKey:newKey). See Render Cache for more
information.
1import {2 parseDiffFromFile,3 type FileDiffMetadata,4} from '@pierre/diffs';5
6// Parse a diff by comparing two versions of a file.7// This is useful when you have the full file contents8// rather than a patch/diff string.9const oldFile = {10 name: 'example.ts',11 contents: `function greet(name: string) {12 console.log("Hello, " + name);13}`,14};15
16const newFile = {17 name: 'example.ts',18 contents: `function greet(name: string) {19 console.log(\`Hello, \${name}!\`);20}21
22export { greet };`,23};24
25const fileDiff: FileDiffMetadata = parseDiffFromFile(oldFile, newFile);26
27// fileDiff contains:28// - name: the filename29// - hunks: array of diff hunks with line information30// - oldLines/newLines: full file contents split by line31// - Various line counts for renderingParse unified diff / patch file content into structured data. Handles both
single patches and multi-commit patch files (like those from GitHub pull request
.patch URLs). An optional second parameter cacheKeyPrefix can be provided to
generate cache keys for each file in the patch (format:
prefix-patchIndex-fileIndex), enabling
caching of rendered diff results in the worker
pool.
1import {2 parsePatchFiles,3 type ParsedPatch,4} from '@pierre/diffs';5
6// Parse unified diff / patch file content.7// Handles both single patches and multi-commit patch files8// (like those from GitHub PR .patch URLs).9const patchContent = `diff --git a/example.ts b/example.ts10index abc123..def456 10064411--- a/example.ts12+++ b/example.ts13@@ -1,3 +1,4 @@14 function greet(name: string) {15- console.log("Hello, " + name);16+ console.log(\`Hello, \${name}!\`);17 }18+export { greet };19`;20
21// Basic usage22const patches: ParsedPatch[] = parsePatchFiles(patchContent);23
24// With cache key prefix for worker pool caching25// Each file gets a key like 'my-pr-123-0-0', 'my-pr-123-0-1', etc.26// IMPORTANT: The prefix must change when patchContent changes!27// Use a stable identifier like a commit SHA or content hash.28const cachedPatches = parsePatchFiles(patchContent, 'my-pr-123-abc456');29
30// Each ParsedPatch contains:31// - message: commit message (if present)32// - files: array of FileDiffMetadata for each file in the patch33
34for (const patch of patches) {35 console.log('Commit:', patch.message);36 for (const file of patch.files) {37 console.log(' File:', file.name);38 console.log(' Hunks:', file.hunks.length);39 }40}Preload specific themes and languages before rendering to ensure instant highlighting with no async loading delay.
1import { preloadHighlighter } from '@pierre/diffs';2
3// Preload specific themes and languages before rendering.4// This ensures the highlighter is ready with the assets you5// need, avoiding any flash of unstyled content on first render.6//7// By default, themes and languages are loaded on demand,8// but preloading is useful when you know which languages9// you'll be rendering ahead of time.10await preloadHighlighter({11 // Themes to preload12 themes: ['pierre-dark', 'pierre-light', 'github-dark'],13 // Languages to preload14 langs: ['typescript', 'javascript', 'python', 'rust', 'go'],15});16
17// After preloading, rendering diffs in these languages18// will be instant with no async loading delay.Register a custom Shiki theme for use with any component. The theme name you
register must match the name field inside your theme JSON file.
1import { registerCustomTheme } from '@pierre/diffs';2
3// Register a custom Shiki theme before using it.4// The theme name you register must match the 'name' field5// inside your theme JSON file.6
7// Option 1: Dynamic import (recommended for code splitting)8registerCustomTheme('my-custom-theme', () => import('./my-theme.json'));9
10// Option 2: Inline theme object11registerCustomTheme('inline-theme', async () => ({12 name: 'inline-theme',13 type: 'dark',14 colors: {15 'editor.background': '#1a1a2e',16 'editor.foreground': '#eaeaea',17 // ... other VS Code theme colors18 },19 tokenColors: [20 {21 scope: ['comment'],22 settings: { foreground: '#6a6a8a' },23 },24 // ... other token rules25 ],26}));27
28// Once registered, use the theme name in your components:29// <FileDiff options={{ theme: 'my-custom-theme' }} ... />Override the syntax highlighting language for a FileContents or
FileDiffMetadata object. This is useful when the filename doesn't have an
extension or doesn't match the actual language.
1import {2 setLanguageOverride,3 parsePatchFiles,4 type FileContents,5 type FileDiffMetadata,6} from '@pierre/diffs';7
8// setLanguageOverride creates a new FileContents or FileDiffMetadata9// with the language explicitly set. This is useful when:10// - The filename doesn't have an extension11// - The extension doesn't match the actual language12// - You're parsing patches and need to override the detected language13
14// Example 1: Override language on a FileContents15const file: FileContents = {16 name: 'Dockerfile', // No extension, would default to 'text'17 contents: 'FROM node:20\nRUN npm install',18};19const dockerFile = setLanguageOverride(file, 'dockerfile');20
21// Example 2: Override language on a FileDiffMetadata22const patches = parsePatchFiles(patchString);23const diff: FileDiffMetadata = patches[0].files[0];24const typescriptDiff = setLanguageOverride(diff, 'typescript');25
26// The function returns a new object with the lang property set,27// leaving the original unchanged (immutable operation).Diff and code components are rendered using shadow DOM APIs, allowing styles to
be well-isolated from your page's existing CSS. However, it also means you may
have to utilize some custom CSS variables to override default styles. These can
be done in your global CSS, as style props on parent components, or on the
FileDiff component directly.
1:root {2 /* Available Custom CSS Variables. Most should be self explanatory */3 /* Sets code font, very important */4 --diffs-font-family: 'Berkeley Mono', monospace;5 --diffs-font-size: 14px;6 --diffs-line-height: 1.5;7 /* Controls tab character size */8 --diffs-tab-size: 2;9 /* Font used in header and separator components,10 * typically not a monospace font, but it's your call */11 --diffs-header-font-family: Helvetica;12 /* Override or customize any 'font-feature-settings'13 * for your code font */14 --diffs-font-features: normal;15 /* Override the minimum width for the number column. By default16 * it should take into account the number of digits required17 * based on the lines in the file itself, but you can manually18 * override if desired. Generally we recommend using ch units19 * because they work well with monospaced fonts */20 --diffs-min-number-column-width: 3ch;21
22 /* By default we try to inherit the deletion/addition/modified23 * colors from the existing Shiki theme, however if you'd like24 * to override them, you can do so via these css variables: */25 --diffs-deletion-color-override: orange;26 --diffs-addition-color-override: yellow;27 --diffs-modified-color-override: purple;28
29 /* Line selection colors - customize the highlighting when users30 * select lines via enableLineSelection. These support light-dark()31 * for automatic theme adaptation. */32 --diffs-selection-color-override: rgb(37, 99, 235);33 --diffs-bg-selection-override: rgba(147, 197, 253, 0.28);34 --diffs-bg-selection-number-override: rgba(96, 165, 250, 0.55);35 --diffs-bg-selection-background-override: rgba(96, 165, 250, 0.2);36 --diffs-bg-selection-number-background-override: rgba(59, 130, 246, 0.4);37
38 /* Some basic variables for tweaking the layouts of some of the built in39 * components */40 --diffs-gap-inline: 8px;41 --diffs-gap-block: 8px;42}1<FileDiff2 style={{3 '--diffs-font-family': 'JetBrains Mono, monospace',4 '--diffs-font-size': '13px'5 } as React.CSSProperties}6 // ... other props7/>For advanced customization, you can inject arbitrary CSS into the shadow DOM
using the unsafeCSS option. This CSS will be wrapped in an @layer unsafe
block, giving it the highest priority in the cascade. Use this sparingly and
with caution, as it bypasses the normal style isolation.
We also recommend that any CSS you apply uses simple, direct selectors targeting
the existing data attributes. Avoid structural selectors like :first-child,
:last-child, :nth-child(), sibling combinators (+ or ~), deeply nested
descendant selectors, or bare tag selectors—these are susceptible to breaking in
future versions or in edge cases that may be difficult to anticipate.
We cannot currently guarantee backwards compatibility for this feature across any future changes to the library, even in patch versions. Please reach out so that we can discuss a more permanent solution for modifying styles.
1<FileDiff2 options={{3 unsafeCSS: /* css */ `4[data-line-index='0'] {5 border-top: 1px solid var(--diffs-bg-context);6}7
8[data-line] {9 border-bottom: 1px solid var(--diffs-bg-context);10}11
12[data-column-number] {13 border-right: 1px solid var(--diffs-bg-context);14}`15 }}16 // ... other props17/>This feature is experimental and undergoing active development. There may be bugs and the API is subject to change.
Import worker utilities from @pierre/diffs/worker.
By default, syntax highlighting runs on the main thread using Shiki. If you're rendering large files or many diffs, this can cause a bottleneck on your JavaScript thread resulting in jank or unresponsiveness. To work around this, we've provided some APIs to run all syntax highlighting in worker threads. The main thread will still attempt to render plain text synchronously and then apply the syntax highlighting when we get a response from the worker threads.
Basic usage differs a bit depending on if you're using React or Vanilla JS APIs, so continue reading for more details.
One unfortunate side effect of using Web Workers is that different bundlers and environments require slightly different approaches to create a Web Worker. You'll need to create a function that spawns a worker that's appropriate for your environment and bundler and then pass that function to our provided APIs.
Lets begin with the workerFactory function. We've provided some examples for
common use cases below.
Only the Vite and NextJS examples have been tested by us. Additional examples were generated by AI. If any of them are incorrect, please let us know.
You may need to explicitly set the worker.format option in your
Vite Config to 'es'.
1import WorkerUrl from '@pierre/diffs/worker/worker.js?worker&url';2
3export function workerFactory(): Worker {4 return new Worker(WorkerUrl, { type: 'module' });5}Workers only work in client components. Ensure your function has the 'use client' directive if using App Router.
1'use client';2
3export function workerFactory(): Worker {4 return new Worker(5 new URL(6 '@pierre/diffs/worker/worker.js',7 import.meta.url8 )9 );10}VS Code webviews have special security restrictions that require a different approach. You'll need to configure both the extension side (to expose the worker file) and the webview side (to load it via blob URL).
Extension side: Add the worker directory to localResourceRoots in your
getWebviewOptions():
1function getWebviewOptions(extensionUri: vscode.Uri): vscode.WebviewOptions {2 return {3 enableScripts: true,4 localResourceRoots: [5 // ... your other roots6 vscode.Uri.joinPath(7 extensionUri,8 'node_modules',9 '@pierre',10 'diffs',11 'dist',12 'worker'13 ),14 ],15 };16}Create the worker URI in _getHtmlForWebview(). Note: use worker-portable.js
instead of worker.js — the portable version is designed for environments where
ES modules aren't supported in web workers.
1const workerScriptPath = vscode.Uri.joinPath(2 this._extensionUri,3 'node_modules',4 '@pierre',5 'diffs',6 'dist',7 'worker',8 'worker-portable.js'9);10const workerScriptUri = webview.asWebviewUri(workerScriptPath);Pass the URI to the webview via an inline script in your HTML:
1<script nonce="${nonce}">window.WORKER_URI = "${workerScriptUri}";</script>Your Content Security Policy must include worker-src and connect-src:
1worker-src ${webview.cspSource} blob:;2connect-src ${webview.cspSource};Webview side: Declare the global type for the URI:
1declare global {2 interface Window {3 WORKER_URI: string;4 }5}Fetch the worker code and create a blob URL:
1async function createWorkerBlobUrl(): Promise<string> {2 const response = await fetch(window.WORKER_URI);3 const workerCode = await response.text();4 const blob = new Blob([workerCode], { type: 'application/javascript' });5 return URL.createObjectURL(blob);6}Create the workerFactory function:
1const workerBlobUrl = await createWorkerBlobUrl();2
3function workerFactory() {4 return new Worker(workerBlobUrl);5}1export function workerFactory(): Worker {2 return new Worker(3 new URL(4 '@pierre/diffs/worker/worker.js',5 import.meta.url6 ),7 { type: 'module' }8 );9}1export function workerFactory(): Worker {2 return new Worker(3 new URL(4 '@pierre/diffs/worker/worker.js',5 import.meta.url6 ),7 { type: 'module' }8 );9}If your bundler doesn't have special worker support, build and serve the worker file statically:
1// For Rollup or bundlers without special worker support:2// 1. Copy worker.js to your static/public folder3// 2. Reference it by URL4
5export function workerFactory(): Worker {6 return new Worker('/static/workers/worker.js', { type: 'module' });7}For projects without a bundler, host the worker file on your server and reference it directly:
1// No bundler / Vanilla JS2// Host worker.js on your server and reference it by URL3
4export function workerFactory() {5 return new Worker('/path/to/worker.js', { type: 'module' });6}With your workerFactory function created, you can integrate it with our
provided APIs. In React, you'll want to pass this workerFactory to a
<WorkerPoolContextProvider> so all components can inherit the pool
automatically. If you're using the Vanilla JS APIs, we provide a
getOrCreateWorkerPoolSingleton helper that ensures a single pool instance that
you can then manually pass to all your File/FileDiff instances.
When using the worker pool, the theme, lineDiffType, and
tokenizeMaxLineLength render options are controlled by WorkerPoolManager,
not individual components. Passing these options into component instances will
be ignored. To change render options after WorkerPoolManager instantiates, use
the setRenderOptions() method on the WorkerPoolManager. Note: Changing
render options will force all mounted components to re-render and will clear
the render cache.
Wrap your component tree with WorkerPoolContextProvider from
@pierre/diffs/react. All FileDiff and File components nested within will
automatically use the worker pool for syntax highlighting.
The WorkerPoolContextProvider will automatically spin up or shut down the
worker pool based on its react lifecycle. If you have multiple context
providers, they will all share the same pool, and termination won't occur until
all contexts are unmounted.
Workers only work in client components. Ensure your function has the 'use client' directive if using App Router.
To change themes or other render options dynamically, use the useWorkerPool()
hook to access the pool manager and call setRenderOptions().
1// components/HighlightProvider.tsx2'use client';3
4import {5 useWorkerPool,6 WorkerPoolContextProvider,7} from '@pierre/diffs/react';8import type { ReactNode } from 'react';9import { workerFactory } from '@/utils/workerFactory';10
11// Create a client component that wraps children with the worker pool.12// Import this in your layout to provide the worker pool to all pages.13export function HighlightProvider({ children }: { children: ReactNode }) {14 return (15 <WorkerPoolContextProvider16 poolOptions={{17 workerFactory,18 // poolSize defaults to 8. More workers = more parallelism but19 // also more memory. Too many can actually slow things down.20 // poolSize: 8,21 }}22 highlighterOptions={{23 theme: { dark: 'pierre-dark', light: 'pierre-light' },24 // Optionally preload languages to avoid lazy-loading delays25 langs: ['typescript', 'javascript', 'css', 'html'],26 }}27 >28 {children}29 </WorkerPoolContextProvider>30 );31}32
33// layout.tsx34// import { HighlightProvider } from '@/components/HighlightProvider';35//36// export default function Layout({ children }) {37// return (38// <html>39// <body>40// <HighlightProvider>{children}</HighlightProvider>41// </body>42// </html>43// );44// }45
46// Any File, FileDiff, MultiFileDiff, or PatchDiff component nested within47// the layout will automatically use the worker pool, no additional props required.48
49// ---50
51// To change render options dynamically, use the useWorkerPool hook:52function ThemeSwitcher() {53 const workerPool = useWorkerPool();54
55 const switchToGitHub = () => {56 // setRenderOptions accepts a Partial<WorkerRenderingOptions>.57 // Any omitted options will use defaults:58 // - theme: { dark: 'pierre-dark', light: 'pierre-light' }59 // - lineDiffType: 'word-alt'60 // - tokenizeMaxLineLength: 100061 void workerPool?.setRenderOptions({62 theme: { dark: 'github-dark', light: 'github-light' },63 });64 };65
66 return <button onClick={switchToGitHub}>Switch to GitHub theme</button>;67}68// WARNING: Changing render options will force all mounted components69// to re-render and will clear the render cache.Use getOrCreateWorkerPoolSingleton to spin up a singleton worker pool. Then
pass that as the second argument to File and/or FileDiff. When you are done
with the worker pool, you can use terminateWorkerPoolSingleton to free up
resources.
To change themes or other render options dynamically, call
setRenderOptions(options) on the pool instance.
1import { FileDiff } from '@pierre/diffs';2import {3 getOrCreateWorkerPoolSingleton,4 terminateWorkerPoolSingleton,5} from '@pierre/diffs/worker';6import { workerFactory } from './utils/workerFactory';7
8// Create a singleton worker pool instance using your workerFactory.9// This ensures the same pool is reused across your app.10const workerPool = getOrCreateWorkerPoolSingleton({11 poolOptions: {12 workerFactory,13 // poolSize defaults to 8. More workers = more parallelism but14 // also more memory. Too many can actually slow things down.15 // poolSize: 8,16 },17 highlighterOptions: {18 theme: { dark: 'pierre-dark', light: 'pierre-light' },19 // Optionally preload languages to avoid lazy-loading delays20 langs: ['typescript', 'javascript', 'css', 'html'],21 },22});23
24// Pass the workerPool as the second argument to FileDiff25const instance = new FileDiff(26 { theme: { dark: 'pierre-dark', light: 'pierre-light' } },27 workerPool28);29
30// Note: Store file objects in variables rather than inlining them.31// FileDiff uses reference equality to detect changes and skip32// unnecessary re-renders.33const oldFile = { name: 'example.ts', contents: 'const x = 1;' };34const newFile = { name: 'example.ts', contents: 'const x = 2;' };35
36instance.render({ oldFile, newFile, containerWrapper: document.body });37
38// To change render options dynamically, call setRenderOptions on the worker pool.39// It accepts a Partial<WorkerRenderingOptions>. Any omitted options will use defaults:40// - theme: { dark: 'pierre-dark', light: 'pierre-light' }41// - lineDiffType: 'word-alt'42// - tokenizeMaxLineLength: 100043await workerPool.setRenderOptions({44 theme: { dark: 'github-dark', light: 'github-light' },45});46// WARNING: Changing render options will force all mounted components47// to re-render and will clear the render cache.48
49// Optional: terminate workers when no longer needed (e.g., SPA navigation)50// Page unload automatically cleans up workers, but for SPAs you may want51// to call this when unmounting to free resources sooner.52// terminateWorkerPoolSingleton();This is an experimental feature being validated in production use cases. The API is subject to change.
The worker pool can cache rendered AST results to avoid redundant highlighting
work. When a file or diff has a cacheKey, subsequent requests with the same
key will return cached results immediately instead of reprocessing through a
worker. This works automatically for both React and Vanilla JS APIs.
Caching is enabled per-file/diff by setting a cacheKey property. Files and
diffs without a cacheKey will not be cached. The cache also validates
against render options — if options like theme or line diff type change, the
cached result is skipped and re-rendered.
1import {2 getOrCreateWorkerPoolSingleton,3} from '@pierre/diffs/worker';4import { workerFactory } from './utils/workerFactory';5
6const workerPool = getOrCreateWorkerPoolSingleton({7 poolOptions: {8 workerFactory,9 // Optional: configure cache size per cache (default: 100)10 // Two separate LRU caches are maintained: one for files,11 // one for diffs, so combined cache size will be double12 totalASTLRUCacheSize: 200,13 },14 highlighterOptions: {15 theme: { dark: 'pierre-dark', light: 'pierre-light' },16 },17});18
19// Caching is enabled automatically when files/diffs have a cacheKey.20// Files and diffs without cacheKey will not be cached.21
22const fileWithCaching = {23 name: 'example.ts',24 contents: 'const x = 42;',25 cacheKey: 'file-abc123', // <-- Enables caching for this file26};27
28const fileWithoutCaching = {29 name: 'other.ts',30 contents: 'const y = 1;',31 // No cacheKey - will not be cached32};33
34// IMPORTANT: The cacheKey must change whenever the content changes!35// If content changes but the key stays the same, stale cached results36// will be returned. Use content hashes or version numbers in your keys.37const fileV1 = { name: 'file.ts', contents: 'v1', cacheKey: 'file-v1' };38const fileV2 = { name: 'file.ts', contents: 'v2', cacheKey: 'file-v2' };39
40// Cache key best practices:41// - DON'T use file contents as the key - large strings potentially42// waste memory43// - DON'T rely solely on filenames - they may not be unique or stable44// - DO use stable identifiers like commit SHAs, file IDs, or version numbers45// - DO combine identifiers when needed: `${fileId}-${version}`46
47// How caching works:48// - Files/diffs with cacheKey are stored in an LRU cache after rendering49// - Subsequent renders with the same cacheKey return cached results instantly50// - No worker processing required for cache hits51// - Cache is validated against render options (e.g., theme, lineDiffType)52// - If options changed, cached result is skipped and re-rendered53// - Cache is cleared when the pool is terminated54
55// Inspect cache contents (for debugging)56const { fileCache, diffCache } = workerPool.inspectCaches();57console.log('Cached files:', fileCache.size);58console.log('Cached diffs:', diffCache.size);59
60// Evict specific items from the cache when content is invalidated61// (e.g., user edits a file, new commit is pushed)62workerPool.evictFileFromCache('file-abc123');63workerPool.evictDiffFromCache('diff-xyz789');These methods are exposed for advanced use cases. In most scenarios, you should
use the WorkerPoolContextProvider for React or pass the pool instance via the
workerPool option for Vanilla JS rather than calling these methods directly.
1// WorkerPoolManager constructor2new WorkerPoolManager(poolOptions, highlighterOptions)3
4// Parameters:5// - poolOptions: WorkerPoolOptions6// - workerFactory: () => Worker - Function that creates a Worker instance7// - poolSize?: number (default: 8) - Number of workers8// - totalASTLRUCacheSize?: number (default: 100) - Max items per cache9// (Two separate LRU caches are maintained: one for files, one for diffs.10// Each cache has this limit, so total cached items can be 2x this value.)11// - highlighterOptions: WorkerInitializationRenderOptions12// - theme?: DiffsThemeNames | ThemesType - Theme name or { dark, light } object13// - lineDiffType?: 'word' | 'word-alt' | 'char' - How to diff lines (default: 'word-alt')14// - tokenizeMaxLineLength?: number - Max line length to tokenize (default: 1000)15// - langs?: SupportedLanguages[] - Array of languages to preload16
17// Methods:18poolManager.initialize()19// Returns: Promise<void> - Initializes workers (auto-called on first render)20
21poolManager.isInitialized()22// Returns: boolean23
24poolManager.setRenderOptions(options)25// Returns: Promise<void> - Changes render options dynamically26// Accepts: Partial<WorkerRenderingOptions>27// - theme?: DiffsThemeNames | ThemesType28// - lineDiffType?: 'word' | 'word-alt' | 'char'29// - tokenizeMaxLineLength?: number30// Omitted options will use defaults. WARNING: This forces all mounted31// components to re-render and clears the render cache.32
33poolManager.getRenderOptions()34// Returns: WorkerRenderingOptions - Current render options (copy)35
36poolManager.highlightFileAST(fileInstance, file, options)37// Queues highlighted file render, calls fileInstance.onHighlightSuccess when done38
39poolManager.getPlainFileAST(file, startingLineNumber?)40// Returns: ThemedFileResult | undefined - Sync render with 'text' lang41
42poolManager.highlightDiffAST(fileDiffInstance, diff, options)43// Queues highlighted diff render, calls fileDiffInstance.onHighlightSuccess when done44
45poolManager.getPlainDiffAST(diff, lineDiffType)46// Returns: ThemedDiffResult | undefined - Sync render with 'text' lang47
48poolManager.terminate()49// Terminates all workers and resets state50
51poolManager.getStats()52// Returns: { totalWorkers, busyWorkers, queuedTasks, pendingTasks }53
54poolManager.inspectCaches()55// Returns: { fileCache, diffCache } - LRU cache instances for debugging56
57poolManager.evictFileFromCache(cacheKey)58// Returns: boolean - Evicts a file from the cache by its cacheKey59// Returns true if the item was evicted, false if it wasn't in the cache60
61poolManager.evictDiffFromCache(cacheKey)62// Returns: boolean - Evicts a diff from the cache by its cacheKey63// Returns true if the item was evicted, false if it wasn't in the cacheThe worker pool manages a configurable number of worker threads that each initialize their own Shiki highlighter instance. Tasks are distributed across available workers, with queuing when all workers are busy.
1┌────────────── Main Thread ──────────────┐2│ ┌ React (if used) ────────────────────┐ │3│ │ <WorkerPoolContextProvider> │ │4│ │ <FileDiff /> │ │5│ │ <File /> │ │6│ │ </WorkerPoolContextProvider> │ │7│ │ │ │8│ │ * Each component manages their own │ │9│ │ instances of the Vanilla JS │ │10│ │ Classes │ │11│ └─┬───────────────────────────────────┘ │12│ │ │13│ ↓ │14│ ┌ Vanilla JS Classes ─────────────────┐ │15│ │ new FileDiff(opts, poolManager) │ │16│ │ new File(opts, poolManager) │ │17│ │ │ │18│ │ * Renders plain text synchronously │ │19│ │ * Queue requests to WorkerPool for │ │20│ │ highlighted HAST │ │21│ │ * Automatically render the │ │22│ │ highlighted HAST response │ │23│ └─┬─────────────────────────────────┬─┘ │24│ │ HAST Request ↑ │25│ ↓ HAST Response │ │26│ ┌ WorkerPoolManager ────────────────┴─┐ │27│ │ * Shared singleton │ │28│ │ * Manages WorkerPool instance and │ │29│ │ request queue │ │30│ └─┬─────────────────────────────────┬─┘ │31└───│─────────────────────────────────│───┘32 │ postMessage ↑33 ↓ HAST Response │34┌───┴───────── Worker Threads ────────│───┐35│ ┌ worker.js ────────────────────────│─┐ │36│ │ * 8 threads by default │ │ │37│ │ * Runs Shiki's codeToHast() ──────┘ │ │38│ │ * Manages themes and language │ │39│ │ loading automatically │ │40│ └─────────────────────────────────────┘ │41└─────────────────────────────────────────┘Import SSR utilities from @pierre/diffs/ssr.
The SSR API allows you to pre-render file diffs on the server with syntax highlighting, then hydrate them on the client for full interactivity.
Each preload function returns an object containing the original inputs plus a
prerenderedHTML string. This object can be spread directly into the
corresponding React component for automatic hydration.
Inputs used for pre-rendering must exactly match what's rendered in the client component. We recommend spreading the entire result object into your File or Diff component to ensure the client receives the same inputs that were used to generate the pre-rendered HTML.
1// app/diff/page.tsx (Server Component)2import { preloadMultiFileDiff } from '@pierre/diffs/ssr';3import { DiffViewer } from './DiffViewer';4
5const oldFile = {6 name: 'example.ts',7 contents: `function greet(name: string) {8 console.log("Hello, " + name);9}`,10};11
12const newFile = {13 name: 'example.ts',14 contents: `function greet(name: string) {15 console.log(\`Hello, \${name}!\`);16}`,17};18
19export default async function DiffPage() {20 const preloaded = await preloadMultiFileDiff({21 oldFile,22 newFile,23 options: { theme: 'pierre-dark', diffStyle: 'split' },24 });25
26 return <DiffViewer preloaded={preloaded} />;27}1// app/diff/DiffViewer.tsx (Client Component)2'use client';3
4import { MultiFileDiff } from '@pierre/diffs/react';5import type { PreloadMultiFileDiffResult } from '@pierre/diffs/ssr';6
7interface Props {8 preloaded: PreloadMultiFileDiffResult;9}10
11export function DiffViewer({ preloaded }: Props) {12 // Spread the entire result to ensure inputs match what was pre-rendered13 return <MultiFileDiff {...preloaded} />;14}We provide several preload functions to handle different input formats. Choose the one that matches your data source.
Preloads a single file with syntax highlighting (no diff). Use this when you
want to render a file without any diff context. Spread into the File
component.
1import { preloadFile } from '@pierre/diffs/ssr';2
3const file = {4 name: 'example.ts',5 contents: 'export function hello() { return "world"; }',6};7
8const result = await preloadFile({9 file,10 options: { theme: 'pierre-dark' },11});12
13// Spread result into <File {...result} />Preloads a diff from a FileDiffMetadata object. Use this when you already have
parsed diff metadata (e.g., from parseDiffFromFile or parsePatchFiles).
Spread into the FileDiff component.
1import { preloadFileDiff } from '@pierre/diffs/ssr';2import { parseDiffFromFile } from '@pierre/diffs';3
4const oldFile = { name: 'example.ts', contents: 'const x = 1;' };5const newFile = { name: 'example.ts', contents: 'const x = 2;' };6
7// First parse the diff to get FileDiffMetadata8const fileDiff = parseDiffFromFile(oldFile, newFile);9
10// Then preload for SSR11const result = await preloadFileDiff({12 fileDiff,13 options: { theme: 'pierre-dark' },14});15
16// Spread result into <FileDiff {...result} />Preloads a diff directly from old and new file contents. This is the simplest
option when you have the raw file contents and want to generate a diff. Spread
into the MultiFileDiff component.
1import { preloadMultiFileDiff } from '@pierre/diffs/ssr';2
3const oldFile = { name: 'example.ts', contents: 'const x = 1;' };4const newFile = { name: 'example.ts', contents: 'const x = 2;' };5
6const result = await preloadMultiFileDiff({7 oldFile,8 newFile,9 options: { theme: 'pierre-dark', diffStyle: 'split' },10});11
12// Spread result into <MultiFileDiff {...result} />Preloads a diff from a unified patch string for a single file. Use this when you
have a patch in unified diff format. Spread into the PatchDiff component.
1import { preloadPatchDiff } from '@pierre/diffs/ssr';2
3const patch = `--- a/example.ts4+++ b/example.ts5@@ -1 +1 @@6-const x = 1;7+const x = 2;`;8
9const result = await preloadPatchDiff({10 patch,11 options: { theme: 'pierre-dark' },12});13
14// Spread result into <PatchDiff {...result} />Preloads multiple diffs from a multi-file patch string. Returns an array of
results, one for each file in the patch. Each result can be spread into a
FileDiff component.
1import { preloadPatchFile } from '@pierre/diffs/ssr';2
3// A patch containing multiple file changes4const patch = `diff --git a/foo.ts b/foo.ts5--- a/foo.ts6+++ b/foo.ts7@@ -1 +1 @@8-const a = 1;9+const a = 2;10diff --git a/bar.ts b/bar.ts11--- a/bar.ts12+++ b/bar.ts13@@ -1 +1 @@14-const b = 1;15+const b = 2;`;16
17const results = await preloadPatchFile({18 patch,19 options: { theme: 'pierre-dark' },20});21
22// Spread each result into <FileDiff {...results[i]} />