Map

@pierre/diffs

@pierre/diffs

An open source diff and code rendering library for the web. Built on Shiki for syntax highlighting, with React and vanilla JS APIs, virtualization, SSR support, and extensive theming.

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.

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:

  1. Provide two versions of a file or code snippet to compare
  2. Consume a patch file

You can see examples of these approaches below, in both JavaScript and React.

React Patch File (react_patch_file.tsx):

import {
  type ParsedPatch,
  FileDiff,
  parsePatchFiles,
} from '@pierre/diffs/react';

// If you consume a patch file, then you'll need to spawn multiple
// renderers for each file in the patches
function Patches() {
  const [parsedPatches, setParsedPatches] = useState<ParsedPatch[]>([]);
  useEffect(() => {
    // This is a fake function to fetch a github pr patch file, not an
    // actual api
    fetchGithubPatch('https://github.com/twbs/bootstrap/pull/41766.patch')
      .then(
        (data: string) => {
          setParsedPatches(
            // Github can return multiple patches in 1 file, we handle all
            // of this automatically for you. Just give us a single patch
            // or any number
            parsePatchFiles(data)
          );
        }
      );
  }, []);

  return (
    <>
      {parsedPatches.map((patch, index) => (
        <Fragment key={index}>
          {patch.files.map((fileDiff, index) => (
            // Under the hood, all instances of FileDiff will use a
            // shared Shiki highlighter and manage loading languages
            // and themes for you
            <FileDiff
              key={index}
              // 'fileDiff' is a data structure that includes all
              // hunks for a specific file from a patch
              fileDiff={fileDiff}
              options={{
                // Automatically theme based on users OS settings
                theme: { dark: 'pierre-dark', light: 'pierre-light' },
              }}
            />
          ))}
        </Fragment>
      ))}
    </>
  );
}

React Single File (react_single_file.tsx):

import {
  type FileContents,
  MultiFileDiff,
} from '@pierre/diffs/react';

