Diffs: normalize viewer payload languages

This commit is contained in:
Gustavo Madeira Santana
2026-03-30 20:05:44 -04:00
parent af0c0862f2
commit f96e5bec39
6 changed files with 345 additions and 101 deletions

View File

@@ -0,0 +1,172 @@
import type { FileDiffMetadata } from "@pierre/diffs";
import { describe, expect, it } from "vitest";
import {
filterSupportedLanguageHints,
normalizeDiffViewerPayloadLanguages,
} from "./language-hints.js";
describe("filterSupportedLanguageHints", () => {
it("keeps supported languages", async () => {
await expect(filterSupportedLanguageHints(["typescript", "text"])).resolves.toEqual([
"typescript",
"text",
]);
});
it("drops invalid languages and falls back to text", async () => {
await expect(filterSupportedLanguageHints(["not-a-real-language"])).resolves.toEqual(["text"]);
});
it("keeps valid languages when invalid hints are mixed in", async () => {
await expect(
filterSupportedLanguageHints(["typescript", "not-a-real-language"]),
).resolves.toEqual(["typescript"]);
});
});
describe("normalizeDiffViewerPayloadLanguages", () => {
it("rewrites stale patch payload language overrides to plain text", async () => {
await expect(
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: ["not-a-real-language"],
fileDiff: {
name: "foo.txt",
lang: "not-a-real-language" as never,
} as unknown as FileDiffMetadata,
}),
).resolves.toMatchObject({
langs: ["text"],
fileDiff: {
lang: "text",
},
});
});
it("keeps valid hydrated languages and only downgrades invalid sides", async () => {
await expect(
normalizeDiffViewerPayloadLanguages({
prerenderedHTML: "<div>diff</div>",
options: {
theme: {
light: "pierre-light",
dark: "pierre-dark",
},
diffStyle: "split",
diffIndicators: "classic",
disableLineNumbers: true,
expandUnchanged: true,
themeType: "light",
backgroundEnabled: false,
overflow: "scroll",
unsafeCSS: "",
},
langs: ["typescript", "not-a-real-language"],
oldFile: {
name: "before.unknown",
contents: "before",
lang: "not-a-real-language" as never,
},
newFile: {
name: "after.ts",
contents: "after",
lang: "typescript",
},
}),
).resolves.toMatchObject({
langs: ["typescript", "text"],
oldFile: {
lang: "text",
},
newFile: {
lang: "typescript",
},
});
});
it("rewrites blank explicit language overrides to plain text", async () => {
await expect(
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: [" "],
oldFile: {
name: "before.unknown",
contents: "before",
lang: " " as never,
},
newFile: {
name: "after.txt",
contents: "after",
},
}),
).resolves.toMatchObject({
langs: ["text"],
oldFile: {
lang: "text",
},
});
});
it("does not inject text when a valid file language is the only supported hint", async () => {
await expect(
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: [],
oldFile: {
name: "before.ts",
contents: "before",
lang: "typescript",
},
newFile: {
name: "after.ts",
contents: "after",
lang: "typescript",
},
}),
).resolves.toMatchObject({
langs: ["typescript"],
});
});
});

View File

@@ -1,7 +1,9 @@
import { resolveLanguage } from "@pierre/diffs";
import type { SupportedLanguages } from "@pierre/diffs";
import type { FileContents, FileDiffMetadata, SupportedLanguages } from "@pierre/diffs";
import type { DiffViewerPayload } from "./types.js";
const PASSTHROUGH_LANGUAGE_HINTS = new Set<SupportedLanguages>(["ansi", "text"]);
type DiffPayloadFile = FileContents | FileDiffMetadata;
export async function normalizeSupportedLanguageHint(
value?: string,
@@ -23,6 +25,13 @@ export async function normalizeSupportedLanguageHint(
export async function filterSupportedLanguageHints(
values: Iterable<string>,
): Promise<SupportedLanguages[]> {
return normalizeSupportedLanguageHints(values, { fallbackToText: true });
}
async function normalizeSupportedLanguageHints(
values: Iterable<string>,
options: { fallbackToText: boolean },
): Promise<SupportedLanguages[]> {
const supported = new Set<SupportedLanguages>();
for (const value of values) {
@@ -32,8 +41,76 @@ export async function filterSupportedLanguageHints(
}
supported.add(normalized);
}
if (supported.size === 0) {
if (options.fallbackToText && supported.size === 0) {
supported.add("text");
}
return [...supported];
}
export function collectDiffPayloadLanguageHints(payload: {
fileDiff?: FileDiffMetadata;
oldFile?: FileContents;
newFile?: FileContents;
}): SupportedLanguages[] {
const langs = new Set<SupportedLanguages>();
if (payload.fileDiff?.lang) {
langs.add(payload.fileDiff.lang);
}
if (payload.oldFile?.lang) {
langs.add(payload.oldFile.lang);
}
if (payload.newFile?.lang) {
langs.add(payload.newFile.lang);
}
return [...langs];
}
async function normalizeDiffPayloadFileLanguage(
file: DiffPayloadFile | undefined,
): Promise<DiffPayloadFile | undefined> {
if (!file) {
return undefined;
}
if (typeof file.lang !== "string") {
return file;
}
const normalized = await normalizeSupportedLanguageHint(file.lang);
if (file.lang === normalized) {
return file;
}
if (!normalized) {
return {
...file,
lang: "text",
};
}
return {
...file,
lang: normalized,
};
}
export async function normalizeDiffViewerPayloadLanguages(
payload: DiffViewerPayload,
): 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 }),
]);
const langs = new Set<SupportedLanguages>(payloadLangs);
for (const lang of collectDiffPayloadLanguageHints({ fileDiff, oldFile, newFile })) {
langs.add(lang);
}
if (langs.size === 0) {
langs.add("text");
}
return {
...payload,
fileDiff,
oldFile,
newFile,
langs: [...langs],
};
}

