From 1443024f52ae279387a69954f314944f14d434eb Mon Sep 17 00:00:00 2001 From: Hiroki Osame Date: Sat, 8 Aug 2026 22:34:11 +0900 Subject: [PATCH] feat: export static diff renderer --- .changeset/flat-rivers-diff.md | 5 +++ README.md | 6 ++++ docs/static-renderer.md | 46 +++++++++++++++++++++++++++ package.json | 4 +++ scripts/build-npm.ts | 26 +++++++++++++++ scripts/check-pack.ts | 17 ++++++++++ src/opentui/model.ts | 2 ++ src/static/index.ts | 8 +++++ src/static/types.ts | 17 ++++++++++ src/ui/staticDiffPager.test.ts | 11 +++++++ src/ui/staticDiffPager.ts | 58 ++++++++++++++++++++++------------ tsconfig.static.json | 12 +++++++ 12 files changed, 192 insertions(+), 20 deletions(-) create mode 100644 .changeset/flat-rivers-diff.md create mode 100644 docs/static-renderer.md create mode 100644 src/static/index.ts create mode 100644 src/static/types.ts create mode 100644 tsconfig.static.json diff --git a/.changeset/flat-rivers-diff.md b/.changeset/flat-rivers-diff.md new file mode 100644 index 000000000..9754aa481 --- /dev/null +++ b/.changeset/flat-rivers-diff.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Add a `hunkdiff/static` API for rendering unified patches as ANSI terminal output without starting an interactive review. diff --git a/README.md b/README.md index ddaf6c024..b4671c009 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/docs/static-renderer.md b/docs/static-renderer.md new file mode 100644 index 000000000..1f696cab1 --- /dev/null +++ b/docs/static-renderer.md @@ -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. | diff --git a/package.json b/package.json index f03d0e69d..f0fe1207f 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/scripts/build-npm.ts b/scripts/build-npm.ts index 42a682a08..ce172fd1a 100644 --- a/scripts/build-npm.ts +++ b/scripts/build-npm.ts @@ -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"); @@ -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 = [ @@ -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([ @@ -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")}`); diff --git a/scripts/check-pack.ts b/scripts/check-pack.ts index 217ae74b9..eea36bf9e 100644 --- a/scripts/check-pack.ts +++ b/scripts/check-pack.ts @@ -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"; @@ -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", @@ -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; +}; +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/", diff --git a/src/opentui/model.ts b/src/opentui/model.ts index 4ae4fa6e1..485955768 100644 --- a/src/opentui/model.ts +++ b/src/opentui/model.ts @@ -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"; @@ -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), }, diff --git a/src/static/index.ts b/src/static/index.ts new file mode 100644 index 000000000..f8def0b6d --- /dev/null +++ b/src/static/index.ts @@ -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 => + renderStaticDiffInternal(text, options); diff --git a/src/static/types.ts b/src/static/types.ts new file mode 100644 index 000000000..df9be58c4 --- /dev/null +++ b/src/static/types.ts @@ -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; +} diff --git a/src/ui/staticDiffPager.test.ts b/src/ui/staticDiffPager.test.ts index d0f924027..2257b7071 100644 --- a/src/ui/staticDiffPager.test.ts +++ b/src/ui/staticDiffPager.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { renderStaticDiff } from "../static"; import { renderStaticDiffPager } from "./staticDiffPager"; function stripAnsi(text: string) { @@ -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"; diff --git a/src/ui/staticDiffPager.ts b/src/ui/staticDiffPager.ts index 787df0c5a..0d18f92e1 100644 --- a/src/ui/staticDiffPager.ts +++ b/src/ui/staticDiffPager.ts @@ -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, @@ -384,6 +385,41 @@ 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, @@ -391,30 +427,12 @@ export async function renderStaticDiffPager( 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); diff --git a/tsconfig.static.json b/tsconfig.static.json new file mode 100644 index 000000000..b3f3d2da7 --- /dev/null +++ b/tsconfig.static.json @@ -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"] +}