// Store file objects in variables rather than inlining them.
// The React components use reference equality to detect changes
// and skip unnecessary re-renders, so keep these references stable
// (e.g., with useState or useMemo).
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"});
}
`,
};

function SingleDiff() {
  return (
    <MultiFileDiff
      // We automatically detect the language based on filename
      oldFile={oldFile}
      newFile={newFile}
      options={{ theme: 'pierre-dark' }}
    />
  );
}

Vanilla Patch File (vanilla_patch_file.ts):

import {
  FileDiff,
  ParsedPatch,
  parsePatchFiles,
} from '@pierre/diffs';

// This is a fake function to fetch a GitHub PR patch file,
// not an actual api
const patchFileContent: string = await fetchGithubPatch(
  'https://github.com/twbs/bootstrap/pull/41766.patch'
);

// Github can return multiple patches in 1 file, we handle all of this
// automatically for you. Just give us a single patch or any number
const parsedPatches: ParsedPatch[] = parsePatchFiles(patchFileContent);
for (const patch of parsedPatches) {
  for (const fileDiff of patch.files) {
    // 'fileDiff' is a data structure that includes all hunks for a
    // specific file from a patch
    const instance = new FileDiff({
      // Automatically theme based on users os settings
      theme: { dark: 'pierre-dark', light: 'pierre-light' },
    });
    // Under the hood, all instances of FileDiff will use a shared
    // Shiki highlighter and manage loading languages and themes for
    // you automatically
    instance.render({
      fileDiff,
      containerWrapper: document.body,
    });
  }
}

Vanilla Single File (vanilla_single_file.ts):

import {
  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,
});

Installation

Diffs is published as an npm package. Install Diffs with the package manager of your choice:

Package Exports

The package provides several entry points for different use cases:

Package Description
@pierre/diffs Vanilla JS components and utility functions for parsing and rendering diffs
@pierre/diffs/react React components for rendering diffs with full interactivity
@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

Core Types

Before diving into the components, it's helpful to understand the two core data structures used throughout the library.

FileContents

FileContents represents a single file. Use it when rendering a file with the <File> component, or pass two of them as oldFile and newFile to diff components.

FileDiffMetadata

FileDiffMetadata represents the differences between two files. It contains the hunks (changed regions), line counts, and optionally the full file contents for expansion.

Tip: You can generate FileDiffMetadata using parseDiffFromFile (from two file versions) or parsePatchFiles (from a patch string).

Creating Diffs

There are two ways to create a FileDiffMetadata.

From Two Files

Use parseDiffFromFile when you have both file versions. This approach includes the full file contents, enabling the "expand unchanged" feature.

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.

Tip: If you need to change the language after creating a FileContents or FileDiffMetadata, use the setLanguageOverride utility function.

File Contents Type (FileContents.ts):

import type { FileContents } from '@pierre/diffs';

// FileContents represents a single file
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: Cache key for AST caching in Worker Pool.
  // When provided, rendered AST results are cached and reused.
  // IMPORTANT: The key must change whenever the content, filename
  // or lang changes!
  cacheKey?: string;
}

// Example usage
const 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', // Must change if contents change
};

// With explicit language override
const jsonFile: FileContents = {
  // No extension, so we specify lang
  name: 'config',
  contents: '{ "key": "value" }',
  lang: 'json',
  cacheKey: 'config-file',
};

File Diff Metadata Type (FileDiffMetadata.ts):

import type { FileDiffMetadata, Hunk } from '@pierre/diffs';

// FileDiffMetadata represents the differences between two files
interface FileDiffMetadata {
  // Current filename
  name: string;

  // Previous filename (for renames)
  prevName: string | undefined;

  // 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;

  // Full file contents (when generated using parseDiffFromFile,
  // enables expansion around hunks)
  oldLines?: string[];
  newLines?: string[];

  // 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 patches
interface 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 | undefined;

  // Line position information, mostly used internally for
  // rendering optimizations
  splitLineStart: number;
  splitLineCount: number;
  unifiedLineStart: number;
  unifiedLineCount: number;
}

// ContextContent represents unchanged lines surrounding changes
interface ContextContent {
  type: 'context';
  lines: string[];
  // 'true' if the file does not have a blank newline at the end
  noEOFCR: boolean;
}

// ChangeContent represents a group of additions and deletions
interface ChangeContent {
  type: 'change';
  deletions: string[];
  additions: string[];
  // 'true' if the file does not have a blank newline at the end
  noEOFCRDeletions: boolean;
  noEOFCRAdditions: boolean;
}

Parse Diff From File Example (parseDiffFromFile.ts):

import {
  parseDiffFromFile,
  type FileContents,
  type FileDiffMetadata,
} from '@pierre/diffs';

// Define your two file versions
const 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 the diff metadata
const diff: FileDiffMetadata = parseDiffFromFile(oldFile, newFile);

// The resulting diff includes oldLines and newLines,
// which enables "expand unchanged" functionality in the UI.
// If both files have cacheKey, the diff will have a combined
// cacheKey of "greeting-old:greeting-new" for AST caching.

Parse Patch Files Example (parsePatchFiles.ts):

import {
  parsePatchFiles,
  type ParsedPatch,
  type FileDiffMetadata,
} from '@pierre/diffs';

// Parse a unified diff / patch string
const 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)
// Pass an optional cacheKeyPrefix to enable AST caching with Worker Pool
const patches: ParsedPatch[] = parsePatchFiles(patchString, 'my-patch-key');

// Each ParsedPatch contains an array of FileDiffMetadata
const files: FileDiffMetadata[] = patches[0].files;

// With cacheKeyPrefix, each diff gets a cacheKey like "my-patch-0",
// "my-patch-1", etc.
// This enables AST caching in Worker Pool for parsed patches.

// Note: Diffs from patch files don't include oldLines/newLines,
// so "expand unchanged" won't work unless you add them manually

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:

  • CodeView renders a mixed, virtualized list of files and diffs inside one scroll container
  • MultiFileDiff compares two file versions
  • PatchDiff renders from a patch string
  • FileDiff renders a pre-parsed FileDiffMetadata
  • File renders a single code file without a diff
  • UnresolvedFile renders merge conflict markers with built-in resolution UI
    • Currently in beta/experimental and may change in future releases.

UnresolvedFile is intentionally uncontrolled in React. Treat file as initial input and remount (for example, with a changing key) when you want to reset.

The CodeView tab above is the quick-start version. For the full guide on controlled items, imperative initialItems, ids, version, selection, and scrollTo, see CodeView.

Shared Props

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).

CodeView reuses many of the same option names internally, but it has its own controlled items mode, imperative mode with optional initialItems, viewer ref, and mixed-item render props. See CodeView for the dedicated guide.

Header customization and collapsing behavior:

  • Use renderHeaderPrefix to render custom UI at the beginning of the built-in header, before the filename and icons, while keeping the default header layout.
  • Use renderHeaderMetadata to render custom UI at the end of the built-in header, after the diff stats, while keeping the default header layout.
  • Use renderCustomHeader when you want to replace the built-in header content with your own custom designed one.
  • For diff components, these header callbacks receive fileDiff: FileDiffMetadata.
  • For File, the corresponding header callbacks receive file: FileContents.
  • Use options.collapsed to hide file body content while keeping the file header visible.

Post Render Lifecycle

options.onPostRender(node, instance, phase) is a DOM-node lifecycle callback. It fires with phase: 'mount' after the first committed render or hydration for a container node, phase: 'update' after later DOM-committing renders, and phase: 'unmount' before a mounted container node is removed, replaced, cleaned up, or recycled.

Use this callback when native DOM selection listeners need access to the rendered diff node or its shadow DOM. Attach listeners such as selectstart and selectionchange during mount, and remove them during unmount with teardown state captured by node.

Token callbacks (onTokenClick, onTokenEnter, onTokenLeave) and useTokenTransformer are documented in Token Hooks, including examples, payload details, performance notes, and Worker Pool caveats.

Code View (code_view.tsx):

import {
  parseDiffFromFile,
  type CodeViewItem,
} from '@pierre/diffs';
import { CodeView } from '@pierre/diffs/react';
import { useMemo } from 'react';

const oldAppFile = {
  name: 'src/app.ts',
  contents: 'export function greet() {
  return "hello";
}',
};

const newAppFile = {
  name: 'src/app.ts',
  contents:
    'export function greet(name: string) {
  return "hello " + name;
}',
};

const readmeFile = {
  name: 'README.md',
  contents: '# Docs

This file is rendered inline with the diff list.',
};

export function ReviewSurface() {
  // Pass `items` when React owns the full item list. Use `initialItems` plus a
  // ref instead when item updates should be imperative; omit both item props to
  // start empty and append later.
  const items = useMemo<CodeViewItem[]>(
    () => [
      {
        id: 'diff:src/app.ts',
        type: 'diff',
        fileDiff: parseDiffFromFile(oldAppFile, newAppFile),
        annotations: [{ side: 'additions', lineNumber: 2 }],
      },
      {
        id: 'file:README.md',
        type: 'file',
        file: readmeFile,
      },
    ],
    []
  );

  return (
    <CodeView
      items={items}
      style={{ height: 600, overflow: 'auto' }}
      options={{
        theme: { dark: 'pierre-dark', light: 'pierre-light' },
        stickyHeaders: true,
        layout: { paddingTop: 16, paddingBottom: 16, gap: 12 },
      }}
    />
  );
}

File (file.tsx):

import {
  type FileContents,
  type LineAnnotation,
  File,
} from '@pierre/diffs/react';

// The File component renders a single code file with syntax highlighting.
// Unlike the diff components, it doesn't show any changes - just the file
// contents with optional line annotations.

// Keep file objects stable (useState/useMemo) to avoid re-renders.
// The component uses reference equality for change detection.
const file: FileContents = {
  name: 'example.ts',
  contents: `function greet(name: string) {
  console.log(\`Hello, \${name}!\`);
}

export { greet };`,
};

export function CodeFile() {
  return (
    <File
      // Required: the file to display
      file={file}

      options={{
        theme: { dark: 'pierre-dark', light: 'pierre-light' },
      }}

      // The File component supports similar props to the diff components:
      // lineAnnotations, renderAnnotation, renderHeaderMetadata,
      // renderGutterUtility, selectedLines, className, style, etc.
      //
      // Key difference: File uses LineAnnotation (no 'side' property)
      // instead of DiffLineAnnotation since there's only one column.
      // Use lineNumber: 0 for a file-level annotation rendered above the
      // first file line.
      //
      // See "Shared Props" section above for details on these props.
      // File-specific options exclude diff-only settings like diffStyle,
      // diffIndicators, hunkSeparators, lineDiffType, etc.
    />
  );
}

File Diff (file_diff.tsx):

import {
  type FileDiffMetadata,
  FileDiff,
  parseDiffFromFile,
} from '@pierre/diffs/react';

// FileDiff takes a pre-parsed FileDiffMetadata object.
// Use this when:
// - You've already parsed the diff (e.g., from parsePatchFiles)
// - You want to manipulate the diff before rendering
// - You're using diffAcceptRejectHunk for interactive accept/reject

// Parse the diff yourself
const fileDiff: FileDiffMetadata = parseDiffFromFile(
  { name: 'example.ts', contents: 'console.log("Hello world")' },
  { name: 'example.ts', contents: 'console.warn("Updated message")' }
);

export function MyFileDiff() {
  return (
    <FileDiff
      // Required: pre-parsed FileDiffMetadata
      fileDiff={fileDiff}

      options={{
        theme: { dark: 'pierre-dark', light: 'pierre-light' },
        diffStyle: 'split',
      }}

      // See "Shared Props" tabs for all available props:
      // lineAnnotations, renderAnnotation, renderHeaderMetadata,
      // renderGutterUtility, selectedLines, className, style, etc.
    />
  );
}

Multi File Diff (multi_file_diff.tsx):

import {
  type FileContents,
  MultiFileDiff,
} from '@pierre/diffs/react';

// MultiFileDiff compares two file versions directly.
// Use this when you have the old and new file contents.

// Keep file objects stable (useState/useMemo) to avoid re-renders.
// The component uses reference equality for change detection.
const oldFile: FileContents = {
  name: 'example.ts',
  contents: 'console.log("Hello world")',
};

const newFile: FileContents = {
  name: 'example.ts',
  contents: 'console.warn("Updated message")',
};

export function MyDiff() {
  return (
    <MultiFileDiff
      // Required: the two file versions to compare
      oldFile={oldFile}
      newFile={newFile}

      options={{
        theme: { dark: 'pierre-dark', light: 'pierre-light' },
        diffStyle: 'split',
      }}

      // See "Shared Props" tabs for all available props:
      // lineAnnotations, renderAnnotation, renderHeaderMetadata,
      // renderGutterUtility, selectedLines, className, style, etc.
    />
  );
}

Patch Diff (patch_diff.tsx):

import { PatchDiff } from '@pierre/diffs/react';

// PatchDiff renders from a unified diff/patch string.
// Use this when you have patch content (e.g., from git or GitHub).

const patch = `diff --git a/example.ts b/example.ts
--- a/example.ts
+++ b/example.ts
@@ -1,3 +1,3 @@
-console.log("Hello world");
+console.warn("Updated message");
`;

export function MyPatchDiff() {
  return (
    <PatchDiff
      // Required: the patch/diff string
      patch={patch}

      options={{
        theme: { dark: 'pierre-dark', light: 'pierre-light' },
        diffStyle: 'unified', // patches often look better unified
      }}

      // See "Shared Props" tabs for all available props:
      // lineAnnotations, renderAnnotation, renderHeaderMetadata,
      // renderGutterUtility, selectedLines, className, style, etc.
    />
  );
}

Post Render Lifecycle (post_render_lifecycle.tsx):

import { FileDiff } from '@pierre/diffs/react';

const cleanupByNode = new WeakMap<HTMLElement, () => void>();

<FileDiff
  fileDiff={fileDiff}
  options={{
    onPostRender(node, _instance, phase) {
      if (phase === 'mount') {
        const selectionRoot = node.shadowRoot ?? node;

        const handleSelectStart = () => {
          console.log('selection started in diff');
        };

        const handleSelectionChange = () => {
          const selection = document.getSelection();
          if (selection == null || selection.isCollapsed) {
            return;
          }

          if (
            !containsSelectionNode(selectionRoot, selection.anchorNode) &&
            !containsSelectionNode(selectionRoot, selection.focusNode)
          ) {
            return;
          }

          console.log('selected text', selection.toString());
        };

        selectionRoot.addEventListener('selectstart', handleSelectStart);
        document.addEventListener('selectionchange', handleSelectionChange);
        cleanupByNode.set(node, () => {
          selectionRoot.removeEventListener('selectstart', handleSelectStart);
          document.removeEventListener('selectionchange', handleSelectionChange);
        });
        return;
      }

      if (phase === 'unmount') {
        cleanupByNode.get(node)?.();
        cleanupByNode.delete(node);
      }
    },
  }}
/>

function containsSelectionNode(root: Node, node: Node | null) {
  return node != null && root.contains(node);
}

Shared Diff Options (shared_diff_options.tsx):

// ============================================================
// SHARED OPTIONS FOR DIFF COMPONENTS
// ============================================================
// These options are shared by MultiFileDiff, PatchDiff, and FileDiff.
// Pass them via the `options` prop.

import type {
  DiffTokenEventBaseProps,
  FileDiff as FileDiffClass,
  PostRenderPhase,
} from '@pierre/diffs';
import { MultiFileDiff } from '@pierre/diffs/react';

<MultiFileDiff
  {...}
  options={{
    theme: { dark: 'pierre-dark', light: 'pierre-light' },
    diffStyle: 'split',
    // ... see below for all available options
  }}
/>

interface DiffOptions {
  // ─────────────────────────────────────────────────────────────
  // THEMING
  // ─────────────────────────────────────────────────────────────

  // Theme for syntax highlighting. Can be a single theme name or an
  // object with 'dark' and 'light' keys for automatic switching.
  // Built-in options: 'pierre-dark', 'pierre-light', or any Shiki theme.
  // See: https://shiki.style/themes
  theme: { dark: 'pierre-dark', light: 'pierre-light' },

  // When using dark/light theme object, this controls which is used:
  // 'system' (default) - follows OS preference
  // 'dark' or 'light' - forces specific theme
  themeType: 'system',

  // Choose the Shiki engine:
  // 'shiki-js' (default) - JavaScript regex engine
  // 'shiki-wasm' - WASM Oniguruma engine
  preferredHighlighter: 'shiki-js',

  // ─────────────────────────────────────────────────────────────
  // DIFF DISPLAY
  // ─────────────────────────────────────────────────────────────

  // 'split' (default) - side-by-side view
  // 'unified' - single column view
  diffStyle: 'split',

  // Line change indicators:
  // 'bars' (default) - colored bars on left edge
  // 'classic' - '+' and '-' characters
  // 'none' - no indicators
  diffIndicators: 'bars',

  // Show colored backgrounds on changed lines (default: false)
  disableBackground: false,

  // ─────────────────────────────────────────────────────────────
  // HUNK SEPARATORS
  // ─────────────────────────────────────────────────────────────

  // What to show between diff hunks:
  // 'line-info' (default) - shows collapsed line count, clickable to expand
  // WebKit/Safari bug in version 26 as of this writing: if you use 
  // custom renderGutterUtility with hunkSeparators: 'line-info', you may 
  // experience scroll jumping while moving the mouse.
  // Recommended: avoid this API by just using enableGutterUtility to render
  // the default button, or switch to another hunk separator type 
  // (e.g. 'line-info-basic').
  // For a status of this bug, visit:
  // https://bugs.webkit.org/show_bug.cgi?id=308027
  // 'line-info-basic' - slightly more compact full width line-info variant
  // 'metadata' - shows patch format like '@@ -60,6 +60,22 @@'
  // 'simple' - subtle bar separator
  // We recommend sticking to these built-in string presets in React.
  // The low-level functional separator API is only documented for vanilla JS,
  // is being phased out, and is a poor fit for the container-managed and
  // virtualization-oriented React APIs.
  hunkSeparators: 'line-info',

  // Force unchanged context to always render (default: false)
  // Requires oldFile/newFile API or FileDiffMetadata with newLines
  expandUnchanged: false,

  // Lines revealed per click when expanding collapsed regions
  expansionLineCount: 100,

  // Auto-expand collapsed context regions at or below this size
  // (default: 1)
  collapsedContextThreshold: 1,

  // ─────────────────────────────────────────────────────────────
  // INLINE CHANGE HIGHLIGHTING
  // ─────────────────────────────────────────────────────────────

  // Highlight changed portions within modified lines:
  // 'word-alt' (default) - word boundaries, minimizes single-char gaps
  // 'word' - word boundaries
  // 'char' - character-level granularity
  // 'none' - disable inline highlighting
  lineDiffType: 'word-alt',

  // Skip inline diff for lines exceeding this length
  maxLineDiffLength: 1000,

  // ─────────────────────────────────────────────────────────────
  // LAYOUT & DISPLAY
  // ─────────────────────────────────────────────────────────────

  // Show line numbers (default: true)
  disableLineNumbers: false,

  // Long line handling: 'scroll' (default) or 'wrap'
  overflow: 'scroll',

  // Hide the file header with filename and stats
  disableFileHeader: false,

  // Rethrow rendering errors instead of catching and displaying them
  // in the DOM. Useful for testing or custom error handling.
  // (default: false)
  disableErrorHandling: false,

  // Skip syntax highlighting for lines exceeding this length
  tokenizeMaxLineLength: 1000,

  // Fires after hydration, after DOM-committing render updates, and before
  // mounted DOM is removed. Phase is 'mount' | 'update' | 'unmount'.
  // Receives the outer diffs container element.
  // Useful when you want to measure, observe, or clean up DOM-node state.
  // You can access the shadow DOM from here if you need to inspect lines.
  onPostRender(
    node: HTMLElement,
    instance: FileDiffClass,
    phase: PostRenderPhase
  ) {
    if (phase === 'unmount') {
      return;
    }

    const codeLines = node.shadowRoot?.querySelectorAll('[data-line]');
    console.log('rendered line count', codeLines?.length ?? 0);
  },

  // ─────────────────────────────────────────────────────────────
  // LINE SELECTION
  // ─────────────────────────────────────────────────────────────

  // Enable click-to-select on line numbers
  enableLineSelection: false,

  // Callbacks for selection events
  onLineSelectionStart(range: SelectedLineRange | null) {
    // Fires on pointer down
  },
  onLineSelectionChange(range: SelectedLineRange | null) {
    // Fires while dragging when range grows/shrinks (not initial down)
  },
  onLineSelectionEnd(range: SelectedLineRange | null) {
    // Fires on pointer up
  },
  onLineSelected(range: SelectedLineRange | null) {
    // Fires on pointer up with final range (or null)
  },

  // ─────────────────────────────────────────────────────────────
  // MOUSE EVENTS
  // ─────────────────────────────────────────────────────────────

  // Line hover effect. Sets a data-hovered attribute on the
  // hovered element(s), which you can style via the Styling API.
  // 'disabled' (default) - no hover effect
  // 'both' - highlights both line number and line content
  // 'number' - highlights only the line number
  // 'line' - highlights only the line content
  lineHoverHighlight: 'disabled',

  // Must be true to enable renderGutterUtility prop
  enableGutterUtility: false,

  // Callbacks for mouse events on diff lines
  onLineClick({ lineNumber, side, event }) {
    // Fires when clicking anywhere on a line
  },
  onLineNumberClick({ lineNumber, side, event }) {
    // Fires when clicking anywhere in the line number column
  },
  onLineEnter({ lineNumber, side }) {
    // Fires when mouse enters a line
  },
  onLineLeave({ lineNumber, side }) {
    // Fires when mouse leaves a line
  },

  // See the Token Hooks section for examples, performance notes,
  // and Worker Pool caveats.
  // These APIs preserve more token-level DOM metadata, which increases DOM
  // size and may have a performance impact on larger files.
  // Experimental token callbacks. Useful for token-aware UIs such as
  // LSP textDocument/hover tooltips or temporary token styling.
  // lineCharStart is zero-based and lineCharEnd is end-exclusive.
  // If both token and line click handlers are provided, both will fire.
  onTokenClick({
    tokenText,
    lineNumber,
    lineCharStart,
    lineCharEnd,
    side,
  }: DiffTokenEventBaseProps) {
    // Fires when clicking a token in the code column
  },
  onTokenEnter({
    tokenText,
    lineNumber,
    lineCharStart,
    lineCharEnd,
    side,
    tokenElement,
  }: DiffTokenEventBaseProps) {
    // Use tokenElement for hover styling or tooltips
  },
  onTokenLeave({ tokenText, side, tokenElement }: DiffTokenEventBaseProps) {
    // Clean up token-specific hover UI
  },

  // Include whitespace-only tokens in token callbacks (default: false)
  enableTokenInteractionsOnWhitespace: false,

  // Experimental: force token wrappers/data-char output even when no token
  // callbacks are attached. Usually unnecessary unless you want custom styling.
  // This also increases DOM size and may have a performance impact on
  // larger files.
  useTokenTransformer: false,

  // Preferred: built-in gutter utility button (+)
  // No render callback needed; callback receives a SelectedLineRange.
  // Callback does not control visibility; options.enableGutterUtility does.
  // Fires on pointer up only:
  // - click => single-line range
  // - drag => final range at release
  // Selection callbacks can still fire when line selection is enabled.
  // Can click a single line or apply to a drag interaction started pointer
  // down on the button
  onGutterUtilityClick(range: SelectedLineRange) {
    console.log(range.start, range.end, range.side, range.endSide);
  },
}

Shared Diff Render Props (shared_diff_render_props.tsx):

// ============================================================
// SHARED RENDER PROPS FOR DIFF COMPONENTS
// ============================================================
// These props are shared by MultiFileDiff, PatchDiff, and FileDiff.

import { MultiFileDiff } from '@pierre/diffs/react';

interface ThreadMetadata {
  threadId: string;
}

<MultiFileDiff<ThreadMetadata>
  {...}

  // ─────────────────────────────────────────────────────────────
  // LINE ANNOTATIONS
  // ─────────────────────────────────────────────────────────────

  // Array of annotations to display on specific lines.
  // Keep annotation arrays stable (useState/useMemo) to avoid re-renders.
  // Annotation metadata can be typed any way you'd like.
  // Multiple annotations can target the same side/line.
  // Use lineNumber: 0 for a file-level annotation rendered above the first
  // hunk separator or diff row.
  lineAnnotations={[
    {
      side: 'additions',
      lineNumber: 0,
      metadata: { threadId: 'file-summary' },
    },
    {
      side: 'additions', // or 'deletions'
      lineNumber: 16,    // visual line number in the diff
      metadata: { threadId: 'abc123' },
    },
  ]}

  // Render function for each annotation. Despite the diff being
  // rendered in shadow DOM, annotations use slots so you can use
  // normal CSS and styling.
  renderAnnotation={(annotation) => (
    <CommentThread threadId={annotation.metadata.threadId} />
  )}

  // ─────────────────────────────────────────────────────────────
  // HEADER CALLBACKS
  // ─────────────────────────────────────────────────────────────

  // All diff header render callbacks receive FileDiffMetadata directly.
  // This includes renderCustomHeader, renderHeaderPrefix, and
  // renderHeaderMetadata.
  // renderHeaderPrefix renders at the beginning of the built-in header,
  // before the filename.
  // renderHeaderMetadata renders at the end of the built-in header,
  // after the +/- line metrics.
  // renderCustomHeader replaces the built-in header content entirely.
  //
  // Render custom content on the right side of the built-in header.
  // Callback arg: FileDiffMetadata
  renderHeaderMetadata={(fileDiff) => (
    <span>{fileDiff.name}</span>
  )}

  // ─────────────────────────────────────────────────────────────
  // GUTTER UTILITY
  // ─────────────────────────────────────────────────────────────

  // Preferred: built-in + button (no custom render function).
  // Callback receives a SelectedLineRange.
  // Visibility is still controlled by options.enableGutterUtility.
  // Fires on pointer up only:
  // - click => single-line range
  // - drag => final range at release
  // Selection callbacks can still fire when line selection is enabled.
  // Can click a single line or apply to a drag interaction started pointer
  // down on the button
  onGutterUtilityClick={(range) => {
    console.log(range.start, range.end, range.side, range.endSide);
  }}

  // Advanced: render your own UI in the line number column on hover.
  // Prefer onGutterUtilityClick unless you need fully custom content.
  // Requires options.enableGutterUtility = true
  // Do not combine with onGutterUtilityClick.
  // WebKit/Safari bug version 26 as of this writing: if you use this custom
  // API with hunkSeparators: 'line-info', you may see scroll jumping while
  // moving the mouse.
  // Recommended: Just enable 'enableGutterUtility' for the default button,
  // or switch hunk separators to 'line-info-basic', 'metadata', or 'simple'.
  // For a status of this bug, visit:
  // https://bugs.webkit.org/show_bug.cgi?id=308027
  //
  // Note: This is NOT reactive - render is not called on every
  // mouse move. Use getHoveredLine() in click handlers.
  renderGutterUtility={(getHoveredLine) => (
    <button
      onClick={() => {
        const { lineNumber, side } = getHoveredLine();
        console.log(`Clicked line ${lineNumber} on ${side}`);
      }}
    >
      +
    </button>
  )}

  // ─────────────────────────────────────────────────────────────
  // LINE SELECTION (controlled)
  // ─────────────────────────────────────────────────────────────

  // Programmatically control which lines are selected.
  // Works with both 'split' and 'unified' diff styles.
  selectedLines={{
    start: 3,
    end: 5,
    side: 'additions',      // optional, defaults to 'additions'
    endSide: 'additions',   // optional, defaults to 'side'
  }}

  // ─────────────────────────────────────────────────────────────
  // STYLING
  // ─────────────────────────────────────────────────────────────

  className="my-diff"
  style={{ maxHeight: 500 }}

  // ─────────────────────────────────────────────────────────────
  // SSR (advanced)
  // ─────────────────────────────────────────────────────────────

  // Pre-rendered HTML from server for hydration
  // See the SSR section for details
  prerenderedHTML={htmlFromServer}
/>

Shared File Options (shared_file_options.tsx):

// ============================================================
// OPTIONS FOR THE FILE COMPONENT
// ============================================================
// Pass these via the `options` prop on the File component.

import type {
  File as FileClass,
  PostRenderPhase,
  TokenEventBase,
} from '@pierre/diffs';
import { File } from '@pierre/diffs/react';

<File
  {...}
  options={{
    theme: { dark: 'pierre-dark', light: 'pierre-light' },
    // ... see below for all available options
  }}
/>

interface FileOptions {
  // ─────────────────────────────────────────────────────────────
  // THEMING
  // ─────────────────────────────────────────────────────────────

  // Theme for syntax highlighting. Can be a single theme name or an
  // object with 'dark' and 'light' keys for automatic switching.
  // Built-in options: 'pierre-dark', 'pierre-light', or any Shiki theme.
  // See: https://shiki.style/themes
  theme: { dark: 'pierre-dark', light: 'pierre-light' },

  // When using dark/light theme object, this controls which is used:
  // 'system' (default) - follows OS preference
  // 'dark' or 'light' - forces specific theme
  themeType: 'system',

  // Choose the Shiki engine:
  // 'shiki-js' (default) - JavaScript regex engine
  // 'shiki-wasm' - WASM Oniguruma engine
  preferredHighlighter: 'shiki-js',

  // ─────────────────────────────────────────────────────────────
  // LAYOUT & DISPLAY
  // ─────────────────────────────────────────────────────────────

  // Show line numbers (default: true)
  disableLineNumbers: false,

  // Long line handling: 'scroll' (default) or 'wrap'
  overflow: 'scroll',

  // Hide the file header with filename
  disableFileHeader: false,

  // Rethrow rendering errors instead of catching and displaying them
  // in the DOM. Useful for testing or custom error handling.
  // (default: false)
  disableErrorHandling: false,

  // Skip syntax highlighting for lines exceeding this length
  tokenizeMaxLineLength: 1000,

  // Fires after hydration, after DOM-committing render updates, and before
  // mounted DOM is removed. Phase is 'mount' | 'update' | 'unmount'.
  // Receives the outer diffs container element.
  // Useful when you want to measure, observe, or clean up DOM-node state.
  // You can access the shadow DOM from here if you need to inspect lines.
  onPostRender(
    node: HTMLElement,
    instance: FileClass,
    phase: PostRenderPhase
  ) {
    if (phase === 'unmount') {
      return;
    }

    const codeLines = node.shadowRoot?.querySelectorAll('[data-line]');
    console.log('rendered line count', codeLines?.length ?? 0);
  },

  // ─────────────────────────────────────────────────────────────
  // LINE SELECTION
  // ─────────────────────────────────────────────────────────────

  // Enable click-to-select on line numbers
  enableLineSelection: false,

  // Callbacks for selection events
  onLineSelectionStart(range: SelectedLineRange | null) {
    // Fires on pointer down
  },
  onLineSelectionChange(range: SelectedLineRange | null) {
    // Fires while dragging when range grows/shrinks (not initial down)
  },
  onLineSelectionEnd(range: SelectedLineRange | null) {
    // Fires on pointer up
  },
  onLineSelected(range: SelectedLineRange | null) {
    // Fires on pointer up with final range (or null)
  },

  // ─────────────────────────────────────────────────────────────
  // MOUSE EVENTS
  // ─────────────────────────────────────────────────────────────

  // Line hover effect. Sets a data-hovered attribute on the
  // hovered element(s), which you can style via the Styling API.
  // 'disabled' (default) - no hover effect
  // 'both' - highlights both line number and line content
  // 'number' - highlights only the line number
  // 'line' - highlights only the line content
  lineHoverHighlight: 'disabled',

  // Must be true to enable renderGutterUtility prop
  enableGutterUtility: false,

  // Callbacks for mouse events on file lines
  onLineClick({ lineNumber, event }) {
    // Fires when clicking anywhere on a line
  },
  onLineNumberClick({ lineNumber, event }) {
    // Fires when clicking anywhere in the line number column
  },
  onLineEnter({ lineNumber }) {
    // Fires when mouse enters a line
  },
  onLineLeave({ lineNumber }) {
    // Fires when mouse leaves a line
  },

  // See the Token Hooks section for examples, performance notes,
  // and Worker Pool caveats.
  // These APIs preserve more token-level DOM metadata, which increases DOM
  // size and may have a performance impact on larger files.
  // Experimental token callbacks. Useful for token-aware UIs such as
  // LSP textDocument/hover tooltips or temporary token styling.
  // lineCharStart is zero-based and lineCharEnd is end-exclusive.
  // If both token and line click handlers are provided, both will fire.
  onTokenClick({
    tokenText,
    lineNumber,
    lineCharStart,
    lineCharEnd,
  }: TokenEventBase) {
    // Fires when clicking a token in the code column
  },
  onTokenEnter({
    tokenText,
    lineNumber,
    lineCharStart,
    lineCharEnd,
    tokenElement,
  }: TokenEventBase) {
    // Use tokenElement for hover styling or tooltips
  },
  onTokenLeave({ tokenText, tokenElement }: TokenEventBase) {
    // Clean up token-specific hover UI
  },

  // Include whitespace-only tokens in token callbacks (default: false)
  enableTokenInteractionsOnWhitespace: false,

  // Experimental: force token wrappers/data-char output even when no token
  // callbacks are attached. Usually unnecessary unless you want custom styling.
  // This also increases DOM size and may have a performance impact on larger
  // files.
  useTokenTransformer: false,

  // Preferred: built-in gutter utility button (+)
  // No render callback needed; callback receives a SelectedLineRange.
  // Callback does not control visibility; options.enableGutterUtility does.
  // Fires on pointer up only:
  // - click => single-line range
  // - drag => final range at release
  // Selection callbacks can still fire when line selection is enabled.
  // Can click a single line or apply to a drag interaction started pointer
  // down on the button
  onGutterUtilityClick(range: SelectedLineRange) {
    console.log(range.start, range.end);
  },
}

Shared File Render Props (shared_file_render_props.tsx):

// ============================================================
// RENDER PROPS FOR THE FILE COMPONENT
// ============================================================
// These props are available on the File component.

import { File } from '@pierre/diffs/react';

interface CommentMetadata {
  commentId: string;
}

<File<CommentMetadata>
  {...}

  // ─────────────────────────────────────────────────────────────
  // LINE ANNOTATIONS
  // ─────────────────────────────────────────────────────────────

  // Array of annotations to display on specific lines.
  // Keep annotation arrays stable (useState/useMemo) to avoid re-renders.
  // Annotation metadata can be typed any way you'd like.
  // Multiple annotations can target the same line.
  //
  // Note: Unlike diff components, File uses LineAnnotation which
  // has no 'side' property since there's only one column.
  // Use lineNumber: 0 for a file-level annotation rendered above the
  // first file line.
  lineAnnotations={[
    {
      lineNumber: 0,
      metadata: { commentId: 'file-summary' },
    },
    {
      lineNumber: 5,    // visual line number in the file
      metadata: { commentId: 'comment-123' },
    },
  ]}

  // Render function for each annotation. Despite the file being
  // rendered in shadow DOM, annotations use slots so you can use
  // normal CSS and styling.
  renderAnnotation={(annotation) => (
    <Comment commentId={annotation.metadata.commentId} />
  )}

  // ─────────────────────────────────────────────────────────────
  // HEADER CALLBACKS
  // ─────────────────────────────────────────────────────────────

  // File header callbacks receive FileContents directly.
  // renderHeaderPrefix renders at the beginning of the built-in header,
  // before the filename.
  // renderHeaderMetadata renders at the end of the built-in header.
  // renderCustomHeader replaces the built-in header content entirely.
  // Callback arg: FileContents
  //
  // Render custom content on the right side of the built-in header.
  renderHeaderMetadata={(file) => (
    <span>{file.name}</span>
  )}

  // ─────────────────────────────────────────────────────────────
  // GUTTER UTILITY
  // ─────────────────────────────────────────────────────────────

  // Preferred: built-in + button (no custom render function).
  // Callback receives a SelectedLineRange.
  // Visibility is still controlled by options.enableGutterUtility.
  // Fires on pointer up only:
  // - click => single-line range
  // - drag => final range at release
  // Selection callbacks can still fire when line selection is enabled.
  // Can click a single line or apply to a drag interaction started pointer
  // down on the button
  onGutterUtilityClick={(range) => {
    console.log(range.start, range.end);
  }}

  // Advanced: render your own UI in the line number column on hover.
  // Prefer onGutterUtilityClick unless you need fully custom content.
  // Requires options.enableGutterUtility = true
  // Do not combine with onGutterUtilityClick.
  // WebKit/Safari note: there is a specific scroll-jump issue is tied to
  // diff views using custom renderGutterUtility + hunkSeparators: 'line-info'.
  // File views do not use hunk separators, so this case does not apply here.
  //
  // Note: This is NOT reactive - render is not called on every
  // mouse move. Use getHoveredLine() in click handlers.
  renderGutterUtility={(getHoveredLine) => (
    <button
      onClick={() => {
        const { lineNumber } = getHoveredLine();
        console.log(`Clicked line ${lineNumber}`);
      }}
    >
      +
    </button>
  )}

  // ─────────────────────────────────────────────────────────────
  // LINE SELECTION (controlled)
  // ─────────────────────────────────────────────────────────────

  // Programmatically control which lines are selected.
  selectedLines={{
    start: 3,
    end: 5,
  }}

  // ─────────────────────────────────────────────────────────────
  // STYLING
  // ─────────────────────────────────────────────────────────────

  className="my-file"
  style={{ maxHeight: 500 }}

  // ─────────────────────────────────────────────────────────────
  // SSR (advanced)
  // ─────────────────────────────────────────────────────────────

  // Pre-rendered HTML from server for hydration
  // See the SSR section for details
  prerenderedHTML={htmlFromServer}
/>

Unresolved File (unresolved_file.tsx):

import type {
  PostRenderPhase,
  UnresolvedFile as UnresolvedFileClass,
} from '@pierre/diffs';
import { UnresolvedFile, type FileContents } from '@pierre/diffs/react';
import { useState } from 'react';

// UnresolvedFile renders Git-style merge conflict markers.
// React UnresolvedFile is intentionally uncontrolled:
// - The `file` prop is treated as the initial source
// - Conflict buttons apply changes internally
// - To reset, remount the component (shown with the key below)

const initialFile: FileContents = {
  name: 'auth.ts',
  contents: `export function createSession() {
<<<<<<< HEAD
  return { source: 'server', ttl: 12 };
=======
  return { source: 'web', ttl: 24 };
>>>>>>> feature/web-session
}`,
};

export function MergeConflictPreview() {
  const [instanceKey, setInstanceKey] = useState(0);

  return (
    <>
      <button onClick={() => setInstanceKey((v) => v + 1)}>Reset</button>
      <UnresolvedFile
        key={instanceKey}
        file={initialFile}
        options={{
          theme: { dark: 'pierre-dark', light: 'pierre-light' },
          diffIndicators: 'none',
          onPostRender(
            node: HTMLElement,
            instance: UnresolvedFileClass,
            phase: PostRenderPhase
          ) {
            if (phase === 'unmount') {
              return;
            }

            const codeLines = node.shadowRoot?.querySelectorAll(
              '[data-line]'
            );
            console.log('rendered line count', codeLines?.length ?? 0);
          },
        }}
      />
    </>
  );
}

Vanilla JS API

Import vanilla JavaScript classes, components, and methods from @pierre/diffs.

Components

The Vanilla JS API exposes four core components: CodeView (render a mixed, virtualized list of files and diffs in one scroll container), FileDiff (compare two file versions or render a pre-parsed FileDiffMetadata), File (render a single code file without diff), and UnresolvedFile (render merge conflicts with built-in resolution controls). Typically you'll want to interface with these as they'll handle the complicated aspects of syntax highlighting, theming, and interactivity for you.

UnresolvedFile is currently beta/experimental and may change in future releases.

UnresolvedFile in vanilla supports both uncontrolled and controlled callbacks (onMergeConflictResolve / onMergeConflictAction).

The CodeView tab above is the quick-start version. For the deeper guide on setup, setItems, addItems, getItem, updateItem, selection, and scrollTo, see CodeView.

Props

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).

