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.
It‘s in the name, it‘s probably why you‘re here. 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 (available in both JavaScript and React versions).
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/precision-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});420npm install @pierre/precision-diffsThe package provides several entry points for different use cases:
@pierre/precision-diffs — Vanilla JS components and utility functions for parsing and rendering diffs@pierre/precision-diffs/react — React components for rendering diffs with full interactivity@pierre/precision-diffs/ssr — Server-side rendering utilities for pre-rendering diffs with syntax highlighting@pierre/precision-diffs/worker — Worker pool utilities for offloading syntax highlighting to background threadsYou can import the React components from @pierre/precision-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 (compare two file versions), PatchDiff (render from a patch string), FileDiff (render a pre-parsed FileDiffMetadata), and File (render a single code file without diff).
1import {2 type FileContents,3 MultiFileDiff,4} from '@pierre/precision-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 // Language is auto-detected from filename.29 // Override with options.lang if needed.30 options={{31 theme: { dark: 'pierre-dark', light: 'pierre-light' },32 diffStyle: 'split',33 }}34
35 // See "Shared Props" tabs for all available props:36 // lineAnnotations, renderAnnotation, renderHeaderMetadata,37 // renderHoverUtility, selectedLines, className, style, etc.38 />39 );40}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/precision-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 // Override automatic language detection (usually not needed96 // because we detect language automatically based on file name)97 // See: https://shiki.style/languages98 // lang: 'typescript',99
100 // Skip syntax highlighting for lines exceeding this length101 tokenizeMaxLineLength: 1000,102
103 // ─────────────────────────────────────────────────────────────104 // LINE SELECTION105 // ─────────────────────────────────────────────────────────────106
107 // Enable click-to-select on line numbers108 enableLineSelection: false,109
110 // Callbacks for selection events111 onLineSelected(range: SelectedLineRange | null) {112 // Fires continuously during drag113 },114 onLineSelectionStart(range: SelectedLineRange | null) {115 // Fires on mouse down116 },117 onLineSelectionEnd(range: SelectedLineRange | null) {118 // Fires on mouse up - good for saving selection119 },120
121 // ─────────────────────────────────────────────────────────────122 // MOUSE EVENTS123 // ─────────────────────────────────────────────────────────────124
125 // Must be true to enable renderHoverUtility prop126 enableHoverUtility: false,127
128 // Callbacks for mouse events on diff lines129 onLineClick({ lineNumber, side, event }) {130 // Fires when clicking anywhere on a line131 },132 onLineNumberClick({ lineNumber, side, event }) {133 // Fires when clicking anywhere in the line number column134 },135 onLineEnter({ lineNumber, side }) {136 // Fires when mouse enters a line137 },138 onLineLeave({ lineNumber, side }) {139 // Fires when mouse leaves a line140 },141}You can import the vanilla JavaScript classes, components and methods from @pierre/precision-diffs
We offer two components, FileDiff for rendering diffs, and File for rendering plain files. Typically you'll want to interface with these as they'll handle all the complicated aspects of syntax highlighting and themeing for you.
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).
1import { FileDiff, type FileContents } from '@pierre/precision-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/precision-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 // Override automatic language detection (usually not needed)84 // See: https://shiki.style/languages85 // lang: 'typescript',86
87 // Skip syntax highlighting for lines exceeding this length88 tokenizeMaxLineLength: 1000,89
90 // ─────────────────────────────────────────────────────────────91 // LINE SELECTION92 // ─────────────────────────────────────────────────────────────93
94 // Enable click-to-select on line numbers95 enableLineSelection: false,96
97 // Callbacks for selection events98 onLineSelected(range) {99 // Fires continuously during drag100 },101 onLineSelectionStart(range) {102 // Fires on mouse down103 },104 onLineSelectionEnd(range) {105 // Fires on mouse up - good for saving selection106 },107
108 // ─────────────────────────────────────────────────────────────109 // MOUSE EVENTS110 // ─────────────────────────────────────────────────────────────111
112 // Must be true to enable renderHoverUtility113 enableHoverUtility: false,114
115 // Fires when clicking anywhere on a line116 onLineClick({ lineNumber, side, event }) {},117
118 // Fires when clicking anywhere in the line number column119 onLineNumberClick({ lineNumber, side, event }) {},120
121 // Fires when mouse enters a line122 onLineEnter({ lineNumber, side }) {},123
124 // Fires when mouse leaves a line125 onLineLeave({ lineNumber, side }) {},126
127 // ─────────────────────────────────────────────────────────────128 // RENDER CALLBACKS129 // ─────────────────────────────────────────────────────────────130
131 // Render custom content in the file header (after +/- stats)132 renderHeaderMetadata({ oldFile, newFile, fileDiff }) {133 const span = document.createElement('span');134 span.textContent = fileDiff?.newName ?? '';135 return span;136 },137
138 // Render annotations on specific lines139 renderAnnotation(annotation) {140 const element = document.createElement('div');141 element.textContent = annotation.metadata.threadId;142 return element;143 },144
145 // Render UI in the line number column on hover146 // Requires enableHoverUtility: true147 renderHoverUtility(getHoveredLine) {148 const button = document.createElement('button');149 button.textContent = '+';150 button.addEventListener('click', () => {151 const { lineNumber, side } = getHoveredLine();152 console.log('Clicked line', lineNumber, 'on', side);153 });154 return button;155 },156
157});158
159// ─────────────────────────────────────────────────────────────160// INSTANCE METHODS161// ─────────────────────────────────────────────────────────────162
163// Render the diff164instance.render({165 oldFile: { name: 'file.ts', contents: '...' },166 newFile: { name: 'file.ts', contents: '...' },167 lineAnnotations: [{ side: 'additions', lineNumber: 5, metadata: {} }],168 containerWrapper: document.body,169});170
171// Update options (full replacement, not merge)172instance.setOptions({ ...instance.options, diffStyle: 'unified' });173
174// Update line annotations after initial render175instance.setLineAnnotations([176 { side: 'additions', lineNumber: 5, metadata: { threadId: 'abc' } }177]);178
179// Programmatically control selected lines180instance.setSelectedLines({181 start: 12,182 end: 22,183 side: 'additions',184 endSide: 'deletions',185});186
187// Force re-render (useful after changing options)188instance.rerender();189
190// Programmatically expand a collapsed hunk191instance.expandHunk(0, 'down'); // hunkIndex, direction: 'up' | 'down' | 'all'192
193// Change the active theme type194instance.setThemeType('dark'); // 'dark' | 'light' | 'system'195
196// Clean up (removes DOM, event listeners, clears state)197instance.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/precision-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(--pjs-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(--pjs-column-content-width)';20 contentCol.style.left = 'var(--pjs-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(--pjs-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(--pjs-column-content-width)';51 wrapper.style.left = 'var(--pjs-column-number-width)';52 return wrapper;53 },54})Note: 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 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/precision-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/precision-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);You can import these utility functions from @pierre/precision-diffs
These utilities 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/precision-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).25fileDiff = diffAcceptRejectHunk(fileDiff, 0, 'accept');26
27// Or reject a hunk - reverts to the old (deletions) version.28// fileDiff = diffAcceptRejectHunk(fileDiff, 0, 'reject');29
30// Re-render with the updated fileDiff - the accepted hunk31// now appears as context lines instead of additions/deletions32instance.render({33 fileDiff,34 containerWrapper: document.getElementById('diff-container')!,35});Dispose the shared Shiki highlighter instance to free memory. Useful when cleaning up resources in single-page applications.
1import { disposeHighlighter } from '@pierre/precision-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/precision-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.
1import {2 parseDiffFromFile,3 type FileDiffMetadata,4} from '@pierre/precision-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 PR .patch URLs).
1import {2 parsePatchFiles,3 type ParsedPatch,4} from '@pierre/precision-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
21const patches: ParsedPatch[] = parsePatchFiles(patchContent);22
23// Each ParsedPatch contains:24// - message: commit message (if present)25// - files: array of FileDiffMetadata for each file in the patch26
27for (const patch of patches) {28 console.log('Commit:', patch.message);29 for (const file of patch.files) {30 console.log(' File:', file.name);31 console.log(' Hunks:', file.hunks.length);32 }33}Preload specific themes and languages before rendering to ensure instant highlighting with no async loading delay.
1import { preloadHighlighter } from '@pierre/precision-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/precision-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' }} ... />Diff and code are rendered using shadow DOM APIs. This means that the styles applied to the diffs will be well isolated from your page's existing CSS. However, it also means if you want to customize the built-in styles, you'll have to utilize some custom CSS variables. These can be done either 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 --pjs-font-family: 'Berkeley Mono', monospace;5 --pjs-font-size: 14px;6 --pjs-line-height: 1.5;7 /* Controls tab character size */8 --pjs-tab-size: 2;9 /* Font used in header and separator components,10 * typically not a monospace font, but it's your call */11 --pjs-header-font-family: Helvetica;12 /* Override or customize any 'font-feature-settings'13 * for your code font */14 --pjs-font-features: normal;15 /* Override the minimum width for the number column. Be default16 * it should accomodate 4 numbers with padding at a value 17 * of 7ch (the default) */18 --pjs-min-number-column-width: 7ch;19
20 /* By default we try to inherit the deletion/addition/modified21 * colors from the existing Shiki theme, however if you'd like22 * to override them, you can do so via these css variables: */23 --pjs-deletion-color-override: orange;24 --pjs-addition-color-override: yellow;25 --pjs-modified-color-override: purple;26
27 /* Line selection colors - customize the highlighting when users28 * select lines via enableLineSelection. These support light-dark()29 * for automatic theme adaptation. */30 --pjs-selection-color-override: rgb(37, 99, 235);31 --pjs-bg-selection-override: rgba(147, 197, 253, 0.28);32 --pjs-bg-selection-number-override: rgba(96, 165, 250, 0.55);33 --pjs-bg-selection-background-override: rgba(96, 165, 250, 0.2);34 --pjs-bg-selection-number-background-override: rgba(59, 130, 246, 0.4);35
36 /* Some basic variables for tweaking the layouts of some of the built in37 * components */38 --pjs-gap-inline: 8px;39 --pjs-gap-block: 8px;40}1<FileDiff2 style={{3 '--pjs-font-family': 'JetBrains Mono, monospace',4 '--pjs-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 the style change that you're looking for.
1<FileDiff2 options={{3 unsafeCSS: /* css */ `[data-pjs] {4 border: 2px solid #C635E4;5 border-bottom-left-radius: 8px;6 border-bottom-right-radius: 8px; }`7 }}8 // ... other props9/>You can import the worker utilities from @pierre/precision-diffs/worker
By default, syntax highlighting runs on the main thread using Shiki. If you are 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. Basically you will 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 that workerFactory function. We've provided some examples for common use cases below.
Side note, we've only tested the Vite and NextJS examples, the rest were generated by AI. If any of them are incorrect, please let us know
1import ShikiWorkerUrl from '@pierre/precision-diffs/worker/shiki-worker.js?worker&url';2
3export function workerFactory(): Worker {4 return new Worker(ShikiWorkerUrl, { type: 'module' });5}Important: Workers only work in client components. Make sure 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/precision-diffs/worker/shiki-worker.js',7 import.meta.url8 ),9 { type: 'module' }10 );11}1export function workerFactory(): Worker {2 return new Worker(3 new URL(4 '@pierre/precision-diffs/worker/shiki-worker.js',5 import.meta.url6 ),7 { type: 'module' }8 );9}1export function workerFactory(): Worker {2 return new Worker(3 new URL(4 '@pierre/precision-diffs/worker/shiki-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 shiki-worker.js to your static/public folder3// 2. Reference it by URL4
5export function workerFactory(): Worker {6 return new Worker('/static/workers/shiki-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 shiki-worker.js on your server and reference it by URL3
4export function workerFactory() {5 return new Worker('/path/to/shiki-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.
Note: When using the worker pool, theme settings are controlled by the pool manager, not individual component props. Any theme options passed to File or FileDiff components will be ignored. To change the theme, use the setTheme() method on the pool manager. All connected instances will automatically re-render with the new theme.
Wrap your component tree with WorkerPoolContextProvider from @pierre/precision-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.
Important: The worker pool only works in client components. Make sure your provider is in a component with the 'use client' directive if you're using NextJS App Router. Workers cannot be instantiated during server-side rendering.
To change themes dynamically, use the useWorkerPool() hook to access the pool manager and call setTheme().
1// components/HighlightProvider.tsx2'use client';3
4import {5 useWorkerPool,6 WorkerPoolContextProvider,7} from '@pierre/precision-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 themes dynamically, use the useWorkerPool hook:52function ThemeSwitcher() {53 const workerPool = useWorkerPool();54
55 const switchToGitHub = () => {56 void workerPool?.setTheme({ dark: 'github-dark', light: 'github-light' });57 };58
59 return <button onClick={switchToGitHub}>Switch to GitHub theme</button>;60}61// All connected File, FileDiff, MultiFileDiff, and PatchDiff instances62// will automatically re-render with the new theme once it has loaded.Simply 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 dynamically, call workerPool.setTheme(theme) on the pool instance.
1import { FileDiff } from '@pierre/precision-diffs';2import {3 getOrCreateWorkerPoolSingleton,4 terminateWorkerPoolSingleton,5} from '@pierre/precision-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 themes dynamically, call setTheme on the worker pool:39await workerPool.setTheme({ dark: 'github-dark', light: 'github-light' });40// All connected File and FileDiff instances will automatically re-render41// with the new theme once it has loaded.42
43// Optional: terminate workers when no longer needed (e.g., SPA navigation)44// Page unload automatically cleans up workers, but for SPAs you may want45// to call this when unmounting to free resources sooner.46// terminateWorkerPoolSingleton();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// - highlighterOptions: WorkerHighlighterOptions9// - theme: PJSThemeNames | ThemesType - Theme name or { dark, light } object10// - langs?: SupportedLanguages[] - Array of languages to preload11// - preferWasmHighlighter?: boolean - Use WASM highlighter12
13// Methods:14poolManager.initialize()15// Returns: Promise<void> - Initializes workers (auto-called on first render)16
17poolManager.isInitialized()18// Returns: boolean19
20poolManager.setTheme(theme)21// Returns: Promise<void> - Changes the active theme22
23poolManager.highlightFileAST(fileInstance, file, options)24// Queues highlighted file render, calls fileInstance.onHighlightSuccess when done25
26poolManager.getPlainFileAST(file, startingLineNumber?)27// Returns: ThemedFileResult | undefined - Sync render with 'text' lang28
29poolManager.highlightDiffAST(fileDiffInstance, diff, options)30// Queues highlighted diff render, calls fileDiffInstance.onHighlightSuccess when done31
32poolManager.getPlainDiffAST(diff, lineDiffType)33// Returns: ThemedDiffResult | undefined - Sync render with 'text' lang34
35poolManager.terminate()36// Terminates all workers and resets state37
38poolManager.getStats()39// Returns: { totalWorkers, busyWorkers, queuedTasks, pendingTasks }The 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│ ┌ shiki-worker.js ──────────────────│─┐ │36│ │ * 8 threads by default │ │ │37│ │ * Runs Shiki's codeToHast() ──────┘ │ │38│ │ * Manages themes and language │ │39│ │ loading automatically │ │40│ └─────────────────────────────────────┘ │41└─────────────────────────────────────────┘You can import the SSR utilities from @pierre/precision-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.
Important: The inputs used for pre-rendering must exactly match what's rendered in the client component. This is why we recommend spreading the entire result object—it ensures the client receives the same inputs that were used to generate the prerendered HTML.
1// app/diff/page.tsx (Server Component)2import { preloadMultiFileDiff } from '@pierre/precision-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/precision-diffs/react';5import type { PreloadMultiFileDiffResult } from '@pierre/precision-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/precision-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/precision-diffs/ssr';2import { parseDiffFromFile } from '@pierre/precision-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/precision-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/precision-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/precision-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]} />