diffs: reconcile main hardening with file mode enhancements

This commit is contained in:
Gustavo Madeira Santana
2026-03-02 00:59:17 -05:00
parent 756f9c9fef
commit 5f383a633a
16 changed files with 1133 additions and 454 deletions

View File

@@ -1,34 +1,24 @@
---
title: "Diffs"
summary: "Read-only diff viewer and PNG renderer for agents (optional plugin tool)"
description: "Use the optional Diffs plugin to render before or after text or unified patches as a gateway-hosted diff view, a PNG image, or both."
summary: "Read-only diff viewer and file renderer for agents (optional plugin tool)"
description: "Use the optional Diffs plugin to render before and after text or unified patches as a gateway-hosted diff view, a file (PNG or PDF), or both."
read_when:
- You want agents to show code or markdown edits as diffs
- You want a canvas-ready viewer URL or a rendered diff PNG
- You need controlled, temporary diff artifacts with secure defaults
- You want a canvas-ready viewer URL or a rendered diff file
---
# Diffs
`diffs` is an optional plugin tool that turns change content into a read-only diff artifact for agents.
`diffs` is an **optional plugin tool** that renders a read-only diff from either:
It accepts either:
- arbitrary `before` and `after` text
- a unified patch
- `before` and `after` text
- a unified `patch`
The tool can produce:
It can return:
- a gateway viewer URL for canvas presentation
- a rendered PNG path for message delivery
- both outputs in one call
## Quick start
1. Enable the plugin.
2. Call `diffs` with `mode: "view"` for canvas-first flows.
3. Call `diffs` with `mode: "image"` for chat/image-first flows.
4. Call `diffs` with `mode: "both"` when you need both artifacts.
- a gateway-hosted viewer URL for canvas use
- a rendered file artifact (PNG or PDF) for message delivery
- both outputs together
## Enable the plugin
@@ -44,18 +34,21 @@ It can return:
}
```
## Typical agent workflow
## What agents get back
1. Agent calls `diffs`.
2. Agent reads `details` fields.
3. Agent either:
- opens `details.viewerUrl` with `canvas present`
- sends `details.imagePath` with `message` using `path` or `filePath`
- does both
- `mode: "view"` returns `details.viewerUrl` and `details.viewerPath`
- `mode: "file"` returns `details.filePath`
- `mode: "both"` returns the viewer details plus `details.filePath`
- file-producing modes also return `details.fileFormat` (`png` or `pdf`)
## Input examples
Typical agent patterns:
Before and after:
- open `details.viewerUrl` in canvas with `canvas present`
- send `details.filePath` with the `message` tool using `path` or `filePath`
## Tool inputs
Before and after input:
```json
{
@@ -66,7 +59,7 @@ Before and after:
}
```
Patch:
Patch input:
```json
{
@@ -75,59 +68,20 @@ Patch:
}
```
## Tool input reference
Useful options:
All fields are optional unless noted:
- `before` (`string`): original text. Required with `after` when `patch` is omitted.
- `after` (`string`): updated text. Required with `before` when `patch` is omitted.
- `patch` (`string`): unified diff text. Mutually exclusive with `before` and `after`.
- `path` (`string`): display filename for before and after mode.
- `lang` (`string`): language override hint for before and after mode.
- `title` (`string`): viewer title override.
- `mode` (`"view" | "image" | "both"`): output mode. Defaults to plugin default `defaults.mode`.
- `theme` (`"light" | "dark"`): viewer theme. Defaults to plugin default `defaults.theme`.
- `layout` (`"unified" | "split"`): diff layout. Defaults to plugin default `defaults.layout`.
- `expandUnchanged` (`boolean`): expand unchanged sections.
- `ttlSeconds` (`number`): viewer artifact TTL in seconds. Default 1800, max 21600.
- `baseUrl` (`string`): viewer URL origin override. Must be `http` or `https`, no query/hash.
Validation and limits:
- `before` and `after` each max 512 KiB.
- `patch` max 2 MiB.
- `path` max 2048 bytes.
- `lang` max 128 bytes.
- `title` max 1024 bytes.
- Patch complexity cap: max 128 files and 120000 total lines.
- `patch` and `before` or `after` together are rejected.
## Output details contract
The tool returns structured metadata under `details`.
Shared fields for modes that create a viewer:
- `artifactId`
- `viewerUrl`
- `viewerPath`
- `title`
- `expiresAt`
- `inputKind`
- `fileCount`
- `mode`
Image fields when PNG is rendered:
- `imagePath`
- `path` (same value as `imagePath`, for message tool compatibility)
- `imageBytes`
Mode behavior summary:
- `mode: "view"`: viewer fields only.
- `mode: "image"`: image fields only, no viewer artifact.
- `mode: "both"`: viewer fields plus image fields. If screenshot fails, viewer still returns with `imageError`.
- `mode`: `view`, `file`, or `both`
- `layout`: `unified` or `split`
- `theme`: `light` or `dark`
- `fileFormat`: `png` or `pdf` (default: `png`)
- `fileQuality`: `standard`, `hq`, or `print` (file mode only)
- `fileScale`: override file device scale factor (`1`-`4`)
- `fileMaxWidth`: override file max width in CSS pixels (`640`-`2400`)
- `expandUnchanged`: expand unchanged sections instead of collapsing them
- `path`: display name for before and after input
- `title`: explicit diff title
- `ttlSeconds`: viewer artifact lifetime
- `baseUrl`: override the gateway base URL used in the returned viewer link
## Plugin defaults
@@ -150,6 +104,10 @@ Set plugin-wide defaults in `~/.openclaw/openclaw.json`:
wordWrap: true,
background: true,
theme: "dark",
fileFormat: "png",
fileQuality: "standard",
fileScale: 2,
fileMaxWidth: 960,
mode: "both",
},
},
@@ -170,151 +128,24 @@ Supported defaults:
- `wordWrap`
- `background`
- `theme`
- `fileFormat`
- `fileQuality`
- `fileScale`
- `fileMaxWidth`
- `mode`
Explicit tool parameters override these defaults.
Explicit tool parameters override the plugin defaults.
## Security config
Compatibility note: `defaults.format` is accepted as an alias for `defaults.fileFormat`.
- `security.allowRemoteViewer` (`boolean`, default `false`)
- `false`: non-loopback requests to viewer routes are denied.
- `true`: remote viewers are allowed if tokenized path is valid.
## Notes
Example:
```json5
{
plugins: {
entries: {
diffs: {
enabled: true,
config: {
security: {
allowRemoteViewer: false,
},
},
},
},
},
}
```
## Artifact lifecycle and storage
- Artifacts are stored under the temp subfolder: `$TMPDIR/openclaw-diffs`.
- Viewer artifact metadata contains:
- random artifact ID (20 hex chars)
- random token (48 hex chars)
- `createdAt` and `expiresAt`
- stored `viewer.html` path
- Default viewer TTL is 30 minutes when not specified.
- Maximum accepted viewer TTL is 6 hours.
- Cleanup runs opportunistically after artifact creation.
- Expired artifacts are deleted.
- Fallback cleanup removes stale folders older than 24 hours when metadata is missing.
## Viewer URL and network behavior
Viewer route:
- `/plugins/diffs/view/{artifactId}/{token}`
Viewer assets:
- `/plugins/diffs/assets/viewer.js`
- `/plugins/diffs/assets/viewer-runtime.js`
URL construction behavior:
- If `baseUrl` is provided, it is used after strict validation.
- Without `baseUrl`, viewer URL defaults to loopback `127.0.0.1`.
- If gateway bind mode is `custom` and `gateway.customBindHost` is set, that host is used.
`baseUrl` rules:
- Must be `http://` or `https://`.
- Query and hash are rejected.
- Origin plus optional base path is allowed.
## Security model
Viewer hardening:
- Loopback-only by default.
- Tokenized viewer paths with strict ID and token validation.
- Viewer response CSP:
- `default-src 'none'`
- scripts and assets only from self
- no outbound `connect-src`
- Remote miss throttling when remote access is enabled:
- 40 failures per 60 seconds
- 60 second lockout (`429 Too Many Requests`)
Image rendering hardening:
- Screenshot browser request routing is deny-by-default.
- Only local viewer assets from `http://127.0.0.1/plugins/diffs/assets/*` are allowed.
- External network requests are blocked.
## Browser requirements for image mode
`mode: "image"` and `mode: "both"` need a Chromium-compatible browser.
Resolution order:
1. `browser.executablePath` in OpenClaw config.
2. Environment variables:
- `OPENCLAW_BROWSER_EXECUTABLE_PATH`
- `BROWSER_EXECUTABLE_PATH`
- `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH`
3. Platform command/path discovery fallback.
Common failure text:
- `Diff image rendering requires a Chromium-compatible browser...`
Fix by installing Chrome, Chromium, Edge, or Brave, or setting one of the executable path options above.
## Troubleshooting
Input validation errors:
- `Provide patch or both before and after text.`
- Include both `before` and `after`, or provide `patch`.
- `Provide either patch or before/after input, not both.`
- Do not mix input modes.
- `Invalid baseUrl: ...`
- Use `http(s)` origin with optional path, no query/hash.
- `{field} exceeds maximum size (...)`
- Reduce payload size.
- Large patch rejection
- Reduce patch file count or total lines.
Viewer accessibility issues:
- Viewer URL resolves to `127.0.0.1` by default.
- For remote access scenarios, either:
- pass `baseUrl` per tool call, or
- use `gateway.bind=custom` and `gateway.customBindHost`
- Enable `security.allowRemoteViewer` only when you intend external viewer access.
Artifact not found:
- Artifact expired due TTL.
- Token or path changed.
- Cleanup removed stale data.
## Operational guidance
- Prefer `mode: "view"` for local interactive reviews in canvas.
- Prefer `mode: "image"` for outbound chat channels that need an attachment.
- Keep `allowRemoteViewer` disabled unless your deployment requires remote viewer URLs.
- Set explicit short `ttlSeconds` for sensitive diffs.
- Avoid sending secrets in diff input when not required.
Diff rendering engine:
- Powered by [Diffs](https://diffs.com).
- Viewer pages are hosted locally by the gateway under `/plugins/diffs/...`.
- Viewer artifacts are ephemeral and stored locally.
- `mode: "file"` uses a faster file-only render path and does not create a viewer URL.
- File quality presets include hard pixel caps to prevent runaway renders on very large diffs.
- PNG or PDF rendering requires a Chromium-compatible browser. If auto-detection is not enough, set `browser.executablePath`.
- Diff rendering is powered by [Diffs](https://diffs.com).
## Related docs

View File

@@ -4,26 +4,27 @@ Read-only diff viewer plugin for **OpenClaw** agents.
It gives agents one tool, `diffs`, that can:
- render a gateway-hosted diff viewer for canvas use
- render the same diff to a PNG image
- accept either arbitrary `before`/`after` text or a unified patch
- render a gateway-hosted diff view, a file (PNG or PDF), or both
- accept either arbitrary `before` and `after` text or a unified patch
## What Agents Get
The tool can return:
- `details.viewerUrl`: a gateway URL that can be opened in the canvas
- `details.imagePath`: a local PNG artifact when image rendering is requested
- `details.filePath`: a local rendered artifact path when file rendering is requested
- `details.fileFormat`: the rendered file format (`png` or `pdf`)
- `details.format`: compatibility alias for `details.fileFormat`
This means an agent can:
- call `diffs` with `mode=view`, then pass `details.viewerUrl` to `canvas present`
- call `diffs` with `mode=image`, then send the PNG through the normal `message` tool using `path` or `filePath`
- call `diffs` with `mode=file` (optionally `fileFormat=pdf`), then send the file through the normal `message` tool using `path` or `filePath`
- call `diffs` with `mode=both` when it wants both outputs
## Tool Inputs
Before/after:
Before and after:
```json
{
@@ -45,20 +46,18 @@ Patch:
Useful options:
- `mode`: `view`, `image`, or `both`
- `mode`: `view`, `file`, or `both`
- `layout`: `unified` or `split`
- `theme`: `light` or `dark` (default: `dark`)
- `fileFormat`: `png` or `pdf` (default: `png`)
- `fileQuality`: `standard`, `hq`, or `print`
- `fileScale`: device scale override (`1`-`4`)
- `fileMaxWidth`: max width override in CSS pixels (`640`-`2400`)
- `expandUnchanged`: expand unchanged sections
- `path`: display name for before/after input
- `path`: display name for before and after input
- `title`: explicit viewer title
- `ttlSeconds`: artifact lifetime
- `baseUrl`: override the gateway base URL used in the returned viewer link (origin or origin+base path only; no query/hash)
Input safety limits:
- `before` / `after`: max 512 KiB each
- `patch`: max 2 MiB
- patch rendering cap: max 128 files / 120,000 lines
- `baseUrl`: override the gateway base URL used in the returned viewer link
## Plugin Defaults
@@ -81,6 +80,10 @@ Set plugin-wide defaults in `~/.openclaw/openclaw.json`:
wordWrap: true,
background: true,
theme: "dark",
fileFormat: "png",
fileQuality: "standard",
fileScale: 2,
fileMaxWidth: 960,
mode: "both",
},
},
@@ -92,16 +95,14 @@ Set plugin-wide defaults in `~/.openclaw/openclaw.json`:
Explicit tool parameters still win over these defaults.
Security options:
- `security.allowRemoteViewer` (default `false`): allows non-loopback access to `/plugins/diffs/view/...` token URLs
Compatibility note: `defaults.format` is accepted as an alias for `defaults.fileFormat`.
## Example Agent Prompts
Open in canvas:
```text
Use the `diffs` tool in `view` mode for this before/after content, then open the returned viewer URL in the canvas.
Use the `diffs` tool in `view` mode for this before and after content, then open the returned viewer URL in the canvas.
Path: docs/example.md
@@ -119,7 +120,7 @@ This is version two.
Render a PNG:
```text
Use the `diffs` tool in `image` mode for this before/after input. After it returns `details.imagePath`, use the `message` tool with `path` or `filePath` to send me the rendered diff image.
Use the `diffs` tool in `file` mode for this before and after input. After it returns `details.filePath`, use the `message` tool with `path` or `filePath` to send me the rendered diff image.
Path: README.md
@@ -133,7 +134,7 @@ OpenClaw supports plugins and hosted diff views.
Do both:
```text
Use the `diffs` tool in `both` mode for this diff. Open the viewer in the canvas and then send the rendered PNG by passing `details.imagePath` to the `message` tool.
Use the `diffs` tool in `both` mode for this diff. Open the viewer in the canvas and then send the rendered image by passing `details.filePath` to the `message` tool.
Path: src/demo.ts
@@ -162,8 +163,6 @@ diff --git a/src/example.ts b/src/example.ts
## Notes
- The viewer is hosted locally through the gateway under `/plugins/diffs/...`.
- Artifacts are ephemeral and stored in the plugin temp subfolder (`$TMPDIR/openclaw-diffs`).
- Default viewer URLs use loopback (`127.0.0.1`) unless you set `baseUrl` (or use `gateway.bind=custom` + `gateway.customBindHost`).
- Remote viewer misses are throttled to reduce token-guess abuse.
- PNG rendering requires a Chromium-compatible browser. Set `browser.executablePath` if auto-detection is not enough.
- Artifacts are ephemeral and stored in the local temp directory.
- PNG/PDF rendering requires a Chromium-compatible browser. Set `browser.executablePath` if auto-detection is not enough.
- Diff rendering is powered by [Diffs](https://diffs.com).

View File

@@ -14,7 +14,7 @@ import { createDiffsTool } from "./src/tool.js";
const plugin = {
id: "diffs",
name: "Diffs",
description: "Read-only diff viewer and PNG renderer for agents.",
description: "Read-only diff viewer and PNG/PDF renderer for agents.",
configSchema: diffsPluginConfigSchema,
register(api: OpenClawPluginApi) {
const defaults = resolveDiffsPluginDefaults(api.pluginConfig);

View File

@@ -1,7 +1,7 @@
{
"id": "diffs",
"name": "Diffs",
"description": "Read-only diff viewer and image renderer for agents.",
"description": "Read-only diff viewer and file renderer for agents.",
"uiHints": {
"defaults.fontFamily": {
"label": "Default Font",
@@ -39,9 +39,25 @@
"label": "Default Theme",
"help": "Initial viewer theme."
},
"defaults.fileFormat": {
"label": "Default File Format",
"help": "Rendered file format for file mode (PNG or PDF)."
},
"defaults.fileQuality": {
"label": "Default File Quality",
"help": "Quality preset for PNG/PDF rendering."
},
"defaults.fileScale": {
"label": "Default File Scale",
"help": "Device scale factor used while rendering file artifacts."
},
"defaults.fileMaxWidth": {
"label": "Default File Max Width",
"help": "Maximum file render width in CSS pixels."
},
"defaults.mode": {
"label": "Default Output Mode",
"help": "Tool default when mode is omitted. Use view for canvas/gateway viewer, image for PNG, or both."
"help": "Tool default when mode is omitted. Use view for canvas/gateway viewer, file for PNG/PDF, or both."
},
"security.allowRemoteViewer": {
"label": "Allow Remote Viewer",
@@ -99,9 +115,53 @@
"enum": ["light", "dark"],
"default": "dark"
},
"fileFormat": {
"type": "string",
"enum": ["png", "pdf"],
"default": "png"
},
"format": {
"type": "string",
"enum": ["png", "pdf"]
},
"fileQuality": {
"type": "string",
"enum": ["standard", "hq", "print"],
"default": "standard"
},
"fileScale": {
"type": "number",
"minimum": 1,
"maximum": 4,
"default": 2
},
"fileMaxWidth": {
"type": "number",
"minimum": 640,
"maximum": 2400,
"default": 960
},
"imageFormat": {
"type": "string",
"enum": ["png", "pdf"]
},
"imageQuality": {
"type": "string",
"enum": ["standard", "hq", "print"]
},
"imageScale": {
"type": "number",
"minimum": 1,
"maximum": 4
},
"imageMaxWidth": {
"type": "number",
"minimum": 640,
"maximum": 2400
},
"mode": {
"type": "string",
"enum": ["view", "image", "both"],
"enum": ["view", "image", "file", "both"],
"default": "both"
}
}

View File

@@ -35,7 +35,11 @@ describe("PlaywrightDiffScreenshotter", () => {
});
it("reuses the same browser across renders and closes it after the idle window", async () => {
const pages: Array<{ close: ReturnType<typeof vi.fn> }> = [];
const pages: Array<{
close: ReturnType<typeof vi.fn>;
screenshot: ReturnType<typeof vi.fn>;
pdf: ReturnType<typeof vi.fn>;
}> = [];
const browser = createMockBrowser(pages);
launchMock.mockResolvedValue(browser);
const { PlaywrightDiffScreenshotter } = await import("./browser.js");
@@ -49,11 +53,25 @@ describe("PlaywrightDiffScreenshotter", () => {
html: '<html><head></head><body><main class="oc-frame"></main></body></html>',
outputPath,
theme: "dark",
image: {
format: "png",
qualityPreset: "standard",
scale: 2,
maxWidth: 960,
maxPixels: 8_000_000,
},
});
await screenshotter.screenshotHtml({
html: '<html><head></head><body><main class="oc-frame"></main></body></html>',
outputPath,
theme: "dark",
image: {
format: "png",
qualityPreset: "standard",
scale: 2,
maxWidth: 960,
maxPixels: 8_000_000,
},
});
expect(launchMock).toHaveBeenCalledTimes(1);
@@ -75,10 +93,53 @@ describe("PlaywrightDiffScreenshotter", () => {
html: '<html><head></head><body><main class="oc-frame"></main></body></html>',
outputPath,
theme: "light",
image: {
format: "png",
qualityPreset: "standard",
scale: 2,
maxWidth: 960,
maxPixels: 8_000_000,
},
});
expect(launchMock).toHaveBeenCalledTimes(2);
});
it("renders PDF output when format is pdf", async () => {
const pages: Array<{
close: ReturnType<typeof vi.fn>;
screenshot: ReturnType<typeof vi.fn>;
pdf: ReturnType<typeof vi.fn>;
}> = [];
const browser = createMockBrowser(pages);
launchMock.mockResolvedValue(browser);
const { PlaywrightDiffScreenshotter } = await import("./browser.js");
const screenshotter = new PlaywrightDiffScreenshotter({
config: createConfig(),
browserIdleMs: 1_000,
});
const pdfPath = path.join(rootDir, "preview.pdf");
await screenshotter.screenshotHtml({
html: '<html><head></head><body><main class="oc-frame"></main></body></html>',
outputPath: pdfPath,
theme: "light",
image: {
format: "pdf",
qualityPreset: "standard",
scale: 2,
maxWidth: 960,
maxPixels: 8_000_000,
},
});
expect(launchMock).toHaveBeenCalledTimes(1);
expect(pages).toHaveLength(1);
expect(pages[0]?.pdf).toHaveBeenCalledTimes(1);
expect(pages[0]?.screenshot).toHaveBeenCalledTimes(0);
await expect(fs.readFile(pdfPath, "utf8")).resolves.toContain("%PDF-1.7");
});
});
function createConfig(): OpenClawConfig {
@@ -89,7 +150,13 @@ function createConfig(): OpenClawConfig {
} as OpenClawConfig;
}
function createMockBrowser(pages: Array<{ close: ReturnType<typeof vi.fn> }>) {
function createMockBrowser(
pages: Array<{
close: ReturnType<typeof vi.fn>;
screenshot: ReturnType<typeof vi.fn>;
pdf: ReturnType<typeof vi.fn>;
}>,
) {
const browser = {
newPage: vi.fn(async () => {
const page = createMockPage();
@@ -103,19 +170,26 @@ function createMockBrowser(pages: Array<{ close: ReturnType<typeof vi.fn> }>) {
}
function createMockPage() {
const screenshot = vi.fn(async ({ path: screenshotPath }: { path: string }) => {
await fs.writeFile(screenshotPath, Buffer.from("png"));
});
const pdf = vi.fn(async ({ path: pdfPath }: { path: string }) => {
await fs.writeFile(pdfPath, "%PDF-1.7 mock");
});
return {
route: vi.fn(async () => {}),
setContent: vi.fn(async () => {}),
waitForFunction: vi.fn(async () => {}),
evaluate: vi.fn(async () => {}),
emulateMedia: vi.fn(async () => {}),
locator: vi.fn(() => ({
waitFor: vi.fn(async () => {}),
boundingBox: vi.fn(async () => ({ x: 40, y: 40, width: 640, height: 240 })),
})),
setViewportSize: vi.fn(async () => {}),
screenshot: vi.fn(async ({ path: screenshotPath }: { path: string }) => {
await fs.writeFile(screenshotPath, Buffer.from("png"));
}),
screenshot,
pdf,
close: vi.fn(async () => {}),
};
}

View File

@@ -3,14 +3,19 @@ import fs from "node:fs/promises";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { chromium } from "playwright-core";
import type { DiffTheme } from "./types.js";
import type { DiffRenderOptions, DiffTheme } from "./types.js";
import { VIEWER_ASSET_PREFIX, getServedViewerAsset } from "./viewer-assets.js";
const DEFAULT_BROWSER_IDLE_MS = 30_000;
const SHARED_BROWSER_KEY = "__default__";
export type DiffScreenshotter = {
screenshotHtml(params: { html: string; outputPath: string; theme: DiffTheme }): Promise<string>;
screenshotHtml(params: {
html: string;
outputPath: string;
theme: DiffTheme;
image: DiffRenderOptions["image"];
}): Promise<string>;
};
type BrowserInstance = Awaited<ReturnType<typeof chromium.launch>>;
@@ -49,6 +54,7 @@ export class PlaywrightDiffScreenshotter implements DiffScreenshotter {
html: string;
outputPath: string;
theme: DiffTheme;
image: DiffRenderOptions["image"];
}): Promise<string> {
await fs.mkdir(path.dirname(params.outputPath), { recursive: true });
const lease = await acquireSharedBrowser({
@@ -56,121 +62,186 @@ export class PlaywrightDiffScreenshotter implements DiffScreenshotter {
idleMs: this.browserIdleMs,
});
let page: Awaited<ReturnType<BrowserInstance["newPage"]>> | undefined;
let currentScale = params.image.scale;
const maxRetries = 2;
try {
page = await lease.browser.newPage({
viewport: { width: 1200, height: 900 },
deviceScaleFactor: 2,
colorScheme: params.theme,
});
await page.route("**/*", async (route) => {
const requestUrl = route.request().url();
if (requestUrl === "about:blank" || requestUrl.startsWith("data:")) {
await route.continue();
return;
}
let parsed: URL;
try {
parsed = new URL(requestUrl);
} catch {
await route.abort();
return;
}
if (parsed.protocol !== "http:" || parsed.hostname !== "127.0.0.1") {
await route.abort();
return;
}
if (!parsed.pathname.startsWith(VIEWER_ASSET_PREFIX)) {
await route.abort();
return;
}
const asset = await getServedViewerAsset(parsed.pathname);
if (!asset) {
await route.abort();
return;
}
await route.fulfill({
status: 200,
contentType: asset.contentType,
body: asset.body,
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
page = await lease.browser.newPage({
viewport: {
width: Math.max(Math.ceil(params.image.maxWidth + 240), 1200),
height: 900,
},
deviceScaleFactor: currentScale,
colorScheme: params.theme,
});
});
await page.setContent(injectBaseHref(params.html), { waitUntil: "load" });
await page.waitForFunction(
() => {
if (document.documentElement.dataset.openclawDiffsReady === "true") {
return true;
await page.route("**/*", async (route) => {
const requestUrl = route.request().url();
if (requestUrl === "about:blank" || requestUrl.startsWith("data:")) {
await route.continue();
return;
}
return [...document.querySelectorAll("[data-openclaw-diff-host]")].every((element) => {
return (
element instanceof HTMLElement && element.shadowRoot?.querySelector("[data-diffs]")
);
let parsed: URL;
try {
parsed = new URL(requestUrl);
} catch {
await route.abort();
return;
}
if (parsed.protocol !== "http:" || parsed.hostname !== "127.0.0.1") {
await route.abort();
return;
}
if (!parsed.pathname.startsWith(VIEWER_ASSET_PREFIX)) {
await route.abort();
return;
}
const pathname = parsed.pathname;
const asset = await getServedViewerAsset(pathname);
if (!asset) {
await route.abort();
return;
}
await route.fulfill({
status: 200,
contentType: asset.contentType,
body: asset.body,
});
},
{
timeout: 10_000,
},
);
await page.evaluate(async () => {
await document.fonts.ready;
});
await page.evaluate(() => {
const frame = document.querySelector(".oc-frame");
if (frame instanceof HTMLElement) {
frame.dataset.renderMode = "image";
});
await page.setContent(injectBaseHref(params.html), { waitUntil: "load" });
await page.waitForFunction(
() => {
if (document.documentElement.dataset.openclawDiffsReady === "true") {
return true;
}
return [...document.querySelectorAll("[data-openclaw-diff-host]")].every((element) => {
return (
element instanceof HTMLElement && element.shadowRoot?.querySelector("[data-diffs]")
);
});
},
{
timeout: 10_000,
},
);
await page.evaluate(async () => {
await document.fonts.ready;
});
await page.evaluate(() => {
const frame = document.querySelector(".oc-frame");
if (frame instanceof HTMLElement) {
frame.dataset.renderMode = "image";
}
});
const frame = page.locator(".oc-frame");
await frame.waitFor();
const initialBox = await frame.boundingBox();
if (!initialBox) {
throw new Error("Diff frame did not render.");
}
});
const frame = page.locator(".oc-frame");
await frame.waitFor();
const initialBox = await frame.boundingBox();
if (!initialBox) {
throw new Error("Diff frame did not render.");
const isPdf = params.image.format === "pdf";
const padding = isPdf ? 0 : 20;
const clipWidth = Math.ceil(initialBox.width + padding * 2);
const clipHeight = Math.ceil(Math.max(initialBox.height + padding * 2, 320));
await page.setViewportSize({
width: Math.max(clipWidth + padding, 900),
height: Math.max(clipHeight + padding, 700),
});
const box = await frame.boundingBox();
if (!box) {
throw new Error("Diff frame was lost after resizing.");
}
if (isPdf) {
await page.emulateMedia({ media: "screen" });
await page.evaluate(() => {
const html = document.documentElement;
const body = document.body;
const frame = document.querySelector(".oc-frame");
html.style.background = "transparent";
body.style.margin = "0";
body.style.padding = "0";
body.style.background = "transparent";
body.style.setProperty("-webkit-print-color-adjust", "exact");
if (frame instanceof HTMLElement) {
frame.style.margin = "0";
}
});
const pdfBox = await frame.boundingBox();
if (!pdfBox) {
throw new Error("Diff frame was lost before PDF render.");
}
await page.pdf({
path: params.outputPath,
width: `${Math.max(Math.ceil(pdfBox.width), 1)}px`,
height: `${Math.max(Math.ceil(pdfBox.height), 1)}px`,
printBackground: true,
margin: {
top: "0",
right: "0",
bottom: "0",
left: "0",
},
pageRanges: "1",
});
return params.outputPath;
}
const dpr = await page.evaluate(() => window.devicePixelRatio || 1);
// Raw clip in CSS px
const rawX = Math.max(box.x - padding, 0);
const rawY = Math.max(box.y - padding, 0);
const rawRight = rawX + clipWidth;
const rawBottom = rawY + clipHeight;
// Snap to device-pixel grid to avoid soft text from sub-pixel crop
const x = Math.floor(rawX * dpr) / dpr;
const y = Math.floor(rawY * dpr) / dpr;
const right = Math.ceil(rawRight * dpr) / dpr;
const bottom = Math.ceil(rawBottom * dpr) / dpr;
const cssWidth = Math.max(right - x, 1);
const cssHeight = Math.max(bottom - y, 1);
const estimatedPixels = cssWidth * cssHeight * dpr * dpr;
if (estimatedPixels > params.image.maxPixels && currentScale > 1) {
const maxScaleForPixels = Math.sqrt(params.image.maxPixels / (cssWidth * cssHeight));
const reducedScale = Math.max(
1,
Math.round(Math.min(currentScale, maxScaleForPixels) * 100) / 100,
);
if (reducedScale < currentScale - 0.01 && attempt < maxRetries) {
await page.close().catch(() => {});
page = undefined;
currentScale = reducedScale;
continue;
}
}
await page.screenshot({
path: params.outputPath,
type: "png",
scale: "device",
clip: {
x,
y,
width: cssWidth,
height: cssHeight,
},
});
return params.outputPath;
}
const padding = 20;
const clipWidth = Math.ceil(initialBox.width + padding * 2);
const clipHeight = Math.ceil(Math.max(initialBox.height + padding * 2, 320));
await page.setViewportSize({
width: Math.max(clipWidth + padding, 900),
height: Math.max(clipHeight + padding, 700),
});
const box = await frame.boundingBox();
if (!box) {
throw new Error("Diff frame was lost after resizing.");
}
const dpr = await page.evaluate(() => window.devicePixelRatio || 1);
// Raw clip in CSS px
const rawX = Math.max(box.x - padding, 0);
const rawY = Math.max(box.y - padding, 0);
const rawRight = rawX + clipWidth;
const rawBottom = rawY + clipHeight;
// Snap to device-pixel grid to avoid soft text from sub-pixel crop
const x = Math.floor(rawX * dpr) / dpr;
const y = Math.floor(rawY * dpr) / dpr;
const right = Math.ceil(rawRight * dpr) / dpr;
const bottom = Math.ceil(rawBottom * dpr) / dpr;
await page.screenshot({
path: params.outputPath,
type: "png",
scale: "device",
clip: {
x,
y,
width: right - x,
height: bottom - y,
},
});
return params.outputPath;
throw new Error("Diff frame did not render within image size limits.");
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
throw new Error(
`Diff image rendering requires a Chromium-compatible browser. Set browser.executablePath or install Chrome/Chromium. ${reason}`,
`Diff PNG/PDF rendering requires a Chromium-compatible browser. Set browser.executablePath or install Chrome/Chromium. ${reason}`,
);
} finally {
await page?.close().catch(() => {});

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
DEFAULT_DIFFS_PLUGIN_SECURITY,
DEFAULT_DIFFS_TOOL_DEFAULTS,
resolveDiffImageRenderOptions,
resolveDiffsPluginDefaults,
resolveDiffsPluginSecurity,
} from "./config.js";
@@ -24,7 +25,11 @@ describe("resolveDiffsPluginDefaults", () => {
wordWrap: false,
background: false,
theme: "light",
mode: "view",
fileFormat: "pdf",
fileQuality: "hq",
fileScale: 2.6,
fileMaxWidth: 1280,
mode: "file",
},
}),
).toEqual({
@@ -37,7 +42,11 @@ describe("resolveDiffsPluginDefaults", () => {
wordWrap: false,
background: false,
theme: "light",
mode: "view",
fileFormat: "pdf",
fileQuality: "hq",
fileScale: 2.6,
fileMaxWidth: 1280,
mode: "file",
});
});
@@ -74,6 +83,88 @@ describe("resolveDiffsPluginDefaults", () => {
lineSpacing: DEFAULT_DIFFS_TOOL_DEFAULTS.lineSpacing,
});
});
it("derives file defaults from quality preset and clamps explicit overrides", () => {
expect(
resolveDiffsPluginDefaults({
defaults: {
fileQuality: "print",
},
}),
).toMatchObject({
fileQuality: "print",
fileScale: 3,
fileMaxWidth: 1400,
});
expect(
resolveDiffsPluginDefaults({
defaults: {
fileQuality: "hq",
fileScale: 99,
fileMaxWidth: 99999,
},
}),
).toMatchObject({
fileQuality: "hq",
fileScale: 4,
fileMaxWidth: 2400,
});
});
it("falls back to png for invalid file format defaults", () => {
expect(
resolveDiffsPluginDefaults({
defaults: {
fileFormat: "invalid" as "png",
},
}),
).toMatchObject({
fileFormat: "png",
});
});
it("resolves file render format from defaults and explicit overrides", () => {
const defaults = resolveDiffsPluginDefaults({
defaults: {
fileFormat: "pdf",
},
});
expect(resolveDiffImageRenderOptions({ defaults }).format).toBe("pdf");
expect(resolveDiffImageRenderOptions({ defaults, fileFormat: "png" }).format).toBe("png");
expect(resolveDiffImageRenderOptions({ defaults, format: "png" }).format).toBe("png");
});
it("accepts format as a config alias for fileFormat", () => {
expect(
resolveDiffsPluginDefaults({
defaults: {
format: "pdf",
},
}),
).toMatchObject({
fileFormat: "pdf",
});
});
it("accepts image* config aliases for backward compatibility", () => {
expect(
resolveDiffsPluginDefaults({
defaults: {
imageFormat: "pdf",
imageQuality: "hq",
imageScale: 2.2,
imageMaxWidth: 1024,
},
}),
).toMatchObject({
fileFormat: "pdf",
fileQuality: "hq",
fileScale: 2.2,
fileMaxWidth: 1024,
});
});
});
describe("resolveDiffsPluginSecurity", () => {

View File

@@ -1,12 +1,17 @@
import type { OpenClawPluginConfigSchema } from "openclaw/plugin-sdk";
import {
DIFF_IMAGE_QUALITY_PRESETS,
DIFF_INDICATORS,
DIFF_LAYOUTS,
DIFF_MODES,
DIFF_OUTPUT_FORMATS,
DIFF_THEMES,
type DiffFileDefaults,
type DiffImageQualityPreset,
type DiffIndicators,
type DiffLayout,
type DiffMode,
type DiffOutputFormat,
type DiffPresentationDefaults,
type DiffTheme,
type DiffToolDefaults,
@@ -23,6 +28,16 @@ type DiffsPluginConfig = {
wordWrap?: boolean;
background?: boolean;
theme?: DiffTheme;
fileFormat?: DiffOutputFormat;
fileQuality?: DiffImageQualityPreset;
fileScale?: number;
fileMaxWidth?: number;
format?: DiffOutputFormat;
// Backward-compatible aliases retained for existing configs.
imageFormat?: DiffOutputFormat;
imageQuality?: DiffImageQualityPreset;
imageScale?: number;
imageMaxWidth?: number;
mode?: DiffMode;
};
security?: {
@@ -30,6 +45,27 @@ type DiffsPluginConfig = {
};
};
const DEFAULT_IMAGE_QUALITY_PROFILES = {
standard: {
scale: 2,
maxWidth: 960,
maxPixels: 8_000_000,
},
hq: {
scale: 2.5,
maxWidth: 1200,
maxPixels: 14_000_000,
},
print: {
scale: 3,
maxWidth: 1400,
maxPixels: 24_000_000,
},
} as const satisfies Record<
DiffImageQualityPreset,
{ scale: number; maxWidth: number; maxPixels: number }
>;
export const DEFAULT_DIFFS_TOOL_DEFAULTS: DiffToolDefaults = {
fontFamily: "Fira Code",
fontSize: 15,
@@ -40,6 +76,10 @@ export const DEFAULT_DIFFS_TOOL_DEFAULTS: DiffToolDefaults = {
wordWrap: true,
background: true,
theme: "dark",
fileFormat: "png",
fileQuality: "standard",
fileScale: DEFAULT_IMAGE_QUALITY_PROFILES.standard.scale,
fileMaxWidth: DEFAULT_IMAGE_QUALITY_PROFILES.standard.maxWidth,
mode: "both",
};
@@ -93,6 +133,50 @@ const DIFFS_PLUGIN_CONFIG_JSON_SCHEMA = {
enum: [...DIFF_THEMES],
default: DEFAULT_DIFFS_TOOL_DEFAULTS.theme,
},
fileFormat: {
type: "string",
enum: [...DIFF_OUTPUT_FORMATS],
default: DEFAULT_DIFFS_TOOL_DEFAULTS.fileFormat,
},
format: {
type: "string",
enum: [...DIFF_OUTPUT_FORMATS],
},
fileQuality: {
type: "string",
enum: [...DIFF_IMAGE_QUALITY_PRESETS],
default: DEFAULT_DIFFS_TOOL_DEFAULTS.fileQuality,
},
fileScale: {
type: "number",
minimum: 1,
maximum: 4,
default: DEFAULT_DIFFS_TOOL_DEFAULTS.fileScale,
},
fileMaxWidth: {
type: "number",
minimum: 640,
maximum: 2400,
default: DEFAULT_DIFFS_TOOL_DEFAULTS.fileMaxWidth,
},
imageFormat: {
type: "string",
enum: [...DIFF_OUTPUT_FORMATS],
},
imageQuality: {
type: "string",
enum: [...DIFF_IMAGE_QUALITY_PRESETS],
},
imageScale: {
type: "number",
minimum: 1,
maximum: 4,
},
imageMaxWidth: {
type: "number",
minimum: 640,
maximum: 2400,
},
mode: {
type: "string",
enum: [...DIFF_MODES],
@@ -142,6 +226,9 @@ export function resolveDiffsPluginDefaults(config: unknown): DiffToolDefaults {
return { ...DEFAULT_DIFFS_TOOL_DEFAULTS };
}
const fileQuality = normalizeFileQuality(defaults.fileQuality ?? defaults.imageQuality);
const profile = DEFAULT_IMAGE_QUALITY_PROFILES[fileQuality];
return {
fontFamily: normalizeFontFamily(defaults.fontFamily),
fontSize: normalizeFontSize(defaults.fontSize),
@@ -152,6 +239,13 @@ export function resolveDiffsPluginDefaults(config: unknown): DiffToolDefaults {
wordWrap: defaults.wordWrap !== false,
background: defaults.background !== false,
theme: normalizeTheme(defaults.theme),
fileFormat: normalizeFileFormat(defaults.fileFormat ?? defaults.imageFormat ?? defaults.format),
fileQuality,
fileScale: normalizeFileScale(defaults.fileScale ?? defaults.imageScale, profile.scale),
fileMaxWidth: normalizeFileMaxWidth(
defaults.fileMaxWidth ?? defaults.imageMaxWidth,
profile.maxWidth,
),
mode: normalizeMode(defaults.mode),
};
}
@@ -230,6 +324,80 @@ function normalizeTheme(theme?: DiffTheme): DiffTheme {
return theme && DIFF_THEMES.includes(theme) ? theme : DEFAULT_DIFFS_TOOL_DEFAULTS.theme;
}
function normalizeFileFormat(fileFormat?: DiffOutputFormat): DiffOutputFormat {
return fileFormat && DIFF_OUTPUT_FORMATS.includes(fileFormat)
? fileFormat
: DEFAULT_DIFFS_TOOL_DEFAULTS.fileFormat;
}
function normalizeFileQuality(fileQuality?: DiffImageQualityPreset): DiffImageQualityPreset {
return fileQuality && DIFF_IMAGE_QUALITY_PRESETS.includes(fileQuality)
? fileQuality
: DEFAULT_DIFFS_TOOL_DEFAULTS.fileQuality;
}
function normalizeFileScale(fileScale: number | undefined, fallback: number): number {
if (fileScale === undefined || !Number.isFinite(fileScale)) {
return fallback;
}
const rounded = Math.round(fileScale * 100) / 100;
return Math.min(Math.max(rounded, 1), 4);
}
function normalizeFileMaxWidth(fileMaxWidth: number | undefined, fallback: number): number {
if (fileMaxWidth === undefined || !Number.isFinite(fileMaxWidth)) {
return fallback;
}
const rounded = Math.round(fileMaxWidth);
return Math.min(Math.max(rounded, 640), 2400);
}
function normalizeMode(mode?: DiffMode): DiffMode {
return mode && DIFF_MODES.includes(mode) ? mode : DEFAULT_DIFFS_TOOL_DEFAULTS.mode;
}
export function resolveDiffImageRenderOptions(params: {
defaults: DiffFileDefaults;
fileFormat?: DiffOutputFormat;
format?: DiffOutputFormat;
fileQuality?: DiffImageQualityPreset;
fileScale?: number;
fileMaxWidth?: number;
imageFormat?: DiffOutputFormat;
imageQuality?: DiffImageQualityPreset;
imageScale?: number;
imageMaxWidth?: number;
}): {
format: DiffOutputFormat;
qualityPreset: DiffImageQualityPreset;
scale: number;
maxWidth: number;
maxPixels: number;
} {
const format = normalizeFileFormat(
params.fileFormat ?? params.imageFormat ?? params.format ?? params.defaults.fileFormat,
);
const qualityOverrideProvided =
params.fileQuality !== undefined || params.imageQuality !== undefined;
const qualityPreset = normalizeFileQuality(
params.fileQuality ?? params.imageQuality ?? params.defaults.fileQuality,
);
const profile = DEFAULT_IMAGE_QUALITY_PROFILES[qualityPreset];
const scale = normalizeFileScale(
params.fileScale ?? params.imageScale,
qualityOverrideProvided ? profile.scale : params.defaults.fileScale,
);
const maxWidth = normalizeFileMaxWidth(
params.fileMaxWidth ?? params.imageMaxWidth,
qualityOverrideProvided ? profile.maxWidth : params.defaults.fileMaxWidth,
);
return {
format,
qualityPreset,
scale,
maxWidth,
maxPixels: profile.maxPixels,
};
}

View File

@@ -2,9 +2,10 @@ export const DIFFS_AGENT_GUIDANCE = [
"When you need to show edits as a real diff, prefer the `diffs` tool instead of writing a manual summary.",
"The `diffs` tool accepts either `before` + `after` text, or a unified `patch` string.",
"Use `mode=view` when you want an interactive gateway-hosted viewer. After the tool returns, use `details.viewerUrl` with the canvas tool via `canvas present` or `canvas navigate`.",
"Use `mode=image` when you need a rendered PNG. The tool result includes `details.imagePath` for the generated file.",
"When you need to deliver the PNG to a user or channel, do not rely on the raw tool-result image renderer. Instead, call the `message` tool and pass `details.imagePath` through `path` or `filePath`.",
"Use `mode=both` when you want both the gateway viewer URL and the PNG artifact.",
"Use `mode=file` when you need a rendered file artifact. Set `fileFormat=png` (default) or `fileFormat=pdf`. The tool result includes `details.filePath`.",
"For large or high-fidelity files, use `fileQuality` (`standard`|`hq`|`print`) and optionally override `fileScale`/`fileMaxWidth`.",
"When you need to deliver the rendered file to a user or channel, do not rely on the raw tool-result renderer. Instead, call the `message` tool and pass `details.filePath` through `path` or `filePath`.",
"Use `mode=both` when you want both the gateway viewer URL and the rendered artifact.",
"If the user has configured diffs plugin defaults, prefer omitting `mode`, `theme`, `layout`, and related presentation options unless you need to override them for this specific diff.",
"Include `path` for before/after text when you know the file name.",
].join("\n");

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_DIFFS_TOOL_DEFAULTS } from "./config.js";
import { DEFAULT_DIFFS_TOOL_DEFAULTS, resolveDiffImageRenderOptions } from "./config.js";
import { renderDiffDocument } from "./render.js";
describe("renderDiffDocument", () => {
@@ -13,6 +13,7 @@ describe("renderDiffDocument", () => {
},
{
presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
expandUnchanged: false,
},
);
@@ -61,6 +62,11 @@ describe("renderDiffDocument", () => {
layout: "split",
theme: "dark",
},
image: resolveDiffImageRenderOptions({
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
fileQuality: "hq",
fileMaxWidth: 1180,
}),
expandUnchanged: true,
},
);
@@ -68,6 +74,7 @@ describe("renderDiffDocument", () => {
expect(rendered.title).toBe("Workspace patch");
expect(rendered.fileCount).toBe(2);
expect(rendered.html).toContain("Workspace patch");
expect(rendered.imageHtml).toContain("max-width: 1180px;");
});
it("rejects patches that exceed file-count limits", async () => {
@@ -90,6 +97,7 @@ describe("renderDiffDocument", () => {
},
{
presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
expandUnchanged: false,
},
),

View File

@@ -197,6 +197,7 @@ function buildHtmlDocument(params: {
title: string;
bodyHtml: string;
theme: DiffRenderOptions["presentation"]["theme"];
imageMaxWidth: number;
runtimeMode: "viewer" | "image";
}): string {
return `<!doctype html>
@@ -239,7 +240,7 @@ function buildHtmlDocument(params: {
}
.oc-frame[data-render-mode="image"] {
max-width: 960px;
max-width: ${Math.max(640, Math.round(params.imageMaxWidth))}px;
}
[data-openclaw-diff-root] {
@@ -407,12 +408,14 @@ export async function renderDiffDocument(
title,
bodyHtml: rendered.viewerBodyHtml,
theme: options.presentation.theme,
imageMaxWidth: options.image.maxWidth,
runtimeMode: "viewer",
}),
imageHtml: buildHtmlDocument({
title,
bodyHtml: rendered.imageBodyHtml,
theme: options.presentation.theme,
imageMaxWidth: options.image.maxWidth,
runtimeMode: "image",
}),
title,

View File

@@ -49,7 +49,7 @@ describe("DiffArtifactStore", () => {
expect(loaded).toBeNull();
});
it("updates the stored image path", async () => {
it("updates the stored file path", async () => {
const artifact = await store.createArtifact({
html: "<html>demo</html>",
title: "Demo",
@@ -57,12 +57,13 @@ describe("DiffArtifactStore", () => {
fileCount: 1,
});
const imagePath = store.allocateImagePath(artifact.id);
const updated = await store.updateImagePath(artifact.id, imagePath);
expect(updated.imagePath).toBe(imagePath);
const filePath = store.allocateFilePath(artifact.id);
const updated = await store.updateFilePath(artifact.id, filePath);
expect(updated.filePath).toBe(filePath);
expect(updated.imagePath).toBe(filePath);
});
it("rejects image paths that escape the store root", async () => {
it("rejects file paths that escape the store root", async () => {
const artifact = await store.createArtifact({
html: "<html>demo</html>",
title: "Demo",
@@ -70,7 +71,7 @@ describe("DiffArtifactStore", () => {
fileCount: 1,
});
await expect(store.updateImagePath(artifact.id, "../outside.png")).rejects.toThrow(
await expect(store.updateFilePath(artifact.id, "../outside.png")).rejects.toThrow(
"escapes store root",
);
});
@@ -91,10 +92,42 @@ describe("DiffArtifactStore", () => {
await expect(store.readHtml(artifact.id)).rejects.toThrow("escapes store root");
});
it("allocates standalone image paths outside artifact metadata", async () => {
const imagePath = store.allocateStandaloneImagePath();
expect(imagePath).toMatch(/preview\.png$/);
expect(imagePath).toContain(rootDir);
it("allocates standalone file paths outside artifact metadata", async () => {
const filePath = store.allocateStandaloneFilePath();
expect(filePath).toMatch(/preview\.png$/);
expect(filePath).toContain(rootDir);
});
it("supports image path aliases for backward compatibility", async () => {
const artifact = await store.createArtifact({
html: "<html>demo</html>",
title: "Demo",
inputKind: "before_after",
fileCount: 1,
});
const imagePath = store.allocateImagePath(artifact.id, "pdf");
expect(imagePath).toMatch(/preview\.pdf$/);
const standaloneImagePath = store.allocateStandaloneImagePath();
expect(standaloneImagePath).toMatch(/preview\.png$/);
const updated = await store.updateImagePath(artifact.id, imagePath);
expect(updated.filePath).toBe(imagePath);
expect(updated.imagePath).toBe(imagePath);
});
it("allocates PDF file paths when format is pdf", async () => {
const artifact = await store.createArtifact({
html: "<html>demo</html>",
title: "Demo",
inputKind: "before_after",
fileCount: 1,
});
const artifactPdf = store.allocateFilePath(artifact.id, "pdf");
const standalonePdf = store.allocateStandaloneFilePath("pdf");
expect(artifactPdf).toMatch(/preview\.pdf$/);
expect(standalonePdf).toMatch(/preview\.pdf$/);
});
it("throttles cleanup sweeps across repeated artifact creation", async () => {

View File

@@ -2,7 +2,7 @@ import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import type { PluginLogger } from "openclaw/plugin-sdk";
import type { DiffArtifactMeta } from "./types.js";
import type { DiffArtifactMeta, DiffOutputFormat } from "./types.js";
const DEFAULT_TTL_MS = 30 * 60 * 1000;
const MAX_TTL_MS = 6 * 60 * 60 * 1000;
@@ -87,27 +87,40 @@ export class DiffArtifactStore {
return await fs.readFile(htmlPath, "utf8");
}
async updateImagePath(id: string, imagePath: string): Promise<DiffArtifactMeta> {
async updateFilePath(id: string, filePath: string): Promise<DiffArtifactMeta> {
const meta = await this.readMeta(id);
if (!meta) {
throw new Error(`Diff artifact not found: ${id}`);
}
const normalizedImagePath = this.normalizeStoredPath(imagePath, "imagePath");
const normalizedFilePath = this.normalizeStoredPath(filePath, "filePath");
const next: DiffArtifactMeta = {
...meta,
imagePath: normalizedImagePath,
filePath: normalizedFilePath,
imagePath: normalizedFilePath,
};
await this.writeMeta(next);
return next;
}
allocateImagePath(id: string): string {
return path.join(this.artifactDir(id), "preview.png");
async updateImagePath(id: string, imagePath: string): Promise<DiffArtifactMeta> {
return this.updateFilePath(id, imagePath);
}
allocateStandaloneImagePath(): string {
allocateFilePath(id: string, format: DiffOutputFormat = "png"): string {
return path.join(this.artifactDir(id), `preview.${format}`);
}
allocateStandaloneFilePath(format: DiffOutputFormat = "png"): string {
const id = crypto.randomBytes(10).toString("hex");
return path.join(this.artifactDir(id), "preview.png");
return path.join(this.artifactDir(id), `preview.${format}`);
}
allocateImagePath(id: string, format: DiffOutputFormat = "png"): string {
return this.allocateFilePath(id, format);
}
allocateStandaloneImagePath(format: DiffOutputFormat = "png"): string {
return this.allocateStandaloneFilePath(format);
}
scheduleCleanup(): void {

View File

@@ -41,7 +41,30 @@ describe("diffs tool", () => {
it("returns an image artifact in image mode", async () => {
const cleanupSpy = vi.spyOn(store, "scheduleCleanup");
const screenshotter = createScreenshotter();
const screenshotter = {
screenshotHtml: vi.fn(
async ({
html,
outputPath,
image,
}: {
html: string;
outputPath: string;
image: { format: string; qualityPreset: string; scale: number; maxWidth: number };
}) => {
expect(html).not.toContain("/plugins/diffs/assets/viewer.js");
expect(image).toMatchObject({
format: "png",
qualityPreset: "standard",
scale: 2,
maxWidth: 960,
});
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, Buffer.from("png"));
return outputPath;
},
),
};
const tool = createDiffsTool({
api: createApi(),
@@ -57,14 +80,161 @@ describe("diffs tool", () => {
});
expect(screenshotter.screenshotHtml).toHaveBeenCalledTimes(1);
expect(readTextContent(result, 0)).toContain("Diff image generated at:");
expect(readTextContent(result, 0)).toContain("Diff PNG generated at:");
expect(readTextContent(result, 0)).toContain("Use the `message` tool");
expect(result?.content).toHaveLength(1);
expect((result?.details as Record<string, unknown>).filePath).toBeDefined();
expect((result?.details as Record<string, unknown>).imagePath).toBeDefined();
expect((result?.details as Record<string, unknown>).format).toBe("png");
expect((result?.details as Record<string, unknown>).fileQuality).toBe("standard");
expect((result?.details as Record<string, unknown>).imageQuality).toBe("standard");
expect((result?.details as Record<string, unknown>).fileScale).toBe(2);
expect((result?.details as Record<string, unknown>).imageScale).toBe(2);
expect((result?.details as Record<string, unknown>).fileMaxWidth).toBe(960);
expect((result?.details as Record<string, unknown>).imageMaxWidth).toBe(960);
expect((result?.details as Record<string, unknown>).viewerUrl).toBeUndefined();
expect(cleanupSpy).toHaveBeenCalledTimes(1);
});
it("renders PDF output when fileFormat is pdf", async () => {
const screenshotter = {
screenshotHtml: vi.fn(
async ({
outputPath,
image,
}: {
outputPath: string;
image: { format: string; qualityPreset: string; scale: number; maxWidth: number };
}) => {
expect(image.format).toBe("pdf");
expect(outputPath).toMatch(/preview\.pdf$/);
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, Buffer.from("%PDF-1.7"));
return outputPath;
},
),
};
const tool = createDiffsTool({
api: createApi(),
store,
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
screenshotter,
});
const result = await tool.execute?.("tool-2b", {
before: "one\n",
after: "two\n",
mode: "image",
fileFormat: "pdf",
});
expect(screenshotter.screenshotHtml).toHaveBeenCalledTimes(1);
expect(readTextContent(result, 0)).toContain("Diff PDF generated at:");
expect((result?.details as Record<string, unknown>).format).toBe("pdf");
expect((result?.details as Record<string, unknown>).filePath).toMatch(/preview\.pdf$/);
});
it("accepts mode=file as an alias for file artifact rendering", async () => {
const screenshotter = {
screenshotHtml: vi.fn(async ({ outputPath }: { outputPath: string }) => {
expect(outputPath).toMatch(/preview\.png$/);
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, Buffer.from("png"));
return outputPath;
}),
};
const tool = createDiffsTool({
api: createApi(),
store,
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
screenshotter,
});
const result = await tool.execute?.("tool-2c", {
before: "one\n",
after: "two\n",
mode: "file",
});
expect(screenshotter.screenshotHtml).toHaveBeenCalledTimes(1);
expect((result?.details as Record<string, unknown>).mode).toBe("file");
expect((result?.details as Record<string, unknown>).viewerUrl).toBeUndefined();
});
it("accepts image* tool options for backward compatibility", async () => {
const screenshotter = {
screenshotHtml: vi.fn(
async ({
outputPath,
image,
}: {
outputPath: string;
image: { qualityPreset: string; scale: number; maxWidth: number };
}) => {
expect(image).toMatchObject({
qualityPreset: "hq",
scale: 2.4,
maxWidth: 1100,
});
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, Buffer.from("png"));
return outputPath;
},
),
};
const tool = createDiffsTool({
api: createApi(),
store,
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
screenshotter,
});
const result = await tool.execute?.("tool-2legacy", {
before: "one\n",
after: "two\n",
mode: "file",
imageQuality: "hq",
imageScale: 2.4,
imageMaxWidth: 1100,
});
expect((result?.details as Record<string, unknown>).fileQuality).toBe("hq");
expect((result?.details as Record<string, unknown>).fileScale).toBe(2.4);
expect((result?.details as Record<string, unknown>).fileMaxWidth).toBe(1100);
});
it("honors defaults.mode=file when mode is omitted", async () => {
const screenshotter = {
screenshotHtml: vi.fn(async ({ outputPath }: { outputPath: string }) => {
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, Buffer.from("png"));
return outputPath;
}),
};
const tool = createDiffsTool({
api: createApi(),
store,
defaults: {
...DEFAULT_DIFFS_TOOL_DEFAULTS,
mode: "file",
},
screenshotter,
});
const result = await tool.execute?.("tool-2d", {
before: "one\n",
after: "two\n",
});
expect(screenshotter.screenshotHtml).toHaveBeenCalledTimes(1);
expect((result?.details as Record<string, unknown>).mode).toBe("file");
expect((result?.details as Record<string, unknown>).viewerUrl).toBeUndefined();
});
it("falls back to view output when both mode cannot render an image", async () => {
const tool = createDiffsTool({
api: createApi(),
@@ -84,7 +254,8 @@ describe("diffs tool", () => {
});
expect(result?.content).toHaveLength(1);
expect(readTextContent(result, 0)).toContain("Image rendering failed");
expect(readTextContent(result, 0)).toContain("File rendering failed");
expect((result?.details as Record<string, unknown>).fileError).toBe("browser missing");
expect((result?.details as Record<string, unknown>).imageError).toBe("browser missing");
});
@@ -105,23 +276,6 @@ describe("diffs tool", () => {
).rejects.toThrow("Invalid baseUrl");
});
it("rejects oversized before/after payloads", async () => {
const tool = createDiffsTool({
api: createApi(),
store,
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
});
const large = "x".repeat(600_000);
await expect(
tool.execute?.("tool-large-before", {
before: large,
after: "ok",
mode: "view",
}),
).rejects.toThrow("before exceeds maximum size");
});
it("rejects oversized patch payloads", async () => {
const tool = createDiffsTool({
api: createApi(),
@@ -130,13 +284,30 @@ describe("diffs tool", () => {
});
await expect(
tool.execute?.("tool-large-patch", {
tool.execute?.("tool-oversize-patch", {
patch: "x".repeat(2_100_000),
mode: "view",
}),
).rejects.toThrow("patch exceeds maximum size");
});
it("rejects oversized before/after payloads", async () => {
const tool = createDiffsTool({
api: createApi(),
store,
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
});
const large = "x".repeat(600_000);
await expect(
tool.execute?.("tool-oversize-before", {
before: large,
after: "ok",
mode: "view",
}),
).rejects.toThrow("before exceeds maximum size");
});
it("uses configured defaults when tool params omit them", async () => {
const tool = createDiffsTool({
api: createApi(),
@@ -171,7 +342,30 @@ describe("diffs tool", () => {
});
it("prefers explicit tool params over configured defaults", async () => {
const screenshotter = createScreenshotter();
const screenshotter = {
screenshotHtml: vi.fn(
async ({
html,
outputPath,
image,
}: {
html: string;
outputPath: string;
image: { format: string; qualityPreset: string; scale: number; maxWidth: number };
}) => {
expect(html).not.toContain("/plugins/diffs/assets/viewer.js");
expect(image).toMatchObject({
format: "png",
qualityPreset: "print",
scale: 2.75,
maxWidth: 1320,
});
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, Buffer.from("png"));
return outputPath;
},
),
};
const tool = createDiffsTool({
api: createApi(),
store,
@@ -180,6 +374,9 @@ describe("diffs tool", () => {
mode: "view",
theme: "light",
layout: "split",
fileQuality: "hq",
fileScale: 2.2,
fileMaxWidth: 1180,
},
screenshotter,
});
@@ -190,10 +387,17 @@ describe("diffs tool", () => {
mode: "both",
theme: "dark",
layout: "unified",
fileQuality: "print",
fileScale: 2.75,
fileMaxWidth: 1320,
});
expect((result?.details as Record<string, unknown>).mode).toBe("both");
expect(screenshotter.screenshotHtml).toHaveBeenCalledTimes(1);
expect((result?.details as Record<string, unknown>).format).toBe("png");
expect((result?.details as Record<string, unknown>).fileQuality).toBe("print");
expect((result?.details as Record<string, unknown>).fileScale).toBe(2.75);
expect((result?.details as Record<string, unknown>).fileMaxWidth).toBe(1320);
const viewerPath = String((result?.details as Record<string, unknown>).viewerPath);
const [id] = viewerPath.split("/").filter(Boolean).slice(-2);
const html = await store.readHtml(id);
@@ -242,14 +446,3 @@ function readTextContent(result: unknown, index: number): string {
const entry = content?.[index];
return entry?.type === "text" ? (entry.text ?? "") : "";
}
function createScreenshotter() {
return {
screenshotHtml: vi.fn(async ({ html, outputPath }: { html: string; outputPath: string }) => {
expect(html).not.toContain("/plugins/diffs/assets/viewer.js");
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, Buffer.from("png"));
return outputPath;
}),
};
}

View File

@@ -2,16 +2,21 @@ import fs from "node:fs/promises";
import { Static, Type } from "@sinclair/typebox";
import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk";
import { PlaywrightDiffScreenshotter, type DiffScreenshotter } from "./browser.js";
import { resolveDiffImageRenderOptions } from "./config.js";
import { renderDiffDocument } from "./render.js";
import type { DiffArtifactStore } from "./store.js";
import type { DiffToolDefaults } from "./types.js";
import type { DiffRenderOptions, DiffToolDefaults } from "./types.js";
import {
DIFF_IMAGE_QUALITY_PRESETS,
DIFF_LAYOUTS,
DIFF_MODES,
DIFF_OUTPUT_FORMATS,
DIFF_THEMES,
type DiffInput,
type DiffImageQualityPreset,
type DiffLayout,
type DiffMode,
type DiffOutputFormat,
type DiffTheme,
} from "./types.js";
import { buildViewerUrl, normalizeViewerBaseUrl } from "./url.js";
@@ -59,10 +64,47 @@ const DiffsToolSchema = Type.Object(
}),
),
mode: Type.Optional(
stringEnum(DIFF_MODES, "Output mode: view, image, or both. Default: both."),
stringEnum(DIFF_MODES, "Output mode: view, file, image, or both. Default: both."),
),
theme: Type.Optional(stringEnum(DIFF_THEMES, "Viewer theme. Default: dark.")),
layout: Type.Optional(stringEnum(DIFF_LAYOUTS, "Diff layout. Default: unified.")),
fileQuality: Type.Optional(
stringEnum(DIFF_IMAGE_QUALITY_PRESETS, "File quality preset: standard, hq, or print."),
),
fileFormat: Type.Optional(stringEnum(DIFF_OUTPUT_FORMATS, "Rendered file format: png or pdf.")),
fileScale: Type.Optional(
Type.Number({
description: "Optional rendered-file device scale factor override (1-4).",
minimum: 1,
maximum: 4,
}),
),
fileMaxWidth: Type.Optional(
Type.Number({
description: "Optional rendered-file max width in CSS pixels (640-2400).",
minimum: 640,
maximum: 2400,
}),
),
imageQuality: Type.Optional(
stringEnum(DIFF_IMAGE_QUALITY_PRESETS, "Deprecated alias for fileQuality."),
),
format: Type.Optional(stringEnum(DIFF_OUTPUT_FORMATS, "Deprecated alias for fileFormat.")),
imageFormat: Type.Optional(stringEnum(DIFF_OUTPUT_FORMATS, "Deprecated alias for fileFormat.")),
imageScale: Type.Optional(
Type.Number({
description: "Deprecated alias for fileScale.",
minimum: 1,
maximum: 4,
}),
),
imageMaxWidth: Type.Optional(
Type.Number({
description: "Deprecated alias for fileMaxWidth.",
minimum: 640,
maximum: 2400,
}),
),
expandUnchanged: Type.Optional(
Type.Boolean({ description: "Expand unchanged sections instead of collapsing them." }),
),
@@ -95,7 +137,7 @@ export function createDiffsTool(params: {
name: "diffs",
label: "Diffs",
description:
"Create a read-only diff viewer from before/after text or a unified patch. Returns a gateway viewer URL for canvas use and can also render the same diff to a PNG.",
"Create a read-only diff viewer from before/after text or a unified patch. Returns a gateway viewer URL for canvas use and can also render the same diff to a PNG or PDF.",
parameters: DiffsToolSchema,
execute: async (_toolCallId, rawParams) => {
const toolParams = rawParams as DiffsToolParams;
@@ -105,6 +147,15 @@ export function createDiffsTool(params: {
const layout = normalizeLayout(toolParams.layout, params.defaults.layout);
const expandUnchanged = toolParams.expandUnchanged === true;
const ttlMs = normalizeTtlMs(toolParams.ttlSeconds);
const image = resolveDiffImageRenderOptions({
defaults: params.defaults,
fileFormat: normalizeOutputFormat(
toolParams.fileFormat ?? toolParams.imageFormat ?? toolParams.format,
),
fileQuality: normalizeFileQuality(toolParams.fileQuality ?? toolParams.imageQuality),
fileScale: toolParams.fileScale ?? toolParams.imageScale,
fileMaxWidth: toolParams.fileMaxWidth ?? toolParams.imageMaxWidth,
});
const rendered = await renderDiffDocument(input, {
presentation: {
@@ -112,20 +163,21 @@ export function createDiffsTool(params: {
layout,
theme,
},
image,
expandUnchanged,
});
const screenshotter =
params.screenshotter ?? new PlaywrightDiffScreenshotter({ config: params.api.config });
if (mode === "image") {
const imagePath = params.store.allocateStandaloneImagePath();
await screenshotter.screenshotHtml({
if (isArtifactOnlyMode(mode)) {
const artifactFile = await renderDiffArtifactFile({
screenshotter,
store: params.store,
html: rendered.imageHtml,
outputPath: imagePath,
theme,
image,
});
const imageStats = await fs.stat(imagePath);
params.store.scheduleCleanup();
return {
@@ -133,8 +185,8 @@ export function createDiffsTool(params: {
{
type: "text",
text:
`Diff image generated at: ${imagePath}\n` +
"Use the `message` tool with `path` or `filePath` to send the PNG.",
`Diff ${image.format.toUpperCase()} generated at: ${artifactFile.path}\n` +
"Use the `message` tool with `path` or `filePath` to send this file.",
},
],
details: {
@@ -142,9 +194,19 @@ export function createDiffsTool(params: {
inputKind: rendered.inputKind,
fileCount: rendered.fileCount,
mode,
imagePath,
path: imagePath,
imageBytes: imageStats.size,
filePath: artifactFile.path,
imagePath: artifactFile.path,
path: artifactFile.path,
fileBytes: artifactFile.bytes,
imageBytes: artifactFile.bytes,
format: image.format,
fileFormat: image.format,
fileQuality: image.qualityPreset,
imageQuality: image.qualityPreset,
fileScale: image.scale,
imageScale: image.scale,
fileMaxWidth: image.maxWidth,
imageMaxWidth: image.maxWidth,
},
};
}
@@ -187,14 +249,15 @@ export function createDiffsTool(params: {
}
try {
const imagePath = params.store.allocateImagePath(artifact.id);
await screenshotter.screenshotHtml({
const artifactFile = await renderDiffArtifactFile({
screenshotter,
store: params.store,
artifactId: artifact.id,
html: rendered.imageHtml,
outputPath: imagePath,
theme,
image,
});
await params.store.updateImagePath(artifact.id, imagePath);
const imageStats = await fs.stat(imagePath);
await params.store.updateFilePath(artifact.id, artifactFile.path);
return {
content: [
@@ -202,15 +265,25 @@ export function createDiffsTool(params: {
type: "text",
text:
`Diff viewer: ${viewerUrl}\n` +
`Diff image generated at: ${imagePath}\n` +
"Use the `message` tool with `path` or `filePath` to send the PNG.",
`Diff ${image.format.toUpperCase()} generated at: ${artifactFile.path}\n` +
"Use the `message` tool with `path` or `filePath` to send this file.",
},
],
details: {
...baseDetails,
imagePath,
path: imagePath,
imageBytes: imageStats.size,
filePath: artifactFile.path,
imagePath: artifactFile.path,
path: artifactFile.path,
fileBytes: artifactFile.bytes,
imageBytes: artifactFile.bytes,
format: image.format,
fileFormat: image.format,
fileQuality: image.qualityPreset,
imageQuality: image.qualityPreset,
fileScale: image.scale,
imageScale: image.scale,
fileMaxWidth: image.maxWidth,
imageMaxWidth: image.maxWidth,
},
};
} catch (error) {
@@ -221,11 +294,12 @@ export function createDiffsTool(params: {
type: "text",
text:
`Diff viewer ready.\n${viewerUrl}\n` +
`Image rendering failed: ${error instanceof Error ? error.message : String(error)}`,
`File rendering failed: ${error instanceof Error ? error.message : String(error)}`,
},
],
details: {
...baseDetails,
fileError: error instanceof Error ? error.message : String(error),
imageError: error instanceof Error ? error.message : String(error),
},
};
@@ -236,6 +310,46 @@ export function createDiffsTool(params: {
};
}
function normalizeFileQuality(
fileQuality: DiffImageQualityPreset | undefined,
): DiffImageQualityPreset | undefined {
return fileQuality && DIFF_IMAGE_QUALITY_PRESETS.includes(fileQuality) ? fileQuality : undefined;
}
function normalizeOutputFormat(format: DiffOutputFormat | undefined): DiffOutputFormat | undefined {
return format && DIFF_OUTPUT_FORMATS.includes(format) ? format : undefined;
}
function isArtifactOnlyMode(mode: DiffMode): mode is "image" | "file" {
return mode === "image" || mode === "file";
}
async function renderDiffArtifactFile(params: {
screenshotter: DiffScreenshotter;
store: DiffArtifactStore;
artifactId?: string;
html: string;
theme: DiffTheme;
image: DiffRenderOptions["image"];
}): Promise<{ path: string; bytes: number }> {
const outputPath = params.artifactId
? params.store.allocateFilePath(params.artifactId, params.image.format)
: params.store.allocateStandaloneFilePath(params.image.format);
await params.screenshotter.screenshotHtml({
html: params.html,
outputPath,
theme: params.theme,
image: params.image,
});
const stats = await fs.stat(outputPath);
return {
path: outputPath,
bytes: stats.size,
};
}
function normalizeDiffInput(params: DiffsToolParams): DiffInput {
const patch = params.patch?.trim();
const before = params.before;
@@ -285,6 +399,13 @@ function normalizeDiffInput(params: DiffsToolParams): DiffInput {
};
}
function assertMaxBytes(value: string, label: string, maxBytes: number): void {
if (Buffer.byteLength(value, "utf8") <= maxBytes) {
return;
}
throw new PluginToolInputError(`${label} exceeds maximum size (${maxBytes} bytes).`);
}
function normalizeBaseUrl(baseUrl?: string): string | undefined {
const normalized = baseUrl?.trim();
if (!normalized) {
@@ -322,10 +443,3 @@ class PluginToolInputError extends Error {
this.name = "ToolInputError";
}
}
function assertMaxBytes(value: string, label: string, maxBytes: number): void {
if (Buffer.byteLength(value, "utf8") <= maxBytes) {
return;
}
throw new PluginToolInputError(`${label} exceeds maximum size (${maxBytes} bytes).`);
}

View File

@@ -1,14 +1,18 @@
import type { FileContents, FileDiffMetadata, SupportedLanguages } from "@pierre/diffs";
export const DIFF_LAYOUTS = ["unified", "split"] as const;
export const DIFF_MODES = ["view", "image", "both"] as const;
export const DIFF_MODES = ["view", "image", "file", "both"] as const;
export const DIFF_THEMES = ["light", "dark"] as const;
export const DIFF_INDICATORS = ["bars", "classic", "none"] as const;
export const DIFF_IMAGE_QUALITY_PRESETS = ["standard", "hq", "print"] as const;
export const DIFF_OUTPUT_FORMATS = ["png", "pdf"] as const;
export type DiffLayout = (typeof DIFF_LAYOUTS)[number];
export type DiffMode = (typeof DIFF_MODES)[number];
export type DiffTheme = (typeof DIFF_THEMES)[number];
export type DiffIndicators = (typeof DIFF_INDICATORS)[number];
export type DiffImageQualityPreset = (typeof DIFF_IMAGE_QUALITY_PRESETS)[number];
export type DiffOutputFormat = (typeof DIFF_OUTPUT_FORMATS)[number];
export type DiffPresentationDefaults = {
fontFamily: string;
@@ -22,10 +26,18 @@ export type DiffPresentationDefaults = {
theme: DiffTheme;
};
export type DiffToolDefaults = DiffPresentationDefaults & {
mode: DiffMode;
export type DiffFileDefaults = {
fileFormat: DiffOutputFormat;
fileQuality: DiffImageQualityPreset;
fileScale: number;
fileMaxWidth: number;
};
export type DiffToolDefaults = DiffPresentationDefaults &
DiffFileDefaults & {
mode: DiffMode;
};
export type BeforeAfterDiffInput = {
kind: "before_after";
before: string;
@@ -45,6 +57,13 @@ export type DiffInput = BeforeAfterDiffInput | PatchDiffInput;
export type DiffRenderOptions = {
presentation: DiffPresentationDefaults;
image: {
format: DiffOutputFormat;
qualityPreset: DiffImageQualityPreset;
scale: number;
maxWidth: number;
maxPixels: number;
};
expandUnchanged: boolean;
};
@@ -90,6 +109,7 @@ export type DiffArtifactMeta = {
fileCount: number;
viewerPath: string;
htmlPath: string;
filePath?: string;
imagePath?: string;
};