CodeView forwards many of those same options to each rendered item, while adding CodeView-specific controls like layout, itemMetrics, stickyHeaders, pointerEventsOnScroll, and smoothScrollSettings. Its class instance also exposes item-level methods such as addItems, getItem, and updateItem. See CodeView for the dedicated guide.

Header customization and collapsing behavior:

  • Use renderHeaderPrefix to render custom UI at the beginning of the built-in FileDiff header, before the filename and icon, while keeping the default header layout.
  • Use renderHeaderMetadata to render custom UI at the end of the built-in FileDiff header, after the diff stats, while keeping the default header layout.
  • Use renderCustomHeader when you want to replace the built-in header content entirely.
  • In File, header callbacks receive file: FileContents.
  • Use collapsed in constructor options to hide file body content while keeping the file header visible.

Post Render Lifecycle

onPostRender(node, instance, phase) is a DOM-node lifecycle callback. It fires with phase: 'mount' after the first committed render or hydration for a container node, phase: 'update' after later DOM-committing renders, and phase: 'unmount' before a mounted container node is removed, replaced, cleaned up, or recycled.

Use this callback when native DOM selection listeners need access to the rendered diff node or its shadow DOM. Attach listeners such as selectstart and selectionchange during mount, and remove them during unmount with teardown state captured by node.

Token callbacks (onTokenClick, onTokenEnter, onTokenLeave) and useTokenTransformer are documented in Token Hooks, including examples, payload details, performance notes, and Worker Pool caveats.

Custom Hunk Separators

Start with the Hunk Separators section first. In most cases, styling the built-in separator markup with unsafeCSS is the better approach.

If that is still not enough, the low-level hunkSeparators(hunkData, instance) function remains available in Vanilla JS as a last-resort escape hatch. It is being phased out and is not the recommended path for new integrations, but the example below shows how it works when you truly need to render your own elements:

Renderers

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.

DiffHunksRenderer

Takes a FileDiffMetadata data structure and renders out the raw HAST (Hypertext Abstract Syntax Tree) elements for diff hunks. You can generate FileDiffMetadata via parseDiffFromFile or parsePatchFiles utility functions.

FileRenderer

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.

Code View Example (code_view_example.ts):

import {
  CodeView,
  parseDiffFromFile,
  type CodeViewItem,
} from '@pierre/diffs';

const root = document.getElementById('review-root');
if (root == null) {
  throw new Error('Expected #review-root to exist');
}

root.style.height = '600px';
root.style.overflow = 'auto';

const viewer = new CodeView({
  theme: { dark: 'pierre-dark', light: 'pierre-light' },
  stickyHeaders: true,
  layout: { paddingTop: 16, paddingBottom: 16, gap: 12 },
});

viewer.setup(root);

const items: CodeViewItem[] = [
  {
    id: 'diff:src/app.ts',
    type: 'diff',
    fileDiff: parseDiffFromFile(
      {
        name: 'src/app.ts',
        contents: 'export function greet() {\n  return "hello";\n}',
      },
      {
        name: 'src/app.ts',
        contents:
          'export function greet(name: string) {\n  return "hello " + name;\n}',
      }
    ),
    annotations: [{ side: 'additions', lineNumber: 2 }],
  },
  {
    id: 'file:README.md',
    type: 'file',
    file: {
      name: 'README.md',
      contents: '# Docs\n\nThis file is rendered inline with the diff list.',
    },
  },
];

viewer.setItems(items);

const appItem = viewer.getItem('diff:src/app.ts');
if (appItem?.type === 'diff') {
  viewer.updateItem({
    ...appItem,
    version: 2,
    annotations: [{ side: 'additions', lineNumber: 2 }],
  });
}

viewer.addItems([
  {
    id: 'file:CHANGELOG.md',
    type: 'file',
    file: {
      name: 'CHANGELOG.md',
      contents: '# Changelog

- Added personalized greetings.',
    },
  },
]);

window.addEventListener('beforeunload', () => {
  viewer.cleanUp();
});

Custom Hunk File (hunks_example.ts):

import { FileDiff } from '@pierre/diffs';

// This is a low-level vanilla-only escape hatch.
// Prefer built-in hunk separators plus CSS customization when possible.
// This function-based API is being phased out and does not fit the
// container-managed and virtualization-oriented APIs.

// A hunk separator that utilizes the existing grid to have
// a number column and a content column where neither will
// scroll with the code
const instance = new FileDiff({
  hunkSeparators(hunkData: HunkData) {
    const fragment = document.createDocumentFragment();
    const numCol = document.createElement('div');
    numCol.textContent = `${hunkData.lines}`;
    numCol.style.position = 'sticky';
    numCol.style.left = '0';
    numCol.style.backgroundColor = 'var(--diffs-bg)';
    numCol.style.zIndex = '2';
    fragment.appendChild(numCol);
    const contentCol = document.createElement('div');
    contentCol.textContent = 'unmodified lines';
    contentCol.style.position = 'sticky';
    contentCol.style.width = 'var(--diffs-column-content-width)';
    contentCol.style.left = 'var(--diffs-column-number-width)';
    fragment.appendChild(contentCol);
    return fragment;
  },
})

// If you want to create a single column that spans both colums
// and doesn't scroll, you can do something like this:
const instance2 = new FileDiff({
  hunkSeparators(hunkData: HunkData) {
    const wrapper = document.createElement('div');
    wrapper.style.gridColumn = 'span 2';
    const contentCol = document.createElement('div');
    contentCol.textContent = `${hunkData.lines} unmodified lines`;
    contentCol.style.position = 'sticky';
    contentCol.style.width = 'var(--diffs-column-width)';
    contentCol.style.left = '0';
    wrapper.appendChild(contentCol);
    return wrapper;
  },
})

// If you want to create a single column that's aligned with the content
// column and doesn't scroll, you can do something like this:
const instance3 = new FileDiff({
  hunkSeparators(hunkData: HunkData) {
    const wrapper = document.createElement('div');
    wrapper.style.gridColumn = '2 / 3';
    wrapper.textContent = `${hunkData.lines} unmodified lines`;
    wrapper.style.position = 'sticky';
    wrapper.style.width = 'var(--diffs-column-content-width)';
    wrapper.style.left = 'var(--diffs-column-number-width)';
    return wrapper;
  },
})

File Diff Example (file_diff_example.ts):

import { FileDiff, type FileContents } from '@pierre/diffs';

// Create the instance with options
const instance = new FileDiff({
  theme: { dark: 'pierre-dark', light: 'pierre-light' },
  diffStyle: 'split',
});

// Define your files (keep references stable to avoid re-renders)
const oldFile: FileContents = {
  name: 'example.ts',
  contents: 'console.log("Hello world")',
};

const newFile: FileContents = {
  name: 'example.ts',
  contents: 'console.warn("Updated message")',
};

// Render the diff into a container
instance.render({
  oldFile,
  newFile,
  containerWrapper: document.getElementById('diff-container'),
});

// Update options later if needed (full replacement, not merge)
instance.setOptions({ ...instance.options, diffStyle: 'unified' });
instance.rerender(); // Must call rerender() after updating options

// Clean up when done
instance.cleanUp();

File Diff Props (file_diff_props.ts):

import { FileDiff, type DiffTokenEventBaseProps } from '@pierre/diffs';

