mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 00:10:25 +00:00
Diffs: normalize viewer payload languages
This commit is contained in:
172
extensions/diffs/src/language-hints.test.ts
Normal file
172
extensions/diffs/src/language-hints.test.ts
Normal 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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user