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.
123456const std = @import("std");pub fn main() !void {const stdout = std.io.getStdOut().writer();try stdout.print("Hi you, {s}!\n", .{"world"});}123456const std = @import("std");pub fn main() !void {const stdout = std.io.getStdOut().writer();try stdout.print("Hello there, {s}!\n", .{"zig"});}
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.
import { type FileContents, FileDiff,} from '@pierre/diffs';
// Store file objects in variables rather than inlining them.// FileDiff uses reference equality to detect changes and skip// unnecessary re-renders, so keep these references stable.const oldFile: FileContents = { name: 'main.zig', contents: `const std = @import("std");
pub fn main() !void { const stdout = std.io.getStdOut().writer(); try stdout.print("Hi you, {s}!\\\\n", .{"world"});}`,};
const newFile: FileContents = { name: 'main.zig', contents: `const std = @import("std");
pub fn main() !void { const stdout = std.io.getStdOut().writer(); try stdout.print("Hello there, {s}!\\\\n", .{"zig"});}`,};
// We automatically detect the language based on the filename// You can also provide a lang property when instantiating FileDiff.const fileDiffInstance = new FileDiff({ theme: 'pierre-dark' });
// render() is synchronous. Syntax highlighting happens async in the// background and the diff updates automatically when complete.fileDiffInstance.render({ oldFile, newFile, // where to render the diff into containerWrapper: document.body,});Diffs is published as an npm package. Install Diffs with the package manager of your choice:
npm 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.
import type { FileContents } from '@pierre/diffs';
// FileContents represents a single fileinterface FileContents { // The filename (used for display and language detection) name: string;
// The file's text content contents: string;
// Optional: Override the detected language for syntax highlighting // See: https://shiki.style/languages lang?: SupportedLanguages;
// Optional: Cache key for AST caching in Worker Pool. // When provided, rendered AST results are cached and reused. // IMPORTANT: The key must change whenever the content, filename // or lang changes! cacheKey?: string;}
// Example usageconst file: FileContents = { // We'll attempt to detect the language based on file extension name: 'example.tsx', contents: 'export function Hello() { return <div>Hello</div>; }', cacheKey: 'example-file-v1', // Must change if contents change};
// With explicit language overrideconst jsonFile: FileContents = { // No extension, so we specify lang name: 'config', contents: '{ "key": "value" }', lang: 'json', cacheKey: 'config-file',};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).
import type { FileDiffMetadata, Hunk } from '@pierre/diffs';
// FileDiffMetadata represents the differences between two filesinterface FileDiffMetadata { // Current filename name: string;
// Previous filename (for renames) prevName: string | undefined;
// Optional: Override language for syntax highlighting. Normally // language is detected automatically base on file extension and you do not // need to set this. If you need to set a custom lang on a FileDiffMetadata // instance, use the `setLanguageOverride(diff, 'ruby')` method. lang?: SupportedLanguages;
// Type of change: 'change' | 'rename-pure' | 'rename-changed' | 'new' | 'deleted' type: ChangeTypes;
// Array of diff hunks containing the actual changes hunks: Hunk[];
// Line counts for split and unified views splitLineCount: number; unifiedLineCount: number;
// Full file contents (when generated using parseDiffFromFile, // enables expansion around hunks) oldLines?: string[]; newLines?: string[];
// Optional: Cache key for AST caching in Worker Pool. // When provided, rendered diff AST results are cached and reused. // IMPORTANT: The key must change whenever the diff changes! cacheKey?: string;}
// Hunk represents a single changed region in the diff// Think of it like the sections defined by the '@@' lines in patchesinterface Hunk { // Addition/deletion counts, parsed out from patch data additionCount: number; additionStart: number; additionLines: number; deletionCount: number; deletionStart: number; deletionLines: number;
// The actual content of the hunk (context and changes) hunkContent: (ContextContent | ChangeContent)[];
// Optional context shown in hunk headers (e.g., function name) hunkContext: string | undefined;
// Line position information, mostly used internally for // rendering optimizations splitLineStart: number; splitLineCount: number; unifiedLineStart: number; unifiedLineCount: number;}
// ContextContent represents unchanged lines surrounding changesinterface ContextContent { type: 'context'; lines: string[]; // 'true' if the file does not have a blank newline at the end noEOFCR: boolean;}
// ChangeContent represents a group of additions and deletionsinterface ChangeContent { type: 'change'; deletions: string[]; additions: string[]; // 'true' if the file does not have a blank newline at the end noEOFCRDeletions: boolean; noEOFCRAdditions: boolean;}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.
import { parseDiffFromFile, type FileContents, type FileDiffMetadata,} from '@pierre/diffs';
// Define your two file versionsconst oldFile: FileContents = { name: 'greeting.ts', contents: 'export const greeting = "Hello";', cacheKey: 'greeting-old', // Optional: enables AST caching};
const newFile: FileContents = { name: 'greeting.ts', contents: 'export const greeting = "Hello, World!";', cacheKey: 'greeting-new',};
// Generate the diff metadataconst diff: FileDiffMetadata = parseDiffFromFile(oldFile, newFile);
// The resulting diff includes oldLines and newLines,// which enables "expand unchanged" functionality in the UI.// If both files have cacheKey, the diff will have a combined// 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.
import { parsePatchFiles, type ParsedPatch, type FileDiffMetadata,} from '@pierre/diffs';
// Parse a unified diff / patch stringconst patchString = `--- a/file.ts+++ b/file.ts@@ -1,3 +1,3 @@ const x = 1;-const y = 2;+const y = 3; const z = 4;`;
// Returns an array of ParsedPatch objects (one per commit in the patch)// Pass an optional cacheKeyPrefix to enable AST caching with Worker Poolconst patches: ParsedPatch[] = parsePatchFiles(patchString, 'my-patch-key');
// Each ParsedPatch contains an array of FileDiffMetadataconst files: FileDiffMetadata[] = patches[0].files;
// With cacheKeyPrefix, each diff gets a cacheKey like "my-patch-0",// "my-patch-1", etc.// This enables AST caching in Worker Pool for parsed patches.
// Note: Diffs from patch files don't include oldLines/newLines,// 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 diffimport { type FileContents, MultiFileDiff,} from '@pierre/diffs/react';
// MultiFileDiff compares two file versions directly.// Use this when you have the old and new file contents.
// Keep file objects stable (useState/useMemo) to avoid re-renders.// The component uses reference equality for change detection.const oldFile: FileContents = { name: 'example.ts', contents: 'console.log("Hello world")',};
const newFile: FileContents = { name: 'example.ts', contents: 'console.warn("Updated message")',};
export function MyDiff() { return ( <MultiFileDiff // Required: the two file versions to compare oldFile={oldFile} newFile={newFile}
options={{ theme: { dark: 'pierre-dark', light: 'pierre-light' }, diffStyle: 'split', }}
// See "Shared Props" tabs for all available props: // lineAnnotations, renderAnnotation, renderHeaderMetadata, // renderHoverUtility, selectedLines, className, style, etc. /> );}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).
// ============================================================// SHARED OPTIONS FOR DIFF COMPONENTS// ============================================================// These options are shared by MultiFileDiff, PatchDiff, and FileDiff.// Pass them via the `options` prop.
import { MultiFileDiff } from '@pierre/diffs/react';
<MultiFileDiff {...} options={{ theme: { dark: 'pierre-dark', light: 'pierre-light' }, diffStyle: 'split', // ... see below for all available options }}/>
interface DiffOptions { // ───────────────────────────────────────────────────────────── // THEMING // ─────────────────────────────────────────────────────────────
// Theme for syntax highlighting. Can be a single theme name or an // object with 'dark' and 'light' keys for automatic switching. // Built-in options: 'pierre-dark', 'pierre-light', or any Shiki theme. // See: https://shiki.style/themes theme: { dark: 'pierre-dark', light: 'pierre-light' },
// When using dark/light theme object, this controls which is used: // 'system' (default) - follows OS preference // 'dark' or 'light' - forces specific theme themeType: 'system',
// ───────────────────────────────────────────────────────────── // DIFF DISPLAY // ─────────────────────────────────────────────────────────────
// 'split' (default) - side-by-side view // 'unified' - single column view diffStyle: 'split',
// Line change indicators: // 'bars' (default) - colored bars on left edge // 'classic' - '+' and '-' characters // 'none' - no indicators diffIndicators: 'bars',
// Show colored backgrounds on changed lines (default: true) disableBackground: false,
// ───────────────────────────────────────────────────────────── // HUNK SEPARATORS // ─────────────────────────────────────────────────────────────
// What to show between diff hunks: // 'line-info' (default) - shows collapsed line count, clickable to expand // 'metadata' - shows patch format like '@@ -60,6 +60,22 @@' // 'simple' - subtle bar separator hunkSeparators: 'line-info',
// Force unchanged context to always render (default: false) // Requires oldFile/newFile API or FileDiffMetadata with newLines expandUnchanged: false,
// Lines revealed per click when expanding collapsed regions expansionLineCount: 100,
// ───────────────────────────────────────────────────────────── // INLINE CHANGE HIGHLIGHTING // ─────────────────────────────────────────────────────────────
// Highlight changed portions within modified lines: // 'word-alt' (default) - word boundaries, minimizes single-char gaps // 'word' - word boundaries // 'char' - character-level granularity // 'none' - disable inline highlighting lineDiffType: 'word-alt',
// Skip inline diff for lines exceeding this length maxLineDiffLength: 1000,
// ───────────────────────────────────────────────────────────── // LAYOUT & DISPLAY // ─────────────────────────────────────────────────────────────
// Show line numbers (default: true) disableLineNumbers: false,
// Long line handling: 'scroll' (default) or 'wrap' overflow: 'scroll',
// Hide the file header with filename and stats disableFileHeader: false,
// Skip syntax highlighting for lines exceeding this length tokenizeMaxLineLength: 1000,
// ───────────────────────────────────────────────────────────── // LINE SELECTION // ─────────────────────────────────────────────────────────────
// Enable click-to-select on line numbers enableLineSelection: false,
// Callbacks for selection events onLineSelected(range: SelectedLineRange | null) { // Fires continuously during drag }, onLineSelectionStart(range: SelectedLineRange | null) { // Fires on mouse down }, onLineSelectionEnd(range: SelectedLineRange | null) { // Fires on mouse up - good for saving selection },
// ───────────────────────────────────────────────────────────── // MOUSE EVENTS // ─────────────────────────────────────────────────────────────
// Must be true to enable renderHoverUtility prop enableHoverUtility: false,
// Callbacks for mouse events on diff lines onLineClick({ lineNumber, side, event }) { // Fires when clicking anywhere on a line }, onLineNumberClick({ lineNumber, side, event }) { // Fires when clicking anywhere in the line number column }, onLineEnter({ lineNumber, side }) { // Fires when mouse enters a line }, onLineLeave({ lineNumber, side }) { // Fires when mouse leaves a line },}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.
import { FileDiff, type FileContents } from '@pierre/diffs';
// Create the instance with optionsconst instance = new FileDiff({ theme: { dark: 'pierre-dark', light: 'pierre-light' }, diffStyle: 'split',});
// Define your files (keep references stable to avoid re-renders)const oldFile: FileContents = { name: 'example.ts', contents: 'console.log("Hello world")',};
const newFile: FileContents = { name: 'example.ts', contents: 'console.warn("Updated message")',};
// Render the diff into a containerinstance.render({ oldFile, newFile, containerWrapper: document.getElementById('diff-container'),});
// Update options later if needed (full replacement, not merge)instance.setOptions({ ...instance.options, diffStyle: 'unified' });instance.rerender(); // Must call rerender() after updating options
// Clean up when doneinstance.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).
import { FileDiff } from '@pierre/diffs';
// All available options for the FileDiff classconst instance = new FileDiff({
// ───────────────────────────────────────────────────────────── // THEMING // ─────────────────────────────────────────────────────────────
// Theme for syntax highlighting. Can be a single theme name or an // object with 'dark' and 'light' keys for automatic switching. // Built-in options: 'pierre-dark', 'pierre-light', or any Shiki theme. // See: https://shiki.style/themes theme: { dark: 'pierre-dark', light: 'pierre-light' },
// When using dark/light theme object, this controls which is used: // 'system' (default) - follows OS preference // 'dark' or 'light' - forces specific theme themeType: 'system',
// ───────────────────────────────────────────────────────────── // DIFF DISPLAY // ─────────────────────────────────────────────────────────────
// 'split' (default) - side-by-side view // 'unified' - single column view diffStyle: 'split',
// Line change indicators: // 'bars' (default) - colored bars on left edge // 'classic' - '+' and '-' characters // 'none' - no indicators diffIndicators: 'bars',
// Show colored backgrounds on changed lines (default: true) disableBackground: false,
// ───────────────────────────────────────────────────────────── // HUNK SEPARATORS // ─────────────────────────────────────────────────────────────
// What to show between diff hunks: // 'line-info' (default) - shows collapsed line count, clickable to expand // 'metadata' - shows patch format like '@@ -60,6 +60,22 @@' // 'simple' - subtle bar separator // Or pass a function for custom rendering (see Hunk Separators section) hunkSeparators: 'line-info',
// Force unchanged context to always render (default: false) // Requires oldFile/newFile API or FileDiffMetadata with newLines expandUnchanged: false,
// Lines revealed per click when expanding collapsed regions expansionLineCount: 100,
// ───────────────────────────────────────────────────────────── // INLINE CHANGE HIGHLIGHTING // ─────────────────────────────────────────────────────────────
// Highlight changed portions within modified lines: // 'word-alt' (default) - word boundaries, minimizes single-char gaps // 'word' - word boundaries // 'char' - character-level granularity // 'none' - disable inline highlighting lineDiffType: 'word-alt',
// Skip inline diff for lines exceeding this length maxLineDiffLength: 1000,
// ───────────────────────────────────────────────────────────── // LAYOUT & DISPLAY // ─────────────────────────────────────────────────────────────
// Show line numbers (default: true) disableLineNumbers: false,
// Long line handling: 'scroll' (default) or 'wrap' overflow: 'scroll',
// Hide the file header with filename and stats disableFileHeader: false,
// Skip syntax highlighting for lines exceeding this length tokenizeMaxLineLength: 1000,
// ───────────────────────────────────────────────────────────── // LINE SELECTION // ─────────────────────────────────────────────────────────────
// Enable click-to-select on line numbers enableLineSelection: false,
// Callbacks for selection events onLineSelected(range) { // Fires continuously during drag }, onLineSelectionStart(range) { // Fires on mouse down }, onLineSelectionEnd(range) { // Fires on mouse up - good for saving selection },
// ───────────────────────────────────────────────────────────── // MOUSE EVENTS // ─────────────────────────────────────────────────────────────
// Must be true to enable renderHoverUtility enableHoverUtility: false,
// Fires when clicking anywhere on a line onLineClick({ lineNumber, side, event }) {},
// Fires when clicking anywhere in the line number column onLineNumberClick({ lineNumber, side, event }) {},
// Fires when mouse enters a line onLineEnter({ lineNumber, side }) {},
// Fires when mouse leaves a line onLineLeave({ lineNumber, side }) {},
// ───────────────────────────────────────────────────────────── // RENDER CALLBACKS // ─────────────────────────────────────────────────────────────
// Render custom content in the file header (after +/- stats) renderHeaderMetadata({ oldFile, newFile, fileDiff }) { const span = document.createElement('span'); span.textContent = fileDiff?.newName ?? ''; return span; },
// Render annotations on specific lines renderAnnotation(annotation) { const element = document.createElement('div'); element.textContent = annotation.metadata.threadId; return element; },
// Render UI in the line number column on hover // Requires enableHoverUtility: true renderHoverUtility(getHoveredLine) { const button = document.createElement('button'); button.textContent = '+'; button.addEventListener('click', () => { const { lineNumber, side } = getHoveredLine(); console.log('Clicked line', lineNumber, 'on', side); }); return button; },
});
// ─────────────────────────────────────────────────────────────// INSTANCE METHODS// ─────────────────────────────────────────────────────────────
// Render the diffinstance.render({ oldFile: { name: 'file.ts', contents: '...' }, newFile: { name: 'file.ts', contents: '...' }, lineAnnotations: [{ side: 'additions', lineNumber: 5, metadata: {} }], containerWrapper: document.body,});
// Update options (full replacement, not merge)instance.setOptions({ ...instance.options, diffStyle: 'unified' });
// Update line annotations after initial renderinstance.setLineAnnotations([ { side: 'additions', lineNumber: 5, metadata: { threadId: 'abc' } }]);
// Programmatically control selected linesinstance.setSelectedLines({ start: 12, end: 22, side: 'additions', endSide: 'deletions',});
// Force re-render (useful after changing options)instance.rerender();
// Programmatically expand a collapsed hunkinstance.expandHunk(0, 'down'); // hunkIndex, direction: 'up' | 'down' | 'all'
// Change the active theme typeinstance.setThemeType('dark'); // 'dark' | 'light' | 'system'
// Clean up (removes DOM, event listeners, clears state)instance.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:
import { FileDiff } from '@pierre/diffs';
// A hunk separator that utilizes the existing grid to have// a number column and a content column where neither will// scroll with the codeconst instance = new FileDiff({ hunkSeparators(hunkData: HunkData) { const fragment = document.createDocumentFragment(); const numCol = document.createElement('div'); numCol.textContent = `${hunkData.lines}`; numCol.style.position = 'sticky'; numCol.style.left = '0'; numCol.style.backgroundColor = 'var(--diffs-bg)'; numCol.style.zIndex = '2'; fragment.appendChild(numCol); const contentCol = document.createElement('div'); contentCol.textContent = 'unmodified lines'; contentCol.style.position = 'sticky'; contentCol.style.width = 'var(--diffs-column-content-width)'; contentCol.style.left = 'var(--diffs-column-number-width)'; fragment.appendChild(contentCol); return fragment; },})
// If you want to create a single column that spans both colums// and doesn't scroll, you can do something like this:const instance2 = new FileDiff({ hunkSeparators(hunkData: HunkData) { const wrapper = document.createElement('div'); wrapper.style.gridColumn = 'span 2'; const contentCol = document.createElement('div'); contentCol.textContent = `${hunkData.lines} unmodified lines`; contentCol.style.position = 'sticky'; contentCol.style.width = 'var(--diffs-column-width)'; contentCol.style.left = '0'; wrapper.appendChild(contentCol); return wrapper; },})
// If you want to create a single column that's aligned with the content// column and doesn't scroll, you can do something like this:const instance3 = new FileDiff({ hunkSeparators(hunkData: HunkData) { const wrapper = document.createElement('div'); wrapper.style.gridColumn = '2 / 3'; wrapper.textContent = `${hunkData.lines} unmodified lines`; wrapper.style.position = 'sticky'; wrapper.style.width = 'var(--diffs-column-content-width)'; wrapper.style.left = 'var(--diffs-column-number-width)'; return wrapper; },})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.
import { DiffHunksRenderer, type FileDiffMetadata, type HunksRenderResult, parseDiffFromFile,} from '@pierre/diffs';
const instance = new DiffHunksRenderer();
// Set options (this is a full replacement, not a merge)instance.setOptions({ theme: 'github-dark', diffStyle: 'split' });
// Parse diff content from 2 versions of a fileconst fileDiff: FileDiffMetadata = parseDiffFromFile( { name: 'file.ts', contents: 'const greeting = "Hello";' }, { name: 'file.ts', contents: 'const greeting = "Hello, World!";' });
// Render hunks (async - waits for highlighter initialization)const result: HunksRenderResult = await instance.asyncRender(fileDiff);
// result contains hast nodes for each column based on diffStyle:// - 'split' mode: additionsAST and deletionsAST (side-by-side)// - 'unified' mode: unifiedAST only (single column)// - preNode: the wrapper <pre> element as a hast node// - headerNode: the file header element// - hunkData: metadata about each hunk (for custom separators)
// Render to a complete HTML string (includes <pre> and <code> wrappers)const fullHTML: string = instance.renderFullHTML(result);
// Or render just a specific column to HTMLconst additionsHTML: string = instance.renderPartialHTML( result.additionsAST, 'additions' // wraps in <code data-additions>);
// Or render without the <code> wrapperconst rawHTML: string = instance.renderPartialHTML(result.additionsAST);
// Or get the full AST for further transformationconst 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.
import { FileRenderer, type FileContents, type FileRenderResult,} from '@pierre/diffs';
const instance = new FileRenderer();
// Set options (this is a full replacement, not a merge)instance.setOptions({ theme: 'pierre-dark', overflow: 'scroll', disableLineNumbers: false, disableFileHeader: false, // Starting line number (useful for showing snippets) startingLineNumber: 1, // Skip syntax highlighting for very long lines tokenizeMaxLineLength: 1000,});
const file: FileContents = { name: 'example.ts', contents: `function greet(name: string) { console.log(\`Hello, \${name}!\`);}
export { greet };`,};
// Render file (async - waits for highlighter initialization)const result: FileRenderResult = await instance.asyncRender(file);
// result contains:// - codeAST: array of hast ElementContent nodes for each line// - preAST: the wrapper <pre> element as a hast node// - headerAST: the file header element (if not disabled)// - totalLines: number of lines in the file// - themeStyles: CSS custom properties for theming
// Render to a complete HTML string (includes <pre> wrapper)const fullHTML: string = instance.renderFullHTML(result);
// Or render just the code lines to HTMLconst partialHTML: string = instance.renderPartialHTML(result.codeAST);
// Or get the full AST for further transformationconst 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.
import { diffAcceptRejectHunk, FileDiff, parseDiffFromFile, type FileDiffMetadata,} from '@pierre/diffs';
// Parse a diff from two file versionslet fileDiff: FileDiffMetadata = parseDiffFromFile( { name: 'file.ts', contents: 'const x = 1;\nconst y = 2;' }, { name: 'file.ts', contents: 'const x = 1;\nconst y = 3;\nconst z = 4;' });
// Create a FileDiff instanceconst instance = new FileDiff({ theme: 'pierre-dark' });
// Render the initial diff showing the changesinstance.render({ fileDiff, containerWrapper: document.getElementById('diff-container')!,});
// Accept a hunk - keeps the new (additions) version.// The hunk is converted to context lines (no longer shows as a change).// Note: If the diff has a cacheKey, it's automatically updated by // this function.fileDiff = diffAcceptRejectHunk(fileDiff, 0, 'accept');
// Or reject a hunk - reverts to the old (deletions) version.// fileDiff = diffAcceptRejectHunk(fileDiff, 0, 'reject');
// Re-render with the updated fileDiff - the accepted hunk// now appears as context lines instead of additions/deletionsinstance.render({ fileDiff, containerWrapper: document.getElementById('diff-container')!,});Dispose the shared Shiki highlighter instance to free memory. Useful when cleaning up resources in single-page applications.
import { disposeHighlighter } from '@pierre/diffs';
// Dispose the shared highlighter instance to free memory.// This is useful when you're done rendering diffs and want// to clean up resources (e.g., in a single-page app when// navigating away from a diff view).//// Note: After calling this, all themes and languages will// need to be reloaded on the next render.disposeHighlighter();Get direct access to the shared Shiki highlighter instance used internally by all components. Useful for custom highlighting operations.
import { getSharedHighlighter, DiffsHighlighter } from '@pierre/diffs';
// Get the shared Shiki highlighter instance.// This is the same instance used internally by all FileDiff// and File components. Useful if you need direct access to// Shiki for custom highlighting operations.//// The highlighter is initialized lazily - themes and languages// are loaded on demand as you render different files.const highlighter: DiffsHighlighter = await getSharedHighlighter();
// You can use it directly for custom highlighting, see the Shiki // docs at https://shiki.style/ for detailsconst tokens = highlighter.codeToTokens('const x = 1;'); 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.
import { parseDiffFromFile, type FileDiffMetadata,} from '@pierre/diffs';
// Parse a diff by comparing two versions of a file.// This is useful when you have the full file contents// rather than a patch/diff string.const oldFile = { name: 'example.ts', contents: `function greet(name: string) { console.log("Hello, " + name);}`,};
const newFile = { name: 'example.ts', contents: `function greet(name: string) { console.log(\`Hello, \${name}!\`);}
export { greet };`,};
const fileDiff: FileDiffMetadata = parseDiffFromFile(oldFile, newFile);
// fileDiff contains:// - name: the filename// - hunks: array of diff hunks with line information// - oldLines/newLines: full file contents split by line// - 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.
import { parsePatchFiles, type ParsedPatch,} from '@pierre/diffs';
// Parse unified diff / patch file content.// Handles both single patches and multi-commit patch files// (like those from GitHub PR .patch URLs).const patchContent = `diff --git a/example.ts b/example.tsindex abc123..def456 100644--- a/example.ts+++ b/example.ts@@ -1,3 +1,4 @@ function greet(name: string) {- console.log("Hello, " + name);+ console.log(\`Hello, \${name}!\`); }+export { greet };`;
// Basic usageconst patches: ParsedPatch[] = parsePatchFiles(patchContent);
// With cache key prefix for worker pool caching// Each file gets a key like 'my-pr-123-0-0', 'my-pr-123-0-1', etc.// IMPORTANT: The prefix must change when patchContent changes!// Use a stable identifier like a commit SHA or content hash.const cachedPatches = parsePatchFiles(patchContent, 'my-pr-123-abc456');
// Each ParsedPatch contains:// - message: commit message (if present)// - files: array of FileDiffMetadata for each file in the patch
for (const patch of patches) { console.log('Commit:', patch.message); for (const file of patch.files) { console.log(' File:', file.name); console.log(' Hunks:', file.hunks.length); }}Preload specific themes and languages before rendering to ensure instant highlighting with no async loading delay.
import { preloadHighlighter } from '@pierre/diffs';
// Preload specific themes and languages before rendering.// This ensures the highlighter is ready with the assets you// need, avoiding any flash of unstyled content on first render.//// By default, themes and languages are loaded on demand,// but preloading is useful when you know which languages// you'll be rendering ahead of time.await preloadHighlighter({ // Themes to preload themes: ['pierre-dark', 'pierre-light', 'github-dark'], // Languages to preload langs: ['typescript', 'javascript', 'python', 'rust', 'go'],});
// After preloading, rendering diffs in these languages// 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.
import { registerCustomTheme } from '@pierre/diffs';
// Register a custom Shiki theme before using it.// The theme name you register must match the 'name' field// inside your theme JSON file.
// Option 1: Dynamic import (recommended for code splitting)registerCustomTheme('my-custom-theme', () => import('./my-theme.json'));
// Option 2: Inline theme objectregisterCustomTheme('inline-theme', async () => ({ name: 'inline-theme', type: 'dark', colors: { 'editor.background': '#1a1a2e', 'editor.foreground': '#eaeaea', // ... other VS Code theme colors }, tokenColors: [ { scope: ['comment'], settings: { foreground: '#6a6a8a' }, }, // ... other token rules ],}));
// Once registered, use the theme name in your components:// <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.
import { setLanguageOverride, parsePatchFiles, type FileContents, type FileDiffMetadata,} from '@pierre/diffs';
// setLanguageOverride creates a new FileContents or FileDiffMetadata// with the language explicitly set. This is useful when:// - The filename doesn't have an extension// - The extension doesn't match the actual language// - You're parsing patches and need to override the detected language
// Example 1: Override language on a FileContentsconst file: FileContents = { name: 'Dockerfile', // No extension, would default to 'text' contents: 'FROM node:20\nRUN npm install',};const dockerFile = setLanguageOverride(file, 'dockerfile');
// Example 2: Override language on a FileDiffMetadataconst patches = parsePatchFiles(patchString);const diff: FileDiffMetadata = patches[0].files[0];const typescriptDiff = setLanguageOverride(diff, 'typescript');
// The function returns a new object with the lang property set,// 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.
:root { /* Available Custom CSS Variables. Most should be self explanatory */ /* Sets code font, very important */ --diffs-font-family: 'Berkeley Mono', monospace; --diffs-font-size: 14px; --diffs-line-height: 1.5; /* Controls tab character size */ --diffs-tab-size: 2; /* Font used in header and separator components, * typically not a monospace font, but it's your call */ --diffs-header-font-family: Helvetica; /* Override or customize any 'font-feature-settings' * for your code font */ --diffs-font-features: normal; /* Override the minimum width for the number column. By default * it should take into account the number of digits required * based on the lines in the file itself, but you can manually * override if desired. Generally we recommend using ch units * because they work well with monospaced fonts */ --diffs-min-number-column-width: 3ch;
/* By default we try to inherit the deletion/addition/modified * colors from the existing Shiki theme, however if you'd like * to override them, you can do so via these css variables: */ --diffs-deletion-color-override: orange; --diffs-addition-color-override: yellow; --diffs-modified-color-override: purple;
/* Line selection colors - customize the highlighting when users * select lines via enableLineSelection. These support light-dark() * for automatic theme adaptation. */ --diffs-selection-color-override: rgb(37, 99, 235); --diffs-bg-selection-override: rgba(147, 197, 253, 0.28); --diffs-bg-selection-number-override: rgba(96, 165, 250, 0.55); --diffs-bg-selection-background-override: rgba(96, 165, 250, 0.2); --diffs-bg-selection-number-background-override: rgba(59, 130, 246, 0.4);
/* Some basic variables for tweaking the layouts of some of the built in * components */ --diffs-gap-inline: 8px; --diffs-gap-block: 8px;}<FileDiff style={{ '--diffs-font-family': 'JetBrains Mono, monospace', '--diffs-font-size': '13px' } as React.CSSProperties} // ... other props/>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.
<FileDiff options={{ unsafeCSS: /* css */ `[data-line-index='0'] { border-top: 1px solid var(--diffs-bg-context);}
[data-line] { border-bottom: 1px solid var(--diffs-bg-context);}
[data-column-number] { border-right: 1px solid var(--diffs-bg-context);}` }} // ... other props/>Pierre Diffs ships with our custom open source themes, Pierre Light and Pierre Dark. We generate our themes with a custom build process that takes a shared color palette, assigns colors to specific roles for syntax highlighting, and builds JSON files and editor extensions. This makes our themes compatible with Shiki, Visual Studio Code, and Cursor.
| Editor / Platform | Source |
|---|---|
| Visual Studio Code | VS Code Marketplace |
| Cursor | Open VSX |
| Shiki | Theme repository |
While you can use any Shiki theme with Pierre Diffs by passing the theme name to
the theme option, you can also create and register custom themes compatible
with Shiki and Visual Studio Code. We recommend using our themes as a starting
point for your own custom themes—head to our themes documentation to
get started.
Themes documentation
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'.
import WorkerUrl from '@pierre/diffs/worker/worker.js?worker&url';
export function workerFactory(): Worker { return new Worker(WorkerUrl, { type: 'module' });}Workers only work in client components. Ensure your function has the 'use client' directive if using App Router.
'use client';
export function workerFactory(): Worker { return new Worker( new URL( '@pierre/diffs/worker/worker.js', import.meta.url ) );}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():
function getWebviewOptions(extensionUri: vscode.Uri): vscode.WebviewOptions { return { enableScripts: true, localResourceRoots: [ // ... your other roots vscode.Uri.joinPath( extensionUri, 'node_modules', '@pierre', 'diffs', 'dist', 'worker' ), ], };}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.
const workerScriptPath = vscode.Uri.joinPath( this._extensionUri, 'node_modules', '@pierre', 'diffs', 'dist', 'worker', 'worker-portable.js');const workerScriptUri = webview.asWebviewUri(workerScriptPath);Pass the URI to the webview via an inline script in your HTML:
<script nonce="${nonce}">window.WORKER_URI = "${workerScriptUri}";</script>Your Content Security Policy must include worker-src and connect-src:
worker-src ${webview.cspSource} blob:;connect-src ${webview.cspSource};Webview side: Declare the global type for the URI:
declare global { interface Window { WORKER_URI: string; }}Fetch the worker code and create a blob URL:
async function createWorkerBlobUrl(): Promise<string> { const response = await fetch(window.WORKER_URI); const workerCode = await response.text(); const blob = new Blob([workerCode], { type: 'application/javascript' }); return URL.createObjectURL(blob);}Create the workerFactory function:
const workerBlobUrl = await createWorkerBlobUrl();
function workerFactory() { return new Worker(workerBlobUrl);}export function workerFactory(): Worker { return new Worker( new URL( '@pierre/diffs/worker/worker.js', import.meta.url ), { type: 'module' } );}export function workerFactory(): Worker { return new Worker( new URL( '@pierre/diffs/worker/worker.js', import.meta.url ), { type: 'module' } );}If your bundler doesn't have special worker support, build and serve the worker file statically:
// For Rollup or bundlers without special worker support:// 1. Copy worker.js to your static/public folder// 2. Reference it by URL
export function workerFactory(): Worker { return new Worker('/static/workers/worker.js', { type: 'module' });}For projects without a bundler, host the worker file on your server and reference it directly:
// No bundler / Vanilla JS// Host worker.js on your server and reference it by URL
export function workerFactory() { return new Worker('/path/to/worker.js', { type: 'module' });}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().
// components/HighlightProvider.tsx'use client';
import { useWorkerPool, WorkerPoolContextProvider,} from '@pierre/diffs/react';import type { ReactNode } from 'react';import { workerFactory } from '@/utils/workerFactory';
// Create a client component that wraps children with the worker pool.// Import this in your layout to provide the worker pool to all pages.export function HighlightProvider({ children }: { children: ReactNode }) { return ( <WorkerPoolContextProvider poolOptions={{ workerFactory, // poolSize defaults to 8. More workers = more parallelism but // also more memory. Too many can actually slow things down. // poolSize: 8, }} highlighterOptions={{ theme: { dark: 'pierre-dark', light: 'pierre-light' }, // Optionally preload languages to avoid lazy-loading delays langs: ['typescript', 'javascript', 'css', 'html'], }} > {children} </WorkerPoolContextProvider> );}
// layout.tsx// import { HighlightProvider } from '@/components/HighlightProvider';//// export default function Layout({ children }) {// return (// <html>// <body>// <HighlightProvider>{children}</HighlightProvider>// </body>// </html>// );// }
// Any File, FileDiff, MultiFileDiff, or PatchDiff component nested within// the layout will automatically use the worker pool, no additional props required.
// ---
// To change render options dynamically, use the useWorkerPool hook:function ThemeSwitcher() { const workerPool = useWorkerPool();
const switchToGitHub = () => { // setRenderOptions accepts a Partial<WorkerRenderingOptions>. // Any omitted options will use defaults: // - theme: { dark: 'pierre-dark', light: 'pierre-light' } // - lineDiffType: 'word-alt' // - tokenizeMaxLineLength: 1000 void workerPool?.setRenderOptions({ theme: { dark: 'github-dark', light: 'github-light' }, }); };
return <button onClick={switchToGitHub}>Switch to GitHub theme</button>;}// WARNING: Changing render options will force all mounted components// 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.
import { FileDiff } from '@pierre/diffs';import { getOrCreateWorkerPoolSingleton, terminateWorkerPoolSingleton,} from '@pierre/diffs/worker';import { workerFactory } from './utils/workerFactory';
// Create a singleton worker pool instance using your workerFactory.// This ensures the same pool is reused across your app.const workerPool = getOrCreateWorkerPoolSingleton({ poolOptions: { workerFactory, // poolSize defaults to 8. More workers = more parallelism but // also more memory. Too many can actually slow things down. // poolSize: 8, }, highlighterOptions: { theme: { dark: 'pierre-dark', light: 'pierre-light' }, // Optionally preload languages to avoid lazy-loading delays langs: ['typescript', 'javascript', 'css', 'html'], },});
// Pass the workerPool as the second argument to FileDiffconst instance = new FileDiff( { theme: { dark: 'pierre-dark', light: 'pierre-light' } }, workerPool);
// Note: Store file objects in variables rather than inlining them.// FileDiff uses reference equality to detect changes and skip// unnecessary re-renders.const oldFile = { name: 'example.ts', contents: 'const x = 1;' };const newFile = { name: 'example.ts', contents: 'const x = 2;' };
instance.render({ oldFile, newFile, containerWrapper: document.body });
// To change render options dynamically, call setRenderOptions on the worker pool.// It accepts a Partial<WorkerRenderingOptions>. Any omitted options will use defaults:// - theme: { dark: 'pierre-dark', light: 'pierre-light' }// - lineDiffType: 'word-alt'// - tokenizeMaxLineLength: 1000await workerPool.setRenderOptions({ theme: { dark: 'github-dark', light: 'github-light' },});// WARNING: Changing render options will force all mounted components// to re-render and will clear the render cache.
// Optional: terminate workers when no longer needed (e.g., SPA navigation)// Page unload automatically cleans up workers, but for SPAs you may want// to call this when unmounting to free resources sooner.// 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.
import { getOrCreateWorkerPoolSingleton,} from '@pierre/diffs/worker';import { workerFactory } from './utils/workerFactory';
const workerPool = getOrCreateWorkerPoolSingleton({ poolOptions: { workerFactory, // Optional: configure cache size per cache (default: 100) // Two separate LRU caches are maintained: one for files, // one for diffs, so combined cache size will be double totalASTLRUCacheSize: 200, }, highlighterOptions: { theme: { dark: 'pierre-dark', light: 'pierre-light' }, },});
// Caching is enabled automatically when files/diffs have a cacheKey.// Files and diffs without cacheKey will not be cached.
const fileWithCaching = { name: 'example.ts', contents: 'const x = 42;', cacheKey: 'file-abc123', // <-- Enables caching for this file};
const fileWithoutCaching = { name: 'other.ts', contents: 'const y = 1;', // No cacheKey - will not be cached};
// IMPORTANT: The cacheKey must change whenever the content changes!// If content changes but the key stays the same, stale cached results// will be returned. Use content hashes or version numbers in your keys.const fileV1 = { name: 'file.ts', contents: 'v1', cacheKey: 'file-v1' };const fileV2 = { name: 'file.ts', contents: 'v2', cacheKey: 'file-v2' };
// Cache key best practices:// - DON'T use file contents as the key - large strings potentially// waste memory// - DON'T rely solely on filenames - they may not be unique or stable// - DO use stable identifiers like commit SHAs, file IDs, or version numbers// - DO combine identifiers when needed: `${fileId}-${version}`
// How caching works:// - Files/diffs with cacheKey are stored in an LRU cache after rendering// - Subsequent renders with the same cacheKey return cached results instantly// - No worker processing required for cache hits// - Cache is validated against render options (e.g., theme, lineDiffType)// - If options changed, cached result is skipped and re-rendered// - Cache is cleared when the pool is terminated
// Inspect cache contents (for debugging)const { fileCache, diffCache } = workerPool.inspectCaches();console.log('Cached files:', fileCache.size);console.log('Cached diffs:', diffCache.size);
// Evict specific items from the cache when content is invalidated// (e.g., user edits a file, new commit is pushed)workerPool.evictFileFromCache('file-abc123');workerPool.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.
// WorkerPoolManager constructornew WorkerPoolManager(poolOptions, highlighterOptions)
// Parameters:// - poolOptions: WorkerPoolOptions// - workerFactory: () => Worker - Function that creates a Worker instance// - poolSize?: number (default: 8) - Number of workers// - totalASTLRUCacheSize?: number (default: 100) - Max items per cache// (Two separate LRU caches are maintained: one for files, one for diffs.// Each cache has this limit, so total cached items can be 2x this value.)// - highlighterOptions: WorkerInitializationRenderOptions// - theme?: DiffsThemeNames | ThemesType - Theme name or { dark, light } object// - lineDiffType?: 'word' | 'word-alt' | 'char' - How to diff lines (default: 'word-alt')// - tokenizeMaxLineLength?: number - Max line length to tokenize (default: 1000)// - langs?: SupportedLanguages[] - Array of languages to preload
// Methods:poolManager.initialize()// Returns: Promise<void> - Initializes workers (auto-called on first render)
poolManager.isInitialized()// Returns: boolean
poolManager.setRenderOptions(options)// Returns: Promise<void> - Changes render options dynamically// Accepts: Partial<WorkerRenderingOptions>// - theme?: DiffsThemeNames | ThemesType// - lineDiffType?: 'word' | 'word-alt' | 'char'// - tokenizeMaxLineLength?: number// Omitted options will use defaults. WARNING: This forces all mounted// components to re-render and clears the render cache.
poolManager.getRenderOptions()// Returns: WorkerRenderingOptions - Current render options (copy)
poolManager.highlightFileAST(fileInstance, file, options)// Queues highlighted file render, calls fileInstance.onHighlightSuccess when done
poolManager.getPlainFileAST(file, startingLineNumber?)// Returns: ThemedFileResult | undefined - Sync render with 'text' lang
poolManager.highlightDiffAST(fileDiffInstance, diff, options)// Queues highlighted diff render, calls fileDiffInstance.onHighlightSuccess when done
poolManager.getPlainDiffAST(diff, lineDiffType)// Returns: ThemedDiffResult | undefined - Sync render with 'text' lang
poolManager.terminate()// Terminates all workers and resets state
poolManager.getStats()// Returns: { totalWorkers, busyWorkers, queuedTasks, pendingTasks }
poolManager.inspectCaches()// Returns: { fileCache, diffCache } - LRU cache instances for debugging
poolManager.evictFileFromCache(cacheKey)// Returns: boolean - Evicts a file from the cache by its cacheKey// Returns true if the item was evicted, false if it wasn't in the cache
poolManager.evictDiffFromCache(cacheKey)// Returns: boolean - Evicts a diff from the cache by its cacheKey// 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.
┌────────────── Main Thread ──────────────┐│ ┌ React (if used) ────────────────────┐ ││ │ <WorkerPoolContextProvider> │ ││ │ <FileDiff /> │ ││ │ <File /> │ ││ │ </WorkerPoolContextProvider> │ ││ │ │ ││ │ * Each component manages their own │ ││ │ instances of the Vanilla JS │ ││ │ Classes │ ││ └─┬───────────────────────────────────┘ ││ │ ││ ↓ ││ ┌ Vanilla JS Classes ─────────────────┐ ││ │ new FileDiff(opts, poolManager) │ ││ │ new File(opts, poolManager) │ ││ │ │ ││ │ * Renders plain text synchronously │ ││ │ * Queue requests to WorkerPool for │ ││ │ highlighted HAST │ ││ │ * Automatically render the │ ││ │ highlighted HAST response │ ││ └─┬─────────────────────────────────┬─┘ ││ │ HAST Request ↑ ││ ↓ HAST Response │ ││ ┌ WorkerPoolManager ────────────────┴─┐ ││ │ * Shared singleton │ ││ │ * Manages WorkerPool instance and │ ││ │ request queue │ ││ └─┬─────────────────────────────────┬─┘ │└───│─────────────────────────────────│───┘ │ postMessage ↑ ↓ HAST Response │┌───┴───────── Worker Threads ────────│───┐│ ┌ worker.js ────────────────────────│─┐ ││ │ * 8 threads by default │ │ ││ │ * Runs Shiki's codeToHast() ──────┘ │ ││ │ * Manages themes and language │ ││ │ loading automatically │ ││ └─────────────────────────────────────┘ │└─────────────────────────────────────────┘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.
// app/diff/page.tsx (Server Component)import { preloadMultiFileDiff } from '@pierre/diffs/ssr';import { DiffViewer } from './DiffViewer';
const oldFile = { name: 'example.ts', contents: `function greet(name: string) { console.log("Hello, " + name);}`,};
const newFile = { name: 'example.ts', contents: `function greet(name: string) { console.log(\`Hello, \${name}!\`);}`,};
export default async function DiffPage() { const preloaded = await preloadMultiFileDiff({ oldFile, newFile, options: { theme: 'pierre-dark', diffStyle: 'split' }, });
return <DiffViewer preloaded={preloaded} />;}// app/diff/DiffViewer.tsx (Client Component)'use client';
import { MultiFileDiff } from '@pierre/diffs/react';import type { PreloadMultiFileDiffResult } from '@pierre/diffs/ssr';
interface Props { preloaded: PreloadMultiFileDiffResult;}
export function DiffViewer({ preloaded }: Props) { // Spread the entire result to ensure inputs match what was pre-rendered return <MultiFileDiff {...preloaded} />;}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.
import { preloadFile } from '@pierre/diffs/ssr';
const file = { name: 'example.ts', contents: 'export function hello() { return "world"; }',};
const result = await preloadFile({ file, options: { theme: 'pierre-dark' },});
// 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.
import { preloadFileDiff } from '@pierre/diffs/ssr';import { parseDiffFromFile } from '@pierre/diffs';
const oldFile = { name: 'example.ts', contents: 'const x = 1;' };const newFile = { name: 'example.ts', contents: 'const x = 2;' };
// First parse the diff to get FileDiffMetadataconst fileDiff = parseDiffFromFile(oldFile, newFile);
// Then preload for SSRconst result = await preloadFileDiff({ fileDiff, options: { theme: 'pierre-dark' },});
// 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.
import { preloadMultiFileDiff } from '@pierre/diffs/ssr';
const oldFile = { name: 'example.ts', contents: 'const x = 1;' };const newFile = { name: 'example.ts', contents: 'const x = 2;' };
const result = await preloadMultiFileDiff({ oldFile, newFile, options: { theme: 'pierre-dark', diffStyle: 'split' },});
// 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.
import { preloadPatchDiff } from '@pierre/diffs/ssr';
const patch = `--- a/example.ts+++ b/example.ts@@ -1 +1 @@-const x = 1;+const x = 2;`;
const result = await preloadPatchDiff({ patch, options: { theme: 'pierre-dark' },});
// 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.
import { preloadPatchFile } from '@pierre/diffs/ssr';
// A patch containing multiple file changesconst patch = `diff --git a/foo.ts b/foo.ts--- a/foo.ts+++ b/foo.ts@@ -1 +1 @@-const a = 1;+const a = 2;diff --git a/bar.ts b/bar.ts--- a/bar.ts+++ b/bar.ts@@ -1 +1 @@-const b = 1;+const b = 2;`;
const results = await preloadPatchFile({ patch, options: { theme: 'pierre-dark' },});
// Spread each result into <FileDiff {...results[i]} />