// All available options for the FileDiff class
const instance = new FileDiff({

  // ─────────────────────────────────────────────────────────────
  // THEMING
  // ─────────────────────────────────────────────────────────────

  // Theme for syntax highlighting. Can be a single theme name or an
  // object with 'dark' and 'light' keys for automatic switching.
  // Built-in options: 'pierre-dark', 'pierre-light', or any Shiki theme.
  // See: https://shiki.style/themes
  theme: { dark: 'pierre-dark', light: 'pierre-light' },

  // When using dark/light theme object, this controls which is used:
  // 'system' (default) - follows OS preference
  // 'dark' or 'light' - forces specific theme
  themeType: 'system',

  // Choose the Shiki engine:
  // 'shiki-js' (default) - JavaScript regex engine
  // 'shiki-wasm' - WASM Oniguruma engine
  preferredHighlighter: 'shiki-js',

  // ─────────────────────────────────────────────────────────────
  // DIFF DISPLAY
  // ─────────────────────────────────────────────────────────────

  // 'split' (default) - side-by-side view
  // 'unified' - single column view
  diffStyle: 'split',

  // Line change indicators:
  // 'bars' (default) - colored bars on left edge
  // 'classic' - '+' and '-' characters
  // 'none' - no indicators
  diffIndicators: 'bars',

  // Show colored backgrounds on changed lines (default: false)
  disableBackground: false,

  // ─────────────────────────────────────────────────────────────
  // HUNK SEPARATORS
  // ─────────────────────────────────────────────────────────────

  // What to show between diff hunks:
  // 'line-info' (default) - shows collapsed line count, clickable to expand
  // WebKit/Safari bug in version 26 as of this writing: if you use
  // 'renderGutterUtility' with hunkSeparators: 'line-info', you may see
  // scroll jumping while moving the mouse.
  // Recommended: use the built-in gutter utility button by not using this API,
  // or switch to another hunk separator type (for example 'line-info-basic').
  // For a status of this bug, visit:
  // https://bugs.webkit.org/show_bug.cgi?id=308027
  // 'line-info-basic' - slightly more compact full width line-info variant
  // 'metadata' - shows patch format like '@@ -60,6 +60,22 @@'
  // 'simple' - subtle bar separator
  // Prefer the built-in presets plus CSS first (see the Hunk Separators
  // section). The low-level functional API is documented only for vanilla JS,
  // is being phased out, and should be treated as a last-resort escape hatch.
  hunkSeparators: 'line-info',

  // Force unchanged context to always render (default: false)
  // Requires oldFile/newFile API or FileDiffMetadata with newLines
  expandUnchanged: false,

  // Lines revealed per click when expanding collapsed regions
  expansionLineCount: 100,

  // Auto-expand collapsed context regions at or below this size
  // (default: 1)
  collapsedContextThreshold: 1,

  // ─────────────────────────────────────────────────────────────
  // INLINE CHANGE HIGHLIGHTING
  // ─────────────────────────────────────────────────────────────

  // Highlight changed portions within modified lines:
  // 'word-alt' (default) - word boundaries, minimizes single-char gaps
  // 'word' - word boundaries
  // 'char' - character-level granularity
  // 'none' - disable inline highlighting
  lineDiffType: 'word-alt',

  // Skip inline diff for lines exceeding this length
  maxLineDiffLength: 1000,

  // ─────────────────────────────────────────────────────────────
  // LAYOUT & DISPLAY
  // ─────────────────────────────────────────────────────────────

  // Show line numbers (default: true)
  disableLineNumbers: false,

  // Long line handling: 'scroll' (default) or 'wrap'
  overflow: 'scroll',

  // Hide the file header with filename and stats
  disableFileHeader: false,

  // Rethrow rendering errors instead of catching and displaying them
  // in the DOM. Useful for testing or custom error handling.
  // (default: false)
  disableErrorHandling: false,

  // Skip syntax highlighting for lines exceeding this length
  tokenizeMaxLineLength: 1000,

  // Fires after hydration, after DOM-committing render updates, and before
  // mounted DOM is removed. Phase is 'mount' | 'update' | 'unmount'.
  // Receives the outer diffs container element.
  // Useful when you want to measure, observe, or clean up DOM-node state.
  // You can access the shadow DOM from here if you need to inspect lines.
  onPostRender(node, fileDiffInstance, phase) {
    if (phase === 'unmount') {
      return;
    }

    const codeLines = node.shadowRoot?.querySelectorAll('[data-line]');
    console.log('rendered line count', codeLines?.length ?? 0);
  },

  // ─────────────────────────────────────────────────────────────
  // LINE SELECTION
  // ─────────────────────────────────────────────────────────────

  // Enable click-to-select on line numbers
  enableLineSelection: false,

  // Callbacks for selection events
  onLineSelectionStart(range) {
    // Fires on pointer down
  },
  onLineSelectionChange(range) {
    // Fires while dragging when range grows/shrinks (not initial down)
  },
  onLineSelectionEnd(range) {
    // Fires on pointer up
  },
  onLineSelected(range) {
    // Fires on pointer up with final range (or null)
  },

  // ─────────────────────────────────────────────────────────────
  // MOUSE EVENTS
  // ─────────────────────────────────────────────────────────────

  // Line hover effect. Sets a data-hovered attribute on the
  // hovered element(s), which you can style via the Styling API.
  // 'disabled' (default) - no hover effect
  // 'both' - highlights both line number and line content
  // 'number' - highlights only the line number
  // 'line' - highlights only the line content
  lineHoverHighlight: 'disabled',

  // Must be true to enable renderGutterUtility
  enableGutterUtility: false,

  // Fires when clicking anywhere on a line
  onLineClick({ lineNumber, side, event }) {},

  // Fires when clicking anywhere in the line number column
  onLineNumberClick({ lineNumber, side, event }) {},

  // Fires when mouse enters a line
  onLineEnter({ lineNumber, side }) {},

  // Fires when mouse leaves a line
  onLineLeave({ lineNumber, side }) {},

  // See the Token Hooks section for examples, performance notes,
  // and Worker Pool caveats.
  // These APIs preserve more token-level DOM metadata, which increases DOM
  // size and can have a noticeable cost on larger files.
  // Experimental token callbacks. Useful for token-aware UIs such as
  // LSP textDocument/hover tooltips or temporary token styling.
  // lineCharStart is zero-based and lineCharEnd is end-exclusive.
  // If both token and line click handlers are provided, both will fire.
  onTokenClick({
    tokenText,
    lineNumber,
    lineCharStart,
    lineCharEnd,
    side,
  }: DiffTokenEventBaseProps) {},
  onTokenEnter({
    tokenText,
    lineNumber,
    lineCharStart,
    lineCharEnd,
    side,
    tokenElement,
  }: DiffTokenEventBaseProps) {},
  onTokenLeave({ tokenText, side, tokenElement }: DiffTokenEventBaseProps) {},

  // Include whitespace-only tokens in token callbacks (default: false)
  enableTokenInteractionsOnWhitespace: false,

  // Experimental: force token wrappers/data-char output even when no token
  // callbacks are attached. Usually unnecessary unless you want custom styling.
  // This also increases DOM size and may impact larger files.
  useTokenTransformer: false,

  // Preferred: built-in gutter utility button (+)
  // No render callback needed; callback receives a SelectedLineRange.
  // Callback does not control visibility; enableGutterUtility does.
  // Fires on pointer up only:
  // - click => single-line range
  // - drag => final range at release
  // Selection callbacks can still fire when line selection is enabled.
  // Can click a single line or apply to a drag interaction started pointer
  // down on the button
  onGutterUtilityClick(range) {
    console.log(range.start, range.end, range.side, range.endSide);
  },

  // ─────────────────────────────────────────────────────────────
  // RENDER CALLBACKS
  // ─────────────────────────────────────────────────────────────

  // Diff header render callbacks receive FileDiffMetadata directly.
  // This includes renderCustomHeader, renderHeaderPrefix, and
  // renderHeaderMetadata.
  // renderHeaderPrefix renders at the beginning of the built-in header,
  // before the filename and icon.
  // renderHeaderMetadata renders at the end of the built-in header,
  // after the +/- line metrics.
  // renderCustomHeader replaces the built-in header content entirely.
  //
  // Render custom content at the end of the built-in header.
  renderHeaderMetadata(fileDiff) {
    const span = document.createElement('span');
    span.textContent = fileDiff.name;
    return span;
  },

  // Render annotations on specific lines. Use lineNumber: 0 for a file-level
  // annotation above the first hunk separator or diff row.
  renderAnnotation(annotation) {
    const element = document.createElement('div');
    element.textContent = annotation.metadata.threadId;
    return element;
  },

  // Advanced: render your own custom gutter utility UI on hover.
  // Prefer onGutterUtilityClick unless you need fully custom content.
  // Requires enableGutterUtility: true
  // Do not combine with onGutterUtilityClick.
  // WebKit/Safari bug in version 26 as of this writing: if you use this custom
  // API with hunkSeparators: 'line-info', you may see scroll jumping while
  // moving the mouse.
  // Recommended: use the built-in gutter utility API, or switch hunk
  // separators to 'line-info-basic', 'metadata', or 'simple'. See:
  // https://bugs.webkit.org/show_bug.cgi?id=308027
  renderGutterUtility(getHoveredLine) {
    const button = document.createElement('button');
    button.textContent = '+';
    button.addEventListener('click', () => {
      const { lineNumber, side } = getHoveredLine();
      console.log('Clicked line', lineNumber, 'on', side);
    });
    return button;
  },

});

// ─────────────────────────────────────────────────────────────
// INSTANCE METHODS
// ─────────────────────────────────────────────────────────────

// Render the diff
instance.render({
  oldFile: { name: 'file.ts', contents: '...' },
  newFile: { name: 'file.ts', contents: '...' },
  lineAnnotations: [
    { side: 'additions', lineNumber: 0, metadata: {} },
    { side: 'additions', lineNumber: 5, metadata: {} },
  ],
  containerWrapper: document.body,
});

// Update options (full replacement, not merge)
instance.setOptions({ ...instance.options, diffStyle: 'unified' });

// Update line annotations after initial render
instance.setLineAnnotations([
  { side: 'additions', lineNumber: 0, metadata: { threadId: 'file-summary' } },
  { side: 'additions', lineNumber: 5, metadata: { threadId: 'abc' } }
]);

// Programmatically control selected lines
instance.setSelectedLines({
  start: 12,
  end: 22,
  side: 'additions',
  endSide: 'deletions',
});

// Force re-render (useful after changing options)
instance.rerender();

// Programmatically expand a collapsed hunk
instance.expandHunk(0, 'down'); // hunkIndex, direction: 'up' | 'down' | 'both'

// Expand an entire collapsed hunk
instance.expandHunk(0, 'both', Number.POSITIVE_INFINITY);

// Change the active theme type
instance.setThemeType('dark'); // 'dark' | 'light' | 'system'

// Clean up (removes DOM, event listeners, clears state)
instance.cleanUp();

File Example (file_example.ts):

import { File, type FileContents } from '@pierre/diffs';

// Create the instance with options
const instance = new File({
  theme: { dark: 'pierre-dark', light: 'pierre-light' },
  overflow: 'scroll',
});

// Define your file (keep reference stable to avoid re-renders)
const file: FileContents = {
  name: 'example.ts',
  contents: `function greet(name: string) {
  console.log(\`Hello, \${name}!\`);
}

export { greet };`,
};

// Render the file into a container
instance.render({
  file,
  containerWrapper: document.getElementById('file-container'),
});

// Update options later if needed (full replacement, not merge)
instance.setOptions({ ...instance.options, overflow: 'wrap' });
instance.rerender(); // Must call rerender() after updating options

// Clean up when done
instance.cleanUp();

File Props (file_props.ts):

import { File, type TokenEventBase } from '@pierre/diffs';

// All available options for the File class
const instance = new File({

  // ─────────────────────────────────────────────────────────────
  // THEMING
  // ─────────────────────────────────────────────────────────────

  // Theme for syntax highlighting. Can be a single theme name or an
  // object with 'dark' and 'light' keys for automatic switching.
  // Built-in options: 'pierre-dark', 'pierre-light', or any Shiki theme.
  // See: https://shiki.style/themes
  theme: { dark: 'pierre-dark', light: 'pierre-light' },

  // When using dark/light theme object, this controls which is used:
  // 'system' (default) - follows OS preference
  // 'dark' or 'light' - forces specific theme
  themeType: 'system',

  // Choose the Shiki engine:
  // 'shiki-js' (default) - JavaScript regex engine
  // 'shiki-wasm' - WASM Oniguruma engine
  preferredHighlighter: 'shiki-js',

  // ─────────────────────────────────────────────────────────────
  // LAYOUT & DISPLAY
  // ─────────────────────────────────────────────────────────────

  // Show line numbers (default: true)
  disableLineNumbers: false,

  // Long line handling: 'scroll' (default) or 'wrap'
  overflow: 'scroll',

  // Hide the file header with filename
  disableFileHeader: false,

  // Rethrow rendering errors instead of catching and displaying them
  // in the DOM. Useful for testing or custom error handling.
  // (default: false)
  disableErrorHandling: false,

  // Skip syntax highlighting for lines exceeding this length
  tokenizeMaxLineLength: 1000,

  // Fires after hydration, after DOM-committing render updates, and before
  // mounted DOM is removed. Phase is 'mount' | 'update' | 'unmount'.
  // Receives the outer diffs container element.
  // Useful when you want to measure, observe, or clean up DOM-node state.
  // You can access the shadow DOM from here if you need to inspect lines.
  onPostRender(node, fileInstance, phase) {
    if (phase === 'unmount') {
      return;
    }

    const codeLines = node.shadowRoot?.querySelectorAll('[data-line]');
    console.log('rendered line count', codeLines?.length ?? 0);
  },

  // ─────────────────────────────────────────────────────────────
  // LINE SELECTION
  // ─────────────────────────────────────────────────────────────

  // Enable click-to-select on line numbers
  enableLineSelection: false,

  // Callbacks for selection events
  onLineSelectionStart(range) {
    // Fires on pointer down
  },
  onLineSelectionChange(range) {
    // Fires while dragging when range grows/shrinks (not initial down)
  },
  onLineSelectionEnd(range) {
    // Fires on pointer up
  },
  onLineSelected(range) {
    // Fires on pointer up with final range (or null)
  },

  // ─────────────────────────────────────────────────────────────
  // MOUSE EVENTS
  // ─────────────────────────────────────────────────────────────

  // Line hover effect. Sets a data-hovered attribute on the
  // hovered element(s), which you can style via the Styling API.
  // 'disabled' (default) - no hover effect
  // 'both' - highlights both line number and line content
  // 'number' - highlights only the line number
  // 'line' - highlights only the line content
  lineHoverHighlight: 'disabled',

  // Must be true to enable renderGutterUtility
  enableGutterUtility: false,

  // Fires when clicking anywhere on a line
  onLineClick({ lineNumber, event }) {},

  // Fires when clicking anywhere in the line number column
  onLineNumberClick({ lineNumber, event }) {},

  // Fires when mouse enters a line
  onLineEnter({ lineNumber }) {},

  // Fires when mouse leaves a line
  onLineLeave({ lineNumber }) {},

  // See the Token Hooks section for examples, performance notes,
  // and Worker Pool caveats.
  // These APIs preserve more token-level DOM metadata, which increases DOM
  // size and may have a performance impact on larger files.
  // Experimental token callbacks. Useful for token-aware UIs such as
  // LSP textDocument/hover tooltips or temporary token styling.
  // lineCharStart is zero-based and lineCharEnd is end-exclusive.
  // If both token and line click handlers are provided, both will fire.
  onTokenClick({
    tokenText,
    lineNumber,
    lineCharStart,
    lineCharEnd,
  }: TokenEventBase) {},
  onTokenEnter({
    tokenText,
    lineNumber,
    lineCharStart,
    lineCharEnd,
    tokenElement,
  }: TokenEventBase) {},
  onTokenLeave({ tokenText, tokenElement }: TokenEventBase) {},

  // Include whitespace-only tokens in token callbacks (default: false)
  enableTokenInteractionsOnWhitespace: false,

  // Experimental: force token wrappers/data-char output even when no token
  // callbacks are attached. Usually unnecessary unless you want custom styling.
  // This also increases DOM size and may impact larger files.
  useTokenTransformer: false,

  // Preferred: built-in gutter utility button (+)
  // No render callback needed; callback receives a SelectedLineRange.
  // Callback does not control visibility; enableGutterUtility does.
  // Fires on pointer up only:
  // - click => single-line range
  // - drag => final range at release
  // Selection callbacks can still fire when line selection is enabled.
  // Can click a single line or apply to a drag interaction started pointer
  // down on the button
  onGutterUtilityClick(range) {
    console.log(range.start, range.end);
  },

  // ─────────────────────────────────────────────────────────────
  // RENDER CALLBACKS
  // ─────────────────────────────────────────────────────────────

  // Render custom content in the file header
  renderHeaderMetadata(file) {
    const span = document.createElement('span');
    span.textContent = file.name;
    return span;
  },

  // Render annotations on specific lines. Use lineNumber: 0 for a file-level
  // annotation above the first file line.
  // Note: File uses LineAnnotation (no 'side' property)
  renderAnnotation(annotation) {
    const element = document.createElement('div');
    element.textContent = annotation.metadata.commentId;
    return element;
  },

  // Advanced: render your own custom gutter utility UI on hover.
  // Prefer onGutterUtilityClick unless you need fully custom content.
  // Requires enableGutterUtility: true
  // Do not combine with onGutterUtilityClick.
  // WebKit/Safari note: there is a specific scroll-jump issue is tied to 
  // diff views using custom renderGutterUtility + hunkSeparators:
  // 'line-info'. File views do not use hunk separators, so this case
  // does not apply here but you should be aware of it.
  renderGutterUtility(getHoveredLine) {
    const button = document.createElement('button');
    button.textContent = '+';
    button.addEventListener('click', () => {
      const { lineNumber } = getHoveredLine();
      console.log('Clicked line', lineNumber);
    });
    return button;
  },

});

// ─────────────────────────────────────────────────────────────
// INSTANCE METHODS
// ─────────────────────────────────────────────────────────────

// Render the file
instance.render({
  file: { name: 'example.ts', contents: '...' },
  lineAnnotations: [
    { lineNumber: 0, metadata: {} },
    { lineNumber: 5, metadata: {} },
  ],
  containerWrapper: document.body,
});

// Update options (full replacement, not merge)
instance.setOptions({ ...instance.options, overflow: 'wrap' });

// Update line annotations after initial render
instance.setLineAnnotations([
  { lineNumber: 0, metadata: { commentId: 'file-summary' } },
  { lineNumber: 5, metadata: { commentId: 'abc' } }
]);

// Programmatically control selected lines
instance.setSelectedLines({ start: 3, end: 8 });

