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 line number26 // 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' api and you45 // can use all your normal normal css and styling for them46 return <CommentThread threadId={annotation.metadata.threadId} />;47 }}48 // Here's every property you can pass to options, with their default49 // values if not specified. However its generally a good idea to pass50 // a 'theme' or 'themes' property51 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 // For the rest of the available shiki themes, check out:59 // https://shiki.style/themes60 theme: 'none',61 // Or can also provide a 'themes' prop, which allows the code to adapt62 // to your OS light or dark theme63 // themes: { dark: 'pierre-dark', light: 'pierre-light' },64
65 // When using the 'themes' prop, 'themeType' allows you to force 'dark'66 // or 'light' theme, or inherit from the OS ('system') theme.67 themeType: 'system',68
69 // Disable the line numbers for your diffs, generally not recommended70 disableLineNumbers: false,71
72 // Whether code should 'wrap' with long lines or 'scroll'.73 overflow: 'scroll',74
75 // Normally you shouldn't need this prop, but if you don't provide a76 // valid filename or your file doesn't have an extension you may want to77 // override the automatic detection. You can specify that language here:78 // https://shiki.style/languages79 // lang?: SupportedLanguages;80
81 // 'diffStyle' controls whether the diff is presented side by side or82 // in a unified (single column) view83 diffStyle: 'split',84
85 // Line decorators to help highlight changes.86 // 'bars' (default):87 // Shows some red-ish or green-ish (theme dependent) bars on the left88 // edge of relevant lines89 //90 // 'classic':91 // shows '+' characters on additions and '-' characters on deletions92 //93 // 'none':94 // No special diff indicators are shown95 diffIndicators: 'bars',96
97 // By default green-ish or red-ish background are shown on added and98 // deleted lines respectively. Disable that feature here99 disableBackground: false,100
101 // Diffs are split up into hunks, this setting customizes what to show102 // between each hunk.103 //104 // 'line-info' (default):105 // Shows a bar that tells you how many lines are collapsed. If you are106 // using the oldFile/newFile API then you can click those bars to107 // expand the content between them108 //109 // 'metadata':110 // Shows the content you'd see in a normal patch file, usually in some111 // format like '@@ -60,6 +60,22 @@'. You cannot use these to expand112 // hidden content113 //114 // 'simple':115 // Just a subtle bar separator between each hunk116 hunkSeparators: 'line-info',117
118 // On lines that have both additions and deletions, we can run a119 // separate diff check to mark parts of the lines that change.120 // 'none':121 // Do not show these secondary highlights122 //123 // 'char':124 // Show changes at a per character granularity125 //126 // 'word':127 // Show changes but rounded up to word boundaries128 //129 // 'word-alt' (default):130 // Similar to 'word', however we attempt to minimize single character131 // gaps between highlighted changes132 lineDiffType: 'word-alt',133
134 // If lines exceed these character lengths then we won't perform the135 // line lineDiffType check136 maxLineDiffLength: 1000,137
138 // If any line in the diff exceeds this value then we won't attempt to139 // syntax highlight the diff140 maxLineLengthForHighlighting: 1000,141
142 // Enabling this property will hide the file header with file name and143 // diff stats.144 disableFileHeader: false,145 }}146 />147 );148}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 number26 // 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 adapt43 // 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 'dark'47 // 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 want to58 // override the automatic detection. You can specify that language here:59 // https://shiki.style/languages60 // lang?: SupportedLanguages;61
62 // 'diffStyle' controls whether the diff is presented side by side or63 // in a unified (single column) view64 diffStyle: 'split',65
66 // Line decorators to help highlight changes.67 // 'bars' (default):68 // Shows some red-ish or green-ish (theme dependent) bars on the left69 // edge of relevant lines70 //71 // 'classic':72 // shows '+' characters on additions and '-' characters on deletions73 //74 // 'none':75 // No special diff indicators are shown76 diffIndicators: 'bars',77
78 // By default green-ish or red-ish background are shown on added and79 // deleted lines respectively. Disable that feature here80 disableBackground: false,81
82 // Diffs are split up into hunks, this setting customizes what to show83 // between each hunk.84 //85 // 'line-info' (default):86 // Shows a bar that tells you how many lines are collapsed. If you are87 // using the oldFile/newFile API then you can click those bars to88 // expand the content between them89 //90 // (hunk: HunkData) => HTMLElement | DocumentFragment:91 // If you want to fully customize what gets displayed for hunks you can92 // pass a custom function to generate dom nodes to render. 'hunkData'93 // will include the number of lines collapsed as well as the 'type' of94 // column you are rendering into. Bear in the elements you return will be95 // subject to the css grid of the document, and if you want to prevent the96 // elements from scrolling with content you will need to use a few tricks.97 // See a code example below this file example. Click to expand will98 // happen automatically.99 //100 // 'metadata':101 // Shows the content you'd see in a normal patch file, usually in some102 // format like '@@ -60,6 +60,22 @@'. You cannot use these to expand103 // hidden content104 //105 // 'simple':106 // Just a subtle bar separator between each hunk107 hunkSeparators: 'line-info',108
109 // On lines that have both additions and deletions, we can run a110 // separate diff check to mark parts of the lines that change.111 // 'none':112 // Do not show these secondary highlights113 //114 // 'char':115 // Show changes at a per character granularity116 //117 // 'word':118 // Show changes but rounded up to word boundaries119 //120 // 'word-alt' (default):121 // Similar to 'word', however we attempt to minimize single character122 // gaps between highlighted changes123 lineDiffType: 'word-alt',124
125 // If lines exceed these character lengths then we won't perform the126 // line lineDiffType check127 maxLineDiffLength: 1000,128
129 // If any line in the diff exceeds this value then we won't attempt to130 // syntax highlight the diff131 maxLineLengthForHighlighting: 1000,132
133 // Enabling this property will hide the file header with file name and134 // diff stats.135 disableFileHeader: false,136
137 // You can optionally pass a render function for rendering out line138 // annotations. Just return the dom node to render139 renderAnnotation(annotation: DiffLineAnnotation<ThreadMetadata>): HTMLElement {140 // Despite the diff itself being rendered in the shadow dom,141 // annotations are inserted via the web components 'slots' api and you142 // can use all your normal normal css and styling for them143 const element = document.createElement('div');144 element.innerText = annotation.metadata.threadId;145 return element;146 },147});148
149// If you ever want to update the options for an instance, simple call150// 'setOptions' with the new options. Bear in mind, this does NOT merge151// existing properties, it's a full replace152instance.setOptions({153 ...instance.options,154 theme: 'pierre-dark',155 themes: undefined,156});157
158// When ready to render, simply call .render with old/new file, optional159// annotations and a container element to hold the diff160await instance.render({161 oldFile,162 newFile,163 lineAnnotations,164 containerWrapper: document.body,165});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 a number column and4// a content column where neither will scroll with the code5const instance = new FileDiff({6hunkSeparators(hunkData: HunkData) {7const fragment = document.createDocumentFragment();8const numCol = document.createElement('div');9numCol.textContent = `${hunkData.lines}`;10numCol.style.position = 'sticky';11numCol.style.left = '0';12numCol.style.backgroundColor = 'var(--pjs-bg)';13numCol.style.zIndex = '2';14fragment.appendChild(numCol);15const contentCol = document.createElement('div');16contentCol.textContent = 'unmodified lines';17contentCol.style.position = 'sticky';18contentCol.style.width = 'var(--pjs-column-content-width)';19contentCol.style.left = 'var(--pjs-column-number-width)';20fragment.appendChild(contentCol);21return fragment;22},23})24
25// If you want to create a single column that spans both colums and doesn't26// scroll, you can do something like this:27const instance2 = new FileDiff({28hunkSeparators(hunkData: HunkData) {29const wrapper = document.createElement('div');30wrapper.style.gridColumn = 'span 2';31const contentCol = document.createElement('div');32contentCol.textContent = `${hunkData.lines} unmodified lines`;33contentCol.style.position = 'sticky';34contentCol.style.width = 'var(--pjs-column-width)';35contentCol.style.left = '0';36wrapper.appendChild(contentCol);37return wrapper;38},39})40
41// If you want to create a single column that's aligned with the content column42// and doesn't scroll, you can do something like this:43const instance2 = new FileDiff({44hunkSeparators(hunkData: HunkData) {45const wrapper = document.createElement('div');46wrapper.style.gridColumn = '2 / 3';47wrapper.textContent = `${hunkData.lines} unmodified lines`;48wrapper.style.position = 'sticky';49wrapper.style.width = 'var(--pjs-column-content-width)';50wrapper.style.left = 'var(--pjs-column-number-width)';51return wrapper;52},53})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 will not merge in11// 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 = await instance.render(fileDiff);22// Depending on your diffStyle settings and depending the type of changes,23// you'll get raw hast nodes for each line for each column type based on your24// settings. If your diffStyle is 'unified', then additionsAST and deletionsAST25// will be undefined and 'split' will be the inverse26console.log(result?.additionsAST);27console.log(result?.deletionsAST);28console.log(result?.unifiedAST);29
30// There are 2 utility methods on the instance to render these hast nodes to31// 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 for your theme and the15// name in your shiki json theme are identical)16registerCustomTheme('my-custom-theme', () => import('./theme.json'));17
18// Get the shared highlighter instance19const highlighter = await getSharedHighlighter();20
21// Cleanup when shutting down. Just note that if you call this, all themes and22// languages will have to be reloaded23disposeHighlighter();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, typically not a monospace10 * font, but it's your call */11 --pjs-header-font-family: Helvetica;12 /* Override or customize any 'font-feature-settings' for your code font */13 --pjs-font-features: normal;14
15 /* By default we try to inherit the deletion/addition/modified colors from16 * the existing Shiki theme, however if you'd like to override them, you can do17 * so via these css variables: */18 --pjs-deletion-color-override: orange;19 --pjs-addition-color-override: yellow;20 --pjs-modified-color-override: purple;21}1<FileDiff2 style={{3 '--pjs-font-family': 'JetBrains Mono, monospace',4 '--pjs-font-size': '13px'5 } as React.CSSProperties}6 // ... other props7/>