Install the Precision Diffs package using bun, pnpm, npm, or yarn:
1bun add @pierre/precision-diffsPrecision Diffs is in early active development—APIs are subject to change.
Precision 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");2
3pub fn main() !void {4 const stdout = std.io.getStdOut().writer();5 try stdout.print("Hi you, {s}!\n", .{"world"});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 easy to use APIs for how you might 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 both these approaches below, in both JavaScript and React.
1import {2 type FileContents,3 FileDiff,4} from '@pierre/precision-diffs';5
6// Comparing two files7const oldFile: FileContents = {8 name: 'main.zig',9 contents: `const std = @import("std");10
11pub fn main() !void {12 const stdout = std.io.getStdOut().writer();13 try stdout.print("Hi you, {s}!\\\\n", .{"world"});14}15`,16};17
18const newFile: FileContents = {19 name: 'main.zig',20 contents: `const std = @import("std");21
22pub fn main() !void {23 const stdout = std.io.getStdOut().writer();24 try stdout.print("Hello there, {s}!\\\\n", .{"zig"});25}26`,27};28
29// We automatically detect the language based on the filename30// You can also provide a lang property when instantiating FileDiff.31const fileDiffInstance = new FileDiff({ theme: 'pierre-dark' });32
33// Render is awaitable if you need that34await fileDiffInstance.render({35 oldFile,36 newFile,37 // where to render the diff into38 containerWrapper: document.body,39});Right now the React API exposes two main components, FileDiff (for rendering diffs for a specific file) and File for rendering just a single code file. We plan to add more components like a file picker and tools for virtualization of longer diffs in the future.
You can import the react components from @pierre/precision-diffs/react
1import {2 type FileContents,3 type DiffLineAnnotation,4 FileDiff,5} from '@pierre/precision-diffs/react';6
7const oldFile: FileContents = {8 name: 'filename.ts',9 contents: 'console.log("Hello world")',10};11
12const newFile: FileContents = {13 name: 'filename.ts',14 contents: 'console.warn("Uh oh")',15};16
17interface ThreadMetadata {18 threadId: string;19}20
21// Annotation metadata can be typed any way you'd like22const lineAnnotations: DiffLineAnnotation<ThreadMetadata>[] = [23 {24 side: 'additions',25 // The line number specified for an annotation is the visual line26 // number you see in the number column of a diff27 lineNumber: 16,28 metadata: { threadId: '68b329da9893e34099c7d8ad5cb9c940' },29 },30];31
32// Comparing two files33export function SingleDiff() {34 return (35 <FileDiff<ThreadMetadata>36 // We automatically detect the language based on filename37 // You can also provide 'lang' property in 'options' when38 // rendering FileDiff.39 oldFile={oldFile}40 newFile={newFile}41 lineAnnotations={lineAnnotations}42 renderLineAnnotation={(annotation: DiffLineAnnotation) => {43 // Despite the diff itself being rendered in the shadow dom,44 // annotations are inserted via the web components 'slots'45 // api and you can use all your normal normal css and styling46 // for them47 return <CommentThread threadId={annotation.metadata.threadId} />;48 }}49 // Here's every property you can pass to options, with their50 // default values if not specified. However its generally a51 // good idea to pass a 'theme' or 'themes' property52 options={{53 // You can provide a 'theme' prop that maps to any54 // built in shiki theme or you can register a custom55 // theme. We also include 2 custom themes56 //57 // 'pierre-dark' and 'pierre-light58 //59 // For the rest of the available shiki themes, check out:60 // https://shiki.style/themes61 theme: 'none',62 // Or can also provide a 'themes' prop, which allows the code63 // to adapt to your OS light or dark theme64 // themes: { dark: 'pierre-dark', light: 'pierre-light' },65
66 // When using the 'themes' prop, 'themeType' allows you to67 // force 'dark' or 'light' theme, or inherit from the68 // OS ('system') theme.69 themeType: 'system',70
71 // Disable the line numbers for your diffs, generally 72 // not recommended73 disableLineNumbers: false,74
75 // Whether code should 'wrap' with long lines or 'scroll'.76 overflow: 'scroll',77
78 // Normally you shouldn't need this prop, but if you don't 79 // provide a valid filename or your file doesn't have an 80 // extension you may want to override the automatic detection81 // You can specify that language here:82 // https://shiki.style/languages83 // lang?: SupportedLanguages;84
85 // 'diffStyle' controls whether the diff is presented side by 86 // side or in a unified (single column) view87 diffStyle: 'split',88
89 // Line decorators to help highlight changes.90 // 'bars' (default):91 // Shows some red-ish or green-ish (theme dependent) bars on the92 // left edge of relevant lines93 //94 // 'classic':95 // shows '+' characters on additions and '-' characters96 // on deletions97 //98 // 'none':99 // No special diff indicators are shown100 diffIndicators: 'bars',101
102 // By default green-ish or red-ish background are shown on added103 // and deleted lines respectively. Disable that feature here104 disableBackground: false,105
106 // Diffs are split up into hunks, this setting customizes what 107 // to show between each hunk.108 //109 // 'line-info' (default):110 // Shows a bar that tells you how many lines are collapsed. If 111 // you are using the oldFile/newFile API then you can click those112 // bars to expand the content between them113 //114 // 'metadata':115 // Shows the content you'd see in a normal patch file, usually in116 // some format like '@@ -60,6 +60,22 @@'. You cannot use these to117 // expand hidden content118 //119 // 'simple':120 // Just a subtle bar separator between each hunk121 hunkSeparators: 'line-info',122
123 // On lines that have both additions and deletions, we can run a124 // separate diff check to mark parts of the lines that change.125 // 'none':126 // Do not show these secondary highlights127 //128 // 'char':129 // Show changes at a per character granularity130 //131 // 'word':132 // Show changes but rounded up to word boundaries133 //134 // 'word-alt' (default):135 // Similar to 'word', however we attempt to minimize single136 // character gaps between highlighted changes137 lineDiffType: 'word-alt',138
139 // If lines exceed these character lengths then we won't perform140 // the line lineDiffType check141 maxLineDiffLength: 1000,142
143 // If any line in the diff exceeds this value then we won't 144 // attempt to syntax highlight the diff145 maxLineLengthForHighlighting: 1000,146
147 // Enabling this property will hide the file header with file 148 // name and diff stats.149 disableFileHeader: false,150 }}151 />152 );153}Alternatively, if you already have a unified diff for a single file, pass it via the patch prop instead of oldFile and newFile.
1import { FileDiff } from '@pierre/precision-diffs/react';2
3const patch = `diff --git a/foo.ts b/foo.ts4--- a/foo.ts5+++ b/foo.ts6@@ -1,3 +1,3 @@7-console.log("Hello world");8+console.warn("Uh oh");9`;10
11export function SingleDiffFromPatch() {12 return <FileDiff patch={patch} />;13}The vanilla JS api for Precision Diffs exposes a mix of components and raw classes. The components and the React API are built on many of these foundation classes. The goal has been to abstract away a lot of the heavy lifting when working with Shiki directly and provide a set of standardized APIs that can be used with any framework and even server rendered if necessary.
You can import all of this via the core package @pierre/precision-diffs
There are two core components in the vanilla js API, FileDiff and File
1import {2 type FileContents,3 FileDiff,4 type DiffLineAnnotation,5} from '@pierre/precision-diffs';6
7const oldFile: FileContents = {8 name: 'filename.ts',9 contents: 'console.log("Hello world")',10};11
12const newFile: FileContents = {13 name: 'filename.ts',14 contents: 'console.warn("Uh oh")',15};16
17interface ThreadMetadata {18 threadId: string;19}20
21// Annotation metadata can be typed any way you'd like22const lineAnnotations: DiffLineAnnotation<ThreadMetadata>[] = [23 {24 side: 'additions',25 // The line number specified for an annotation is the visual line 26 // number you see in the number column of a diff27 lineNumber: 16,28 metadata: { threadId: '68b329da9893e34099c7d8ad5cb9c940' },29 },30];31
32const instance = new FileDiff<ThreadMetadata>({33 // You can provide a 'theme' prop that maps to any34 // built in shiki theme or you can register a custom35 // theme. We also include 2 custom themes36 //37 // 'pierre-dark' and 'pierre-light38 //39 // For the rest of the available shiki themes, check out:40 // https://shiki.style/themes41 theme: 'none',42 // Or can also provide a 'themes' prop, which allows the code to 43 // adapt to your OS light or dark theme44 // themes: { dark: 'pierre-dark', light: 'pierre-light' },45
46 // When using the 'themes' prop, 'themeType' allows you to force 47 // 'dark' or 'light' theme, or inherit from the OS ('system') theme.48 themeType: 'system',49
50 // Disable the line numbers for your diffs, generally not recommended51 disableLineNumbers: false,52
53 // Whether code should 'wrap' with long lines or 'scroll'.54 overflow: 'scroll',55
56 // Normally you shouldn't need this prop, but if you don't provide a57 // valid filename or your file doesn't have an extension you may want58 // to override the automatic detection. You can specify that 59 // language here:60 // https://shiki.style/languages61 // lang?: SupportedLanguages;62
63 // 'diffStyle' controls whether the diff is presented side by side or64 // in a unified (single column) view65 diffStyle: 'split',66
67 // Line decorators to help highlight changes.68 // 'bars' (default):69 // Shows some red-ish or green-ish (theme dependent) bars on the left70 // edge of relevant lines71 //72 // 'classic':73 // shows '+' characters on additions and '-' characters on deletions74 //75 // 'none':76 // No special diff indicators are shown77 diffIndicators: 'bars',78
79 // By default green-ish or red-ish background are shown on added and80 // deleted lines respectively. Disable that feature here81 disableBackground: false,82
83 // Diffs are split up into hunks, this setting customizes what to84 // show between each hunk.85 //86 // 'line-info' (default):87 // Shows a bar that tells you how many lines are collapsed. If you88 // are using the oldFile/newFile API then you can click those bars89 // to expand the content between them90 //91 // (hunk: HunkData) => HTMLElement | DocumentFragment:92 // If you want to fully customize what gets displayed for hunks you93 // can pass a custom function to generate dom nodes to render.94 // 'hunkData' will include the number of lines collapsed as well as95 // the 'type' of column you are rendering into. Bear in the elements96 // you return will be subject to the css grid of the document, and 97 // if you want to prevent the elements from scrolling with content 98 // you will need to use a few tricks. See a code example below this 99 // file example. Click to expand will happen automatically.100 //101 // 'metadata':102 // Shows the content you'd see in a normal patch file, usually in 103 // some format like '@@ -60,6 +60,22 @@'. You cannot use these to104 // expand hidden content105 //106 // 'simple':107 // Just a subtle bar separator between each hunk108 hunkSeparators: 'line-info',109
110 // On lines that have both additions and deletions, we can run a111 // separate diff check to mark parts of the lines that change.112 // 'none':113 // Do not show these secondary highlights114 //115 // 'char':116 // Show changes at a per character granularity117 //118 // 'word':119 // Show changes but rounded up to word boundaries120 //121 // 'word-alt' (default):122 // Similar to 'word', however we attempt to minimize single character123 // gaps between highlighted changes124 lineDiffType: 'word-alt',125
126 // If lines exceed these character lengths then we won't perform the127 // line lineDiffType check128 maxLineDiffLength: 1000,129
130 // If any line in the diff exceeds this value then we won't attempt to131 // syntax highlight the diff132 maxLineLengthForHighlighting: 1000,133
134 // Enabling this property will hide the file header with file name and135 // diff stats.136 disableFileHeader: false,137
138 // You can optionally pass a render function for rendering out line139 // annotations. Just return the dom node to render140 renderAnnotation(141 annotation: DiffLineAnnotation<ThreadMetadata>142 ): HTMLElement {143 // Despite the diff itself being rendered in the shadow dom,144 // annotations are inserted via the web components 'slots' api and you145 // can use all your normal normal css and styling for them146 const element = document.createElement('div');147 element.innerText = annotation.metadata.threadId;148 return element;149 },150});151
152// If you ever want to update the options for an instance, simple call153// 'setOptions' with the new options. Bear in mind, this does NOT merge154// existing properties, it's a full replace155instance.setOptions({156 ...instance.options,157 theme: 'pierre-dark',158 themes: undefined,159});160
161// When ready to render, simply call .render with old/new file, optional162// annotations and a container element to hold the diff163await instance.render({164 oldFile,165 newFile,166 lineAnnotations,167 containerWrapper: document.body,168});If you would like to render custom hunk separators that won't scroll with the content, there's 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 have 4// 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 instance2 = 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})These core classes can be thought of as the building blocks for the different components and APIs in Precision Diffs. Most of them should be usable in a variety of environments (server and browser).
Essentially a class that takes FileDiffMetadata data structure and can render out the raw hast elements of the code which can be subsequently rendered as html strings or transformed further. 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// this API is a full replacement of any existing options, it will11// not merge in existing options already set12instance.setOptions({ theme: 'github-dark', diffStyle: 'split' });13
14// Parse diff content from 2 versions of a file15const fileDiff: FileDiffMetadata = parseDiffFromFile(16 { name: 'file.ts', contents: 'const greeting = "Hello";' },17 { name: 'file.ts', contents: 'const greeting = "Hello, World!";' }18);19
20// Render hunks21const result: HunksRenderResult | undefined =22 await instance.render(fileDiff);23
24// Depending on your diffStyle settings and depending the type of25// changes, you'll get raw hast nodes for each line for each column26// type based on your settings. If your diffStyle is 'unified',27// then additionsAST and deletionsAST will be undefined and 'split'28// will be the inverse29console.log(result?.additionsAST);30console.log(result?.deletionsAST);31console.log(result?.unifiedAST);32
33// There are 2 utility methods on the instance to render these hast34// nodes to html, '.renderFullHTML' and '.renderPartialHTML'Because it‘s important to re-use your highlighter instance when using Shiki, we‘ve ensured that all the classes and components you use with Precision Diffs will automatically use a shared highlighter instance and also automatically load languages and themes on demand as necessary.
We provide APIs to preload the highlighter, themes, and languages if you want to have that ready before rendering. Also there are some cleanup utilities if you want to be memory concious.
Shiki comes with a lot of built in themes, however if you would like to use your own custom or modified theme, you simply have to register it and then it‘ll just work as any other built in theme.
1import {2 getSharedHighlighter,3 preloadHighlighter,4 registerCustomTheme,5 disposeHighlighter6} from '@pierre/precision-diffs';7
8// Preload themes and languages9await preloadHighlighter({10 themes: ['pierre-dark', 'github-light'],11 langs: ['typescript', 'python', 'rust']12});13
14// Register custom themes (make sure the name you pass 15// for your theme and the name in your shiki json theme 16// are identical)17registerCustomTheme('my-custom-theme', () => import('./theme.json'));18
19// Get the shared highlighter instance20const highlighter = await getSharedHighlighter();21
22// Cleanup when shutting down. Just note that if you call this,23// all themes and languages will have to be reloaded24disposeHighlighter();Diff and code are rendered using shadow dom APIs. This means that the styles applied to the diffs will be well isolated from your pages 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 the event 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
16 /* By default we try to inherit the deletion/addition/modified 17 * colors from the existing Shiki theme, however if you'd like18 * to override them, you can do so via these css variables: */19 --pjs-deletion-color-override: orange;20 --pjs-addition-color-override: yellow;21 --pjs-modified-color-override: purple;22}1<FileDiff2 style={{3 '--pjs-font-family': 'JetBrains Mono, monospace',4 '--pjs-font-size': '13px'5 } as React.CSSProperties}6 // ... other props7/>