// Force re-render (useful after changing options)
instance.rerender();

// Change the active theme type
instance.setThemeType('dark'); // 'dark' | 'light' | 'system'

// Clean up (removes DOM, event listeners, clears state)
instance.cleanUp();

File Renderer (file_renderer.ts):

import {
  FileRenderer,
  type FileContents,
  type FileRenderResult,
} from '@pierre/diffs';

const instance = new FileRenderer();

// Set options (this is a full replacement, not a merge)
instance.setOptions({
  theme: 'pierre-dark',
  overflow: 'scroll',
  disableLineNumbers: false,
  disableFileHeader: false,
  // Starting line number (useful for showing snippets)
  startingLineNumber: 1,
  // Skip syntax highlighting for very long lines
  tokenizeMaxLineLength: 1000,
});

const file: FileContents = {
  name: 'example.ts',
  contents: `function greet(name: string) {
  console.log(\`Hello, \${name}!\`);
}

export { greet };`,
};

// Render file (async - waits for highlighter initialization)
const result: FileRenderResult = await instance.asyncRender(file);

// result contains:
// - gutterAST/contentAST: arrays of hast ElementContent nodes for each line
// - preAST: the wrapper <pre> element as a hast node
// - headerAST: the file header element (if not disabled)
// - totalLines: number of lines in the file
// - themeStyles: CSS custom properties for theming

// Render to a complete HTML string (includes <pre> wrapper)
const fullHTML: string = instance.renderFullHTML(result);

// Or render just the code lines to HTML
const partialHTML: string = instance.renderPartialHTML(
  instance.renderCodeAST(result)
);

// Or get the full AST for further transformation
const fullAST = instance.renderFullAST(result);

Hunks Renderer File (hunks_renderer_file.ts):

import {
  DiffHunksRenderer,
  type FileDiffMetadata,
  type HunksRenderResult,
  parseDiffFromFile,
} from '@pierre/diffs';

const instance = new DiffHunksRenderer();

// Set options (this is a full replacement, not a merge)
instance.setOptions({ theme: 'github-dark', diffStyle: 'split' });

// Parse diff content from 2 versions of a file
const fileDiff: FileDiffMetadata = parseDiffFromFile(
  { name: 'file.ts', contents: 'const greeting = "Hello";' },
  { name: 'file.ts', contents: 'const greeting = "Hello, World!";' }
);

// Render hunks (async - waits for highlighter initialization)
const result: HunksRenderResult = await instance.asyncRender(fileDiff);

// result contains hast nodes for each column based on diffStyle:
// - 'split' mode: additionsAST and deletionsAST (side-by-side)
// - 'unified' mode: unifiedAST only (single column)
// - preNode: the wrapper <pre> element as a hast node
// - headerNode: the file header element
// - hunkData: metadata about each hunk (for custom separators)

// Render to a complete HTML string (includes <pre> and <code> wrappers)
const fullHTML: string = instance.renderFullHTML(result);

// Or render just a specific column to HTML
const additionsHTML: string = instance.renderPartialHTML(
  instance.renderCodeAST('additions', result),
  'additions' // wraps in <code data-additions>
);

// Or render without the <code> wrapper
const rawHTML: string = instance.renderPartialHTML(
  instance.renderCodeAST('additions', result)
);

// Or get the full AST for further transformation
const fullAST = instance.renderFullAST(result);

Hunks Renderer Patch File (hunks_renderer_patch.ts):

import {
  DiffHunksRenderer,
  type FileDiffMetadata,
  type HunksRenderResult,
  parsePatchFiles,
} from '@pierre/diffs';

// If you have the string data for any github or git/unified
// patch file, you can alternatively load that into parsePatchContent
const patches =
  parsePatchFiles(`commit e4c066d37a38889612d8e3d18089729e4109fd09
Merge: 2103046 7210630
Author: James Dean <jamesdean@jamesdean.co>
Date:   Mon Sep 15 11:25:22 2025 -0700

    Merge branch 'react-tests'

diff --git a/eslint.config.js b/eslint.config.js
index c52c9ca..f3b592b 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -2,6 +2,7 @@ import js from '@eslint/js';
 import tseslint from 'typescript-eslint';

 export default tseslint.config(
+  { ignores: ['dist/**'] },
   js.configs.recommended,
   ...tseslint.configs.recommended,
   {
@@ -10,7 +11,6 @@ export default tseslint.config(
       'error',
       { argsIgnorePattern: '^_' },
     ],
-      '@typescript-eslint/no-explicit-any': 'warn',
   },
 }
);
`);

for (const patch of patches) {
  for (const fileDiff of patch.files) {
    // Create a new renderer for each file
    const instance = new DiffHunksRenderer({
      diffStyle: 'unified',
      theme: 'pierre-dark',
    });

    // Render hunks (async - waits for highlighter initialization)
    const result: HunksRenderResult = await instance.asyncRender(fileDiff);

    // result contains hast nodes based on diffStyle:
    // - 'unified' mode: unifiedGutterAST/unifiedContentAST
    // - 'split' mode: additionsGutterAST/additionsContentAST and deletionsGutterAST/deletionsContentAST

    // Render to complete HTML (includes <pre> and <code> wrappers)
    const fullHTML: string = instance.renderFullHTML(result);

    // Or render just the unified column with <code> wrapper
    const unifiedHTML: string = instance.renderPartialHTML(
      instance.renderCodeAST('unified', result),
      'unified'
    );

    // Or render without any wrapper
    const rawHTML: string = instance.renderPartialHTML(
      instance.renderCodeAST('unified', result)
    );

    // Or get the full AST for custom transformation
    const fullAST = instance.renderFullAST(result);
  }
}

Post Render Lifecycle (post_render_lifecycle.ts):

import { FileDiff } from '@pierre/diffs';

const cleanupByNode = new WeakMap<HTMLElement, () => void>();

const instance = new FileDiff({
  onPostRender(node, _instance, phase) {
    if (phase === 'mount') {
      const selectionRoot = node.shadowRoot ?? node;

      const handleSelectStart = () => {
        console.log('selection started in diff');
      };

      const handleSelectionChange = () => {
        const selection = document.getSelection();
        if (selection == null || selection.isCollapsed) {
          return;
        }

        if (
          !containsSelectionNode(selectionRoot, selection.anchorNode) &&
          !containsSelectionNode(selectionRoot, selection.focusNode)
        ) {
          return;
        }

        console.log('selected text', selection.toString());
      };

      selectionRoot.addEventListener('selectstart', handleSelectStart);
      document.addEventListener('selectionchange', handleSelectionChange);
      cleanupByNode.set(node, () => {
        selectionRoot.removeEventListener('selectstart', handleSelectStart);
        document.removeEventListener('selectionchange', handleSelectionChange);
      });
      return;
    }

    if (phase === 'unmount') {
      cleanupByNode.get(node)?.();
      cleanupByNode.delete(node);
    }
  },
});

function containsSelectionNode(root: Node, node: Node | null) {
  return node != null && root.contains(node);
}

Unresolved File Example (unresolved_file_example.ts):

import {
  UnresolvedFile,
  resolveMergeConflict,
  type FileContents,
  type MergeConflictActionPayload,
} from '@pierre/diffs';

const container = document.getElementById('diff-container');
if (container == null) {
  throw new Error('Expected #diff-container to exist');
}

let file: FileContents = {
  name: 'auth.ts',
  contents: `export function createSession() {
<<<<<<< HEAD
  return { source: 'server', ttl: 12 };
=======
  return { source: 'web', ttl: 24 };
>>>>>>> feature/web-session
}`,
};

const instance = new UnresolvedFile({
  theme: { dark: 'pierre-dark', light: 'pierre-light' },

  // Controlled mode (optional): apply payloads yourself.
  onMergeConflictAction(payload: MergeConflictActionPayload) {
    file = {
      ...file,
      contents: resolveMergeConflict(file.contents, payload),
    };

    instance.render({ file, containerWrapper: container });
  },
});

instance.render({ file, containerWrapper: container });

Virtualization

Virtualization in Diffs uses estimated line and file heights to keep large renders fast. It renders placeholders and spacer buffers for off-screen content, then renders visible lines in hunk-sized batches as you scroll.

If your scrollable region is only code, start with CodeView instead. It is the more optimized path: it owns the entire code surface, only renders what you can actually see, and is generally more performant and less prone to blanking.

Use the lower-level virtualization APIs when you need a more flexible, mixed-content layout where the code has to live alongside other DOM that is harder for CodeView to control. That flexibility comes with tradeoffs: every top-level file or diff container stays mounted, and the experience is more likely to blank during fast scroll.

Internally, the virtualizer listens to scroll and resize updates, computes a window with overscan, and reconciles measured DOM heights after render. This keeps scroll position stable even when line heights change because of wrapped lines or annotations.

For best results, you'll need to pass a metrics config object to your files or diffs when your layout differs from the defaults. These metrics help the Virtualizer estimate file and diff sizes more accurately before content is measured. For large diffs, using virtualization with a Worker Pool is strongly recommended.

Getting Started

To use virtualization, start with a scrollable container (an HTML element or the window). Directly inside that container, add a content wrapper that holds all diff/file instances and any other content you render. The virtualizer uses this wrapper to track content size changes.

Inside that scroll container, render the VirtualizedFile and VirtualizedFileDiff components. In React, this is handled automatically by the built-in Virtualizer context. In vanilla JS, you manage this explicitly by creating a Virtualizer instance and wiring it to VirtualizedFile / VirtualizedFileDiff instead of the traditional APIs.

React

In React, wrap your diff/file components in Virtualizer from @pierre/diffs/react. The Virtualizer component is your scroll container. Currently, the React wrapper does not support window scrolling unless you orchestrate your own provider via VirtualizerContext.Provider (from @pierre/diffs/react) and pass a manually created Virtualizer instance (from @pierre/diffs).

You can tune virtualization behavior with the config prop.

Virtualizer props:

  • config: partial virtualizer config (overscrollSize, intersectionObserverMargin, resizeDebugging)
  • className / style: applied to the outer scroll root
  • contentClassName / contentStyle: applied to the inner content wrapper

Vanilla JS

In vanilla JS, create a Virtualizer instance and pass it into VirtualizedFileDiff or VirtualizedFile.

Notes

  • Prefer virtualization for very large files or long diff lists any sort of scenario where it's hard to anticipate the constraints of the of the scroll view
  • Keep metrics aligned with your layout if you customize heights.
  • Use resizeDebugging with the Virtualizer temporarily when tuning metrics, and to confirm everything is working properly. Don't forget to disable it in production.

React Basic (react_virtualizer_basic.tsx):

import {
  MultiFileDiff,
  Virtualizer,
  WorkerPoolContextProvider,
} from '@pierre/diffs/react';
import { workerFactory } from './utils/workerFactory';

function Example({ oldFile, newFile }) {
  return (
    <WorkerPoolContextProvider
      poolOptions={{ workerFactory }}
      highlighterOptions={{
        theme: { dark: 'pierre-dark', light: 'pierre-light' },
        langs: ['typescript', 'javascript', 'css', 'html'],
      }}
    >
      <Virtualizer
        className="max-h-[70vh] overflow-auto"
        contentClassName="space-y-4"
      >
        <MultiFileDiff
          oldFile={oldFile}
          newFile={newFile}
          options={{
            theme: { dark: 'pierre-dark', light: 'pierre-light' },
            diffStyle: 'split',
          }}
        />
      </Virtualizer>
    </WorkerPoolContextProvider>
  );
}

// Any diff/file component inside <Virtualizer> automatically uses
// virtualized rendering internally:
// - <MultiFileDiff />
// - <PatchDiff />
// - <FileDiff />
// - <File />

React Config (react_virtualizer_config.tsx):

import { MultiFileDiff, Virtualizer } from '@pierre/diffs/react';

function Example({ oldFile, newFile }) {
  return (
    <Virtualizer
      className="h-[80vh] overflow-auto"
      contentClassName="space-y-6"
      config={{
        // Extra viewport size in pixels rendered above and below the viewport.
        // (default: 1000)
        overscrollSize: 1000,

        // IntersectionObserver root margin in pixels for visibility tracking.
        // (default: 4000)
        intersectionObserverMargin: 4000,

        // Logs size changes for debugging measurement jitter.
        // Keep disabled in production because it will hurt performance.
        // Useful to confirm that your metrics are accurate.
        resizeDebugging: false,
      }}
    >
      <MultiFileDiff oldFile={oldFile} newFile={newFile} />
    </Virtualizer>
  );
}

Vanilla Diff (vanilla_virtualized_file_diff.ts):

import {
  Virtualizer,
  VirtualizedFile,
  VirtualizedFileDiff,
  type FileContents,
} from '@pierre/diffs';
import {
  getOrCreateWorkerPoolSingleton,
  terminateWorkerPoolSingleton,
} from '@pierre/diffs/worker';
import { workerFactory } from './utils/workerFactory';

const root = document.getElementById('diff-scroll-root');
const content = document.getElementById('diff-scroll-content');
if (root == null || content == null) {
  throw new Error('Missing container elements');
}

const oldFile: FileContents = {
  name: 'example.ts',
  contents: 'export const value = 1;\n',
};

const newFile: FileContents = {
  name: 'example.ts',
  contents: 'export const value = 2;\n',
};

const workerPool = getOrCreateWorkerPoolSingleton({
  poolOptions: { workerFactory },
  highlighterOptions: {
    theme: { dark: 'pierre-dark', light: 'pierre-light' },
    langs: ['typescript', 'javascript', 'css', 'html'],
  },
});

const virtualizer = new Virtualizer({
  // Extra viewport size in pixels rendered above and below the viewport.
  // (default: 1000)
  overscrollSize: 1000,

  // IntersectionObserver root margin in pixels for visibility tracking.
  // (default: 4000)
  intersectionObserverMargin: 4000,

  // Logs size changes for debugging measurement jitter.
  // Keep disabled in production because it will hurt performance.
  // Useful to confirm that your metrics are accurate.
  resizeDebugging: false,
});
virtualizer.setup(root, content);

// Optional partial metrics override.
// Only include values that differ from defaults.
const metrics = {
  lineHeight: 22,
  spacing: 10,
};

const diff = new VirtualizedFileDiff(
  {
    theme: { dark: 'pierre-dark', light: 'pierre-light' },
    diffStyle: 'split',
  },
  virtualizer,
  metrics,
  workerPool
);

diff.render({
  oldFile,
  newFile,
  containerWrapper: content,
});

const file = new VirtualizedFile(
  {
    theme: { dark: 'pierre-dark', light: 'pierre-light' },
    overflow: 'scroll',
  },
  virtualizer,
  metrics,
  workerPool
);

file.render({
  file: {
    name: 'another-example.ts',
    contents: 'export function hello() { return "world"; }\n',
  },
  containerWrapper: content,
});

// Later cleanup
diff.cleanUp();
file.cleanUp();
virtualizer.cleanUp();
terminateWorkerPoolSingleton();

Hunk Separators

The hunkSeparators option controls how collapsed unchanged regions are displayed. For customization, we recommend starting with a built-in preset and layering unsafeCSS on top.

Passing a render function is only documented for the Vanilla JS APIs. It is being phased out, does not work well with the container-managed and virtualization-oriented React APIs, and is not compatible with SSR. We strongly recommend avoiding that path and customizing built-in separators with unsafeCSS instead.

The Custom CSS example below keeps the built-in line-info-basic markup and tweaks it with CSS.

  • blends the separator row with the diff background
  • hunk content and controls are rendered in every gutter and content region, but the custom CSS targets only the left-most gutter elements
  • aligns the arrow glyphs with the number column
  • replaces the built-in SVG icon with CSS-generated arrows
  • renders the Expand All button, which is normally hidden by default

Built-in Types

  • line-info: Rounded corner separator with collapsed line count and expansion controls.
  • line-info-basic: Compact, full-width variant of line-info with expansion controls.
  • metadata: Patch-style separator (@@ -x,y +a,b @@) with no expansion controls.
  • simple: Minimal separator bar.

Custom CSS Example

If CSS hooks are not enough, the low-level hunkSeparators(hunkData, instance) function still exists on the Vanilla JS FileDiff API. We only recommend that escape hatch as a last resort. It is being phased out, and it does not fit the container-managed and virtualization-oriented APIs that the React components rely on.

React Example (custom_hunk_separators.tsx):

import type { FileContents } from '@pierre/diffs';
import { MultiFileDiff } from '@pierre/diffs/react';

