Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/flat-rivers-diff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": minor
---

Add a `hunkdiff/static` API for rendering unified patches as ANSI terminal output without starting an interactive review.
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,12 @@ Hunk also publishes `HunkDiffView` and lower-level primitives from `hunkdiff/ope

See [docs/opentui-component.md](docs/opentui-component.md) for install, API, and runnable examples.

### Static renderer

`hunkdiff/static` renders an existing unified patch as colored ANSI text without starting Hunk's interactive application. It is useful for terminal hosts that already have patch text and need stack or split presentation.

See [docs/static-renderer.md](docs/static-renderer.md) for the API and options.

## Examples

Ready-to-run demo diffs live in [`examples/`](examples/README.md).
Expand Down
46 changes: 46 additions & 0 deletions docs/static-renderer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Static renderer

`hunkdiff/static` turns a unified patch into Hunk's non-interactive ANSI output. Use it when your application already has patch text and needs a terminal-rendered diff without creating an OpenTUI application.

## Install

```bash
npm i hunkdiff
```

## Usage

```ts
import { renderStaticDiff } from "hunkdiff/static";

const patch = [
"diff --git a/greeting.ts b/greeting.ts",
"--- a/greeting.ts",
"+++ b/greeting.ts",
"@@ -1 +1 @@",
"-export const greeting = 'hello';",
"+export const greeting = 'hello, world';",
"",
].join("\n");

const output = await renderStaticDiff(patch, {
layout: "stack",
width: process.stdout.columns,
});

