Files
openclaw/extensions/diffs/src/render.ts
2026-03-02 10:46:45 -05:00

471 lines
13 KiB
TypeScript

import type { FileContents, FileDiffMetadata, SupportedLanguages } from "@pierre/diffs";
import { parsePatchFiles } from "@pierre/diffs";
import { preloadFileDiff, preloadMultiFileDiff } from "@pierre/diffs/ssr";
import type {
DiffInput,
DiffRenderOptions,
DiffViewerOptions,
DiffViewerPayload,
RenderedDiffDocument,
} from "./types.js";
import { VIEWER_LOADER_PATH } from "./viewer-assets.js";
const DEFAULT_FILE_NAME = "diff.txt";
const MAX_PATCH_FILE_COUNT = 128;
const MAX_PATCH_TOTAL_LINES = 120_000;
function escapeCssString(value: string): string {
return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
}
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function escapeJsonScript(value: unknown): string {
return JSON.stringify(value).replaceAll("<", "\\u003c");
}
function buildDiffTitle(input: DiffInput): string {
if (input.title?.trim()) {
return input.title.trim();
}
if (input.kind === "before_after") {
return input.path?.trim() || "Text diff";
}
return "Patch diff";
}
function resolveBeforeAfterFileName(input: Extract<DiffInput, { kind: "before_after" }>): string {
if (input.path?.trim()) {
return input.path.trim();
}
if (input.lang?.trim()) {
return `diff.${input.lang.trim().replace(/^\.+/, "")}`;
}
return DEFAULT_FILE_NAME;
}
function buildDiffOptions(options: DiffRenderOptions): DiffViewerOptions {
const fontFamily = escapeCssString(options.presentation.fontFamily);
const fontSize = Math.max(10, Math.floor(options.presentation.fontSize));
const lineHeight = Math.max(20, Math.round(fontSize * options.presentation.lineSpacing));
return {
theme: {
light: "pierre-light",
dark: "pierre-dark",
},
diffStyle: options.presentation.layout,
diffIndicators: options.presentation.diffIndicators,
disableLineNumbers: !options.presentation.showLineNumbers,
expandUnchanged: options.expandUnchanged,
themeType: options.presentation.theme,
backgroundEnabled: options.presentation.background,
overflow: options.presentation.wordWrap ? "wrap" : "scroll",
unsafeCSS: `
:host {
--diffs-font-family: "${fontFamily}", "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--diffs-header-font-family: "${fontFamily}", "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--diffs-font-size: ${fontSize}px;
--diffs-line-height: ${lineHeight}px;
}
[data-diffs-header] {
min-height: 64px;
padding-inline: 18px 14px;
}
[data-header-content] {
gap: 10px;
}
[data-metadata] {
gap: 10px;
}
.oc-diff-toolbar {
display: inline-flex;
align-items: center;
gap: 6px;
margin-inline-start: 6px;
flex: 0 0 auto;
}
.oc-diff-toolbar-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
margin: 0;
border: 0;
border-radius: 0;
background: transparent;
color: inherit;
cursor: pointer;
opacity: 0.6;
line-height: 0;
overflow: visible;
transition: opacity 120ms ease;
flex: 0 0 auto;
}
.oc-diff-toolbar-button:hover {
opacity: 1;
}
.oc-diff-toolbar-button[data-active="true"] {
opacity: 0.92;
}
.oc-diff-toolbar-button svg {
display: block;
width: 16px;
height: 16px;
min-width: 16px;
min-height: 16px;
overflow: visible;
flex: 0 0 auto;
color: inherit;
fill: currentColor;
pointer-events: none;
}
`,
};
}
function buildImageRenderOptions(options: DiffRenderOptions): DiffRenderOptions {
return {
...options,
presentation: {
...options.presentation,
fontSize: Math.max(16, options.presentation.fontSize),
},
};
}
function buildRenderVariants(options: DiffRenderOptions): {
viewerOptions: DiffViewerOptions;
imageOptions: DiffViewerOptions;
} {
return {
viewerOptions: buildDiffOptions(options),
imageOptions: buildDiffOptions(buildImageRenderOptions(options)),
};
}
function normalizeSupportedLanguage(value?: string): SupportedLanguages | undefined {
const normalized = value?.trim();
return normalized ? (normalized as SupportedLanguages) : undefined;
}
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>
<template shadowrootmode="open">${payload.prerenderedHTML}</template>
</diffs-container>
<script type="application/json" data-openclaw-diff-payload>${escapeJsonScript(payload)}</script>
</section>`;
}
function renderStaticDiffCard(prerenderedHTML: string): string {
return `<section class="oc-diff-card">
<diffs-container class="oc-diff-host" data-openclaw-diff-host>
<template shadowrootmode="open">${prerenderedHTML}</template>
</diffs-container>
</section>`;
}
function buildHtmlDocument(params: {
title: string;
bodyHtml: string;
theme: DiffRenderOptions["presentation"]["theme"];
imageMaxWidth: number;
runtimeMode: "viewer" | "image";
}): string {
return `<!doctype html>
<html lang="en"${params.runtimeMode === "image" ? ' data-openclaw-diffs-ready="true"' : ""}>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark light" />
<title>${escapeHtml(params.title)}</title>
<style>
* {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
}
html {
background: #05070b;
}
body {
margin: 0;
min-height: 100vh;
padding: 22px;
font-family:
"Fira Code",
"SF Mono",
Monaco,
Consolas,
monospace;
background: #05070b;
color: #f8fafc;
}
body[data-theme="light"] {
background: #f3f5f8;
color: #0f172a;
}
.oc-frame {
max-width: 1560px;
margin: 0 auto;
}
.oc-frame[data-render-mode="image"] {
max-width: ${Math.max(640, Math.round(params.imageMaxWidth))}px;
}
[data-openclaw-diff-root] {
display: grid;
gap: 18px;
}
.oc-diff-card {
overflow: hidden;
border-radius: 18px;
border: 1px solid rgba(148, 163, 184, 0.16);
background: rgba(15, 23, 42, 0.14);
box-shadow: 0 18px 48px rgba(2, 6, 23, 0.22);
}
body[data-theme="light"] .oc-diff-card {
border-color: rgba(148, 163, 184, 0.22);
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.08);
}
.oc-diff-host {
display: block;
}
.oc-frame[data-render-mode="image"] .oc-diff-card {
min-height: 240px;
}
@media (max-width: 720px) {
body {
padding: 12px;
}
[data-openclaw-diff-root] {
gap: 12px;
}
}
</style>
</head>
<body data-theme="${params.theme}">
<main class="oc-frame" data-render-mode="${params.runtimeMode}">
<div data-openclaw-diff-root>
${params.bodyHtml}
</div>
</main>
${params.runtimeMode === "viewer" ? `<script type="module" src="${VIEWER_LOADER_PATH}"></script>` : ""}
</body>
</html>`;
}
type RenderedSection = {
viewer: string;
image: string;
};
function buildRenderedSection(params: {
viewerPrerenderedHtml: string;
imagePrerenderedHtml: string;
payload: Omit<DiffViewerPayload, "prerenderedHTML">;
}): RenderedSection {
return {
viewer: renderDiffCard({
prerenderedHTML: params.viewerPrerenderedHtml,
...params.payload,
}),
image: renderStaticDiffCard(params.imagePrerenderedHtml),
};
}
function buildRenderedBodies(sections: ReadonlyArray<RenderedSection>): {
viewerBodyHtml: string;
imageBodyHtml: string;
} {
return {
viewerBodyHtml: sections.map((section) => section.viewer).join("\n"),
imageBodyHtml: sections.map((section) => section.image).join("\n"),
};
}
async function renderBeforeAfterDiff(
input: Extract<DiffInput, { kind: "before_after" }>,
options: DiffRenderOptions,
): Promise<{ viewerBodyHtml: string; imageBodyHtml: string; fileCount: number }> {
const fileName = resolveBeforeAfterFileName(input);
const lang = normalizeSupportedLanguage(input.lang);
const oldFile: FileContents = {
name: fileName,
contents: input.before,
...(lang ? { lang } : {}),
};
const newFile: FileContents = {
name: fileName,
contents: input.after,
...(lang ? { lang } : {}),
};
const { viewerOptions, imageOptions } = buildRenderVariants(options);
const [viewerResult, imageResult] = await Promise.all([
preloadMultiFileDiff({
oldFile,
newFile,
options: viewerOptions,
}),
preloadMultiFileDiff({
oldFile,
newFile,
options: imageOptions,
}),
]);
const section = buildRenderedSection({
viewerPrerenderedHtml: viewerResult.prerenderedHTML,
imagePrerenderedHtml: imageResult.prerenderedHTML,
payload: {
oldFile: viewerResult.oldFile,
newFile: viewerResult.newFile,
options: viewerOptions,
langs: buildPayloadLanguages({
oldFile: viewerResult.oldFile,
newFile: viewerResult.newFile,
}),
},
});
return {
...buildRenderedBodies([section]),
fileCount: 1,
};
}
async function renderPatchDiff(
input: Extract<DiffInput, { kind: "patch" }>,
options: DiffRenderOptions,
): Promise<{ viewerBodyHtml: string; imageBodyHtml: string; fileCount: number }> {
const files = parsePatchFiles(input.patch).flatMap((entry) => entry.files ?? []);
if (files.length === 0) {
throw new Error("Patch input did not contain any file diffs.");
}
if (files.length > MAX_PATCH_FILE_COUNT) {
throw new Error(`Patch input contains too many files (max ${MAX_PATCH_FILE_COUNT}).`);
}
const totalLines = files.reduce((sum, fileDiff) => {
const splitLines = Number.isFinite(fileDiff.splitLineCount) ? fileDiff.splitLineCount : 0;
const unifiedLines = Number.isFinite(fileDiff.unifiedLineCount) ? fileDiff.unifiedLineCount : 0;
return sum + Math.max(splitLines, unifiedLines, 0);
}, 0);
if (totalLines > MAX_PATCH_TOTAL_LINES) {
throw new Error(`Patch input is too large to render (max ${MAX_PATCH_TOTAL_LINES} lines).`);
}
const { viewerOptions, imageOptions } = buildRenderVariants(options);
const sections = await Promise.all(
files.map(async (fileDiff) => {
const [viewerResult, imageResult] = await Promise.all([
preloadFileDiff({
fileDiff,
options: viewerOptions,
}),
preloadFileDiff({
fileDiff,
options: imageOptions,
}),
]);
return buildRenderedSection({
viewerPrerenderedHtml: viewerResult.prerenderedHTML,
imagePrerenderedHtml: imageResult.prerenderedHTML,
payload: {
fileDiff: viewerResult.fileDiff,
options: viewerOptions,
langs: buildPayloadLanguages({ fileDiff: viewerResult.fileDiff }),
},
});
}),
);
return {
...buildRenderedBodies(sections),
fileCount: files.length,
};
}
export async function renderDiffDocument(
input: DiffInput,
options: DiffRenderOptions,
): Promise<RenderedDiffDocument> {
const title = buildDiffTitle(input);
const rendered =
input.kind === "before_after"
? await renderBeforeAfterDiff(input, options)
: await renderPatchDiff(input, options);
return {
html: buildHtmlDocument({
title,
bodyHtml: rendered.viewerBodyHtml,
theme: options.presentation.theme,
imageMaxWidth: options.image.maxWidth,
runtimeMode: "viewer",
}),
imageHtml: buildHtmlDocument({
title,
bodyHtml: rendered.imageBodyHtml,
theme: options.presentation.theme,
imageMaxWidth: options.image.maxWidth,
runtimeMode: "image",
}),
title,
fileCount: rendered.fileCount,
inputKind: input.kind,
};
}