const customSeparatorCSS = `
/* Fix bg colors to mux with background */
[data-separator="line-info-basic"] {
  height: 24px;
  background: var(--diffs-bg);
  position: relative;
}

/* Styles are made to target always the leftside gutter, however technically
 * these elements are rendered into every gutter and every content row giving you
 * lots of flexibility in how you may want to show or style them */
[data-diff-type="single"] [data-gutter],
[data-diff-type="split"] [data-deletions] [data-gutter] {
  [data-separator-wrapper] {
    position: absolute;
    left: 100%;
    display: flex;
    align-items: center;
    gap: unset;
    width: max-content;
    background: transparent;
    color: var(--diffs-fg-number);
    font-family: var(--diffs-header-font-family, var(--diffs-header-font-fallback));
    font-size: 0.75rem;
    /* Ensure Arrows align with number column */
    margin-left: calc(-2ch - 2px);
  }

  [data-separator-wrapper][data-separator-multi-button] {
    margin-left: calc(-3ch - 2px);
  }

  [data-expand-button],
  [data-separator-content] {
    display: block;
    align-self: unset;
    min-width: unset;
    min-height: unset;
    padding: 0;
    flex-shrink: 0;
    grid-column: unset;
    border: none;
    width: auto;
    height: auto;
    background-color: unset;
    color: inherit;
    font: inherit;
  }

  [data-expand-button]:not([data-expand-all-button]) {
    &[data-expand-down]::before {
      content: '↑';
    }

    &[data-expand-up]::before {
      content: '↓';
    }

    &[data-expand-both]::before {
      content: '↕';
    }

    /* Hide built in icon */
    svg {
      display: none;
    }
  }

  [data-separator-content] {
    background: transparent;
    margin-left: calc(2px + 1ch);
  }

  /* Expand all button will only appear if the collapsed region is larger than
   * an expand chunk */
  [data-expand-all-button] {
    position: relative;
    margin-left: 14px;
    text-transform: lowercase;

    &:hover {
      color: var(--diffs-fg);
      text-decoration: underline;
    }
  }

  /* A little dot separator */
  [data-expand-all-button]::before {
    content: '';
    display: block;
    position: absolute;
    top: 50%;
    left: -8px;
    margin-top: -1px;
    width: 3px;
    height: 3px;
    border-radius: 2px;
    background-color: var(--diffs-fg-number);
    pointer-events: none;
  }

  [data-separator-content]:hover,
  [data-expand-button]:hover,
  [data-expand-all-button]:hover {
    color: var(--diffs-fg);
  }
}
`;

interface CustomSeparatorExampleProps {
  oldFile: FileContents;
  newFile: FileContents;
}

export function CustomSeparatorExample({
  oldFile,
  newFile,
}: CustomSeparatorExampleProps) {
  return (
    <MultiFileDiff
      oldFile={oldFile}
      newFile={newFile}
      options={{
        hunkSeparators: 'line-info-basic',
        expansionLineCount: 5,
        unsafeCSS: customSeparatorCSS,
      }}
    />
  );
}

Utilities

Import utility functions from @pierre/diffs. These can be used with any framework or rendering approach.

diffAcceptRejectHunk

Programmatically accept or reject individual hunks (or specific change blocks inside a hunk) 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.

To resolve an entire hunk, pass 'accept', 'reject', or 'both'. To resolve only one change block in a hunk, pass an object with type and changeIndex (for example: diffAcceptRejectHunk(diff, hunkIndex, { type: 'accept', changeIndex: 0 })). changeIndex maps to the target entry in that hunk's hunkContent array.

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. You can also use both as a lower-level way to mux the two sides together, which keeps the old lines first and then appends the new lines before collapsing the result back to context. The function returns a new FileDiffMetadata object with all line numbers properly adjusted for subsequent hunks.

resolveMergeConflict

Apply a merge conflict action payload to a file string and return the next contents.

Experimental: UnresolvedFile-related merge conflict APIs are currently beta/experimental and may change in future releases.

Default merge-conflict buttons work even without callbacks: UnresolvedFile applies the resolution internally.

In vanilla, provide onMergeConflictAction for controlled state (for example, to persist resolved contents, sync external stores, or trigger side effects). Use onMergeConflictResolve when you want uncontrolled resolution plus a notification with the resolved file. React UnresolvedFile is intentionally uncontrolled.

disposeHighlighter

Dispose the shared Shiki highlighter instance to free memory. Useful when cleaning up resources in single-page applications.

getSharedHighlighter

Get direct access to the shared Shiki highlighter instance used internally by all components. Useful for custom highlighting operations.

parseDiffFromFile

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.

If both oldFile and newFile have a cacheKey, the resulting FileDiffMetadata will automatically receive a combined cache key (format: oldKey:newKey). See Render Cache for more information.

An optional throwOnError parameter (default: false) controls error handling. When true, parsing errors throw exceptions; when false, errors are logged to the console and parsing continues on a best-effort basis.

parsePatchFiles

Parse unified diff / patch file content into structured data. Handles both single patches and multi-commit patch files (like those from GitHub pull request .patch URLs). An optional second parameter cacheKeyPrefix can be provided to generate cache keys for each file in the patch (format: prefix-patchIndex-fileIndex), enabling caching of rendered diff results in the worker pool.

An optional throwOnError parameter (default: false) controls error handling. When true, parsing errors throw exceptions; when false, errors are logged to the console and parsing continues on a best-effort basis.

trimPatchContext

Trim patches with large context windows down to a fixed context window while keeping valid diff headers.

preloadHighlighter

Preload specific themes and languages before rendering to ensure instant highlighting with no async loading delay.

registerCustomTheme

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.

registerCustomLanguage

Register a custom Shiki language loader and optionally map it to file names or extensions. Use this when you're working with languages not bundled by Shiki or want custom highlighting grammars.

setLanguageOverride

Override the syntax highlighting language for a FileContents or FileDiffMetadata object. This is useful when the filename doesn't have an extension or doesn't match the actual language.

Diff Accept Reject (diffAcceptRejectHunk.ts):

import {
  diffAcceptRejectHunk,
  FileDiff,
  parseDiffFromFile,
  type FileDiffMetadata,
} from '@pierre/diffs';

// Parse a diff from two file versions
let fileDiff: FileDiffMetadata = parseDiffFromFile(
  { name: 'file.ts', contents: 'const x = 1;\nconst y = 2;' },
  { name: 'file.ts', contents: 'const x = 1;\nconst y = 3;\nconst z = 4;' }
);

// Create a FileDiff instance
const instance = new FileDiff({ theme: 'pierre-dark' });

// Render the initial diff showing the changes
instance.render({
  fileDiff,
  containerWrapper: document.getElementById('diff-container')!,
});

// Accept a hunk - keeps the new (additions) version.
// The hunk is converted to context lines (no longer shows as a change).
// Note: If the diff has a cacheKey, it's automatically updated by
// this function.
fileDiff = diffAcceptRejectHunk(fileDiff, 0, 'accept');

// Or reject a hunk - reverts to the old (deletions) version.
// fileDiff = diffAcceptRejectHunk(fileDiff, 0, 'reject');

// Or target a single change block inside the hunk by content index.
// 'changeIndex' maps to that hunk's hunkContent entry.
// fileDiff = diffAcceptRejectHunk(fileDiff, 0, {
//   type: 'accept',
//   changeIndex: 0,
// });

// Re-render with the updated fileDiff - the accepted hunk
// now appears as context lines instead of additions/deletions
instance.render({
  fileDiff,
  containerWrapper: document.getElementById('diff-container')!,
});

Diff Accept Reject React (AcceptRejectExample.tsx):

import {
  diffAcceptRejectHunk,
  type DiffLineAnnotation,
  type FileDiffMetadata,
  parseDiffFromFile,
} from '@pierre/diffs';
import { FileDiff } from '@pierre/diffs/react';
import { useState } from 'react';

interface ChangeMetadata {
  hunkIndex: number;
  changeIndex: number;
}

// Store initial diff outside component to keep reference stable
const initialDiff = parseDiffFromFile(
  { name: 'file.ts', contents: 'const x = 1;' },
  { name: 'file.ts', contents: 'const x = 2;' }
);

// Create annotation for first hunk
const initialAnnotations: DiffLineAnnotation<ChangeMetadata>[] = [
  { side: 'additions', lineNumber: 1, metadata: { hunkIndex: 0, changeIndex: 0 } },
];

export function AcceptRejectExample() {
  const [fileDiff, setFileDiff] = useState<FileDiffMetadata>(initialDiff);
  const [annotations, setAnnotations] = useState(initialAnnotations);

  // Note: diffAcceptRejectHunk automatically updates the cacheKey if present
  const handleAccept = (hunkIndex: number) => {
    setFileDiff((prev) => diffAcceptRejectHunk(prev, hunkIndex, 'accept'));
    // Remove the annotation after accepting
    setAnnotations((prev) =>
      prev.filter((a) => a.metadata.hunkIndex !== hunkIndex)
    );
  };

  const handleAcceptChange = (hunkIndex: number, changeIndex: number) => {
    setFileDiff((prev) =>
      diffAcceptRejectHunk(prev, hunkIndex, { type: 'accept', changeIndex })
    );
    // Remove the annotation for that specific change block
    setAnnotations((prev) =>
      prev.filter(
        (a) =>
          a.metadata.hunkIndex !== hunkIndex ||
          a.metadata.changeIndex !== changeIndex
      )
    );
  };

  const handleReject = (hunkIndex: number) => {
    setFileDiff((prev) => diffAcceptRejectHunk(prev, hunkIndex, 'reject'));
    // Remove the annotation after rejecting
    setAnnotations((prev) =>
      prev.filter((a) => a.metadata.hunkIndex !== hunkIndex)
    );
  };

  return (
    <FileDiff
      fileDiff={fileDiff}
      lineAnnotations={annotations}
      renderAnnotation={(annotation) => (
        <div className="flex gap-2 p-2">
          <button onClick={() => handleReject(annotation.metadata.hunkIndex)}>
            Reject
          </button>
          <button onClick={() => handleAccept(annotation.metadata.hunkIndex)}>
            Accept
          </button>
          <button
            onClick={() =>
              handleAcceptChange(
                annotation.metadata.hunkIndex,
                annotation.metadata.changeIndex
              )
            }
          >
            Accept Change
          </button>
        </div>
      )}
      options={{ theme: 'pierre-dark' }}
    />
  );
}

Dispose Highlighter (disposeHighlighter.ts):

import { disposeHighlighter } from '@pierre/diffs';

// Dispose the shared highlighter instance to free memory.
// This is useful when you're done rendering diffs and want
// to clean up resources (e.g., in a single-page app when
// navigating away from a diff view).
//
// Note: After calling this, all themes and languages will
// need to be reloaded on the next render.
disposeHighlighter();

Get Shared Highlighter (getSharedHighlighter.ts):

import { getSharedHighlighter, DiffsHighlighter } from '@pierre/diffs';

// Get the shared Shiki highlighter instance.
// This is the same instance used internally by all FileDiff
// and File components. Useful if you need direct access to
// Shiki for custom highlighting operations.
//
// The highlighter is initialized lazily - themes and languages
// are loaded on demand as you render different files.
const highlighter: DiffsHighlighter = await getSharedHighlighter();

// You can use it directly for custom highlighting, see the Shiki
// docs at https://shiki.style/ for details
const tokens = highlighter.codeToTokens('const x = 1;'); 

Parse Diff From File (parseDiffFromFile.ts):

import {
  parseDiffFromFile,
  type FileDiffMetadata,
} from '@pierre/diffs';

// Parse a diff by comparing two versions of a file.
// This is useful when you have the full file contents
// rather than a patch/diff string.
const oldFile = {
  name: 'example.ts',
  contents: `function greet(name: string) {
  console.log("Hello, " + name);
}`,
};

const newFile = {
  name: 'example.ts',
  contents: `function greet(name: string) {
  console.log(\`Hello, \${name}!\`);
}

export { greet };`,
};

const fileDiff: FileDiffMetadata = parseDiffFromFile(oldFile, newFile);

// With strict error handling (throws instead of logging)
// const fileDiff = parseDiffFromFile(oldFile, newFile, undefined, true);

// fileDiff contains:
// - name: the filename
// - hunks: array of diff hunks with line information
// - oldLines/newLines: full file contents split by line
// - Various line counts for rendering

Parse Patch Files (parsePatchFiles.ts):

import {
  parsePatchFiles,
  type ParsedPatch,
} from '@pierre/diffs';

// Parse unified diff / patch file content.
// Handles both single patches and multi-commit patch files
// (like those from GitHub PR .patch URLs).
const patchContent = `diff --git a/example.ts b/example.ts
index abc123..def456 100644
--- a/example.ts
+++ b/example.ts
@@ -1,3 +1,4 @@
 function greet(name: string) {
-  console.log("Hello, " + name);
+  console.log(\`Hello, \${name}!\`);
 }
+export { greet };
`;

// Basic usage
const patches: ParsedPatch[] = parsePatchFiles(patchContent);

// With cache key prefix for worker pool caching
// Each file gets a key like 'my-pr-123-0-0', 'my-pr-123-0-1', etc.
// IMPORTANT: The prefix must change when patchContent changes!
// Use a stable identifier like a commit SHA or content hash.
const cachedPatches = parsePatchFiles(patchContent, 'my-pr-123-abc456');

// With strict error handling (throws instead of logging)
// const patches = parsePatchFiles(patchContent, undefined, true);

// Each ParsedPatch contains:
// - message: commit message (if present)
// - files: array of FileDiffMetadata for each file in the patch

for (const patch of patches) {
  console.log('Commit:', patch.message);
  for (const file of patch.files) {
    console.log('  File:', file.name);
    console.log('  Hunks:', file.hunks.length);
  }
}

Preload Highlighter (preloadHighlighter.ts):

import { preloadHighlighter } from '@pierre/diffs';

// Preload specific themes and languages before rendering.
// This ensures the highlighter is ready with the assets you
// need, avoiding any flash of unstyled content on first render.
//
// By default, themes and languages are loaded on demand,
// but preloading is useful when you know which languages
// you'll be rendering ahead of time.
await preloadHighlighter({
  // Themes to preload
  themes: ['pierre-dark', 'pierre-light', 'github-dark'],
  // Languages to preload
  langs: ['typescript', 'javascript', 'python', 'rust', 'go'],
});

// After preloading, rendering diffs in these languages
// will be instant with no async loading delay.

Register Custom Language (registerCustomLanguage.ts):

import { registerCustomLanguage } from '@pierre/diffs';

// Register a custom Shiki language loader before rendering.
// The language name you register becomes available to Shiki.

// Option 1: Dynamic import (recommended for code splitting)
registerCustomLanguage('my-lang', () => import('./my-lang.tmLanguage.json'), [
  // File names (exact match)
  'MySpecialFile',
  // Extensions (without leading dot)
  'mylang',
  // Compound extensions
  'spec.mylang',
]);

// Option 2: No extension mapping (use setLanguageOverride instead)
registerCustomLanguage('my-lang', () => import('./my-lang.tmLanguage.json'));

Register Custom Theme (registerCustomTheme.ts):

import { registerCustomTheme } from '@pierre/diffs';

// Register a custom Shiki theme before using it.
// The theme name you register must match the 'name' field
// inside your theme JSON file.

// Option 1: Dynamic import (recommended for code splitting)
registerCustomTheme('my-custom-theme', () => import('./my-theme.json'));

// Option 2: Inline theme object
registerCustomTheme('inline-theme', async () => ({
  name: 'inline-theme',
  type: 'dark',
  colors: {
    'editor.background': '#1a1a2e',
    'editor.foreground': '#eaeaea',
    // ... other VS Code theme colors
  },
  tokenColors: [
    {
      scope: ['comment'],
      settings: { foreground: '#6a6a8a' },
    },
    // ... other token rules
  ],
}));

// Once registered, use the theme name in your components:
// <FileDiff options={{ theme: 'my-custom-theme' }} ... />

Resolve Merge Conflict (resolveMergeConflict.ts):

import {
  UnresolvedFile,
  type FileContents,
  resolveMergeConflict,
  type MergeConflictActionPayload,
} from '@pierre/diffs';

const container = document.getElementById('diff-container');

let currentFile: FileContents = {
  name: 'App.tsx',
  contents: `export function handler() {
<<<<<<< HEAD
  return 'current';
=======
  return 'incoming';
>>>>>>> feature/new-handler
}`,
};

const instance = new UnresolvedFile({
  // Controlled mode: apply payloads yourself.
  onMergeConflictAction(payload: MergeConflictActionPayload) {
    currentFile = {
      ...currentFile,
      contents: resolveMergeConflict(currentFile.contents, payload),
    };

    instance.render({ file: currentFile, containerWrapper: container });
  },
});

instance.render({ file: currentFile, containerWrapper: container });

Set Language Override (setLanguageOverride.ts):

import {
  setLanguageOverride,
  parsePatchFiles,
  type FileContents,
  type FileDiffMetadata,
} from '@pierre/diffs';

// setLanguageOverride creates a new FileContents or FileDiffMetadata
// with the language explicitly set. This is useful when:
// - The filename doesn't have an extension
// - The extension doesn't match the actual language
// - You're parsing patches and need to override the detected language

// Example 1: Override language on a FileContents
const file: FileContents = {
  name: 'Dockerfile',  // No extension, would default to 'text'
  contents: 'FROM node:20\nRUN npm install',
};
const dockerFile = setLanguageOverride(file, 'dockerfile');

// Example 2: Override language on a FileDiffMetadata
const patches = parsePatchFiles(patchString);
const diff: FileDiffMetadata = patches[0].files[0];
const typescriptDiff = setLanguageOverride(diff, 'typescript');

// The function returns a new object with the lang property set,
// leaving the original unchanged (immutable operation).

Trim Patch Context (trimPatchContext.ts):

