# @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.

- Package: `@pierre/diffs` on [npm](https://www.npmjs.com/package/@pierre/diffs)
- GitHub: https://github.com/pierrecomputer/pierre
- Docs: https://diffs.com/docs

## 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](https://shiki.style/) 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`):

```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`):

```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`):

```typescript
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`):

```typescript
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](https://www.npmjs.com/package/@pierre/diffs).
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](#vanilla-js-api) and [utility functions](#utilities) for parsing and rendering diffs |
| `@pierre/diffs/react`  | [React components](#react-api) for rendering diffs with full interactivity                                   |
| `@pierre/diffs/ssr`    | [Server-side rendering utilities](#ssr) for pre-rendering diffs with syntax highlighting                     |
| `@pierre/diffs/worker` | [Worker pool utilities](#worker-pool) 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`](#utilities-parsedifffromfile) (from two file versions) or
[`parsePatchFiles`](#utilities-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`](#utilities-setlanguageoverride) utility function.

**File Contents Type** (`FileContents.ts`):

```typescript
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`):

```typescript
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`):

```typescript
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`):

```typescript
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](#react-api-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](#code-view).

### 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](#code-view) 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](#token-hooks), including
examples, payload details, performance notes, and Worker Pool caveats.

**Code View** (`code_view.tsx`):

```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`):

```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`):

```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`):

```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`):

```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`):

```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`):

```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`):

```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`):

```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`):

```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`):

```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](#code-view).

### 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](#code-view) 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](#token-hooks), including
examples, payload details, performance notes, and Worker Pool caveats.

#### Custom Hunk Separators

Start with the [Hunk Separators](#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](https://github.com/syntax-tree/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`):

```typescript
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`):

```typescript
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`):

```typescript
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`):

```typescript
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`):

```typescript
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`):

```typescript
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`):

```typescript
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`):

```typescript
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`):

```typescript
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`):

```typescript
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`):

```typescript
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](#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`):

```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`):

```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`):

```typescript
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`):

```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](#worker-pool-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](#worker-pool-render-cache) 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`):

```typescript
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`):

```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`):

```typescript
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`):

```typescript
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`):

```typescript
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`):

```typescript
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`):

```typescript
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`):

```typescript
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`):

```typescript
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`):

```typescript
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`):

```typescript
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`):

```typescript
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`):

```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`):

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

**Unsafe CSS** (`unsafe-css.tsx`):

```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](/theme). 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](https://marketplace.visualstudio.com/items?itemName=pierrecomputer.pierre-theme) |
| Cursor             | [Open VSX](https://open-vsx.org/extension/pierrecomputer/pierre-theme)                                 |
| Zed                | [Zed Extensions](https://zed.dev/extensions/pierre-theme)                                              |
| Shiki              | [Theme repository](https://github.com/pierrecomputer/theme/tree/main/themes)                           |

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](/theme) to
get started.

**Package JSON Example** (`package.json`):

```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`):

```typescript
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`):

```typescript
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`):

```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](https://vite.dev/config/worker-options#worker-format) 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](#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`):

```typescript
// 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`):

```typescript
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`):

```typescript
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`):

```typescript
'use client';

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

**Helper Static** (`utils/workerFactory.ts`):

```typescript
// 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`):

```javascript
// 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`):

```typescript
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`):

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

**React Component** (`CodeView.tsx`):

```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`):

```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`):

```typescript
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`):

```typescript
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`):

```typescript
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`):

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

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

```typescript
const workerBlobUrl = await createWorkerBlobUrl();

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

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

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

**VSCode Inline Script** (`extension.ts`):

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

**VSCode Local Roots** (`extension.ts`):

```typescript
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`):

```typescript
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`):

```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`):

```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`):

```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`):

```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`):

```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`):

```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`):

```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`):

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