Skip to content

Commit f96e5be

Browse files
committed
Diffs: normalize viewer payload languages
1 parent af0c086 commit f96e5be

6 files changed

Lines changed: 345 additions & 101 deletions

File tree

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import type { FileDiffMetadata } from "@pierre/diffs";
2+
import { describe, expect, it } from "vitest";
3+
import {
4+
filterSupportedLanguageHints,
5+
normalizeDiffViewerPayloadLanguages,
6+
} from "./language-hints.js";
7+
8+
describe("filterSupportedLanguageHints", () => {
9+
it("keeps supported languages", async () => {
10+
await expect(filterSupportedLanguageHints(["typescript", "text"])).resolves.toEqual([
11+
"typescript",
12+
"text",
13+
]);
14+
});
15+
16+
it("drops invalid languages and falls back to text", async () => {
17+
await expect(filterSupportedLanguageHints(["not-a-real-language"])).resolves.toEqual(["text"]);
18+
});
19+
20+
it("keeps valid languages when invalid hints are mixed in", async () => {
21+
await expect(
22+
filterSupportedLanguageHints(["typescript", "not-a-real-language"]),
23+
).resolves.toEqual(["typescript"]);
24+
});
25+
});
26+
27+
describe("normalizeDiffViewerPayloadLanguages", () => {
28+
it("rewrites stale patch payload language overrides to plain text", async () => {
29+
await expect(
30+
normalizeDiffViewerPayloadLanguages({
31+
prerenderedHTML: "<div>diff</div>",
32+
options: {
33+
theme: {
34+
light: "pierre-light",
35+
dark: "pierre-dark",
36+
},
37+
diffStyle: "unified",
38+
diffIndicators: "bars",
39+
disableLineNumbers: false,
40+
expandUnchanged: false,
41+
themeType: "dark",
42+
backgroundEnabled: true,
43+
overflow: "wrap",
44+
unsafeCSS: "",
45+
},
46+
langs: ["not-a-real-language"],
47+
fileDiff: {
48+
name: "foo.txt",
49+
lang: "not-a-real-language" as never,
50+
} as unknown as FileDiffMetadata,
51+
}),
52+
).resolves.toMatchObject({
53+
langs: ["text"],
54+
fileDiff: {
55+
lang: "text",
56+
},
57+
});
58+
});
59+
60+
it("keeps valid hydrated languages and only downgrades invalid sides", async () => {
61+
await expect(
62+
normalizeDiffViewerPayloadLanguages({
63+
prerenderedHTML: "<div>diff</div>",
64+
options: {
65+
theme: {
66+
light: "pierre-light",
67+
dark: "pierre-dark",
68+
},
69+
diffStyle: "split",
70+
diffIndicators: "classic",
71+
disableLineNumbers: true,
72+
expandUnchanged: true,
73+
themeType: "light",
74+
backgroundEnabled: false,
75+
overflow: "scroll",
76+
unsafeCSS: "",
77+
},
78+
langs: ["typescript", "not-a-real-language"],
79+
oldFile: {
80+
name: "before.unknown",
81+
contents: "before",
82+
lang: "not-a-real-language" as never,
83+
},
84+
newFile: {
85+
name: "after.ts",
86+
contents: "after",
87+
lang: "typescript",
88+
},
89+
}),
90+
).resolves.toMatchObject({
91+
langs: ["typescript", "text"],
92+
oldFile: {
93+
lang: "text",
94+
},
95+
newFile: {
96+
lang: "typescript",
97+
},
98+
});
99+
});
100+
101+
it("rewrites blank explicit language overrides to plain text", async () => {
102+
await expect(
103+
normalizeDiffViewerPayloadLanguages({
104+
prerenderedHTML: "<div>diff</div>",
105+
options: {
106+
theme: {
107+
light: "pierre-light",
108+
dark: "pierre-dark",
109+
},
110+
diffStyle: "unified",
111+
diffIndicators: "bars",
112+
disableLineNumbers: false,
113+
expandUnchanged: false,
114+
themeType: "dark",
115+
backgroundEnabled: true,
116+
overflow: "wrap",
117+
unsafeCSS: "",
118+
},
119+
langs: [" "],
120+
oldFile: {
121+
name: "before.unknown",
122+
contents: "before",
123+
lang: " " as never,
124+
},
125+
newFile: {
126+
name: "after.txt",
127+
contents: "after",
128+
},
129+
}),
130+
).resolves.toMatchObject({
131+
langs: ["text"],
132+
oldFile: {
133+
lang: "text",
134+
},
135+
});
136+
});
137+
138+
it("does not inject text when a valid file language is the only supported hint", async () => {
139+
await expect(
140+
normalizeDiffViewerPayloadLanguages({
141+
prerenderedHTML: "<div>diff</div>",
142+
options: {
143+
theme: {
144+
light: "pierre-light",
145+
dark: "pierre-dark",
146+
},
147+
diffStyle: "unified",
148+
diffIndicators: "bars",
149+
disableLineNumbers: false,
150+
expandUnchanged: false,
151+
themeType: "dark",
152+
backgroundEnabled: true,
153+
overflow: "wrap",
154+
unsafeCSS: "",
155+
},
156+
langs: [],
157+
oldFile: {
158+
name: "before.ts",
159+
contents: "before",
160+
lang: "typescript",
161+
},
162+
newFile: {
163+
name: "after.ts",
164+
contents: "after",
165+
lang: "typescript",
166+
},
167+
}),
168+
).resolves.toMatchObject({
169+
langs: ["typescript"],
170+
});
171+
});
172+
});