import { trimPatchContext } from '@pierre/diffs';

// Trim a patch's context lines down to a fixed window size.
// Useful for reducing large diffs while preserving change hunks.
const patchContent = `diff --git a/example.ts b/example.ts
index abc123..def456 100644
--- a/example.ts
+++ b/example.ts
@@ -1,12 +1,13 @@
 import { format } from "./format";
 import { log } from "./log";
 import { readConfig } from "./config";
 import { parseEnv } from "./env";
 import { setup } from "./setup";

 function greet(name: string) {
-  log("Hello, " + name);
+  log(format("Hello, " + name));
 }

 export { greet };
`;

// Keep 3 lines of context around changes.
const trimmedPatch = trimPatchContext(patchContent, 3);

/*
trimmedPatch:

diff --git a/example.ts b/example.ts
index abc123..def456 100644
--- a/example.ts
+++ b/example.ts
@@ -5,7 +5,7 @@
 import { setup } from "./setup";

 function greet(name: string) {
-  log("Hello, " + name);
+  log(format("Hello, " + name));
 }

 export { greet };

*/

Styling

Diff and code components are rendered using shadow DOM APIs, allowing styles to be well-isolated from your page's existing CSS. However, it also means you may have to utilize some custom CSS variables to override default styles. These can be done in your global CSS, as style props on parent components, or on the FileDiff component directly.

Advanced: Unsafe CSS

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.

Warning: 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 modifying styles.

Global CSS Variables (global.css):

:root {
  /* Available Custom CSS Variables. Most should be self explanatory */
  /* Sets code font, very important */
  --diffs-font-family: 'Berkeley Mono', monospace;
  --diffs-font-size: 14px;
  --diffs-line-height: 1.5;
  /* Controls tab character size */
  --diffs-tab-size: 2;
  /* Font used in header and separator components,
   * typically not a monospace font, but it's your call */
  --diffs-header-font-family: Helvetica;
  /* Override or customize any 'font-feature-settings'
   * for your code font */
  --diffs-font-features: normal;
  /* Override the minimum width for the number column. By default
   * it should take into account the number of digits required
   * based on the lines in the file itself, but you can manually
   * override if desired.  Generally we recommend using ch units
   * because they work well with monospaced fonts */
  --diffs-min-number-column-width: 3ch;

  /* By default we try to inherit the deletion/addition/modified
   * colors from the existing Shiki theme, however if you'd like
   * to override them, you can do so via these css variables: */
  --diffs-deletion-color-override: orange;
  --diffs-addition-color-override: yellow;
  --diffs-modified-color-override: purple;

  /* Line selection colors - customize the staged selection tint that gets
   * mixed into selected rows and their gutter/number cells. These support
   * light-dark() for automatic theme adaptation. */
  --diffs-selection-color-override: rgb(37, 99, 235);
  --diffs-bg-selection-override: rgba(147, 197, 253, 0.28);
  --diffs-bg-selection-number-override: rgba(96, 165, 250, 0.55);

  /* Some basic variables for tweaking the layouts of some of the built in
   * components */
  --diffs-gap-inline: 8px;
  --diffs-gap-block: 8px;
}

Inline Styles (inline.tsx):

<FileDiff
  style={{
    '--diffs-font-family': 'JetBrains Mono, monospace',
    '--diffs-font-size': '13px'
  } as React.CSSProperties}
  // ... other props
/>

Unsafe CSS (unsafe-css.tsx):

<FileDiff
  options={{
    unsafeCSS: /* css */ `
[data-line-index='0'] {
  border-top: 1px solid var(--diffs-bg-context);
}

[data-line] {
  border-bottom: 1px solid var(--diffs-bg-context);
}

[data-column-number] {
  border-right: 1px solid var(--diffs-bg-context);
}`
  }}
  // ... other props
/>

Themes

Pierre Diffs ships with our custom open source themes, Pierre Light and Pierre Dark. We generate our themes with a custom build process that takes a shared color palette, assigns colors to specific roles for syntax highlighting, and builds JSON files and editor extensions. This makes our themes compatible with Shiki, Visual Studio Code, Cursor, and Zed.

Editor / Platform Source
Visual Studio Code VS Code Marketplace
Cursor Open VSX
Zed Zed Extensions
Shiki Theme repository

While you can use any Shiki theme with Pierre Diffs by passing the theme name to the theme option, you can also create and register custom themes compatible with Shiki and Visual Studio Code. We recommend using our themes as a starting point for your own custom themesβ€”head to our themes documentation to get started.

Package JSON Example (package.json):

{
  "name": "my-custom-theme",
  "displayName": "My Custom Theme",
  "description": "A beautiful theme for VS Code and Shiki",
  "version": "1.0.0",
  "publisher": "your-publisher-id",
  "author": "Your Name",
  "license": "MIT",
  "icon": "images/icon.png",
  "galleryBanner": {
    "color": "#1F1F21",
    "theme": "dark"
  },
  "keywords": ["theme", "color-theme", "dark", "light"],
  "repository": {
    "type": "git",
    "url": "https://github.com/YOUR_USERNAME/my-custom-theme"
  },
  "homepage": "https://github.com/YOUR_USERNAME/my-custom-theme#readme",
  "bugs": {
    "url": "https://github.com/YOUR_USERNAME/my-custom-theme/issues"
  },
  "engines": {
    "vscode": "^1.60.0"
  },
  "categories": ["Themes"],
  "contributes": {
    "themes": [
      {
        "label": "My Theme Dark",
        "uiTheme": "vs-dark",
        "path": "./themes/my-theme-dark.json"
      },
      {
        "label": "My Theme Light",
        "uiTheme": "vs",
        "path": "./themes/my-theme-light.json"
      }
    ]
  }
}

Registering Custom Themes (register-theme.ts):

import { registerCustomTheme } from '@pierre/diffs';

// Register your theme files before rendering.
// The name must match the "name" field in your theme.

// Option 1: Import MJS theme modules (recommended)
registerCustomTheme('my-theme-dark', () => import('my-theme/dark'));
registerCustomTheme('my-theme-light', () => import('my-theme/light'));

// Option 2: Import JSON theme files
registerCustomTheme('my-theme-dark', () => import('./themes/my-theme-dark.json'));
registerCustomTheme('my-theme-light', () => import('./themes/my-theme-light.json'));

// Option 3: Fetch from a URL (for CDN-hosted themes)
registerCustomTheme('my-theme-dark', async () => {
  const response = await fetch('/themes/my-theme-dark.json');
  return response.json();
});

Token Colors Example (src/theme.ts):

import type { Roles } from "./palette";

type VSCodeTheme = {
  name: string;
  type: "light" | "dark";
  colors: Record<string, string>;
  tokenColors: any[];
  semanticTokenColors: Record<string, string | { foreground: string; fontStyle?: string }>;
};

export function makeTheme(name: string, kind: "light" | "dark", c: Roles): VSCodeTheme {
  return {
    name,
    type: kind,
    colors: {
      // Core editor & text
      "editor.background": c.bg.editor,
      "editor.foreground": c.fg.base,
      "foreground": c.fg.base,
      "focusBorder": c.accent.primary,
      "selection.background": c.accent.subtle,

      // Editor chrome
      "editor.selectionBackground": alpha(c.accent.primary, kind === "dark" ? 0.30 : 0.18),
      "editor.lineHighlightBackground": alpha(c.accent.subtle, 0.55),
      "editorCursor.foreground": c.accent.primary,
      "editorLineNumber.foreground": c.fg.fg3,
      "editorLineNumber.activeForeground": c.fg.fg2,
      "editorIndentGuide.background": c.border.indentGuide,
      "editorIndentGuide.activeBackground": c.border.indentGuideActive,

      "diffEditor.insertedTextBackground": alpha(c.states.success, kind === "dark" ? 0.1 : 0.2),
      "diffEditor.deletedTextBackground": alpha(c.states.danger, kind === "dark" ? 0.1 : 0.2),

      // Sidebar
      "sideBar.background": c.bg.window,
      "sideBar.foreground": c.fg.fg2,
      "sideBar.border": c.border.window,
      "sideBarTitle.foreground": c.fg.base,
      "sideBarSectionHeader.background": c.bg.window,
      "sideBarSectionHeader.foreground": c.fg.fg2,
      "sideBarSectionHeader.border": c.border.window,

      // Activity bar
      "activityBar.background": c.bg.window,
      "activityBar.foreground": c.fg.base,
      "activityBar.border": c.border.window,
      "activityBar.activeBorder": c.accent.primary,
      "activityBarBadge.background": c.accent.primary,
      "activityBarBadge.foreground": c.accent.contrastOnAccent,

      // ... tabs, panels, status bar, inputs, buttons, git, terminal

    },

    tokenColors: [
      // COMMENTS
      { scope: ["comment", "punctuation.definition.comment"], settings: { foreground: c.syntax.comment } },

      // STRINGS
      { scope: ["string", "constant.other.symbol"], settings: { foreground: c.syntax.string } },

      // NUMBERS & CONSTANTS
      { scope: ["constant.numeric", "constant.language.boolean"], settings: { foreground: c.syntax.number } },

      // KEYWORDS & STORAGE
      { scope: "keyword", settings: { foreground: c.syntax.keyword } },
      { scope: ["storage", "storage.type", "storage.modifier"], settings: { foreground: c.syntax.keyword } },

      // VARIABLES & IDENTIFIERS
      { scope: ["variable", "identifier", "meta.definition.variable"], settings: { foreground: c.syntax.variable } },
      { scope: "variable.parameter", settings: { foreground: c.syntax.parameter } },

      // FUNCTIONS & METHODS
      { scope: ["support.function", "entity.name.function"], settings: { foreground: c.syntax.func } },

      // TYPES & CLASSES
      { scope: ["support.type", "entity.name.type", "entity.name.class"], settings: { foreground: c.syntax.type } },

      // ... operators, punctuation, language-specific rules, CSS, HTML, Markdown, etc.
    ],

    semanticTokenColors: {
      comment: c.syntax.comment,
      string: c.syntax.string,
      number: c.syntax.number,
      keyword: c.syntax.keyword,
      variable: c.syntax.variable,
      parameter: c.syntax.parameter,
      function: c.syntax.func,
      type: c.syntax.type,
      class: c.syntax.type,
      namespace: c.syntax.namespace,
      // ...
    }
  };
}

Using Custom Themes in Components (DiffWithCustomTheme.tsx):

import { FileDiff } from '@pierre/diffs/react';

export function DiffWithCustomTheme({ fileDiff }) {
  return (
    <FileDiff
      fileDiff={fileDiff}
      options={{
        // Single theme
        theme: 'my-theme-dark',

        // Or both variants for automatic light/dark mode
        theme: {
          dark: 'my-theme-dark',
          light: 'my-theme-light',
        },
      }}
    />
  );
}

Worker Pool

Warning: This feature is experimental and undergoing active development. There may be bugs and the API is subject to change.

Import worker utilities from @pierre/diffs/worker.

By default, syntax highlighting runs on the main thread using Shiki. If you're 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.

Setup

One unfortunate side effect of using Web Workers is that different bundlers and environments require slightly different approaches to create a Web Worker. You'll 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 the workerFactory function. We've provided some examples for common use cases below.

Warning: Only the Vite and NextJS examples have been tested by us. Additional examples were generated by AI. If any of them are incorrect, please let us know.

Vite

You may need to explicitly set the worker.format option in your Vite Config to 'es'.

NextJS

Warning: Workers only work in client components. Ensure your function has the 'use client' directive if using App Router.

VS Code Webview Extension

VS Code webviews have special security restrictions that require a different approach. You'll need to configure both the extension side (to expose the worker file) and the webview side (to load it via blob URL).

Extension side: Add the worker directory to localResourceRoots in your getWebviewOptions():

Create the worker URI in _getHtmlForWebview(). Note: use worker-portable.js instead of worker.js β€” the portable version is designed for environments where ES modules aren't supported in web workers.

Pass the URI to the webview via an inline script in your HTML:

Your Content Security Policy must include worker-src and connect-src:

Webview side: Declare the global type for the URI:

Fetch the worker code and create a blob URL:

Create the workerFactory function:

Webpack 5

esbuild

Rollup / Static Files

If your bundler doesn't have special worker support, build and serve the worker file statically:

Vanilla JS (No Bundler)

For projects without a bundler, host the worker file on your server and reference it directly:

Usage

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.

Warning: When using the worker pool, the theme, lineDiffType, tokenizeMaxLineLength, and useTokenTransformer render options are controlled by WorkerPoolManager, not individual components. Passing these options into component instances will be ignored.

To change render options after WorkerPoolManager instantiates, call setRenderOptions(). Changing render options will force mounted components to re-render and clear the render cache.

If you need token callbacks or experimental token selectors such as data-char, enable useTokenTransformer: true on the worker pool itself. Worker pools can move highlighting work off the main thread, but they do not reduce the extra DOM size created by token metadata. For token callback behavior and performance tradeoffs, see Token Hooks.

If you need to control which Shiki engine is used, set preferredHighlighter when initializing the pool ('shiki-js' by default, 'shiki-wasm' optional).

React

Wrap your component tree with WorkerPoolContextProvider from @pierre/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.

Warning: Workers only work in client components. Ensure your function has the 'use client' directive if using App Router.

To change themes or other render options dynamically, use the useWorkerPool() hook to access the pool manager and call setRenderOptions().

Vanilla JS

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 or other render options dynamically, call setRenderOptions(options) on the pool instance.

Render Cache

Warning: This is an experimental feature being validated in production use cases. The API is subject to change.

The worker pool can cache rendered AST results to avoid redundant highlighting work. When a file or diff has a cacheKey, subsequent requests with the same key will return cached results immediately instead of reprocessing through a worker. This works automatically for both React and Vanilla JS APIs.

Caching is enabled per-file/diff by setting a cacheKey property. Files and diffs without a cacheKey will not be cached. The cache also validates against render options β€” if options like theme or line diff type change, the cached result is skipped and re-rendered.

API Reference

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.

Architecture

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.

API Reference (api-reference.ts):

// WorkerPoolManager constructor
new WorkerPoolManager(poolOptions, highlighterOptions)

// Parameters:
// - poolOptions: WorkerPoolOptions
//   - workerFactory: () => Worker - Function that creates a Worker instance
//   - poolSize?: number (default: 8) - Number of workers
//   - totalASTLRUCacheSize?: number (default: 100) - Max items per cache
//     (Two separate LRU caches are maintained: one for files, one for diffs.
//      Each cache has this limit, so total cached items can be 2x this value.)
// - highlighterOptions: WorkerInitializationRenderOptions
//   - theme?: DiffsThemeNames | ThemesType - Theme name or { dark, light } object
//   - lineDiffType?: 'word' | 'word-alt' | 'char' - How to diff lines (default: 'word-alt')
//   - maxLineDiffLength?: number - Max changed-line length for inline line diffs (default: 1000)
//   - tokenizeMaxLineLength?: number - Max line length to tokenize (default: 1000)
//   - preferredHighlighter?: 'shiki-js' | 'shiki-wasm' - Highlighter engine (default: 'shiki-js')
//   - langs?: SupportedLanguages[] - Array of languages to preload

// Methods:
poolManager.initialize()
// Returns: Promise<void> - Initializes workers (auto-called on first render)

poolManager.isInitialized()
// Returns: boolean

poolManager.setRenderOptions(options)
// Returns: Promise<void> - Changes render options dynamically
// Accepts: Partial<WorkerRenderingOptions>
//   - theme?: DiffsThemeNames | ThemesType
//   - lineDiffType?: 'word' | 'word-alt' | 'char'
//   - maxLineDiffLength?: number
//   - tokenizeMaxLineLength?: number
// Omitted options will use defaults. WARNING: This forces all mounted
// components to re-render and clears the render cache.

poolManager.getRenderOptions()
// Returns: WorkerRenderingOptions - Current render options (copy)

poolManager.highlightFileAST(fileInstance, file, options)
// Queues highlighted file render, calls fileInstance.onHighlightSuccess when done

poolManager.getPlainFileAST(file, startingLineNumber?)
// Returns: ThemedFileResult | undefined - Sync render with 'text' lang

poolManager.highlightDiffAST(fileDiffInstance, diff, options)
// Queues highlighted diff render, calls fileDiffInstance.onHighlightSuccess when done

poolManager.getPlainDiffAST(diff, lineDiffType)
// Returns: ThemedDiffResult | undefined - Sync render with 'text' lang

poolManager.terminate()
// Terminates all workers and resets state

poolManager.getStats()
// Returns: { totalWorkers, busyWorkers, queuedTasks, pendingTasks }

poolManager.inspectCaches()
// Returns: { fileCache, diffCache } - LRU cache instances for debugging

poolManager.evictFileFromCache(cacheKey)
// Returns: boolean - Evicts a file from the cache by its cacheKey
// Returns true if the item was evicted, false if it wasn't in the cache

poolManager.evictDiffFromCache(cacheKey)
// Returns: boolean - Evicts a diff from the cache by its cacheKey
// Returns true if the item was evicted, false if it wasn't in the cache

Caching (caching-example.ts):

