@kodelyth/diffs 2026.5.39 → 2026.5.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/plugin.ts ADDED
@@ -0,0 +1,67 @@
1
+ import path from "node:path";
2
+ import { resolveLivePluginConfigObject } from "klaw/plugin-sdk/plugin-config-runtime";
3
+ import { resolvePreferredKlawTmpDir, type KlawConfig, type KlawPluginApi } from "../api.js";
4
+ import {
5
+ resolveDiffsPluginDefaults,
6
+ resolveDiffsPluginSecurity,
7
+ resolveDiffsPluginViewerBaseUrl,
8
+ } from "./config.js";
9
+ import { createDiffsHttpHandler } from "./http.js";
10
+ import { DIFFS_AGENT_GUIDANCE } from "./prompt-guidance.js";
11
+ import { DiffArtifactStore } from "./store.js";
12
+ import { createDiffsTool } from "./tool.js";
13
+
14
+ export function registerDiffsPlugin(api: KlawPluginApi): void {
15
+ const store = new DiffArtifactStore({
16
+ rootDir: path.join(resolvePreferredKlawTmpDir(), "klaw-diffs"),
17
+ logger: api.logger,
18
+ });
19
+ const resolveCurrentPluginConfig = () =>
20
+ resolveLivePluginConfigObject(
21
+ api.runtime.config?.current ? () => api.runtime.config.current() as KlawConfig : undefined,
22
+ "diffs",
23
+ api.pluginConfig as Record<string, unknown>,
24
+ ) ?? {};
25
+ const resolveCurrentAccessConfig = () => {
26
+ const currentConfig = (api.runtime.config?.current?.() ?? api.config) as KlawConfig;
27
+ const pluginConfig = resolveCurrentPluginConfig();
28
+ return {
29
+ allowRemoteViewer: resolveDiffsPluginSecurity(pluginConfig).allowRemoteViewer,
30
+ trustedProxies: currentConfig.gateway?.trustedProxies,
31
+ allowRealIpFallback: currentConfig.gateway?.allowRealIpFallback === true,
32
+ };
33
+ };
34
+ const initialAccessConfig = resolveCurrentAccessConfig();
35
+
36
+ api.registerTool(
37
+ (ctx) => {
38
+ const pluginConfig = resolveCurrentPluginConfig();
39
+ return createDiffsTool({
40
+ api,
41
+ store,
42
+ defaults: resolveDiffsPluginDefaults(pluginConfig),
43
+ viewerBaseUrl: resolveDiffsPluginViewerBaseUrl(pluginConfig),
44
+ context: ctx,
45
+ });
46
+ },
47
+ {
48
+ name: "diffs",
49
+ },
50
+ );
51
+ api.registerHttpRoute({
52
+ path: "/plugins/diffs",
53
+ auth: "plugin",
54
+ match: "prefix",
55
+ handler: createDiffsHttpHandler({
56
+ store,
57
+ logger: api.logger,
58
+ allowRemoteViewer: initialAccessConfig.allowRemoteViewer,
59
+ trustedProxies: initialAccessConfig.trustedProxies,
60
+ allowRealIpFallback: initialAccessConfig.allowRealIpFallback,
61
+ resolveAccessConfig: resolveCurrentAccessConfig,
62
+ }),
63
+ });
64
+ api.on("before_prompt_build", async () => ({
65
+ prependSystemContext: DIFFS_AGENT_GUIDANCE,
66
+ }));
67
+ }
@@ -0,0 +1,7 @@
1
+ export const DIFFS_AGENT_GUIDANCE = [
2
+ "When you need to show edits as a real diff, prefer the `diffs` tool instead of writing a manual summary.",
3
+ "It accepts either `before` + `after` text or a unified `patch`.",
4
+ "`mode=view` returns `details.viewerUrl` for canvas use; `mode=file` returns `details.filePath`; `mode=both` returns both.",
5
+ "If you need to send the rendered file, use the `message` tool with `path` or `filePath`.",
6
+ "Include `path` when you know the filename, and omit presentation overrides unless needed.",
7
+ ].join("\n");
@@ -0,0 +1,132 @@
1
+ import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const { preloadFileDiffMock, preloadMultiFileDiffMock } = vi.hoisted(() => ({
4
+ preloadFileDiffMock: vi.fn(async ({ fileDiff }: { fileDiff: unknown }) => ({
5
+ prerenderedHTML: "<div>mock diff</div>",
6
+ fileDiff,
7
+ })),
8
+ preloadMultiFileDiffMock: vi.fn(
9
+ async ({ oldFile, newFile }: { oldFile: unknown; newFile: unknown }) => ({
10
+ prerenderedHTML: "<div>mock diff</div>",
11
+ oldFile,
12
+ newFile,
13
+ }),
14
+ ),
15
+ }));
16
+
17
+ vi.mock("@pierre/diffs/ssr", () => ({
18
+ preloadFileDiff: preloadFileDiffMock,
19
+ preloadMultiFileDiff: preloadMultiFileDiffMock,
20
+ }));
21
+
22
+ afterAll(() => {
23
+ vi.doUnmock("@pierre/diffs/ssr");
24
+ vi.resetModules();
25
+ });
26
+
27
+ import { DEFAULT_DIFFS_TOOL_DEFAULTS, resolveDiffImageRenderOptions } from "./config.js";
28
+ import { renderDiffDocument } from "./render.js";
29
+ import { parseViewerPayloadJson } from "./viewer-payload.js";
30
+
31
+ function createRenderOptions() {
32
+ return {
33
+ presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
34
+ image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
35
+ expandUnchanged: false,
36
+ };
37
+ }
38
+
39
+ describe("renderDiffDocument render targets", () => {
40
+ beforeEach(() => {
41
+ preloadFileDiffMock.mockClear();
42
+ preloadMultiFileDiffMock.mockClear();
43
+ });
44
+
45
+ it("renders only the viewer variant for before/after viewer mode", async () => {
46
+ const rendered = await renderDiffDocument(
47
+ {
48
+ kind: "before_after",
49
+ before: "one\n",
50
+ after: "two\n",
51
+ },
52
+ createRenderOptions(),
53
+ "viewer",
54
+ );
55
+
56
+ expect(rendered.html).toContain("mock diff");
57
+ expect(rendered.imageHtml).toBeUndefined();
58
+ expect(preloadMultiFileDiffMock).toHaveBeenCalledTimes(1);
59
+ });
60
+
61
+ it("renders both variants for before/after both mode", async () => {
62
+ const rendered = await renderDiffDocument(
63
+ {
64
+ kind: "before_after",
65
+ before: "one\n",
66
+ after: "two\n",
67
+ },
68
+ createRenderOptions(),
69
+ "both",
70
+ );
71
+
72
+ expect(rendered.html).toContain("mock diff");
73
+ expect(rendered.imageHtml).toContain("mock diff");
74
+ expect(preloadMultiFileDiffMock).toHaveBeenCalledTimes(2);
75
+ });
76
+
77
+ it("renders only the image variant for patch image mode", async () => {
78
+ const rendered = await renderDiffDocument(
79
+ {
80
+ kind: "patch",
81
+ patch: [
82
+ "diff --git a/a.ts b/a.ts",
83
+ "--- a/a.ts",
84
+ "+++ b/a.ts",
85
+ "@@ -1 +1 @@",
86
+ "-a",
87
+ "+b",
88
+ ].join("\n"),
89
+ },
90
+ createRenderOptions(),
91
+ "image",
92
+ );
93
+
94
+ expect(rendered.html).toBeUndefined();
95
+ expect(rendered.imageHtml).toContain("mock diff");
96
+ expect(preloadFileDiffMock).toHaveBeenCalledTimes(1);
97
+ });
98
+
99
+ it("normalizes stale patch payload languages before serializing viewer output", async () => {
100
+ preloadFileDiffMock.mockResolvedValueOnce({
101
+ prerenderedHTML: "<div>mock diff</div>",
102
+ fileDiff: {
103
+ name: "a.ts",
104
+ lang: "not-a-real-language",
105
+ },
106
+ });
107
+
108
+ const rendered = await renderDiffDocument(
109
+ {
110
+ kind: "patch",
111
+ patch: [
112
+ "diff --git a/a.ts b/a.ts",
113
+ "--- a/a.ts",
114
+ "+++ b/a.ts",
115
+ "@@ -1 +1 @@",
116
+ "-a",
117
+ "+b",
118
+ ].join("\n"),
119
+ },
120
+ createRenderOptions(),
121
+ "viewer",
122
+ );
123
+
124
+ const payloads = [
125
+ ...(rendered.html ?? "").matchAll(/data-klaw-diff-payload>(.*?)<\/script>/g),
126
+ ].map((match) => parseViewerPayloadJson(match[1] ?? ""));
127
+
128
+ expect(payloads).toHaveLength(1);
129
+ expect(payloads[0]?.langs).toEqual(["text"]);
130
+ expect(payloads[0]?.fileDiff?.lang).toBe("text");
131
+ });
132
+ });
@@ -0,0 +1,219 @@
1
+ import {
2
+ disposeHighlighter,
3
+ RegisteredCustomThemes,
4
+ ResolvedThemes,
5
+ ResolvingThemes,
6
+ } from "@pierre/diffs";
7
+ import { afterEach, describe, expect, it } from "vitest";
8
+ import { DEFAULT_DIFFS_TOOL_DEFAULTS, resolveDiffImageRenderOptions } from "./config.js";
9
+ import { renderDiffDocument } from "./render.js";
10
+ import { parseViewerPayloadJson } from "./viewer-payload.js";
11
+
12
+ describe("renderDiffDocument", () => {
13
+ afterEach(async () => {
14
+ await disposeHighlighter();
15
+ });
16
+
17
+ it("renders before/after input into a complete viewer document", async () => {
18
+ const rendered = await renderDiffDocument(
19
+ {
20
+ kind: "before_after",
21
+ before: "const value = 1;\n",
22
+ after: "const value = 2;\n",
23
+ path: "src/example.ts",
24
+ },
25
+ {
26
+ presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
27
+ image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
28
+ expandUnchanged: false,
29
+ },
30
+ );
31
+
32
+ expect(rendered.title).toBe("src/example.ts");
33
+ expect(rendered.fileCount).toBe(1);
34
+ expect(rendered.html).toContain("data-klaw-diff-root");
35
+ expect(rendered.html).toContain("src/example.ts");
36
+ expect(rendered.html).toContain("../../assets/viewer.js");
37
+ expect(rendered.imageHtml).toContain("../../assets/viewer.js");
38
+ expect(rendered.imageHtml).toContain("max-width: 960px;");
39
+ expect(rendered.imageHtml).toContain("--diffs-font-size: 16px;");
40
+ expect(rendered.html).toContain("min-height: 100vh;");
41
+ expect(rendered.html).toContain('"diffIndicators":"bars"');
42
+ expect(rendered.html).toContain('"disableLineNumbers":false');
43
+ expect(rendered.html).toContain("--diffs-line-height: 24px;");
44
+ expect(rendered.html).toContain("--diffs-font-size: 15px;");
45
+ expect(rendered.html).not.toContain("fonts.googleapis.com");
46
+ });
47
+
48
+ it("resolves viewer assets under an optional base path", async () => {
49
+ const rendered = await renderDiffDocument(
50
+ {
51
+ kind: "before_after",
52
+ before: "const value = 1;\n",
53
+ after: "const value = 2;\n",
54
+ },
55
+ {
56
+ presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
57
+ image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
58
+ expandUnchanged: false,
59
+ },
60
+ );
61
+
62
+ const html = rendered.html ?? "";
63
+ const loaderSrc = html.match(/<script type="module" src="([^"]+)"><\/script>/)?.[1];
64
+ expect(loaderSrc).toBe("../../assets/viewer.js");
65
+ expect(
66
+ new URL(loaderSrc ?? "", "https://example.com/klaw/plugins/diffs/view/id/token").pathname,
67
+ ).toBe("/klaw/plugins/diffs/assets/viewer.js");
68
+ });
69
+
70
+ it("downgrades invalid language hints to plain text", async () => {
71
+ const rendered = await renderDiffDocument(
72
+ {
73
+ kind: "before_after",
74
+ before: "const value = 1;\n",
75
+ after: "const value = 2;\n",
76
+ lang: "not-a-real-language",
77
+ },
78
+ {
79
+ presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
80
+ image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
81
+ expandUnchanged: false,
82
+ },
83
+ );
84
+
85
+ const html = rendered.html ?? "";
86
+
87
+ expect(rendered.title).toBe("Text diff");
88
+ expect(html).toContain("diff.txt");
89
+ expect(html).not.toContain("not-a-real-language");
90
+
91
+ const payloads = [...html.matchAll(/data-klaw-diff-payload>(.*?)<\/script>/g)].map((match) =>
92
+ parseViewerPayloadJson(match[1] ?? ""),
93
+ );
94
+ expect(payloads).toHaveLength(1);
95
+ expect(payloads[0]?.langs).toEqual(["text"]);
96
+ expect(payloads[0]?.oldFile?.lang).toBeUndefined();
97
+ expect(payloads[0]?.newFile?.lang).toBeUndefined();
98
+ });
99
+
100
+ it("renders multi-file patch input", async () => {
101
+ const patch = [
102
+ "diff --git a/a.ts b/a.ts",
103
+ "--- a/a.ts",
104
+ "+++ b/a.ts",
105
+ "@@ -1 +1 @@",
106
+ "-const a = 1;",
107
+ "+const a = 2;",
108
+ "diff --git a/b.ts b/b.ts",
109
+ "--- a/b.ts",
110
+ "+++ b/b.ts",
111
+ "@@ -1 +1 @@",
112
+ "-const b = 1;",
113
+ "+const b = 2;",
114
+ ].join("\n");
115
+
116
+ const rendered = await renderDiffDocument(
117
+ {
118
+ kind: "patch",
119
+ patch,
120
+ title: "Workspace patch",
121
+ },
122
+ {
123
+ presentation: {
124
+ ...DEFAULT_DIFFS_TOOL_DEFAULTS,
125
+ layout: "split",
126
+ theme: "dark",
127
+ },
128
+ image: resolveDiffImageRenderOptions({
129
+ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
130
+ fileQuality: "hq",
131
+ fileMaxWidth: 1180,
132
+ }),
133
+ expandUnchanged: true,
134
+ },
135
+ );
136
+
137
+ expect(rendered.title).toBe("Workspace patch");
138
+ expect(rendered.fileCount).toBe(2);
139
+ expect(rendered.html).toContain("Workspace patch");
140
+ expect(rendered.imageHtml).toContain("max-width: 1180px;");
141
+ });
142
+
143
+ it("re-registers pierre theme loaders before rendering", async () => {
144
+ await disposeHighlighter();
145
+
146
+ const originalLightLoader = RegisteredCustomThemes.get("pierre-light");
147
+ const originalDarkLoader = RegisteredCustomThemes.get("pierre-dark");
148
+ const brokenLoader = async () => {
149
+ throw new Error("broken pierre theme loader");
150
+ };
151
+
152
+ RegisteredCustomThemes.set("pierre-light", brokenLoader);
153
+ RegisteredCustomThemes.set("pierre-dark", brokenLoader);
154
+ ResolvedThemes.delete("pierre-light");
155
+ ResolvedThemes.delete("pierre-dark");
156
+ ResolvingThemes.delete("pierre-light");
157
+ ResolvingThemes.delete("pierre-dark");
158
+
159
+ try {
160
+ const rendered = await renderDiffDocument(
161
+ {
162
+ kind: "before_after",
163
+ before: "const value = 1;\n",
164
+ after: "const value = 2;\n",
165
+ path: "src/example.ts",
166
+ },
167
+ {
168
+ presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
169
+ image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
170
+ expandUnchanged: false,
171
+ },
172
+ );
173
+
174
+ expect(rendered.fileCount).toBe(1);
175
+ expect(rendered.html).toContain("src/example.ts");
176
+ expect(RegisteredCustomThemes.get("pierre-light")).not.toBe(brokenLoader);
177
+ expect(RegisteredCustomThemes.get("pierre-dark")).not.toBe(brokenLoader);
178
+ } finally {
179
+ if (originalLightLoader) {
180
+ RegisteredCustomThemes.set("pierre-light", originalLightLoader);
181
+ } else {
182
+ RegisteredCustomThemes.delete("pierre-light");
183
+ }
184
+ if (originalDarkLoader) {
185
+ RegisteredCustomThemes.set("pierre-dark", originalDarkLoader);
186
+ } else {
187
+ RegisteredCustomThemes.delete("pierre-dark");
188
+ }
189
+ await disposeHighlighter();
190
+ }
191
+ });
192
+
193
+ it("rejects patches that exceed file-count limits", async () => {
194
+ const patch = Array.from({ length: 129 }, (_, i) => {
195
+ return [
196
+ `diff --git a/f${i}.ts b/f${i}.ts`,
197
+ `--- a/f${i}.ts`,
198
+ `+++ b/f${i}.ts`,
199
+ "@@ -1 +1 @@",
200
+ "-const x = 1;",
201
+ "+const x = 2;",
202
+ ].join("\n");
203
+ }).join("\n");
204
+
205
+ await expect(
206
+ renderDiffDocument(
207
+ {
208
+ kind: "patch",
209
+ patch,
210
+ },
211
+ {
212
+ presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
213
+ image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
214
+ expandUnchanged: false,
215
+ },
216
+ ),
217
+ ).rejects.toThrow("too many files");
218
+ });
219
+ });