feat: split diffs language pack

This commit is contained in:
Dallin Romney
2026-05-26 21:43:54 -07:00
parent 05ff771010
commit 1c2c9c748e
38 changed files with 1024 additions and 103 deletions

1
.github/labeler.yml vendored
View File

@@ -491,6 +491,7 @@
- changed-files:
- any-glob-to-any-file:
- "extensions/diffs/**"
- "extensions/diffs-language-pack/**"
"extensions: elevenlabs":
- changed-files:
- any-glob-to-any-file:

1
.gitignore vendored
View File

@@ -249,6 +249,7 @@ extensions/qa-lab/web/dist/
# Generated bundled plugin runtime dependency manifests
extensions/**/.openclaw-runtime-deps.json
extensions/**/.openclaw-runtime-deps-stamp.json
extensions/diffs-language-pack/assets/viewer-runtime.js
# Output dir for scripts/run-opengrep.sh (local opengrep scans)
/.opengrep-out/

View File

@@ -182,6 +182,7 @@
"dist-runtime/",
"docs/_layouts/",
"extensions/diffs/assets/viewer-runtime.js",
"extensions/diffs-language-pack/assets/viewer-runtime.js",
"node_modules/",
"patches/",
"pnpm-lock.yaml",

View File

@@ -151,6 +151,7 @@ commands.
| [diagnostics-otel](/plugins/reference/diagnostics-otel) | OpenClaw diagnostics OpenTelemetry exporter. | `@openclaw/diagnostics-otel`<br />npm; ClawHub: `clawhub:@openclaw/diagnostics-otel` | plugin |
| [diagnostics-prometheus](/plugins/reference/diagnostics-prometheus) | OpenClaw diagnostics Prometheus exporter. | `@openclaw/diagnostics-prometheus`<br />npm; ClawHub: `clawhub:@openclaw/diagnostics-prometheus` | plugin |
| [diffs](/plugins/reference/diffs) | Read-only diff viewer and file renderer for agents. | `@openclaw/diffs`<br />npm; ClawHub | contracts: tools; skills |
| [diffs-language-pack](/plugins/reference/diffs-language-pack) | Adds syntax highlighting for languages outside the default diffs viewer set. | `@openclaw/diffs-language-pack`<br />npm; ClawHub | plugin |
| [discord](/plugins/reference/discord) | Adds the Discord channel surface for sending and receiving OpenClaw messages. | `@openclaw/discord`<br />npm; ClawHub | channels: discord; contracts: transcriptSourceProviders |
| [feishu](/plugins/reference/feishu) | Adds the Feishu channel surface for sending and receiving OpenClaw messages. | `@openclaw/feishu`<br />npm; ClawHub | channels: feishu; contracts: tools; skills |
| [google-meet](/plugins/reference/google-meet) | Join Google Meet calls through Chrome or Twilio transports. | `@openclaw/google-meet`<br />npm; ClawHub | contracts: tools |

View File

@@ -44,6 +44,7 @@ pnpm plugins:inventory:gen
| [diagnostics-otel](/plugins/reference/diagnostics-otel) | OpenClaw diagnostics OpenTelemetry exporter. | `@openclaw/diagnostics-otel`<br />npm; ClawHub: `clawhub:@openclaw/diagnostics-otel` | plugin |
| [diagnostics-prometheus](/plugins/reference/diagnostics-prometheus) | OpenClaw diagnostics Prometheus exporter. | `@openclaw/diagnostics-prometheus`<br />npm; ClawHub: `clawhub:@openclaw/diagnostics-prometheus` | plugin |
| [diffs](/plugins/reference/diffs) | Read-only diff viewer and file renderer for agents. | `@openclaw/diffs`<br />npm; ClawHub | contracts: tools; skills |
| [diffs-language-pack](/plugins/reference/diffs-language-pack) | Adds syntax highlighting for languages outside the default diffs viewer set. | `@openclaw/diffs-language-pack`<br />npm; ClawHub | plugin |
| [discord](/plugins/reference/discord) | Adds the Discord channel surface for sending and receiving OpenClaw messages. | `@openclaw/discord`<br />npm; ClawHub | channels: discord; contracts: transcriptSourceProviders |
| [document-extract](/plugins/reference/document-extract) | Extract text and fallback page images from local document attachments. | `@openclaw/document-extract-plugin`<br />included in OpenClaw | contracts: documentExtractors |
| [duckduckgo](/plugins/reference/duckduckgo) | Adds web search provider support. | `@openclaw/duckduckgo-plugin`<br />included in OpenClaw | contracts: webSearchProviders |

View File

@@ -0,0 +1,19 @@
---
summary: "Adds syntax highlighting for languages outside the default diffs viewer set."
read_when:
- You are installing, configuring, or auditing the diffs-language-pack plugin
title: "Diffs Language Pack plugin"
---
# Diffs Language Pack plugin
Adds syntax highlighting for languages outside the default diffs viewer set.
## Distribution
- Package: `@openclaw/diffs-language-pack`
- Install route: npm; ClawHub
## Surface
plugin

View File

@@ -136,8 +136,10 @@ All fields are optional unless noted.
Display filename for before and after mode.
</ParamField>
<ParamField path="lang" type="string">
Language override hint for before and after mode. Unknown values fall back to plain text.
Language override hint for before and after mode. Unknown values and languages outside the default viewer set fall back to plain text unless the
Diff Viewer Language Pack plugin is installed.
</ParamField>
<ParamField path="title" type="string">
Viewer title override.
</ParamField>
@@ -200,6 +202,22 @@ All fields are optional unless noted.
</Accordion>
</AccordionGroup>
## Syntax highlighting
OpenClaw includes syntax highlighting for common source, config, and documentation languages:
`javascript`, `typescript`, `tsx`, `jsx`, `json`, `markdown`, `yaml`, `css`, `html`, `sh`, `python`, `go`, `rust`, `java`, `c`, `cpp`, `csharp`, `php`, `sql`, and `docker`.
Common aliases such as `js`, `ts`, `bash`, `md`, `yml`, `c++`, and `dockerfile` are normalized to those default languages.
Install the Diff Viewer Language Pack plugin to highlight other languages:
```bash
openclaw plugins install diffs-language-pack
```
With the language pack available, OpenClaw automatically uses it for languages outside the default list. Without it, those files stay readable as plain text.
## Output details contract
The tool returns structured metadata under `details`.
@@ -385,6 +403,7 @@ Viewer assets:
- `/plugins/diffs/assets/viewer.js`
- `/plugins/diffs/assets/viewer-runtime.js`
- `/plugins/diffs-language-pack/assets/viewer.js` when the diff uses a language from the Diff Viewer Language Pack
The viewer document resolves those assets relative to the viewer URL, so an optional `baseUrl` path prefix is preserved for both asset requests too.

View File

@@ -0,0 +1,6 @@
export {
definePluginEntry,
type OpenClawPluginApi,
type OpenClawPluginHttpRouteHandler,
type PluginLogger,
} from "openclaw/plugin-sdk/plugin-entry";

View File

@@ -0,0 +1,9 @@
import { definePluginEntry } from "./api.js";
import { registerDiffsLanguagePackPlugin } from "./src/plugin.js";
export default definePluginEntry({
id: "diffs-language-pack",
name: "Diff Viewer Language Pack",
description: "Adds syntax highlighting for languages outside the default diffs viewer set.",
register: registerDiffsLanguagePackPlugin,
});

View File

