Overview
Diffs is a library for rendering code and diffs on the web. This includes both high-level, easy-to-use components, as well as exposing many of the internals if you want to selectively use specific pieces. We've built syntax highlighting on top of Shiki which provides a lot of great theme and language support.
123456const std = @import("std");pub fn main() !void {const stdout = std.io.getStdOut().writer();try stdout.print("Hi you, {s}!\n", .{"world"});}123456const std = @import("std");pub fn main() !void {const stdout = std.io.getStdOut().writer();try stdout.print("Hello there, {s}!\n", .{"zig"});}
We have an opinionated stance in our architecture: browsers are rather efficient at rendering raw HTML. We lean into this by having all the lower level APIs purely rendering strings (the raw HTML) that are then consumed by higher-order components and utilities. This gives us great performance and flexibility to support popular libraries like React as well as provide great tools if you want to stick to vanilla JavaScript and HTML. The higher-order components render all this out into Shadow DOM and CSS grid layout.
Generally speaking, you're probably going to want to use the higher level components since they provide an easy-to-use API that you can get started with rather quickly. We currently only have components for vanilla JavaScript and React, but will add more if there's demand.
For this overview, we'll talk about the vanilla JavaScript components for now but there are React equivalents for all of these.
Rendering Diffs
Our goal with visualizing diffs was to provide some flexible and approachable
APIs for how you may want to render diffs. For this, we provide a component
called FileDiff.
There are two ways to render diffs with FileDiff:
- Provide two versions of a file or code snippet to compare
- Consume a patch file
You can see examples of these approaches below, in both JavaScript and React.
123456789101112131415161718192021222324252627282930313233343536373839404142import { type FileContents, FileDiff,} from '@pierre/diffs';
// Store file objects in variables rather than inlining them.// FileDiff uses reference equality to detect changes and skip// unnecessary re-renders, so keep these references stable.const oldFile: FileContents = { name: 'main.zig', contents: `const std = @import("std");
pub fn main() !void { const stdout = std.io.getStdOut().writer(); try stdout.print("Hi you, {s}!\\\\n", .{"world"});}`,};
const newFile: FileContents = { name: 'main.zig', contents: `const std = @import("std");
pub fn main() !void { const stdout = std.io.getStdOut().writer(); try stdout.print("Hello there, {s}!\\\\n", .{"zig"});}`,};
// We automatically detect the language based on the filename// You can also provide a lang property when instantiating FileDiff.const fileDiffInstance = new FileDiff({ theme: 'pierre-dark' });
// render() is synchronous. Syntax highlighting happens async in the// background and the diff updates automatically when complete.fileDiffInstance.render({ oldFile, newFile, // where to render the diff into containerWrapper: document.body,});Merge conflict resolution UI
Render conflicts through a dedicated diff primitive that treats current and incoming sections as structured additions/deletions without running text diffing. Resolve by choosing current, incoming, or both changes and preview the updated file instantly.
19 unmodified lines20212223232425262714 unmodified lines42434444454647454647484950515253545521 unmodified lines19 unmodified linesexport async function createSession(userId: string) { await cleanupExpiredSessions(userId);
|| <<<<<<< HEAD const data = {======= const sessionData = { source: 'web',>>>>>>> feature/oauth-session-source provider: 'password', userId, expiresAt: Date.now() + SESSION_TTL,14 unmodified lines if (oldest) await invalidateSession(oldest.id); }
|| <<<<<<< HEAD await db.auditLog.create({ event: 'session.created', userId, });======= await db.sessionEvent.create({ type: 'audit-log', data: { sessionId: session.id, type: 'created', source: sessionData.source ?? 'credentials', }, });>>>>>>> feature/oauth-session-source
return { session, token };}21 unmodified linesInstallation
Diffs is published as an npm package. Install Diffs with the package manager of your choice:
1pnpm add @pierre/diffsPackage Exports
The package provides several entry points for different use cases:
| Package | Description |
|---|---|
@pierre/diffs | Vanilla JS components, plus utility functions |
@pierre/diffs/react | React components for rendering diffs and files |
@pierre/diffs/edit | Low-level edit mode Editor for attaching editing to rendered file and diff components |
@pierre/diffs/ssr | Server-side rendering utilities for pre-rendering diffs with syntax highlighting |
@pierre/diffs/worker | Worker pool utilities for offloading syntax highlighting to background threads |
Build with agents
Agent skill
Install the
diffs agent skill with
the Skills CLI for access to the entire API and
common recipes, regardless of your integration method.
1npx skills add pierrecomputer/pierre --skill diffsPrompt an agent
Alternatively, you can copy-paste this prompt to your agent to install the skill and point it to these docs as plain-text.
12345678Set up @pierre/diffs in this project.
Install its agent skill first so you have the full API reference:npx skills add pierrecomputer/pierre --skill diffs
Then follow that skill to add @pierre/diffs.Docs: https://diffs.com/docsFull reference for LLMs: https://diffs.com/llms-full.txtThe "For Agents" button on the home page copies the same prompt.
Plain-text docs
Our documentation is also available in condensed, Markdown-formatted plain text, available in two versions:
- llms.txt is a short index of the sections
- llms-full.txt is the everything in the docs — prose, API tables, and code examples
Copy and paste as needed, or provide the URLs to your agent.
Core Types
Before diving into the components, it's helpful to understand the core file, diff, and annotation data structures used throughout the library.
FileContents
FileContents represents one existing file version. Use it when rendering a
file with the <File> component, or pass it as oldFile and/or newFile to
diff components. For added or deleted files, pass null for the intentionally
missing side.
An omitted side is not the same as null. If you provide either oldFile or
newFile, provide the other side too, using null only when that file side
does not exist. Empty file contents are still a real file; represent them with
contents: '', not null.
12345678910111213141516171819202122232425262728293031323334353637import type { FileContents } from '@pierre/diffs';
// FileContents represents one existing file side.// Use null, not FileContents with an empty string, for an intentionally// missing side.interface FileContents { // The filename (used for display and language detection) name: string;
// The file's text content contents: string;
// Optional: Override the detected language for syntax highlighting // See: https://shiki.style/languages lang?: SupportedLanguages;
// Optional revision identity for rendering and Worker Pool // highlight caching cacheKey?: string;}
// Example usageconst file: FileContents = { // We'll attempt to detect the language based on file extension name: 'example.tsx', contents: 'export function Hello() { return <div>Hello</div>; }', cacheKey: 'example-file-v1',};
// With explicit language overrideconst jsonFile: FileContents = { // No extension, so we specify lang name: 'config', contents: '{ "key": "value" }', lang: 'json', cacheKey: 'config-file',};For rendering and Worker Pool caching, cacheKey is optional; when provided,
treat it as a revision identity and change it with the contents, filename,
language, or revision.
FileDiffMetadata
FileDiffMetadata represents the differences between file versions. It contains
the hunks (changed regions), line counts, and optionally the full file contents
for expansion (if possible).
When a component uses loadDiffFiles, treat FileDiffMetadata as mutable
render metadata. A partial metadata object parsed from a patch can be upgraded
in place: isPartial flips to false, hunks and line arrays are replaced with
hydrated values, and the object identity is preserved.
If you reparse a patch or create a new partial FileDiffMetadata, the renderer
treats it as a fresh partial model. Keep the same metadata object stable when
you want hydration to persist.
Hydration appends :hydrated to the partial metadata's cacheKey. Without a
partial key, changed diffs derive a key from both loaded file keys and
parseDiffOptions.disableLineMatching. Pure renames use the loaded new file's
key. Keep parseDiffOptions.disableLineMatching consistent with the options
used to parse the supplied patch.
Tip: You can generate FileDiffMetadata using
parseDiffFromFile (from file contents) or
parsePatchFiles (from a patch string).
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596import type { FileDiffMetadata, Hunk } from '@pierre/diffs';
// FileDiffMetadata represents a parsed file change.interface FileDiffMetadata { // Current filename name: string;
// Previous filename (for renames) prevName?: string;
// Optional: Override language for syntax highlighting. Normally // language is detected automatically base on file extension and you do not // need to set this. If you need to set a custom lang on a FileDiffMetadata // instance, use the `setLanguageOverride(diff, 'ruby')` method. lang?: SupportedLanguages;
// Type of change: 'change' | 'rename-pure' | 'rename-changed' | 'new' | 'deleted' type: ChangeTypes;
// Array of diff hunks containing the actual changes hunks: Hunk[];
// Line counts for split and unified views splitLineCount: number; unifiedLineCount: number;
// True when parsed from a patch, so the line arrays hold only the patch's // lines. False when generated from full files or after getting hydrated // from `loadDiffFiles` isPartial: boolean;
// Lines of the old and new file (or just the patch's lines when partial) deletionLines: string[]; additionLines: string[];
// Total characters in deletionLines and additionLines. deletionCharacterCount: number; additionCharacterCount: number;
// Optional: Cache key for AST caching in Worker Pool. // When provided, rendered diff AST results are cached and reused. // IMPORTANT: The key must change whenever the diff changes! cacheKey?: string;}
// Hunk represents a single changed region in the diff// Think of it like the sections defined by the '@@' lines in patchesinterface Hunk { // Addition/deletion counts, parsed out from patch data additionCount: number; additionStart: number; additionLines: number; deletionCount: number; deletionStart: number; deletionLines: number;
// The actual content of the hunk (context and changes) hunkContent: (ContextContent | ChangeContent)[];
// Optional context shown in hunk headers (e.g., function name) hunkContext?: string;
// Line position information, mostly used internally for // rendering optimizations splitLineStart: number; splitLineCount: number; unifiedLineStart: number; unifiedLineCount: number;
// 'true' if the old/new file does not end with a newline noEOFCRDeletions: boolean; noEOFCRAdditions: boolean;}
// ContextContent represents unchanged lines surrounding changes. The line// text lives in FileDiffMetadata's deletionLines/additionLines.interface ContextContent { type: 'context'; // Number of unchanged lines lines: number; // Where this block starts in additionLines and deletionLines additionLineIndex: number; deletionLineIndex: number;}
// ChangeContent represents a group of additions and deletions. The line// text lives in FileDiffMetadata's deletionLines/additionLines.interface ChangeContent { type: 'change'; // Number of deleted lines, and where they start in deletionLines deletions: number; deletionLineIndex: number; // Number of added lines, and where they start in additionLines additions: number; additionLineIndex: number;}LineAnnotation and DiffLineAnnotation
LineAnnotation<T> places content on a file line and contains lineNumber plus
typed metadata. Metadata is required when T is a concrete type and omitted
when T is undefined. DiffLineAnnotation<T> adds
side: 'additions' | 'deletions' to select a file side. Line coordinates are
one-based on the selected file side, not row positions in the rendered diff. Use
lineNumber: 0 for a file-level annotation above the first file line or, in a
diff, above the first hunk or row on that side.
Callbacks shared by file and diff components use
LineAnnotation[] | DiffLineAnnotation[]. Use isFileAnnotationCollection or
isDiffAnnotationCollection to narrow the collection before reading
shape-specific fields. For individual annotation unions, use isFileAnnotation
or isDiffAnnotation.
Store a stable, position-independent application ID in metadata when an annotation owns drafts or other interactive state. For annotations that survive an edit, edit mode preserves metadata while remapping line coordinates. For the controlled update pattern and exact remapping rules, see Line annotations.
123456789101112131415161718192021222324252627import type { DiffLineAnnotation, LineAnnotation,} from '@pierre/diffs';
interface ThreadMetadata { // Position-independent identity for application-owned state. id: string;}
const fileAnnotations: LineAnnotation<ThreadMetadata>[] = [ { lineNumber: 0, metadata: { id: 'file-summary' } }, { lineNumber: 5, metadata: { id: 'line-five-review' } },];
const diffAnnotations: DiffLineAnnotation<ThreadMetadata>[] = [ { side: 'additions', lineNumber: 12, metadata: { id: 'new-line-review' }, }, { side: 'deletions', lineNumber: 9, metadata: { id: 'old-line-review' }, },];Creating Diffs
There are two ways to create a FileDiffMetadata.
From File Contents
Use parseDiffFromFile when you have the full file contents. Pass both sides
for a changed file, oldFile: null for a new file, or newFile: null for a
deleted file. This approach allows collapsed regions to be expanded.
123456789101112131415161718192021222324252627282930313233import { parseDiffFromFile, type FileContents, type FileDiffMetadata,} from '@pierre/diffs';
// Define the existing file versionsconst oldFile: FileContents = { name: 'greeting.ts', contents: 'export const greeting = "Hello";', cacheKey: 'greeting-old', // Optional: enables AST caching};
const newFile: FileContents = { name: 'greeting.ts', contents: 'export const greeting = "Hello, World!";', cacheKey: 'greeting-new',};
// Generate diff metadata from two existing versionsconst diff: FileDiffMetadata = parseDiffFromFile(oldFile, newFile);
// For added or deleted files, pass null for the side that does not exist.// Omitting the side is not the same as passing null.const addedFileDiff = parseDiffFromFile(null, newFile);const deletedFileDiff = parseDiffFromFile(oldFile, null);
// parseDiffFromFile(null, null) throws because at least one side must exist.
// The resulting diff holds the complete deletionLines and additionLines// (isPartial: false), which enables "expand unchanged" in the UI.// If both existing versions have cacheKey, the diff will have a combined// cacheKey of "greeting-old:greeting-new" for AST caching.From a Patch String
Use parsePatchFiles when you have a unified diff or patch file. This is useful
when working with git output or patch files from APIs. Patch-derived metadata is
partial until a renderer hydrates it with full files from loadDiffFiles.
1234567891011121314151617181920212223242526272829import { parsePatchFiles, type ParsedPatch, type FileDiffMetadata,} from '@pierre/diffs';
// Parse a unified diff / patch stringconst patchString = `--- a/file.ts+++ b/file.ts@@ -1,3 +1,3 @@ const x = 1;-const y = 2;+const y = 3; const z = 4;`;
// Returns an array of ParsedPatch objects (one per commit in the patch)// Set options.cacheKeyPrefix to enable AST caching with Worker Poolconst patches: ParsedPatch[] = parsePatchFiles(patchString, { cacheKeyPrefix: 'my-patch-key' });
// Each ParsedPatch contains an array of FileDiffMetadataconst files: FileDiffMetadata[] = patches[0].files;
// Each file's cacheKey is derived from the prefix, patch index, and file index.// This enables AST caching in Worker Pool for parsed patches.
// Note: Diffs from patch files are partial (isPartial: true), so their// deletionLines and additionLines hold only the lines in the patch.// Renderers can hydrate them with loadDiffFiles when full file// contents are needed for expanding unchanged context.Tip: If you need to change the language after creating a FileContents or
FileDiffMetadata, use the
setLanguageOverride utility function.
Highlighters
@pierre/diffs supports three syntax highlighting backends. Select one with
preferredHighlighter; each backend loads on demand.
| Backend | Description |
|---|---|
shiki-js | The default. Shiki with its JavaScript regex engine. |
shiki-wasm | Shiki with its WebAssembly regex engine. |
highlights | WebAssembly lexers from Highlights. |
Set preferredHighlighter in the component's options:
12345678910import { canUseWASM } from '@pierre/diffs';
// Fall back to shiki-js where WebAssembly is missing or blocked.const preferredHighlighter = canUseWASM() ? 'highlights' : 'shiki-js';
<FileDiff oldFile={oldFile} newFile={newFile} options={{ preferredHighlighter }}/>highlights and shiki-wasm need WebAssembly, and highlights also needs
SIMD. Some browsers lack these, and some pages block WebAssembly with a Content
Security Policy. When you select highlights, we recommend checking
canUseWASM() first and falling back to shiki-js
when it returns false, as in the example above.
The same option works with React and vanilla components, edit mode, streaming,
and SSR preload helpers. When using a worker pool, set
preferredHighlighter in the pool's highlighterOptions; the pool controls the
backend used by its components. Highlights usually performs best without a pool.
Only one backend can be active on a page, in each worker, and in each SSR server
thread. preferredHighlighter selects the backend for the first initialization.
Later requests use the active backend, even with a different preference. If no
backend or preference exists, the default is shiki-js. Dispose every instance
before you select another backend. getHighlighterType() returns the active
backend.
Built-in theme names work across all three backends. Custom TextMate themes and grammars require Shiki; Highlights uses Zed themes and bundled lexers. See Themes for configuration and Highlights for its APIs and supported languages.
Try the backends in the diffs playground.
React API
Import React components from @pierre/diffs/react.
We offer a variety of components to render diffs and files. Many of them share similar types of props, which you can find documented in Shared Props.
Components
The React API exposes six main components:
CodeViewrenders a mixed, virtualized list of files and diffs inside one scroll containerMultiFileDiffcompares file contents directlyPatchDiffrenders from a patch stringFileDiffrenders a pre-parsedFileDiffMetadataFilerenders a single code file without a diffUnresolvedFilerenders merge conflict markers with built-in resolution UI- Currently in beta/experimental and may change in future releases.
1234567891011121314151617181920212223242526272829303132333435363738394041424344