View File

@@ -21,6 +21,7 @@ vi.mock("@pierre/diffs/ssr", () => ({
import { DEFAULT_DIFFS_TOOL_DEFAULTS, resolveDiffImageRenderOptions } from "./config.js";
import { renderDiffDocument } from "./render.js";
import { parseViewerPayloadJson } from "./viewer-payload.js";
function createRenderOptions() {
return {
@@ -89,4 +90,38 @@ describe("renderDiffDocument render targets", () => {
expect(rendered.imageHtml).toContain("mock diff");
expect(preloadFileDiffMock).toHaveBeenCalledTimes(1);
});
it("normalizes stale patch payload languages before serializing viewer output", async () => {
preloadFileDiffMock.mockResolvedValueOnce({
prerenderedHTML: "<div>mock diff</div>",
fileDiff: {
name: "a.ts",
lang: "not-a-real-language",
},
});
const rendered = await renderDiffDocument(
{
kind: "patch",
patch: [
"diff --git a/a.ts b/a.ts",
"--- a/a.ts",
"+++ b/a.ts",
"@@ -1 +1 @@",
"-a",
"+b",
].join("\n"),
},
createRenderOptions(),
"viewer",
);
const payloads = [
...(rendered.html ?? "").matchAll(/data-openclaw-diff-payload>(.*?)<\/script>/g),
].map((match) => parseViewerPayloadJson(match[1] ?? ""));
expect(payloads).toHaveLength(1);
expect(payloads[0]?.langs).toEqual(["text"]);
expect(payloads[0]?.fileDiff?.lang).toBe("text");
});
});

View File

@@ -1,7 +1,11 @@
import type { FileContents, FileDiffMetadata, SupportedLanguages } from "@pierre/diffs";
import { parsePatchFiles } from "@pierre/diffs";
import { preloadFileDiff, preloadMultiFileDiff } from "@pierre/diffs/ssr";
import { normalizeSupportedLanguageHint } from "./language-hints.js";
import {
collectDiffPayloadLanguageHints,
normalizeDiffViewerPayloadLanguages,
normalizeSupportedLanguageHint,
} from "./language-hints.js";
import { ensurePierreThemesRegistered } from "./pierre-themes.js";
import type {
DiffInput,
@@ -179,27 +183,6 @@ function buildRenderVariants(params: { options: DiffRenderOptions; target: DiffR
};
}
function buildPayloadLanguages(payload: {
fileDiff?: FileDiffMetadata;
oldFile?: FileContents;
newFile?: FileContents;
}): SupportedLanguages[] {
const langs = new Set<SupportedLanguages>();
if (payload.fileDiff?.lang) {
langs.add(payload.fileDiff.lang);
}
if (payload.oldFile?.lang) {
langs.add(payload.oldFile.lang);
}
if (payload.newFile?.lang) {
langs.add(payload.newFile.lang);
}
if (langs.size === 0) {
langs.add("text");
}
return [...langs];
}
function renderDiffCard(payload: DiffViewerPayload): string {
return `<section class="oc-diff-card">
<diffs-container class="oc-diff-host" data-openclaw-diff-host>
@@ -377,35 +360,35 @@ async function renderBeforeAfterDiff(
})
: Promise.resolve(undefined),
]);
const section = buildRenderedSection({
...(viewerResult && viewerOptions
? {
viewerPayload: {
prerenderedHTML: viewerResult.prerenderedHTML,
const [viewerPayload, imagePayload] = await Promise.all([
viewerResult && viewerOptions
? normalizeDiffViewerPayloadLanguages({
prerenderedHTML: viewerResult.prerenderedHTML,
oldFile: viewerResult.oldFile,
newFile: viewerResult.newFile,
options: viewerOptions,
langs: collectDiffPayloadLanguageHints({
oldFile: viewerResult.oldFile,
newFile: viewerResult.newFile,
options: viewerOptions,
langs: buildPayloadLanguages({
oldFile: viewerResult.oldFile,
newFile: viewerResult.newFile,
}),
},
}
: {}),
...(imageResult && imageOptions
? {
imagePayload: {
prerenderedHTML: imageResult.prerenderedHTML,
}),
})
: Promise.resolve(undefined),
imageResult && imageOptions
? normalizeDiffViewerPayloadLanguages({
prerenderedHTML: imageResult.prerenderedHTML,
oldFile: imageResult.oldFile,
newFile: imageResult.newFile,
options: imageOptions,
langs: collectDiffPayloadLanguageHints({
oldFile: imageResult.oldFile,
newFile: imageResult.newFile,
options: imageOptions,
langs: buildPayloadLanguages({
oldFile: imageResult.oldFile,
newFile: imageResult.newFile,
}),
},
}
: {}),
}),
})
: Promise.resolve(undefined),
]);
const section = buildRenderedSection({
...(viewerPayload ? { viewerPayload } : {}),
...(imagePayload ? { imagePayload } : {}),
});
return {
@@ -455,27 +438,28 @@ async function renderPatchDiff(
: Promise.resolve(undefined),
]);
const [viewerPayload, imagePayload] = await Promise.all([
viewerResult && viewerOptions
? normalizeDiffViewerPayloadLanguages({
prerenderedHTML: viewerResult.prerenderedHTML,
fileDiff: viewerResult.fileDiff,
options: viewerOptions,
langs: collectDiffPayloadLanguageHints({ fileDiff: viewerResult.fileDiff }),
})
: Promise.resolve(undefined),
imageResult && imageOptions
? normalizeDiffViewerPayloadLanguages({
prerenderedHTML: imageResult.prerenderedHTML,
fileDiff: imageResult.fileDiff,
options: imageOptions,
langs: collectDiffPayloadLanguageHints({ fileDiff: imageResult.fileDiff }),
})
: Promise.resolve(undefined),
]);
return buildRenderedSection({
...(viewerResult && viewerOptions
? {
viewerPayload: {
prerenderedHTML: viewerResult.prerenderedHTML,
fileDiff: viewerResult.fileDiff,
options: viewerOptions,
langs: buildPayloadLanguages({ fileDiff: viewerResult.fileDiff }),
},
}
: {}),
...(imageResult && imageOptions
? {
imagePayload: {
prerenderedHTML: imageResult.prerenderedHTML,
fileDiff: imageResult.fileDiff,
options: imageOptions,
langs: buildPayloadLanguages({ fileDiff: imageResult.fileDiff }),
},
}
: {}),
...(viewerPayload ? { viewerPayload } : {}),
...(imagePayload ? { imagePayload } : {}),
});
}),
);

View File

@@ -1,23 +0,0 @@
import { describe, expect, it } from "vitest";
import { filterSupportedHydrationLanguages } from "./viewer-client.js";
describe("filterSupportedHydrationLanguages", () => {
it("keeps supported languages", async () => {
await expect(filterSupportedHydrationLanguages(["typescript", "text"])).resolves.toEqual([
"typescript",
"text",
]);
});
it("drops invalid languages and falls back to text", async () => {
await expect(filterSupportedHydrationLanguages(["not-a-real-language"])).resolves.toEqual([
"text",
]);
});
it("keeps valid languages when invalid hints are mixed in", async () => {
await expect(
filterSupportedHydrationLanguages(["typescript", "not-a-real-language"]),
).resolves.toEqual(["typescript"]);
});
});

View File

@@ -5,7 +5,7 @@ import type {
FileDiffOptions,
SupportedLanguages,
} from "@pierre/diffs";
import { filterSupportedLanguageHints } from "./language-hints.js";
import { normalizeDiffViewerPayloadLanguages } from "./language-hints.js";
import type { DiffViewerPayload, DiffLayout, DiffTheme } from "./types.js";
import { parseViewerPayloadJson } from "./viewer-payload.js";
@@ -30,12 +30,6 @@ const viewerState: ViewerState = {
wrapEnabled: true,
};
export async function filterSupportedHydrationLanguages(
languages: Iterable<string>,
): Promise<SupportedLanguages[]> {
return filterSupportedLanguageHints(languages);
}
function parsePayload(element: HTMLScriptElement): DiffViewerPayload {
const raw = element.textContent?.trim();
if (!raw) {
@@ -256,7 +250,12 @@ function syncAllControllers(): void {
}
async function hydrateViewer(): Promise<void> {
const cards = getCards();
const cards = await Promise.all(
getCards().map(async ({ host, payload }) => ({
host,
payload: await normalizeDiffViewerPayloadLanguages(payload),
})),
);
const langs = new Set<SupportedLanguages>();
const firstPayload = cards[0]?.payload;
@@ -275,7 +274,7 @@ async function hydrateViewer(): Promise<void> {
await preloadHighlighter({
themes: ["pierre-light", "pierre-dark"],
langs: await filterSupportedHydrationLanguages(langs),
langs: [...langs],
});
syncDocumentTheme();