Install the Precision Diffs package using bun, pnpm, npm, or yarn:
1bun add @pierre/precision-diffsPrecision 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 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// 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 MultiFileDiff,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 <MultiFileDiff<ThreadMetadata>36 // We automatically detect the language based on filename37 // You can also provide 'lang' property in 'options' when38 // rendering MultiFileDiff.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.51 options={{52 // You can provide a 'theme' prop that maps to any53 // built in shiki theme or you can register a custom54 // theme. We also include 2 custom themes55 //56 // 'pierre-dark' and 'pierre-light57 //58 // You can also pass an object with 'dark' and 'light' keys59 // to theme based on OS or 'themeType' setting below.60 //61 // By default we initialize with our custom pierre themes62 // for dark and light theme63 //64 // For the rest of the available shiki themes, either check65 // typescript autocomplete or visit:66 // https://shiki.style/themes67 theme: { dark: 'pierre-dark', light: 'pierre-light' },68
69 // When using the 'theme' prop that specifies dark and light70 // themes, 'themeType' allows you to force 'dark' or 'light'71 // theme, or inherit from the OS ('system') theme.72 themeType: 'system',73
74 // Disable the line numbers for your diffs, generally75 // not recommended76 disableLineNumbers: false,77
78 // Whether code should 'wrap' with long lines or 'scroll'.79 overflow: 'scroll',80
81 // Normally you shouldn't need this prop, but if you don't82 // provide a valid filename or your file doesn't have an83 // extension you may want to override the automatic detection84 // You can specify that language here:85 // https://shiki.style/languages86 // lang?: SupportedLanguages;87
88 // 'diffStyle' controls whether the diff is presented side by89 // side or in a unified (single column) view90 diffStyle: 'split',91
92 // Unchanged context regions are collapsed by default, set this93 // to true to force them to always render. This depends on using94 // the oldFile/newFile API or FileDiffMetadata including newLines.95 expandUnchanged: false,96
97 // Line decorators to help highlight changes.98 // 'bars' (default):99 // Shows some red-ish or green-ish (theme dependent) bars on the100 // left edge of relevant lines101 //102 // 'classic':103 // shows '+' characters on additions and '-' characters104 // on deletions105 //106 // 'none':107 // No special diff indicators are shown108 diffIndicators: 'bars',109
110 // By default green-ish or red-ish background are shown on added111 // and deleted lines respectively. Disable that feature here112 disableBackground: false,113
114 // Diffs are split up into hunks, this setting customizes what115 // to show between each hunk.116 //117 // 'line-info' (default):118 // Shows a bar that tells you how many lines are collapsed. If119 // you are using the oldFile/newFile API then you can click those120 // bars to expand the content between them121 //122 // 'metadata':123 // Shows the content you'd see in a normal patch file, usually in124 // some format like '@@ -60,6 +60,22 @@'. You cannot use these to125 // expand hidden content126 //127 // 'simple':128 // Just a subtle bar separator between each hunk129 hunkSeparators: 'line-info',130
131 // On lines that have both additions and deletions, we can run a132 // separate diff check to mark parts of the lines that change.133 // 'none':134 // Do not show these secondary highlights135 //136 // 'char':137 // Show changes at a per character granularity138 //139 // 'word':140 // Show changes but rounded up to word boundaries141 //142 // 'word-alt' (default):143 // Similar to 'word', however we attempt to minimize single144 // character gaps between highlighted changes145 lineDiffType: 'word-alt',146
147 // If lines exceed these character lengths then we won't perform148 // the line lineDiffType check149 maxLineDiffLength: 1000,150
151 // If any line in the diff exceeds this value then we won't152 // attempt to syntax highlight the diff153 maxLineLengthForHighlighting: 1000,154
155 // Enabling this property will hide the file header with file156 // name and diff stats.157 disableFileHeader: false,158 }}159 />160 );161}The vanilla JavaScript 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 JavaScript 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 line26 // 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 // You can also pass an object with 'dark' and 'light' keys40 // to theme based on OS or 'themeType' setting below.41 //42 // By default we initialize with our custom pierre themes43 // for dark and light theme44 //45 // For the rest of the available shiki themes, either check46 // typescript autocomplete or visit:47 // https://shiki.style/themes48 theme: { dark: 'pierre-dark', light: 'pierre-light' },49
50 // When using the 'theme' prop that specifies dark and light51 // themes, 'themeType' allows you to force 'dark' or 'light'52 // theme, or inherit from the OS ('system') theme.53 themeType: 'system',54
55 // Disable the line numbers for your diffs, generally not recommended56 disableLineNumbers: false,57
58 // Whether code should 'wrap' with long lines or 'scroll'.59 overflow: 'scroll',60
61 // Normally you shouldn't need this prop, but if you don't provide a62 // valid filename or your file doesn't have an extension you may want63 // to override the automatic detection. You can specify that64 // language here:65 // https://shiki.style/languages66 // lang?: SupportedLanguages;67
68 // 'diffStyle' controls whether the diff is presented side by side or69 // in a unified (single column) view70 diffStyle: 'split',71
72 // Unchanged context regions are collapsed by default, set this73 // to true to force them to always render. This depends on using74 // the oldFile/newFile API or FileDiffMetadata including newLines.75 expandUnchanged: false,76
77 // Line decorators to help highlight changes.78 // 'bars' (default):79 // Shows some red-ish or green-ish (theme dependent) bars on the left80 // edge of relevant lines81 //82 // 'classic':83 // shows '+' characters on additions and '-' characters on deletions84 //85 // 'none':86 // No special diff indicators are shown87 diffIndicators: 'bars',88
89 // By default green-ish or red-ish background are shown on added and90 // deleted lines respectively. Disable that feature here91 disableBackground: false,92
93 // Diffs are split up into hunks, this setting customizes what to94 // show between each hunk.95 //96 // 'line-info' (default):97 // Shows a bar that tells you how many lines are collapsed. If you98 // are using the oldFile/newFile API then you can click those bars99 // to expand the content between them100 //101 // (hunk: HunkData) => HTMLElement | DocumentFragment:102 // If you want to fully customize what gets displayed for hunks you103 // can pass a custom function to generate dom nodes to render.104 // 'hunkData' will include the number of lines collapsed as well as105 // the 'type' of column you are rendering into. Bear in the elements106 // you return will be subject to the css grid of the document, and107 // if you want to prevent the elements from scrolling with content108 // you will need to use a few tricks. See a code example below this109 // file example. Click to expand will happen automatically.110 //111 // 'metadata':112 // Shows the content you'd see in a normal patch file, usually in113 // some format like '@@ -60,6 +60,22 @@'. You cannot use these to114 // expand hidden content115 //116 // 'simple':117 // Just a subtle bar separator between each hunk118 hunkSeparators: 'line-info',119
120 // On lines that have both additions and deletions, we can run a121 // separate diff check to mark parts of the lines that change.122 // 'none':123 // Do not show these secondary highlights124 //125 // 'char':126 // Show changes at a per character granularity127 //128 // 'word':129 // Show changes but rounded up to word boundaries130 //131 // 'word-alt' (default):132 // Similar to 'word', however we attempt to minimize single character133 // gaps between highlighted changes134 lineDiffType: 'word-alt',135
136 // If lines exceed these character lengths then we won't perform the137 // line lineDiffType check138 maxLineDiffLength: 1000,139
140 // If any line in the diff exceeds this value then we won't attempt to141 // syntax highlight the diff142 maxLineLengthForHighlighting: 1000,143
144 // Enabling this property will hide the file header with file name and145 // diff stats.146 disableFileHeader: false,147
148 // You can optionally pass a render function for rendering out line149 // annotations. Just return the dom node to render150 renderAnnotation(151 annotation: DiffLineAnnotation<ThreadMetadata>152 ): HTMLElement {153 // Despite the diff itself being rendered in the shadow dom,154 // annotations are inserted via the web components 'slots' api and you155 // can use all your normal normal css and styling for them156 const element = document.createElement('div');157 element.innerText = annotation.metadata.threadId;158 return element;159 },160});161
162// If you ever want to update the options for an instance, simple call163// 'setOptions' with the new options. Bear in mind, this does NOT merge164// existing properties, it's a full replace165instance.setOptions({166 ...instance.options,167 theme: 'pierre-dark',168});169
170// When ready to render, simply call .render with old/new file, optional171// annotations and a container element to hold the diff172await instance.render({173 oldFile,174 newFile,175 lineAnnotations,176 containerWrapper: document.body,177});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 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 conscious.
Shiki comes with a lot of built-in themes, but 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 pass15// for your theme and the name in your shiki json theme16// 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 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 /* Some basic variables for tweaking the layouts of some of the built in28 * components */29 --pjs-gap-inline: 8px;30 --pjs-gap-block: 8px;31}1<FileDiff2 style={{3 '--pjs-font-family': 'JetBrains Mono, monospace',4 '--pjs-font-size': '13px'5 } as React.CSSProperties}6 // ... other props7/>Precision Diffs supports Server-Side Rendering (SSR) for improved performance and SEO. 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.
The SSR functionality is available from the @pierre/precision-diffs/ssr module:
1import {2 // There are matching preload functions for each react component3 preloadMultiFileDiff,4 preloadFileDiff,5 preloadPatchDiff,6 preloadFile,7} from '@pierre/precision-diffs/ssr';Create a server component that pre-renders the diff using preloadFileDiff:
1// app/diff/page.tsx (Server Component)2import { preloadMultiFileDiff } from '@pierre/precision-diffs/ssr';3import { DiffPage } from './DiffPage';4
5const OLD_FILE = {6 name: 'main.ts',7 contents: `function greet(name: string) {8 console.log("Hello, " + name);9}`,10};11
12const NEW_FILE = {13 name: 'main.ts',14 contents: `function greet(name: string) {15 console.log(\`Hello, \${name}!\`);16}`,17};18
19export default async function DiffRoute() {20 const preloadedFileDiff = await preloadMultiFileDiff({21 oldFile: OLD_FILE,22 newFile: NEW_FILE,23 options: {24 theme: 'pierre-dark',25 diffStyle: 'split',26 diffIndicators: 'bars',27 },28 });29
30 return <DiffPage preloadedFileDiff={preloadedFileDiff} />;31}The preloadFileDiff function returns a PreloadedFileDiffResult object containing the original oldFile, newFile, options, and annotations you passed in, plus a prerenderedHTML string with the fully syntax-highlighted diff. This object can be spread directly into the React or raw JS component's for automatic hydration.
Create a client component that hydrates and displays the pre-rendered diff:
1// app/diff/DiffPage.tsx (Client Component)2'use client';3
4import { MultiFileDiff } from '@pierre/precision-diffs/react';5import type { PreloadMultiFileDiffResult } from '@pierre/precision-diffs/ssr';6
7interface DiffPageProps {8 preloadedFileDiff: PreloadMultiFileDiffResult;9}10
11export function DiffPage({ preloadedFileDiff }: DiffPageProps) {12 return (13 <MultiFileDiff14 {...preloadedFileDiff}15 className="overflow-hidden rounded-lg border"16 />17 );18}prerenderedHTML includes inline styles for the theme, eliminating FOUC (Flash of Unstyled Content)