extensions/diffs/src/language-hints.ts

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { resolveLanguage } from "@pierre/diffs";
2-
import type { SupportedLanguages } from "@pierre/diffs";
2+
import type { FileContents, FileDiffMetadata, SupportedLanguages } from "@pierre/diffs";
3+
import type { DiffViewerPayload } from "./types.js";
34

45
const PASSTHROUGH_LANGUAGE_HINTS = new Set<SupportedLanguages>(["ansi", "text"]);
6+
type DiffPayloadFile = FileContents | FileDiffMetadata;
57

68
export async function normalizeSupportedLanguageHint(
79
value?: string,
@@ -23,6 +25,13 @@ export async function normalizeSupportedLanguageHint(
2325

2426
export async function filterSupportedLanguageHints(
2527
values: Iterable<string>,
28+
): Promise<SupportedLanguages[]> {
29+
return normalizeSupportedLanguageHints(values, { fallbackToText: true });
30+
}
31+
32+
async function normalizeSupportedLanguageHints(
33+
values: Iterable<string>,
34+
options: { fallbackToText: boolean },
2635
): Promise<SupportedLanguages[]> {
2736
const supported = new Set<SupportedLanguages>();
2837
for (const value of values) {
@@ -32,8 +41,76 @@ export async function filterSupportedLanguageHints(
3241
}
3342
supported.add(normalized);
3443
}
35-
if (supported.size === 0) {
44+
if (options.fallbackToText && supported.size === 0) {
3645
supported.add("text");
3746
}
3847
return [...supported];
3948
}
49+
50+
export function collectDiffPayloadLanguageHints(payload: {
51+
fileDiff?: FileDiffMetadata;
52+
oldFile?: FileContents;
53+
newFile?: FileContents;
54+
}): SupportedLanguages[] {
55+
const langs = new Set<SupportedLanguages>();
56+
if (payload.fileDiff?.lang) {
57+
langs.add(payload.fileDiff.lang);
58+
}
59+
if (payload.oldFile?.lang) {
60+
langs.add(payload.oldFile.lang);
61+
}
62+
if (payload.newFile?.lang) {
63+
langs.add(payload.newFile.lang);
64+
}
65+
return [...langs];
66+
}
67+
68+
async function normalizeDiffPayloadFileLanguage(
69+
file: DiffPayloadFile | undefined,
70+
): Promise<DiffPayloadFile | undefined> {
71+
if (!file) {
72+
return undefined;
73+
}
74+
if (typeof file.lang !== "string") {
75+
return file;
76+
}
77+
const normalized = await normalizeSupportedLanguageHint(file.lang);
78+
if (file.lang === normalized) {
79+
return file;
80+
}
81+
if (!normalized) {
82+
return {
83+
...file,
84+
lang: "text",
85+
};
86+
}
87+
return {
88+
...file,
89+
lang: normalized,
90+
};
91+
}
92+
93+
export async function normalizeDiffViewerPayloadLanguages(
94+
payload: DiffViewerPayload,
95+
): Promise<DiffViewerPayload> {
96+
const [fileDiff, oldFile, newFile, payloadLangs] = await Promise.all([
97+
normalizeDiffPayloadFileLanguage(payload.fileDiff) as Promise<FileDiffMetadata | undefined>,
98+
normalizeDiffPayloadFileLanguage(payload.oldFile) as Promise<FileContents | undefined>,
99+
normalizeDiffPayloadFileLanguage(payload.newFile) as Promise<FileContents | undefined>,
100+
normalizeSupportedLanguageHints(payload.langs, { fallbackToText: false }),
101+
]);
102+
const langs = new Set<SupportedLanguages>(payloadLangs);
103+
for (const lang of collectDiffPayloadLanguageHints({ fileDiff, oldFile, newFile })) {
104+
langs.add(lang);
105+
}
106+
if (langs.size === 0) {
107+
langs.add("text");
108+
}
109+
return {
110+
...payload,
111+
fileDiff,
112+
oldFile,
113+
newFile,
114+
langs: [...langs],
115+
};
116+
}

extensions/diffs/src/render-target.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ vi.mock("@pierre/diffs/ssr", () => ({
2121

2222
import { DEFAULT_DIFFS_TOOL_DEFAULTS, resolveDiffImageRenderOptions } from "./config.js";
2323
import { renderDiffDocument } from "./render.js";
24+
import { parseViewerPayloadJson } from "./viewer-payload.js";
2425

2526
function createRenderOptions() {
2627
return {
@@ -89,4 +90,38 @@ describe("renderDiffDocument render targets", () => {
8990
expect(rendered.imageHtml).toContain("mock diff");
9091
expect(preloadFileDiffMock).toHaveBeenCalledTimes(1);
9192
});
93+
94+
it("normalizes stale patch payload languages before serializing viewer output", async () => {
95+
preloadFileDiffMock.mockResolvedValueOnce({
96+
prerenderedHTML: "<div>mock diff</div>",
97+
fileDiff: {
98+
name: "a.ts",
99+
lang: "not-a-real-language",
100+
},
101+
});
102+
103+
const rendered = await renderDiffDocument(
104+
{
105+
kind: "patch",
106+
patch: [
107+
"diff --git a/a.ts b/a.ts",
108+
"--- a/a.ts",
109+
"+++ b/a.ts",
110+
"@@ -1 +1 @@",
111+
"-a",
112+
"+b",
113+
].join("\n"),
114+
},
115+
createRenderOptions(),
116+
"viewer",
117+
);
118+
119+
const payloads = [
120+
...(rendered.html ?? "").matchAll(/data-openclaw-diff-payload>(.*?)<\/script>/g),
121+
].map((match) => parseViewerPayloadJson(match[1] ?? ""));
122+
123+
expect(payloads).toHaveLength(1);
124+
expect(payloads[0]?.langs).toEqual(["text"]);
125+
expect(payloads[0]?.fileDiff?.lang).toBe("text");
126+
});
92127
});

0 commit comments

Comments
 (0)