@@ -0,0 +1,12 @@
{
"name": "@openclaw/diffs-language-pack",
"version": "2026.5.26",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/diffs-language-pack",
"version": "2026.5.26"
}
}
}

View File

@@ -0,0 +1,8 @@
{
"id": "diffs-language-pack",
"activation": {
"onStartup": true
},
"name": "Diff Viewer Language Pack",
"description": "Adds syntax highlighting for languages outside the default diffs viewer set."
}

View File

@@ -0,0 +1,47 @@
{
"name": "@openclaw/diffs-language-pack",
"version": "2026.5.26",
"description": "OpenClaw diffs viewer syntax highlighting language pack",
"repository": {
"type": "git",
"url": "https://github.com/openclaw/openclaw"
},
"type": "module",
"scripts": {
"build:viewer": "bun scripts/build-viewer.mjs"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*",
"esbuild": "0.28.0"
},
"openclaw": {
"extensions": [
"./index.ts"
],
"install": {
"npmSpec": "@openclaw/diffs-language-pack",
"localPath": "extensions/diffs-language-pack",
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.30"
},
"compat": {
"pluginApi": ">=2026.5.26"
},
"assetScripts": {
"build": "pnpm build:viewer"
},
"build": {
"openclawVersion": "2026.5.26",
"staticAssets": [
{
"source": "./assets/viewer-runtime.js",
"output": "assets/viewer-runtime.js"
}
]
},
"release": {
"publishToClawHub": true,
"publishToNpm": true
}
}
}

View File

@@ -0,0 +1,38 @@
#!/usr/bin/env node
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { transform } from "esbuild";
const pluginRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const outputPath = path.join(pluginRoot, "assets", "viewer-runtime.js");
await fs.mkdir(path.dirname(outputPath), { recursive: true });
const result = await Bun.build({
entrypoints: [path.join(pluginRoot, "..", "diffs", "src", "viewer-client.ts")],
target: "browser",
format: "esm",
minify: true,
outdir: path.dirname(outputPath),
naming: path.basename(outputPath),
write: true,
});
if (!result.success) {
for (const log of result.logs) {
console.error(log.message);
}
process.exit(1);
}
const runtime = await fs.readFile(outputPath, "utf8");
const minified = await transform(runtime, {
format: "esm",
legalComments: "none",
loader: "js",
minify: true,
target: "es2020",
});
await fs.writeFile(outputPath, minified.code.replace(/[ \t]+$/gm, ""));

View File

@@ -0,0 +1,64 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { OpenClawPluginApi } from "../api.js";
import { VIEWER_ASSET_PREFIX, getServedViewerAsset } from "./viewer-assets.js";
export function registerDiffsLanguagePackPlugin(api: OpenClawPluginApi): void {
api.registerHttpRoute({
path: "/plugins/diffs-language-pack",
auth: "plugin",
match: "prefix",
handler: createDiffsLanguagePackHttpHandler(),
});
}
function createDiffsLanguagePackHttpHandler() {
return async (req: IncomingMessage, res: ServerResponse): Promise<boolean> => {
const parsed = parseRequestUrl(req.url);
if (!parsed?.pathname.startsWith(VIEWER_ASSET_PREFIX)) {
return false;
}
if (req.method !== "GET" && req.method !== "HEAD") {
respondText(res, 405, "Method not allowed");
return true;
}
const asset = await getServedViewerAsset(parsed.pathname);
if (!asset) {
respondText(res, 404, "Asset not found");
return true;
}
res.statusCode = 200;
setSharedHeaders(res, asset.contentType);
if (req.method === "HEAD") {
res.end();
} else {
res.end(asset.body);
}
return true;
};
}
function parseRequestUrl(rawUrl?: string): URL | null {
if (!rawUrl) {
return null;
}
try {
return new URL(rawUrl, "http://127.0.0.1");
} catch {
return null;
}
}
function respondText(res: ServerResponse, statusCode: number, body: string): void {
res.statusCode = statusCode;
setSharedHeaders(res, "text/plain; charset=utf-8");
res.end(body);
}
function setSharedHeaders(res: ServerResponse, contentType: string): void {
res.setHeader("cache-control", "no-store, max-age=0");
res.setHeader("content-type", contentType);
res.setHeader("x-content-type-options", "nosniff");
res.setHeader("referrer-policy", "no-referrer");
}

View File

@@ -0,0 +1,90 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import { fileURLToPath } from "node:url";
export const VIEWER_ASSET_PREFIX = "/plugins/diffs-language-pack/assets/";
export const VIEWER_LOADER_PATH = `${VIEWER_ASSET_PREFIX}viewer.js`;
export const VIEWER_RUNTIME_PATH = `${VIEWER_ASSET_PREFIX}viewer-runtime.js`;
const VIEWER_RUNTIME_RELATIVE_IMPORT_PATH = "./viewer-runtime.js";
const VIEWER_RUNTIME_CANDIDATE_RELATIVE_PATHS = [
"./assets/viewer-runtime.js",
"../assets/viewer-runtime.js",
] as const;
type ServedViewerAsset = {
body: string | Buffer;
contentType: string;
};
type RuntimeAssetCache = {
mtimeMs: number;
runtimeBody: Buffer;
loaderBody: string;
};
let runtimeAssetCache: RuntimeAssetCache | null = null;
function isMissingFileError(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error && error.code === "ENOENT";
}
export async function resolveViewerRuntimeFileUrl(): Promise<URL> {
let missingFileError: NodeJS.ErrnoException | null = null;
for (const relativePath of VIEWER_RUNTIME_CANDIDATE_RELATIVE_PATHS) {
const candidateUrl = new URL(relativePath, import.meta.url);
try {
await fs.stat(fileURLToPath(candidateUrl));
return candidateUrl;
} catch (error) {
if (isMissingFileError(error)) {
missingFileError = error;
continue;
}
throw error;
}
}
if (missingFileError) {
throw missingFileError;
}
throw new Error("viewer runtime asset candidates were not checked");
}
export async function getServedViewerAsset(pathname: string): Promise<ServedViewerAsset | null> {
if (pathname !== VIEWER_LOADER_PATH && pathname !== VIEWER_RUNTIME_PATH) {
return null;
}
const assets = await loadViewerAssets();
if (pathname === VIEWER_LOADER_PATH) {
return {
body: assets.loaderBody,
contentType: "text/javascript; charset=utf-8",
};
}
return {
body: assets.runtimeBody,
contentType: "text/javascript; charset=utf-8",
};
}
async function loadViewerAssets(): Promise<RuntimeAssetCache> {
const runtimeUrl = await resolveViewerRuntimeFileUrl();
const runtimePath = fileURLToPath(runtimeUrl);
const runtimeStat = await fs.stat(runtimePath);
if (runtimeAssetCache && runtimeAssetCache.mtimeMs === runtimeStat.mtimeMs) {
return runtimeAssetCache;
}
const runtimeBody = await fs.readFile(runtimePath);
const hash = crypto.createHash("sha1").update(runtimeBody).digest("hex").slice(0, 12);
runtimeAssetCache = {
mtimeMs: runtimeStat.mtimeMs,
runtimeBody,
loaderBody: `import "${VIEWER_RUNTIME_RELATIVE_IMPORT_PATH}?v=${hash}";\n`,
};
return runtimeAssetCache;
}

View File