process.stdout.write(output);
```

The renderer sanitizes patch text before writing terminal output. It returns ANSI text and does not create an alternate screen, read input, or start Hunk's interactive review UI.

## Options

| Option | Description |
| ----------------------- | ----------------------------------------------------------------------------- |
| `layout` | `"stack"` (default) or `"split"` rendering. |
| `theme` | Built-in Hunk theme id. Unknown ids use the default theme. |
| `lineNumbers` | Show old and new line-number gutters. Defaults to `true`. |
| `hunkHeaders` | Show `@@` hunk headers. Defaults to `true`. |
| `tabWidth` | Source-code tab stop width from 1 through 16. Defaults to `4`. |
| `transparentBackground` | Leave neutral surfaces transparent while preserving changed-line backgrounds. |
| `width` | Available terminal columns. Defaults to stdout columns or 120. |
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@
"types": "./dist/npm/opentui/index.d.ts",
"import": "./dist/npm/opentui/index.js"
},
"./static": {
"types": "./dist/npm/static/index.d.ts",
"import": "./dist/npm/static/index.js"
},
"./package.json": "./package.json"
},
"publishConfig": {
Expand Down
26 changes: 26 additions & 0 deletions scripts/build-npm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ const outdir = path.join(repoRoot, "dist", "npm");
const typesOutdir = path.join(repoRoot, "dist", "npm-types");
const opentuiOutdir = path.join(outdir, "opentui");
const opentuiTypesDir = path.join(typesOutdir, "opentui");
const staticOutdir = path.join(outdir, "static");
const staticTypesDir = path.join(typesOutdir, "static");
const extensionOutdir = path.join(outdir, "extension");
const extensionTypesOutdir = path.join(repoRoot, "dist", "npm-extension-types");

Expand Down Expand Up @@ -43,6 +45,7 @@ rmSync(outdir, { recursive: true, force: true });
rmSync(typesOutdir, { recursive: true, force: true });
rmSync(extensionTypesOutdir, { recursive: true, force: true });
mkdirSync(opentuiOutdir, { recursive: true });
mkdirSync(staticOutdir, { recursive: true });
mkdirSync(extensionOutdir, { recursive: true });

const opentuiNativePackages = [
Expand Down Expand Up @@ -113,6 +116,28 @@ for (const entry of readdirSync(opentuiTypesDir)) {
}
}

runBun([
"build",
path.join(repoRoot, "src", "static", "index.ts"),
"--target",
"node",
"--format",
"esm",
"--external",
"@pierre/diffs",
"--outdir",
staticOutdir,
"--entry-naming",
"index.js",
]);

runBun(["x", "tsc", "-p", path.join(repoRoot, "tsconfig.static.json")]);
for (const entry of readdirSync(staticTypesDir)) {
if (entry.endsWith(".d.ts")) {
copyFileSync(path.join(staticTypesDir, entry), path.join(staticOutdir, entry));
}
}

rmSync(typesOutdir, { recursive: true, force: true });

runBun([
Expand Down Expand Up @@ -146,4 +171,5 @@ rmSync(extensionTypesOutdir, { recursive: true, force: true });

console.log(`Built ${mainJs}`);
console.log(`Built ${path.join(opentuiOutdir, "index.js")}`);
console.log(`Built ${path.join(staticOutdir, "index.js")}`);
console.log(`Built ${path.join(extensionOutdir, "index.js")}`);
17 changes: 17 additions & 0 deletions scripts/check-pack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { readFileSync } from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { checkExtensionConsumerTypes } from "./extension-consumer-check";
import { buildDocExamples } from "./extension-doc-examples";
import { npmCommand } from "./script-helpers";
Expand Down Expand Up @@ -252,6 +253,9 @@ const requiredPaths = [
"dist/npm/extension/index.js",
"dist/npm/opentui/index.d.ts",
"dist/npm/opentui/index.js",
"dist/npm/static/index.d.ts",
"dist/npm/static/index.js",
"dist/npm/static/types.d.ts",
"README.md",
"LICENSE",
"package.json",
Expand All @@ -263,6 +267,19 @@ for (const path of requiredPaths) {
}
}

const staticEntry = path.join(repoRoot, "dist", "npm", "static", "index.js");
const staticRenderer = (await import(pathToFileURL(staticEntry).href)) as {
renderStaticDiff?: (patch: string, options?: { width?: number }) => Promise<string>;
};
const staticOutput = await staticRenderer.renderStaticDiff?.(
"diff --git a/a.ts b/a.ts\n--- a/a.ts\n+++ b/a.ts\n@@ -1 +1 @@\n-const value = 1;\n+const value = 2;\n",
{ width: 80 },
);
const plainStaticOutput = staticOutput?.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "");
if (!plainStaticOutput?.includes("a.ts modified +1 -1")) {
throw new Error("The published static renderer did not render a patch.");
}

const forbiddenPrefixes = [
".github/",
"src/",
Expand Down
2 changes: 2 additions & 0 deletions src/opentui/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { parsePatchFiles } from "@pierre/diffs";
import { patchLooksBinary } from "../core/binary";
import { normalizeDiffMetadataPaths, normalizeDiffPath } from "../core/diffPaths";
import { countDiffStats } from "../core/diffFile";
import { getFiletypeFromFileName } from "../core/fileLanguage";
import { splitPatchIntoFileChunks, findPatchChunk } from "../core/patch/chunks";
import { normalizePatch } from "../core/patch/normalize";
import type { DiffFile } from "../core/types";
Expand Down Expand Up @@ -85,6 +86,7 @@ export function createHunkDiffFilesFromPatch(patchText: string, sourceId = "patc
return buildHunkDiffFile(
{
id: `${sourceId}:${index}:${normalizedMetadata.name}`,
language: getFiletypeFromFileName(normalizedMetadata.name) ?? undefined,
metadata: normalizedMetadata,
patch: findPatchChunk(metadata, chunks, index),
},
Expand Down
8 changes: 8 additions & 0 deletions src/static/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { renderStaticDiff as renderStaticDiffInternal } from "../ui/staticDiffPager";
import type { StaticDiffOptions } from "./types.js";

export type { StaticDiffOptions } from "./types.js";

/** Render a unified patch as ANSI text without starting Hunk's interactive application. */
export const renderStaticDiff = (text: string, options: StaticDiffOptions = {}): Promise<string> =>
renderStaticDiffInternal(text, options);
17 changes: 17 additions & 0 deletions src/static/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/** Options for rendering a unified patch as a non-interactive terminal diff. */
export interface StaticDiffOptions {
/** Stack changed lines vertically or place deletion/addition lines side by side. Defaults to stack. */
layout?: "stack" | "split";
/** Built-in Hunk theme id. Unknown ids fall back to the default theme. */
theme?: string;
/** Show old and new line-number gutters. Defaults to true. */
lineNumbers?: boolean;
/** Show unified hunk headers. Defaults to true. */
hunkHeaders?: boolean;
/** Source-code tab stop width from 1 through 16. Defaults to 4. */
tabWidth?: number;
/** Keep neutral surfaces transparent while preserving changed-line backgrounds. */
transparentBackground?: boolean;
/** Available terminal columns. Defaults to stdout columns or 120 when unavailable. */
width?: number;
}
11 changes: 11 additions & 0 deletions src/ui/staticDiffPager.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test";
import { renderStaticDiff } from "../static";
import { renderStaticDiffPager } from "./staticDiffPager";

function stripAnsi(text: string) {
Expand Down Expand Up @@ -31,6 +32,16 @@ function expectNoUnsafeTerminalControls(text: string) {
}

describe("static diff pager", () => {
test("renders a patch through the public static API", async () => {
const patchText =
"diff --git a/a.ts b/a.ts\n--- a/a.ts\n+++ b/a.ts\n@@ -1 +1 @@\n-const value = 1;\n+const value = 2;\n";

const output = await renderStaticDiff(patchText, { layout: "stack", width: 80 });

expect(stripAnsi(output)).toContain("a.ts modified +1 -1");
expect(output).toContain("\x1b[38;2;");
});

test("renders diff-like stdin as non-interactive ANSI output", async () => {
const patchText =
"diff --git a/a.ts b/a.ts\n--- a/a.ts\n+++ b/a.ts\n@@ -1 +1 @@\n-const value = 1;\n+const value = 2;\n";
Expand Down
58 changes: 38 additions & 20 deletions src/ui/staticDiffPager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@
* here. If the static renderer cannot parse or render safely, callers fall back to the original patch
* text so pager pipelines keep working.
*/
import { loadAppBootstrap } from "../core/loaders";
import { DEFAULT_TAB_WIDTH } from "../core/tabWidth";
import type { CommonOptions, DiffFile, NamedCustomThemeConfig } from "../core/types";
import { createHunkDiffFilesFromPatch, toInternalDiffFile } from "../opentui/model";
import type { StaticDiffOptions } from "../static/types.js";
import {
buildSplitRows,
buildStackRows,
Expand Down Expand Up @@ -384,37 +385,54 @@ function warnFallback(deps: StaticDiffPagerDeps, reason: string) {
);
}

/** Parse and render one patch through Hunk's static ANSI presentation pipeline. */
async function renderStaticPatch(
text: string,
options: CommonOptions,
theme: AppTheme,
width: number,
) {
const files = createHunkDiffFilesFromPatch(text, "static").map(toInternalDiffFile);
if (files.length === 0) {
throw new Error("No diff files could be parsed.");
}

const rendered = await Promise.all(
files.map((file) => renderStaticFile(file, theme, options, width)),
);
return `${rendered.join("\n\n")}\n`;
}

/** Render a unified patch as ANSI text without starting Hunk's interactive application. */
export async function renderStaticDiff(text: string, options: StaticDiffOptions = {}) {
const commonOptions: CommonOptions = {
hunkHeaders: options.hunkHeaders,
lineNumbers: options.lineNumbers,
mode: options.layout,
tabWidth: options.tabWidth,
theme: options.theme,
transparentBackground: options.transparentBackground,
};
const theme = commonOptions.transparentBackground
? withTransparentSurfaces(resolveTheme(commonOptions.theme, null))
: resolveTheme(commonOptions.theme, null);
const width = resolveStaticWidth({ terminalColumns: options.width });
return renderStaticPatch(text, commonOptions, theme, width);
}

/** Render diff-like pager stdin as colored static output, falling back to the original patch on failure. */
export async function renderStaticDiffPager(
text: string,
options: CommonOptions = {},
deps: StaticDiffPagerDeps = { stderr: process.stderr },
) {
try {
const bootstrap = await loadAppBootstrap({
kind: "patch",
file: "-",
text,
options: {
...options,
pager: true,
},
});
const resolvedTheme = resolveTheme(options.theme, null, deps.customThemes);
const theme = options.transparentBackground
? withTransparentSurfaces(resolvedTheme)
: resolvedTheme;
const width = resolveStaticWidth(deps);
const rendered = await Promise.all(
bootstrap.changeset.files.map((file) => renderStaticFile(file, theme, options, width)),
);

if (rendered.length === 0) {
warnFallback(deps, "no files rendered");
return sanitizeTerminalText(text);
}

return `${rendered.join("\n\n")}\n`;
return await renderStaticPatch(text, options, theme, width);
} catch (error) {
warnFallback(deps, fallbackMessage(error));
return sanitizeTerminalText(text);
Expand Down
12 changes: 12 additions & 0 deletions tsconfig.static.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "./dist/npm-types",
"rootDir": "./src"
},
"include": [],
"files": ["src/static/index.ts"]
}