import {
  getOrCreateWorkerPoolSingleton,
} from '@pierre/diffs/worker';
import { workerFactory } from './utils/workerFactory';

const workerPool = getOrCreateWorkerPoolSingleton({
  poolOptions: {
    workerFactory,
    // Optional: configure cache size per cache (default: 100)
    // Two separate LRU caches are maintained: one for files,
    // one for diffs, so combined cache size will be double
    totalASTLRUCacheSize: 200,
  },
  highlighterOptions: {
    theme: { dark: 'pierre-dark', light: 'pierre-light' },
  },
});

// Caching is enabled automatically when files/diffs have a cacheKey.
// Files and diffs without cacheKey will not be cached.

const fileWithCaching = {
  name: 'example.ts',
  contents: 'const x = 42;',
  cacheKey: 'file-abc123', // <-- Enables caching for this file
};

const fileWithoutCaching = {
  name: 'other.ts',
  contents: 'const y = 1;',
  // No cacheKey - will not be cached
};

// IMPORTANT: The cacheKey must change whenever the content changes!
// If content changes but the key stays the same, stale cached results
// will be returned. Use content hashes or version numbers in your keys.
const fileV1 = { name: 'file.ts', contents: 'v1', cacheKey: 'file-v1' };
const fileV2 = { name: 'file.ts', contents: 'v2', cacheKey: 'file-v2' };

// Cache key best practices:
// - DON'T use file contents as the key - large strings potentially
//   waste memory
// - DON'T rely solely on filenames - they may not be unique or stable
// - DO use stable identifiers like commit SHAs, file IDs, or version numbers
// - DO combine identifiers when needed: `${fileId}-${version}`

// How caching works:
// - Files/diffs with cacheKey are stored in an LRU cache after rendering
// - Subsequent renders with the same cacheKey return cached results instantly
// - No worker processing required for cache hits
// - Cache is validated against render options (e.g., theme, lineDiffType, maxLineDiffLength)
// - If options changed, cached result is skipped and re-rendered
// - Cache is cleared when the pool is terminated

// Inspect cache contents (for debugging)
const { fileCache, diffCache } = workerPool.inspectCaches();
console.log('Cached files:', fileCache.size);
console.log('Cached diffs:', diffCache.size);

// Evict specific items from the cache when content is invalidated
// (e.g., user edits a file, new commit is pushed)
workerPool.evictFileFromCache('file-abc123');
workerPool.evictDiffFromCache('diff-xyz789');

Helper esbuild (utils/workerFactory.ts):

export function workerFactory(): Worker {
  return new Worker(
    new URL(
      '@pierre/diffs/worker/worker.js',
      import.meta.url
    ),
    { type: 'module' }
  );
}

Helper Next.js (utils/workerFactory.ts):

'use client';

export function workerFactory(): Worker {
  return new Worker(
    new URL(
      '@pierre/diffs/worker/worker.js',
      import.meta.url
    )
  );
}

Helper Static (utils/workerFactory.ts):

// For Rollup or bundlers without special worker support:
// 1. Copy worker.js to your static/public folder
// 2. Reference it by URL

export function workerFactory(): Worker {
  return new Worker('/static/workers/worker.js', { type: 'module' });
}

Helper Vanilla (utils/workerFactory.js):

// No bundler / Vanilla JS
// Host worker.js on your server and reference it by URL

export function workerFactory() {
  return new Worker('/path/to/worker.js', { type: 'module' });
}

Helper Vite (utils/workerFactory.ts):

import WorkerUrl from '@pierre/diffs/worker/worker.js?worker&url';

export function workerFactory(): Worker {
  return new Worker(WorkerUrl, { type: 'module' });
}

Helper Webpack (utils/workerFactory.ts):

export function workerFactory(): Worker {
  return new Worker(
    new URL(
      '@pierre/diffs/worker/worker.js',
      import.meta.url
    ),
    { type: 'module' }
  );
}

React Component (CodeView.tsx):

'use client';

import { createWorkerAPI } from '@/utils/createWorkerAPI';
import { useEffect, useState } from 'react';

export function CodeView() {
  const [workerAPI] = useState(() =>
    createWorkerAPI({
      poolSize: 8,
      initOptions: {
        themes: ['pierre-dark', 'pierre-light'],
      },
    })
  );

  useEffect(() => {
    return () => workerAPI.terminate();
  }, [workerAPI]);

  // Use workerAPI.renderFileToHast() etc.
}

React Usage (HighlightProvider.tsx):

// components/HighlightProvider.tsx
'use client';

import {
  useWorkerPool,
  WorkerPoolContextProvider,
} from '@pierre/diffs/react';
import type { ReactNode } from 'react';
import { workerFactory } from '@/utils/workerFactory';

// Create a client component that wraps children with the worker pool.
// Import this in your layout to provide the worker pool to all pages.
export function HighlightProvider({ children }: { children: ReactNode }) {
  return (
    <WorkerPoolContextProvider
      poolOptions={{
        workerFactory,
        // poolSize defaults to 8. More workers = more parallelism but
        // also more memory. Too many can actually slow things down.
        // poolSize: 8,
      }}
      highlighterOptions={{
        theme: { dark: 'pierre-dark', light: 'pierre-light' },
        // Optional: skip inline line diffs for very long changed lines
        // maxLineDiffLength: 1000,
        // Optional: pick the Shiki engine ('shiki-js' is default)
        // preferredHighlighter: 'shiki-wasm',
        // Optionally preload languages to avoid lazy-loading delays
        langs: ['typescript', 'javascript', 'css', 'html'],
      }}
    >
      {children}
    </WorkerPoolContextProvider>
  );
}

// layout.tsx
// import { HighlightProvider } from '@/components/HighlightProvider';
//
// export default function Layout({ children }) {
//   return (
//     <html>
//       <body>
//         <HighlightProvider>{children}</HighlightProvider>
//       </body>
//     </html>
//   );
// }

// Any File, FileDiff, MultiFileDiff, or PatchDiff component nested within
// the layout will automatically use the worker pool, no additional props required.

// ---

// To change render options dynamically, use the useWorkerPool hook:
function ThemeSwitcher() {
  const workerPool = useWorkerPool();

  const switchToGitHub = () => {
    // setRenderOptions accepts a Partial<WorkerRenderingOptions>.
    // Any omitted options will use defaults:
    // - theme: { dark: 'pierre-dark', light: 'pierre-light' }
    // - lineDiffType: 'word-alt'
    // - maxLineDiffLength: 1000
    // - tokenizeMaxLineLength: 1000
    void workerPool?.setRenderOptions({
      theme: { dark: 'github-dark', light: 'github-light' },
    });
  };

  return <button onClick={switchToGitHub}>Switch to GitHub theme</button>;
}
// WARNING: Changing render options will force all mounted components
// to re-render and will clear the render cache.

Basic Usage (example.ts):

import { createWorkerAPI } from './utils/createWorkerAPI';

// Create worker pool with 8 workers
const workerAPI = createWorkerAPI({
  poolSize: 8,
  initOptions: {
    themes: ['pierre-dark', 'pierre-light'],
    langs: ['typescript', 'javascript'],
  },
});

// Initialize the pool (optional - auto-initializes on first use)
await workerAPI.ensureInitialized();

// Render a single file
const file = {
  name: 'example.ts',
  contents: 'const x = 42;',
};

const result = await workerAPI.renderFileToHast(file, {
  theme: 'pierre-dark',
});

console.log(result.lines); // Array of ElementContent

// Render a diff from two files
const oldFile = { name: 'example.ts', contents: 'const x = 1;' };
const newFile = { name: 'example.ts', contents: 'const x = 2;' };

const diffResult = await workerAPI.renderDiffToHast(oldFile, newFile, {
  theme: { dark: 'pierre-dark', light: 'pierre-light' },
});
console.log(diffResult.oldLines, diffResult.newLines);

// Check pool status
console.log(workerAPI.getStats());
// { totalWorkers: 8, busyWorkers: 0, queuedTasks: 0, pendingTasks: 0 }

// Clean up when done
workerAPI.terminate();

Vanilla Usage (vanilla-worker-usage.ts):

import { FileDiff } from '@pierre/diffs';
import {
  getOrCreateWorkerPoolSingleton,
  terminateWorkerPoolSingleton,
} from '@pierre/diffs/worker';
import { workerFactory } from './utils/workerFactory';

// Create a singleton worker pool instance using your workerFactory.
// This ensures the same pool is reused across your app.
const workerPool = getOrCreateWorkerPoolSingleton({
  poolOptions: {
    workerFactory,
    // poolSize defaults to 8. More workers = more parallelism but
    // also more memory. Too many can actually slow things down.
    // poolSize: 8,
  },
  highlighterOptions: {
    theme: { dark: 'pierre-dark', light: 'pierre-light' },
    // Optional: skip inline line diffs for very long changed lines
    // maxLineDiffLength: 1000,
    // Optional: pick the Shiki engine ('shiki-js' is default)
    // preferredHighlighter: 'shiki-wasm',
    // Optionally preload languages to avoid lazy-loading delays
    langs: ['typescript', 'javascript', 'css', 'html'],
  },
});

// Pass the workerPool as the second argument to FileDiff
const instance = new FileDiff(
  { theme: { dark: 'pierre-dark', light: 'pierre-light' } },
  workerPool
);

// Note: Store file objects in variables rather than inlining them.
// FileDiff uses reference equality to detect changes and skip
// unnecessary re-renders.
const oldFile = { name: 'example.ts', contents: 'const x = 1;' };
const newFile = { name: 'example.ts', contents: 'const x = 2;' };

instance.render({ oldFile, newFile, containerWrapper: document.body });

// To change render options dynamically, call setRenderOptions on the worker pool.
// It accepts a Partial<WorkerRenderingOptions>. Any omitted options will use defaults:
// - theme: { dark: 'pierre-dark', light: 'pierre-light' }
// - lineDiffType: 'word-alt'
// - maxLineDiffLength: 1000
// - tokenizeMaxLineLength: 1000
await workerPool.setRenderOptions({
  theme: { dark: 'github-dark', light: 'github-light' },
});
// WARNING: Changing render options will force all mounted components
// to re-render and will clear the render cache.

// Optional: terminate workers when no longer needed (e.g., SPA navigation)
// Page unload automatically cleans up workers, but for SPAs you may want
// to call this when unmounting to free resources sooner.
// terminateWorkerPoolSingleton();

VSCode Blob URL (webview-ui/index.ts):

async function createWorkerBlobUrl(): Promise<string> {
  const response = await fetch(window.WORKER_URI);
  const workerCode = await response.text();
  const blob = new Blob([workerCode], { type: 'application/javascript' });
  return URL.createObjectURL(blob);
}

VSCode CSP (extension.ts):

worker-src ${webview.cspSource} blob:;
connect-src ${webview.cspSource};

VSCode Factory (webview-ui/index.ts):

const workerBlobUrl = await createWorkerBlobUrl();

function workerFactory() {
  return new Worker(workerBlobUrl);
}

VSCode Global (webview-ui/index.ts):

declare global {
  interface Window {
    WORKER_URI: string;
  }
}

VSCode Inline Script (extension.ts):

<script nonce="${nonce}">window.WORKER_URI = "${workerScriptUri}";</script>

VSCode Local Roots (extension.ts):

function getWebviewOptions(extensionUri: vscode.Uri): vscode.WebviewOptions {
  return {
    enableScripts: true,
    localResourceRoots: [
      // ... your other roots
      vscode.Uri.joinPath(
        extensionUri,
        'node_modules',
        '@pierre',
        'diffs',
        'dist',
        'worker'
      ),
    ],
  };
}

VSCode Worker URI (extension.ts):

const workerScriptPath = vscode.Uri.joinPath(
  this._extensionUri,
  'node_modules',
  '@pierre',
  'diffs',
  'dist',
  'worker',
  'worker-portable.js'
);
const workerScriptUri = webview.asWebviewUri(workerScriptPath);

SSR

Import SSR utilities from @pierre/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.

If you pass prerenderedHTML, onPostRender still fires on the client after hydration with phase: 'mount'. On later renders, it also fires after DOM-committing updates with phase: 'update', whether those updates are a full replacement or a partial update. Before a mounted container is removed, replaced, or recycled, it fires with phase: 'unmount'. This is useful for measuring, observing, cleaning up, or otherwise manipulating the mounted diff container or content of the diffs itself.

Usage

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.

Warning: Inputs used for pre-rendering must exactly match what's rendered in the client component. We recommend spreading the entire result object into your File or Diff component to ensure the client receives the same inputs that were used to generate the pre-rendered HTML.

Server Component

Client Component

Preloaders

We provide several preload functions to handle different input formats. Choose the one that matches your data source.

preloadFile

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.

preloadUnresolvedFile

Preloads a merge-conflict file for UnresolvedFile hydration. Use this when the file contains conflict markers (<<<<<<<, =======, >>>>>>>) and you want to preserve the unresolved conflict UI from SSR to client.

Experimental: UnresolvedFile and preloadUnresolvedFile are currently beta/experimental and may change in future releases.

preloadFileDiff

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.

preloadMultiFileDiff

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.

preloadPatchDiff

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.

preloadPatchFile

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.

Preload File (example.tsx):

import { preloadFile } from '@pierre/diffs/ssr';

const file = {
  name: 'example.ts',
  contents: 'export function hello() { return "world"; }',
};

const result = await preloadFile({
  file,
  options: { theme: 'pierre-dark' },
});

// Spread result into <File {...result} />

Preload File Diff (example.tsx):

import { preloadFileDiff } from '@pierre/diffs/ssr';
import { parseDiffFromFile } from '@pierre/diffs';

const oldFile = { name: 'example.ts', contents: 'const x = 1;' };
const newFile = { name: 'example.ts', contents: 'const x = 2;' };

// First parse the diff to get FileDiffMetadata
const fileDiff = parseDiffFromFile(oldFile, newFile);

// Then preload for SSR
const result = await preloadFileDiff({
  fileDiff,
  options: { theme: 'pierre-dark' },
});

// Spread result into <FileDiff {...result} />

Preload Multi File Diff (example.tsx):

import { preloadMultiFileDiff } from '@pierre/diffs/ssr';

const oldFile = { name: 'example.ts', contents: 'const x = 1;' };
const newFile = { name: 'example.ts', contents: 'const x = 2;' };

const result = await preloadMultiFileDiff({
  oldFile,
  newFile,
  options: { theme: 'pierre-dark', diffStyle: 'split' },
});

// Spread result into <MultiFileDiff {...result} />

Preload Patch Diff (example.tsx):

import { preloadPatchDiff } from '@pierre/diffs/ssr';

const patch = `--- a/example.ts
+++ b/example.ts
@@ -1 +1 @@
-const x = 1;
+const x = 2;`;

const result = await preloadPatchDiff({
  patch,
  options: { theme: 'pierre-dark' },
});

// Spread result into <PatchDiff {...result} />

Preload Patch File (example.tsx):

import { preloadPatchFile } from '@pierre/diffs/ssr';

// A patch containing multiple file changes
const patch = `diff --git a/foo.ts b/foo.ts
--- a/foo.ts
+++ b/foo.ts
@@ -1 +1 @@
-const a = 1;
+const a = 2;
diff --git a/bar.ts b/bar.ts
--- a/bar.ts
+++ b/bar.ts
@@ -1 +1 @@
-const b = 1;
+const b = 2;`;

const results = await preloadPatchFile({
  patch,
  options: { theme: 'pierre-dark' },
});

// Spread each result into <FileDiff {...results[i]} />

Preload Unresolved File (example.tsx):

import { preloadUnresolvedFile } from '@pierre/diffs/ssr';

const file = {
  name: 'example.ts',
  contents: `<<<<<<< HEAD
const source = "server";
=======
const source = "web";
>>>>>>> feature/web-source
`,
};

const result = await preloadUnresolvedFile({
  file,
  options: { theme: 'pierre-dark' },
});

// Spread result into <UnresolvedFile {...result} />

Client Component (DiffViewer.tsx):

// app/diff/DiffViewer.tsx (Client Component)
'use client';

import { MultiFileDiff } from '@pierre/diffs/react';
import type { PreloadMultiFileDiffResult } from '@pierre/diffs/ssr';

interface Props {
  preloaded: PreloadMultiFileDiffResult;
}

export function DiffViewer({ preloaded }: Props) {
  // Spread the entire result to ensure inputs match what was pre-rendered
  return <MultiFileDiff {...preloaded} />;
}

Server Component (page.tsx):

// app/diff/page.tsx (Server Component)
import { preloadMultiFileDiff } from '@pierre/diffs/ssr';
import { DiffViewer } from './DiffViewer';

const oldFile = {
  name: 'example.ts',
  contents: `function greet(name: string) {
  console.log("Hello, " + name);
}`,
};

const newFile = {
  name: 'example.ts',
  contents: `function greet(name: string) {
  console.log(\`Hello, \${name}!\`);
}`,
};

export default async function DiffPage() {
  const preloaded = await preloadMultiFileDiff({
    oldFile,
    newFile,
    options: { theme: 'pierre-dark', diffStyle: 'split' },
  });

  return <DiffViewer preloaded={preloaded} />;
}