@@ -61,6 +61,7 @@ Useful options:
- `expandUnchanged`: expand unchanged sections (per-call option only, not a plugin default key)
- `path`: display name for before and after input
- `lang`: language hint for before/after input; unknown values fall back to plain text
- Default syntax highlighting covers common languages. Install `diffs-language-pack` for the extended language catalog.
- `title`: explicit viewer title
- `ttlSeconds`: artifact lifetime for viewer and standalone file outputs
- `baseUrl`: override the gateway base URL used in the returned viewer link (origin or origin+base path only; no query/hash)

File diff suppressed because one or more lines are too long

View File

@@ -10,7 +10,12 @@
"dependencies": {
"@pierre/diffs": "1.2.2",
"@pierre/theme": "1.0.3",
"@shikijs/core": "3.23.0",
"@shikijs/engine-javascript": "3.23.0",
"@shikijs/engine-oniguruma": "3.23.0",
"@shikijs/langs": "3.23.0",
"playwright-core": "1.60.0",
"shiki": "3.23.0",
"typebox": "1.1.38",
"zod": "4.4.3"
}

View File

@@ -8,17 +8,23 @@
},
"type": "module",
"scripts": {
"build:viewer": "bun build src/viewer-client.ts --target browser --format esm --minify --outfile assets/viewer-runtime.js && node -e \"let fs=require('fs'),p='assets/viewer-runtime.js',s=fs.readFileSync(p,'utf8').replace(/[ \\\\t]+$/gm,'');fs.writeFileSync(p,s)\""
"build:viewer": "bun scripts/build-viewer.mjs"
},
"dependencies": {
"@pierre/diffs": "1.2.2",
"@pierre/theme": "1.0.3",
"@shikijs/core": "3.23.0",
"@shikijs/engine-javascript": "3.23.0",
"@shikijs/engine-oniguruma": "3.23.0",
"@shikijs/langs": "3.23.0",
"playwright-core": "1.60.0",
"shiki": "3.23.0",
"typebox": "1.1.38",
"zod": "4.4.3"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
"@openclaw/plugin-sdk": "workspace:*",
"esbuild": "0.28.0"
},
"openclaw": {
"extensions": [
@@ -33,6 +39,9 @@
"compat": {
"pluginApi": ">=2026.5.26"
},
"assetScripts": {
"build": "pnpm build:viewer"
},
"build": {
"openclawVersion": "2026.5.26",
"staticAssets": [

View File

@@ -0,0 +1,49 @@
#!/usr/bin/env node
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { transform } from "esbuild";
const pluginRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const outputPath = path.join(pluginRoot, "assets", "viewer-runtime.js");
const curatedShikiPath = path.join(pluginRoot, "src", "shiki-curated.ts");
await fs.mkdir(path.dirname(outputPath), { recursive: true });
const result = await Bun.build({
entrypoints: [path.join(pluginRoot, "src", "viewer-client.ts")],
target: "browser",
format: "esm",
minify: true,
outdir: path.dirname(outputPath),
naming: path.basename(outputPath),
write: true,
plugins: [
{
name: "openclaw-diffs-curated-shiki",
setup(build) {
build.onResolve({ filter: /^shiki$/ }, () => ({
path: curatedShikiPath,
}));
},
},
],
});
if (!result.success) {
for (const log of result.logs) {
console.error(log.message);
}
process.exit(1);
}
const runtime = await fs.readFile(outputPath, "utf8");
const minified = await transform(runtime, {
format: "esm",
legalComments: "none",
loader: "js",
minify: true,
target: "es2020",
});
await fs.writeFile(outputPath, minified.code.replace(/[ \t]+$/gm, ""));

View File

@@ -6,7 +6,12 @@ import { writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtim
import { chromium } from "playwright-core";
import type { OpenClawConfig } from "../api.js";
import type { DiffRenderOptions, DiffTheme } from "./types.js";
import { VIEWER_ASSET_PREFIX, getServedViewerAsset } from "./viewer-assets.js";
import {
LANGUAGE_PACK_VIEWER_ASSET_PREFIX,
VIEWER_ASSET_PREFIX,
getServedLanguagePackViewerAsset,
getServedViewerAsset,
} from "./viewer-assets.js";
const DEFAULT_BROWSER_IDLE_MS = 30_000;
const SHARED_BROWSER_KEY = "__default__";
@@ -97,12 +102,18 @@ export class PlaywrightDiffScreenshotter implements DiffScreenshotter {
await route.abort();
return;
}
if (!parsed.pathname.startsWith(VIEWER_ASSET_PREFIX)) {
const isBaseViewerAsset = parsed.pathname.startsWith(VIEWER_ASSET_PREFIX);
const isLanguagePackViewerAsset = parsed.pathname.startsWith(
LANGUAGE_PACK_VIEWER_ASSET_PREFIX,
);
if (!isBaseViewerAsset && !isLanguagePackViewerAsset) {
await route.abort();
return;
}
const pathname = parsed.pathname;
const asset = await getServedViewerAsset(pathname);
const asset = isLanguagePackViewerAsset
? await getServedLanguagePackViewerAsset(pathname)
: await getServedViewerAsset(pathname);
if (!asset) {
await route.abort();
return;

View File

@@ -1,4 +1,5 @@
import fs from "node:fs";
import os from "node:os";
import { join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { validateJsonSchemaValue, type JsonSchemaObject } from "openclaw/plugin-sdk/config-schema";
@@ -12,10 +13,13 @@ import {
resolveDiffsPluginSecurity,
resolveDiffsPluginViewerBaseUrl,
} from "./config.js";
import { resolveDiffsLanguagePackAvailability } from "./plugin.js";
import { buildViewerUrl, normalizeViewerBaseUrl } from "./url.js";
import {
getServedLanguagePackViewerAsset,
getServedViewerAsset,
resolveViewerRuntimeFileUrl,
LANGUAGE_PACK_VIEWER_LOADER_PATH,
VIEWER_LOADER_PATH,
VIEWER_RUNTIME_PATH,
} from "./viewer-assets.js";
@@ -517,11 +521,52 @@ describe("viewer assets", () => {
expect(String(runtime?.body)).toContain('style.gap="6px"');
});
it("serves the optional language-pack loader only when its generated runtime is present", async () => {
const loader = await getServedLanguagePackViewerAsset(LANGUAGE_PACK_VIEWER_LOADER_PATH);
if (!loader) {
expect(loader).toBeNull();
return;
}
expect(loader.contentType).toBe("text/javascript; charset=utf-8");
expect(String(loader.body)).toContain(`./viewer-runtime.js?v=`);
});
it("returns null for unknown asset paths", async () => {
await expect(getServedViewerAsset("/plugins/diffs/assets/not-real.js")).resolves.toBeNull();
});
});
describe("resolveDiffsLanguagePackAvailability", () => {
it("requires both the sibling language-pack manifest and generated runtime asset", () => {
const root = fs.mkdtempSync(join(os.tmpdir(), "openclaw-diffs-language-pack-"));
try {
const diffsRoot = join(root, "diffs");
const languagePackRoot = join(root, "diffs-language-pack");
fs.mkdirSync(diffsRoot, { recursive: true });
fs.mkdirSync(languagePackRoot, { recursive: true });
fs.writeFileSync(
join(languagePackRoot, "openclaw.plugin.json"),
'{"id":"diffs-language-pack"}\n',
);
const api = {
rootDir: diffsRoot,
config: { plugins: {} },
runtime: { config: { current: () => ({ plugins: {} }) } },
} as Parameters<typeof resolveDiffsLanguagePackAvailability>[0];
expect(resolveDiffsLanguagePackAvailability(api)).toBe(false);
fs.mkdirSync(join(languagePackRoot, "assets"), { recursive: true });
fs.writeFileSync(join(languagePackRoot, "assets", "viewer-runtime.js"), "export {};\n");
expect(resolveDiffsLanguagePackAvailability(api)).toBe(true);
} finally {
fs.rmSync(root, { force: true, recursive: true });
}
});
});
describe("parseViewerPayloadJson", () => {
function buildValidPayload(): Record<string, unknown> {
return {

View File

@@ -7,12 +7,29 @@ import {
describe("filterSupportedLanguageHints", () => {
it("keeps supported languages", async () => {
await expect(filterSupportedLanguageHints(["typescript", "text"])).resolves.toEqual([
await expect(filterSupportedLanguageHints(["typescript", "cpp", "text"])).resolves.toEqual([
"typescript",
"cpp",
"text",
]);
});
it("normalizes common aliases to base viewer languages", async () => {
await expect(
filterSupportedLanguageHints(["ts", "c++", "bash", "dockerfile"]),
).resolves.toEqual(["typescript", "cpp", "sh", "docker"]);
});
it("drops uncommon languages without the language pack", async () => {
await expect(filterSupportedLanguageHints(["abap"])).resolves.toEqual(["text"]);
});
it("keeps uncommon languages when the language pack is available", async () => {
await expect(
filterSupportedLanguageHints(["abap"], { languagePackAvailable: true }),
).resolves.toEqual(["abap"]);
});
it("drops invalid languages and falls back to text", async () => {
await expect(filterSupportedLanguageHints(["not-a-real-language"])).resolves.toEqual(["text"]);
});
@@ -88,6 +105,37 @@ describe("normalizeDiffViewerPayloadLanguages", () => {
expect(result.newFile?.lang).toBe("typescript");
});
it("keeps uncommon hydrated languages when the language pack is available", async () => {
const result = await normalizeDiffViewerPayloadLanguages(
{
prerenderedHTML: "<div>diff</div>",
options: {
theme: {
light: "pierre-light",
dark: "pierre-dark",
},
diffStyle: "unified",
diffIndicators: "bars",
disableLineNumbers: false,
expandUnchanged: false,
themeType: "dark",
backgroundEnabled: true,
overflow: "wrap",
unsafeCSS: "",
},
langs: ["abap" as never],
fileDiff: {
name: "demo.abap",
lang: "abap" as never,
} as unknown as FileDiffMetadata,
},
{ languagePackAvailable: true },
);
expect(result.langs).toEqual(["abap"]);
expect(result.fileDiff?.lang).toBe("abap");
});
it("rewrites blank explicit language overrides to plain text", async () => {
const result = await normalizeDiffViewerPayloadLanguages({
prerenderedHTML: "<div>diff</div>",

View File

@@ -1,8 +1,22 @@
import { resolveLanguage } from "@pierre/diffs";
import type { FileContents, FileDiffMetadata, SupportedLanguages } from "@pierre/diffs";
import { bundledLanguagesBase, bundledLanguagesInfo } from "./shiki-curated-languages.js";
import type { DiffViewerPayload } from "./types.js";
const PASSTHROUGH_LANGUAGE_HINTS = new Set<SupportedLanguages>(["ansi", "text"]);
export const BASE_DIFF_VIEWER_LANGUAGE_HINTS = [
...Object.keys(bundledLanguagesBase),
"text",
"ansi",
] as const satisfies readonly SupportedLanguages[];
export type DiffViewerBaseLanguage = (typeof BASE_DIFF_VIEWER_LANGUAGE_HINTS)[number];
const BASE_LANGUAGE_HINTS = new Set<SupportedLanguages>(BASE_DIFF_VIEWER_LANGUAGE_HINTS);
const BASE_LANGUAGE_ALIASES = new Map<string, SupportedLanguages>(
bundledLanguagesInfo.flatMap(
(language) =>
language.aliases?.map((alias) => [alias, language.id as SupportedLanguages]) ?? [],
),
);
type DiffPayloadFile = FileContents | FileDiffMetadata;
function normalizeOptionalString(value: unknown): string | undefined {
@@ -15,14 +29,22 @@ function normalizeOptionalString(value: unknown): string | undefined {
export async function normalizeSupportedLanguageHint(
value?: string,
options: { languagePackAvailable?: boolean } = {},
): Promise<SupportedLanguages | undefined> {
const normalized = normalizeOptionalString(value);
if (!normalized) {
return undefined;
}
if (PASSTHROUGH_LANGUAGE_HINTS.has(normalized as SupportedLanguages)) {
const baseAlias = BASE_LANGUAGE_ALIASES.get(normalized);
if (baseAlias) {
return baseAlias;
}
if (BASE_LANGUAGE_HINTS.has(normalized as SupportedLanguages)) {
return normalized as SupportedLanguages;
}
if (!options.languagePackAvailable) {
return undefined;
}
try {
await resolveLanguage(normalized as Exclude<SupportedLanguages, "text" | "ansi">);
return normalized as SupportedLanguages;
@@ -33,17 +55,18 @@ export async function normalizeSupportedLanguageHint(
export async function filterSupportedLanguageHints(
values: Iterable<string>,
options: { languagePackAvailable?: boolean } = {},
): Promise<SupportedLanguages[]> {
return normalizeSupportedLanguageHints(values, { fallbackToText: true });
return normalizeSupportedLanguageHints(values, { fallbackToText: true, ...options });
}
async function normalizeSupportedLanguageHints(
values: Iterable<string>,
options: { fallbackToText: boolean },
options: { fallbackToText: boolean; languagePackAvailable?: boolean },
): Promise<SupportedLanguages[]> {
const supported = new Set<SupportedLanguages>();
for (const value of values) {
const normalized = await normalizeSupportedLanguageHint(value);
const normalized = await normalizeSupportedLanguageHint(value, options);
if (!normalized) {
continue;
}
@@ -75,6 +98,7 @@ export function collectDiffPayloadLanguageHints(payload: {
async function normalizeDiffPayloadFileLanguage(
file: DiffPayloadFile | undefined,
options: { languagePackAvailable?: boolean },
): Promise<DiffPayloadFile | undefined> {
if (!file) {
return undefined;
@@ -82,7 +106,7 @@ async function normalizeDiffPayloadFileLanguage(
if (typeof file.lang !== "string") {
return file;
}
const normalized = await normalizeSupportedLanguageHint(file.lang);
const normalized = await normalizeSupportedLanguageHint(file.lang, options);
if (file.lang === normalized) {
return file;
}
@@ -100,12 +124,15 @@ async function normalizeDiffPayloadFileLanguage(
export async function normalizeDiffViewerPayloadLanguages(
payload: DiffViewerPayload,
options: { languagePackAvailable?: boolean } = {},
): Promise<DiffViewerPayload> {
const [fileDiff, oldFile, newFile, payloadLangs] = await Promise.all([
normalizeDiffPayloadFileLanguage(payload.fileDiff) as Promise<FileDiffMetadata | undefined>,
normalizeDiffPayloadFileLanguage(payload.oldFile) as Promise<FileContents | undefined>,
normalizeDiffPayloadFileLanguage(payload.newFile) as Promise<FileContents | undefined>,
normalizeSupportedLanguageHints(payload.langs, { fallbackToText: false }),
normalizeDiffPayloadFileLanguage(payload.fileDiff, options) as Promise<
FileDiffMetadata | undefined
>,
normalizeDiffPayloadFileLanguage(payload.oldFile, options) as Promise<FileContents | undefined>,
normalizeDiffPayloadFileLanguage(payload.newFile, options) as Promise<FileContents | undefined>,
normalizeSupportedLanguageHints(payload.langs, { fallbackToText: false, ...options }),
]);
const langs = new Set<SupportedLanguages>(payloadLangs);
for (const lang of collectDiffPayloadLanguageHints({ fileDiff, oldFile, newFile })) {
@@ -122,3 +149,7 @@ export async function normalizeDiffViewerPayloadLanguages(
langs: [...langs],
};
}
export function isBaseDiffViewerLanguage(lang: string): boolean {
return BASE_LANGUAGE_HINTS.has(lang as SupportedLanguages);
}

View File

@@ -1,3 +1,4 @@
import fs from "node:fs";
import path from "node:path";
import { resolveLivePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime";
import {
@@ -15,6 +16,8 @@ import { DIFFS_AGENT_GUIDANCE } from "./prompt-guidance.js";
import { DiffArtifactStore } from "./store.js";
import { createDiffsTool } from "./tool.js";
const DIFFS_LANGUAGE_PACK_PLUGIN_ID = "diffs-language-pack";
export function registerDiffsPlugin(api: OpenClawPluginApi): void {
const store = new DiffArtifactStore({
rootDir: path.join(resolvePreferredOpenClawTmpDir(), "openclaw-diffs"),
@@ -47,6 +50,7 @@ export function registerDiffsPlugin(api: OpenClawPluginApi): void {
store,
defaults: resolveDiffsPluginDefaults(pluginConfig),
viewerBaseUrl: resolveDiffsPluginViewerBaseUrl(pluginConfig),
languagePackAvailable: resolveDiffsLanguagePackAvailability(api),
context: ctx,
});
},
@@ -71,3 +75,32 @@ export function registerDiffsPlugin(api: OpenClawPluginApi): void {
prependSystemContext: DIFFS_AGENT_GUIDANCE,
}));
}
export function resolveDiffsLanguagePackAvailability(api: OpenClawPluginApi): boolean {
const currentConfig = (api.runtime.config?.current?.() ?? api.config) as OpenClawConfig;
const plugins = currentConfig.plugins;
if (plugins?.enabled === false) {
return false;
}
if (plugins?.deny?.includes(DIFFS_LANGUAGE_PACK_PLUGIN_ID)) {
return false;
}
if (plugins?.allow && !plugins.allow.includes(DIFFS_LANGUAGE_PACK_PLUGIN_ID)) {
return false;
}
if (plugins?.entries?.[DIFFS_LANGUAGE_PACK_PLUGIN_ID]?.enabled === false) {
return false;
}
return hasSiblingLanguagePackRuntime(api.rootDir);
}
function hasSiblingLanguagePackRuntime(rootDir: string | undefined): boolean {
if (!rootDir) {
return false;
}
const languagePackRoot = path.join(path.dirname(rootDir), DIFFS_LANGUAGE_PACK_PLUGIN_ID);
return (
fs.existsSync(path.join(languagePackRoot, "openclaw.plugin.json")) &&
fs.existsSync(path.join(languagePackRoot, "assets", "viewer-runtime.js"))
);
}

View File

@@ -31,6 +31,7 @@ describe("renderDiffDocument", () => {
expect(rendered.title).toBe("src/example.ts");
expect(rendered.fileCount).toBe(1);
expect(rendered.viewerRuntime).toBe("base");
expect(rendered.html).toContain("data-openclaw-diff-root");
expect(rendered.html).toContain("src/example.ts");
expect(rendered.html).toContain("../../assets/viewer.js");
@@ -97,6 +98,60 @@ describe("renderDiffDocument", () => {
expect(payloads[0]?.newFile?.lang).toBeUndefined();
});
it("keeps uncommon language diffs readable without the language pack", async () => {
const rendered = await renderDiffDocument(
{
kind: "before_after",
before: "REPORT z_demo.\n",
after: "REPORT z_demo2.\n",
lang: "abap",
},
{
presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
expandUnchanged: false,
},
"viewer",
);
const html = rendered.html ?? "";
const payload = parseViewerPayloadJson(
html.match(/data-openclaw-diff-payload>(.*?)<\/script>/)?.[1] ?? "",
);
expect(rendered.viewerRuntime).toBe("base");
expect(html).toContain("../../assets/viewer.js");
expect(html).not.toContain("diffs-language-pack");
expect(payload.langs).toEqual(["text"]);
});
it("uses the language-pack viewer runtime for uncommon languages when available", async () => {
const rendered = await renderDiffDocument(
{
kind: "before_after",
before: "REPORT z_demo.\n",
after: "REPORT z_demo2.\n",
lang: "abap",
},
{
presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
expandUnchanged: false,
languagePackAvailable: true,
},
"viewer",
);
const html = rendered.html ?? "";
const payload = parseViewerPayloadJson(
html.match(/data-openclaw-diff-payload>(.*?)<\/script>/)?.[1] ?? "",
);
expect(rendered.viewerRuntime).toBe("language-pack");
expect(html).toContain("../../../diffs-language-pack/assets/viewer.js");
expect(payload.langs).toEqual(["abap"]);
});
it("renders multi-file patch input", async () => {
const patch = [
"diff --git a/a.ts b/a.ts",

View File

@@ -3,6 +3,7 @@ import { parsePatchFiles } from "@pierre/diffs";
import { preloadFileDiff, preloadMultiFileDiff } from "@pierre/diffs/ssr";
import {
collectDiffPayloadLanguageHints,
isBaseDiffViewerLanguage,
normalizeDiffViewerPayloadLanguages,
normalizeSupportedLanguageHint,
} from "./language-hints.js";
@@ -20,6 +21,7 @@ const DEFAULT_FILE_NAME = "diff.txt";
const MAX_PATCH_FILE_COUNT = 128;
const MAX_PATCH_TOTAL_LINES = 120_000;
const VIEWER_LOADER_DOCUMENT_PATH = "../../assets/viewer.js";
const LANGUAGE_PACK_VIEWER_LOADER_DOCUMENT_PATH = "../../../diffs-language-pack/assets/viewer.js";
function escapeCssString(value: string): string {
return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
@@ -198,7 +200,12 @@ function buildHtmlDocument(params: {
theme: DiffRenderOptions["presentation"]["theme"];
imageMaxWidth: number;
runtimeMode: "viewer" | "image";
viewerRuntime: "base" | "language-pack";
}): string {
const viewerLoaderPath =
params.viewerRuntime === "language-pack"
? LANGUAGE_PACK_VIEWER_LOADER_DOCUMENT_PATH
: VIEWER_LOADER_DOCUMENT_PATH;
return `<!doctype html>
<html lang="en">
<head>
@@ -292,7 +299,7 @@ function buildHtmlDocument(params: {
${params.bodyHtml}
</div>
</main>
<script type="module" src="${VIEWER_LOADER_DOCUMENT_PATH}"></script>
<script type="module" src="${viewerLoaderPath}"></script>
</body>
</html>`;
}
@@ -300,8 +307,13 @@ function buildHtmlDocument(params: {
type RenderedSection = {
viewer?: string;
image?: string;
usesLanguagePack?: boolean;
};
function payloadUsesLanguagePack(payload: DiffViewerPayload | undefined): boolean {
return payload?.langs.some((lang) => !isBaseDiffViewerLanguage(lang)) ?? false;
}
function buildRenderedSection(params: {
viewerPayload?: DiffViewerPayload;
imagePayload?: DiffViewerPayload;
@@ -309,6 +321,8 @@ function buildRenderedSection(params: {
return {
...(params.viewerPayload ? { viewer: renderDiffCard(params.viewerPayload) } : {}),
...(params.imagePayload ? { image: renderDiffCard(params.imagePayload) } : {}),
usesLanguagePack:
payloadUsesLanguagePack(params.viewerPayload) || payloadUsesLanguagePack(params.imagePayload),
};
}
@@ -328,10 +342,16 @@ async function renderBeforeAfterDiff(
input: Extract<DiffInput, { kind: "before_after" }>,
options: DiffRenderOptions,
target: DiffRenderTarget,
): Promise<{ viewerBodyHtml?: string; imageBodyHtml?: string; fileCount: number }> {
): Promise<{
viewerBodyHtml?: string;
imageBodyHtml?: string;
fileCount: number;
usesLanguagePack: boolean;
}> {
ensurePierreThemesRegistered();
const lang = await normalizeSupportedLanguageHint(input.lang);
const languagePackAvailable = options.languagePackAvailable === true;
const lang = await normalizeSupportedLanguageHint(input.lang, { languagePackAvailable });
const fileName = resolveBeforeAfterFileName({ input, lang });
const oldFile: FileContents = {
name: fileName,
@@ -362,28 +382,34 @@ async function renderBeforeAfterDiff(
]);
const [viewerPayload, imagePayload] = await Promise.all([
viewerResult && viewerOptions
? normalizeDiffViewerPayloadLanguages({
prerenderedHTML: viewerResult.prerenderedHTML,
oldFile: viewerResult.oldFile,
newFile: viewerResult.newFile,
options: viewerOptions,
langs: collectDiffPayloadLanguageHints({
? normalizeDiffViewerPayloadLanguages(
{
prerenderedHTML: viewerResult.prerenderedHTML,
oldFile: viewerResult.oldFile,
newFile: viewerResult.newFile,
}),
})
options: viewerOptions,
langs: collectDiffPayloadLanguageHints({
oldFile: viewerResult.oldFile,
newFile: viewerResult.newFile,
}),
},
{ languagePackAvailable },
)
: Promise.resolve(undefined),
imageResult && imageOptions
? normalizeDiffViewerPayloadLanguages({
prerenderedHTML: imageResult.prerenderedHTML,
oldFile: imageResult.oldFile,
newFile: imageResult.newFile,
options: imageOptions,
langs: collectDiffPayloadLanguageHints({
? normalizeDiffViewerPayloadLanguages(
{
prerenderedHTML: imageResult.prerenderedHTML,
oldFile: imageResult.oldFile,
newFile: imageResult.newFile,
}),
})
options: imageOptions,
langs: collectDiffPayloadLanguageHints({
oldFile: imageResult.oldFile,
newFile: imageResult.newFile,
}),
},
{ languagePackAvailable },
)
: Promise.resolve(undefined),
]);
const section = buildRenderedSection({
@@ -394,6 +420,7 @@ async function renderBeforeAfterDiff(
return {
...buildRenderedBodies([section]),
fileCount: 1,
usesLanguagePack: section.usesLanguagePack === true,
};
}
@@ -401,10 +428,20 @@ async function renderPatchDiff(
input: Extract<DiffInput, { kind: "patch" }>,
options: DiffRenderOptions,
target: DiffRenderTarget,
): Promise<{ viewerBodyHtml?: string; imageBodyHtml?: string; fileCount: number }> {
): Promise<{
viewerBodyHtml?: string;
imageBodyHtml?: string;
fileCount: number;
usesLanguagePack: boolean;
}> {
ensurePierreThemesRegistered();
const files = parsePatchFiles(input.patch).flatMap((entry) => entry.files ?? []);
const languagePackAvailable = options.languagePackAvailable === true;
const files = await Promise.all(
parsePatchFiles(input.patch)
.flatMap((entry) => entry.files ?? [])
.map((fileDiff) => normalizePatchFileLanguage(fileDiff, { languagePackAvailable })),
);
if (files.length === 0) {
throw new Error("Patch input did not contain any file diffs.");
}
@@ -440,20 +477,26 @@ async function renderPatchDiff(
const [viewerPayload, imagePayload] = await Promise.all([
viewerResult && viewerOptions
? normalizeDiffViewerPayloadLanguages({
prerenderedHTML: viewerResult.prerenderedHTML,
fileDiff: viewerResult.fileDiff,
options: viewerOptions,
langs: collectDiffPayloadLanguageHints({ fileDiff: viewerResult.fileDiff }),
})
? normalizeDiffViewerPayloadLanguages(
{
prerenderedHTML: viewerResult.prerenderedHTML,
fileDiff: viewerResult.fileDiff,
options: viewerOptions,
langs: collectDiffPayloadLanguageHints({ fileDiff: viewerResult.fileDiff }),
},
{ languagePackAvailable },
)
: Promise.resolve(undefined),
imageResult && imageOptions
? normalizeDiffViewerPayloadLanguages({
prerenderedHTML: imageResult.prerenderedHTML,
fileDiff: imageResult.fileDiff,
options: imageOptions,
langs: collectDiffPayloadLanguageHints({ fileDiff: imageResult.fileDiff }),
})
? normalizeDiffViewerPayloadLanguages(
{
prerenderedHTML: imageResult.prerenderedHTML,
fileDiff: imageResult.fileDiff,
options: imageOptions,
langs: collectDiffPayloadLanguageHints({ fileDiff: imageResult.fileDiff }),
},
{ languagePackAvailable },
)
: Promise.resolve(undefined),
]);
@@ -467,6 +510,21 @@ async function renderPatchDiff(
return {
...buildRenderedBodies(sections),
fileCount: files.length,
usesLanguagePack: sections.some((section) => section.usesLanguagePack === true),
};
}
async function normalizePatchFileLanguage(
fileDiff: FileDiffMetadata,
options: { languagePackAvailable: boolean },
): Promise<FileDiffMetadata> {
const lang = await normalizeSupportedLanguageHint(fileDiff.lang, options);
if (lang === fileDiff.lang) {
return fileDiff;
}
return {
...fileDiff,
...(lang ? { lang } : { lang: "text" }),
};
}
@@ -480,6 +538,7 @@ export async function renderDiffDocument(
input.kind === "before_after"
? await renderBeforeAfterDiff(input, options, target)
: await renderPatchDiff(input, options, target);
const viewerRuntime = rendered.usesLanguagePack ? "language-pack" : "base";
return {
...(rendered.viewerBodyHtml
@@ -490,6 +549,7 @@ export async function renderDiffDocument(
theme: options.presentation.theme,
imageMaxWidth: options.image.maxWidth,
runtimeMode: "viewer",
viewerRuntime,
}),
}
: {}),
@@ -501,12 +561,14 @@ export async function renderDiffDocument(
theme: options.presentation.theme,
imageMaxWidth: options.image.maxWidth,
runtimeMode: "image",
viewerRuntime,
}),
}
: {}),
title,
fileCount: rendered.fileCount,
inputKind: input.kind,
viewerRuntime,
};
}

View File

@@ -0,0 +1,56 @@
const javascript = () => import("@shikijs/langs/javascript");
const typescript = () => import("@shikijs/langs/typescript");
const tsx = () => import("@shikijs/langs/tsx");
const jsx = () => import("@shikijs/langs/jsx");
const json = () => import("@shikijs/langs/json");
const markdown = () => import("@shikijs/langs/markdown");
const yaml = () => import("@shikijs/langs/yaml");
const css = () => import("@shikijs/langs/css");
const html = () => import("@shikijs/langs/html");
const sh = () => import("@shikijs/langs/sh");
const python = () => import("@shikijs/langs/python");
const go = () => import("@shikijs/langs/go");
const rust = () => import("@shikijs/langs/rust");
const java = () => import("@shikijs/langs/java");
const c = () => import("@shikijs/langs/c");
const cpp = () => import("@shikijs/langs/cpp");
const csharp = () => import("@shikijs/langs/csharp");
const php = () => import("@shikijs/langs/php");
const sql = () => import("@shikijs/langs/sql");
const docker = () => import("@shikijs/langs/docker");
export const bundledLanguagesInfo = [
{ id: "javascript", name: "JavaScript", aliases: ["js", "mjs", "cjs"], import: javascript },
{ id: "typescript", name: "TypeScript", aliases: ["ts", "mts", "cts"], import: typescript },
{ id: "tsx", name: "TSX", import: tsx },
{ id: "jsx", name: "JSX", import: jsx },
{ id: "json", name: "JSON", aliases: ["jsonc", "json5", "jsonl"], import: json },
{ id: "markdown", name: "Markdown", aliases: ["md"], import: markdown },
{ id: "yaml", name: "YAML", aliases: ["yml"], import: yaml },
{ id: "css", name: "CSS", import: css },
{ id: "html", name: "HTML", import: html },
{ id: "sh", name: "Shell", aliases: ["bash", "shell", "shellscript", "zsh"], import: sh },
{ id: "python", name: "Python", aliases: ["py"], import: python },
{ id: "go", name: "Go", import: go },
{ id: "rust", name: "Rust", aliases: ["rs"], import: rust },
{ id: "java", name: "Java", import: java },
{ id: "c", name: "C", import: c },
{ id: "cpp", name: "C++", aliases: ["c++"], import: cpp },
{ id: "csharp", name: "C#", aliases: ["cs"], import: csharp },
{ id: "php", name: "PHP", import: php },
{ id: "sql", name: "SQL", import: sql },
{ id: "docker", name: "Docker", aliases: ["dockerfile"], import: docker },
] as const;
export const bundledLanguagesBase = Object.fromEntries(
bundledLanguagesInfo.map((language) => [language.id, language.import]),
);
export const bundledLanguagesAlias = Object.fromEntries(
bundledLanguagesInfo.flatMap(
(language) => language.aliases?.map((alias) => [alias, language.import]) ?? [],
),
);
export const bundledLanguages = {
...bundledLanguagesBase,
...bundledLanguagesAlias,
};

View File

@@ -0,0 +1,53 @@
import {
createBundledHighlighter,
createCssVariablesTheme,
createSingletonShorthands,
getTokenStyleObject,
guessEmbeddedLanguages,
normalizeTheme,
stringifyTokenStyle,
} from "@shikijs/core";
import {
createJavaScriptRegexEngine,
defaultJavaScriptRegexConstructor,
} from "@shikijs/engine-javascript";
import { createOnigurumaEngine, loadWasm } from "@shikijs/engine-oniguruma";
import { bundledLanguages } from "./shiki-curated-languages.js";
export * from "@shikijs/core";
export {
bundledLanguages,
bundledLanguagesAlias,
bundledLanguagesBase,
bundledLanguagesInfo,
} from "./shiki-curated-languages.js";
export { bundledThemes, bundledThemesInfo } from "shiki/themes";
import { bundledThemes } from "shiki/themes";
export type BundledLanguage = keyof typeof bundledLanguages;
export type BundledTheme = keyof typeof bundledThemes;
export const createHighlighter = createBundledHighlighter({
langs: bundledLanguages,
themes: bundledThemes,
engine: () => createOnigurumaEngine(import("shiki/wasm")),
});
const shorthands = createSingletonShorthands(createHighlighter, { guessEmbeddedLanguages });
export const codeToHtml = shorthands.codeToHtml;
export const codeToHast = shorthands.codeToHast;
export const codeToTokens = shorthands.codeToTokens;
export const codeToTokensBase = shorthands.codeToTokensBase;
export const codeToTokensWithThemes = shorthands.codeToTokensWithThemes;
export const getSingletonHighlighter = shorthands.getSingletonHighlighter;
export const getLastGrammarState = shorthands.getLastGrammarState;
export {
createCssVariablesTheme,
createJavaScriptRegexEngine,
createOnigurumaEngine,
defaultJavaScriptRegexConstructor,
getTokenStyleObject,
loadWasm,
normalizeTheme,
stringifyTokenStyle,
};

View File

@@ -159,6 +159,7 @@ export function createDiffsTool(params: {
store: DiffArtifactStore;
defaults: DiffToolDefaults;
viewerBaseUrl?: string;
languagePackAvailable?: boolean;
screenshotter?: DiffScreenshotter;
context?: OpenClawPluginToolContext;
}): AnyAgentTool {
@@ -198,6 +199,7 @@ export function createDiffsTool(params: {
},
image,
expandUnchanged,
languagePackAvailable: params.languagePackAvailable,
},
renderTarget,
);

View File

@@ -67,6 +67,7 @@ export type DiffRenderOptions = {
maxPixels: number;
};
expandUnchanged: boolean;
languagePackAvailable?: boolean;
};
export type DiffViewerOptions = {
@@ -99,6 +100,7 @@ export type RenderedDiffDocument = {
title: string;
fileCount: number;
inputKind: DiffInput["kind"];
viewerRuntime: "base" | "language-pack";
};
export type DiffArtifactContext = {

View File

@@ -5,11 +5,18 @@ import { fileURLToPath } from "node:url";
export const VIEWER_ASSET_PREFIX = "/plugins/diffs/assets/";
export const VIEWER_LOADER_PATH = `${VIEWER_ASSET_PREFIX}viewer.js`;
export const VIEWER_RUNTIME_PATH = `${VIEWER_ASSET_PREFIX}viewer-runtime.js`;
export const LANGUAGE_PACK_VIEWER_ASSET_PREFIX = "/plugins/diffs-language-pack/assets/";
export const LANGUAGE_PACK_VIEWER_LOADER_PATH = `${LANGUAGE_PACK_VIEWER_ASSET_PREFIX}viewer.js`;
export const LANGUAGE_PACK_VIEWER_RUNTIME_PATH = `${LANGUAGE_PACK_VIEWER_ASSET_PREFIX}viewer-runtime.js`;
const VIEWER_RUNTIME_RELATIVE_IMPORT_PATH = "./viewer-runtime.js";
const VIEWER_RUNTIME_CANDIDATE_RELATIVE_PATHS = [
"./assets/viewer-runtime.js",
"../assets/viewer-runtime.js",
] as const;
const LANGUAGE_PACK_RUNTIME_CANDIDATE_RELATIVE_PATHS = [
"../../diffs-language-pack/assets/viewer-runtime.js",
"../diffs-language-pack/assets/viewer-runtime.js",
] as const;
type ServedViewerAsset = {
body: string | Buffer;
@@ -23,6 +30,7 @@ type RuntimeAssetCache = {
};
let runtimeAssetCache: RuntimeAssetCache | null = null;
let languagePackRuntimeAssetCache: RuntimeAssetCache | null = null;
type ViewerRuntimeFileUrlParams = {
baseUrl?: string | URL;
@@ -84,20 +92,98 @@ export async function getServedViewerAsset(pathname: string): Promise<ServedView
return null;
}
export async function getServedLanguagePackViewerAsset(
pathname: string,
): Promise<ServedViewerAsset | null> {
if (
pathname !== LANGUAGE_PACK_VIEWER_LOADER_PATH &&
pathname !== LANGUAGE_PACK_VIEWER_RUNTIME_PATH
) {
return null;
}
let assets: RuntimeAssetCache;
try {
const runtimeUrl = await resolveRuntimeFileUrl(LANGUAGE_PACK_RUNTIME_CANDIDATE_RELATIVE_PATHS);
assets = await loadRuntimeAssets({
runtimeUrl,
cache: languagePackRuntimeAssetCache,
updateCache: (cache) => {
languagePackRuntimeAssetCache = cache;
},
});
} catch (error) {
if (isMissingFileError(error)) {
return null;
}
throw error;
}
if (pathname === LANGUAGE_PACK_VIEWER_LOADER_PATH) {
return {
body: assets.loaderBody,
contentType: "text/javascript; charset=utf-8",
};
}
return {
body: assets.runtimeBody,
contentType: "text/javascript; charset=utf-8",
};
}
async function loadViewerAssets(): Promise<RuntimeAssetCache> {
const runtimeUrl = await resolveViewerRuntimeFileUrl();
const runtimePath = fileURLToPath(runtimeUrl);
return loadRuntimeAssets({
runtimeUrl,
cache: runtimeAssetCache,
updateCache: (cache) => {
runtimeAssetCache = cache;
},
});
}
async function loadRuntimeAssets(params: {
cache: RuntimeAssetCache | null;
runtimeUrl: URL;
updateCache(cache: RuntimeAssetCache): void;
}): Promise<RuntimeAssetCache> {
const runtimePath = fileURLToPath(params.runtimeUrl);
const runtimeStat = await fs.stat(runtimePath);
if (runtimeAssetCache && runtimeAssetCache.mtimeMs === runtimeStat.mtimeMs) {
return runtimeAssetCache;
if (params.cache && params.cache.mtimeMs === runtimeStat.mtimeMs) {
return params.cache;
}
const runtimeBody = await fs.readFile(runtimePath);
const hash = crypto.createHash("sha1").update(runtimeBody).digest("hex").slice(0, 12);
runtimeAssetCache = {
const cache = {
mtimeMs: runtimeStat.mtimeMs,
runtimeBody,
loaderBody: `import "${VIEWER_RUNTIME_RELATIVE_IMPORT_PATH}?v=${hash}";\n`,
};
return runtimeAssetCache;
params.updateCache(cache);
return cache;
}
async function resolveRuntimeFileUrl(relativePaths: readonly string[]): Promise<URL> {
let missingFileError: NodeJS.ErrnoException | null = null;
for (const relativePath of relativePaths) {
const candidateUrl = new URL(relativePath, import.meta.url);
try {
await fs.stat(fileURLToPath(candidateUrl));
return candidateUrl;
} catch (error) {
if (isMissingFileError(error)) {
missingFileError = error;
continue;
}
throw error;
}
}
if (missingFileError) {
throw missingFileError;
}
throw new Error("viewer runtime asset candidates were not checked");
}

View File

@@ -43,6 +43,7 @@
"!dist/extensions/diagnostics-otel/**",
"!dist/extensions/diagnostics-prometheus/**",
"!dist/extensions/diffs/**",
"!dist/extensions/diffs-language-pack/**",
"!dist/extensions/discord/**",
"!dist/extensions/feishu/**",
"!dist/extensions/google-meet/**",

27
pnpm-lock.yaml generated
View File

@@ -597,9 +597,24 @@ importers:
'@pierre/theme':
specifier: 1.0.3
version: 1.0.3
'@shikijs/core':
specifier: 3.23.0
version: 3.23.0
'@shikijs/engine-javascript':
specifier: 3.23.0
version: 3.23.0
'@shikijs/engine-oniguruma':
specifier: 3.23.0
version: 3.23.0
'@shikijs/langs':
specifier: 3.23.0
version: 3.23.0
playwright-core:
specifier: 1.60.0
version: 1.60.0
shiki:
specifier: 3.23.0
version: 3.23.0
typebox:
specifier: 1.1.38
version: 1.1.38
@@ -610,6 +625,18 @@ importers:
'@openclaw/plugin-sdk':
specifier: workspace:*
version: link:../../packages/plugin-sdk
esbuild:
specifier: 0.28.0
version: 0.28.0
extensions/diffs-language-pack:
devDependencies:
'@openclaw/plugin-sdk':
specifier: workspace:*
version: link:../../packages/plugin-sdk
esbuild:
specifier: 0.28.0
version: 0.28.0
extensions/discord:
dependencies:

View File

@@ -103,6 +103,23 @@
}
}
},
{
"name": "@openclaw/diffs-language-pack",
"description": "OpenClaw diffs viewer syntax highlighting language pack",
"source": "official",
"kind": "plugin",
"openclaw": {
"plugin": {
"id": "diffs-language-pack",
"label": "Diff Viewer Language Pack"
},
"install": {
"npmSpec": "@openclaw/diffs-language-pack",
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.30"
}
}
},
{
"name": "@openclaw/google-meet",
"description": "OpenClaw Google Meet participant plugin",

View File

@@ -42,6 +42,11 @@ describe("official external plugin catalog", () => {
expect(resolveOfficialExternalPluginInstall(expectCatalogEntry("line"))?.npmSpec).toBe(
"@openclaw/line",
);
expect(resolveOfficialExternalPluginInstall(expectCatalogEntry("diffs-language-pack"))).toEqual({
npmSpec: "@openclaw/diffs-language-pack",
defaultChoice: "npm",
minHostVersion: ">=2026.4.30",
});
});
it("allows invalid-config recovery for externalized stock plugins", () => {

View File

@@ -123,6 +123,7 @@ describe("oxlint config", () => {
expect(ignorePatterns).toContain("**/.cache/**");
expect(ignorePatterns).toContain("**/.openclaw-runtime-deps-copy-*/**");
expect(ignorePatterns).toContain("extensions/diffs/assets/viewer-runtime.js");
expect(ignorePatterns).toContain("extensions/diffs-language-pack/assets/viewer-runtime.js");
});
it("enables strict empty object type lint with named single-extends interfaces allowed", () => {

View File

@@ -39,6 +39,7 @@ describe("runtime postbuild static assets", () => {
"dist/extensions/acpx/error-format.mjs",
"dist/extensions/acpx/mcp-command-line.mjs",
"dist/extensions/acpx/mcp-proxy.mjs",
"dist/extensions/diffs-language-pack/assets/viewer-runtime.js",
"dist/extensions/diffs/assets/viewer-runtime.js",
]);
});
@@ -59,8 +60,12 @@ describe("runtime postbuild static assets", () => {
"dist/extensions/acpx/error-format.mjs",
"dist/extensions/acpx/mcp-command-line.mjs",
"dist/extensions/acpx/mcp-proxy.mjs",
"dist/extensions/diffs-language-pack/assets/viewer-runtime.js",
"dist/extensions/diffs/assets/viewer-runtime.js",
]);
expect(payload.sources).toContain(
"extensions/diffs-language-pack/assets/viewer-runtime.js",
);
expect(payload.sources).toContain("extensions/diffs/assets/viewer-runtime.js");
});