mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 02:43:57 +00:00
* feat(diffs): add changed-files summary nav for multi-file patch diffs * docs: regenerate docs map for diffs multi-file navigation section * fix(diffs): respect reduced motion in summary navigation
794 lines
23 KiB
TypeScript
794 lines
23 KiB
TypeScript
// Diffs plugin module implements render behavior.
|
|
import type { FileContents, FileDiffMetadata, SupportedLanguages } from "@pierre/diffs";
|
|
import { parsePatchFiles } from "@pierre/diffs";
|
|
import { preloadFileDiff, preloadMultiFileDiff } from "@pierre/diffs/ssr";
|
|
import { normalizeDiffFontSize, normalizeDiffLineSpacing } from "./config.js";
|
|
import {
|
|
collectDiffPayloadLanguageHints,
|
|
isBaseDiffViewerLanguage,
|
|
normalizeDiffViewerPayloadLanguages,
|
|
normalizeSupportedLanguageHint,
|
|
} from "./language-hints.js";
|
|
import type {
|
|
DiffInput,
|
|
DiffRenderOptions,
|
|
DiffRenderTarget,
|
|
DiffViewerOptions,
|
|
DiffViewerPayload,
|
|
RenderedDiffDocument,
|
|
} from "./types.js";
|
|
|
|
const DEFAULT_FILE_NAME = "diff.txt";
|
|
const MAX_PATCH_FILE_COUNT = 128;
|
|
const MAX_PATCH_TOTAL_LINES = 120_000;
|
|
const VIEWER_LOADER_DOCUMENT_PATH = "../../assets/viewer.js";
|
|
const LANGUAGE_PACK_VIEWER_LOADER_DOCUMENT_PATH = "../../../diffs-language-pack/assets/viewer.js";
|
|
|
|
export class DiffRenderInputError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = "DiffRenderInputError";
|
|
}
|
|
}
|
|
|
|
function escapeCssString(value: string): string {
|
|
return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
|
|
}
|
|
|
|
function escapeHtml(value: string): string {
|
|
return value
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
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(params: {
|
|
input: Extract<DiffInput, { kind: "before_after" }>;
|
|
lang?: SupportedLanguages;
|
|
}): string {
|
|
const { input, lang } = params;
|
|
if (input.path?.trim()) {
|
|
return input.path.trim();
|
|
}
|
|
if (lang && lang !== "text") {
|
|
return `diff.${lang.replace(/^\.+/, "")}`;
|
|
}
|
|
return DEFAULT_FILE_NAME;
|
|
}
|
|
|
|
function resolveDiffTypography(presentation: DiffRenderOptions["presentation"]): {
|
|
fontSize: number;
|
|
lineHeight: number;
|
|
} {
|
|
const fontSize = normalizeDiffFontSize(presentation.fontSize);
|
|
const lineSpacing = normalizeDiffLineSpacing(presentation.lineSpacing);
|
|
const lineHeight = Math.max(20, Math.round(fontSize * lineSpacing));
|
|
return { fontSize, lineHeight };
|
|
}
|
|
|
|
function buildDiffOptions(options: DiffRenderOptions): DiffViewerOptions {
|
|
const fontFamily = escapeCssString(options.presentation.fontFamily);
|
|
const { fontSize, lineHeight } = resolveDiffTypography(options.presentation);
|
|
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, normalizeDiffFontSize(options.presentation.fontSize)),
|
|
},
|
|
};
|
|
}
|
|
|
|
function shouldRenderViewer(target: DiffRenderTarget): boolean {
|
|
return target === "viewer" || target === "both";
|
|
}
|
|
|
|
function shouldRenderImage(target: DiffRenderTarget): boolean {
|
|
return target === "image" || target === "both";
|
|
}
|
|
|
|
function buildRenderVariants(params: { options: DiffRenderOptions; target: DiffRenderTarget }): {
|
|
viewerOptions?: DiffViewerOptions;
|
|
imageOptions?: DiffViewerOptions;
|
|
} {
|
|
return {
|
|
...(shouldRenderViewer(params.target)
|
|
? { viewerOptions: buildDiffOptions(params.options) }
|
|
: {}),
|
|
...(shouldRenderImage(params.target)
|
|
? { imageOptions: buildDiffOptions(buildImageRenderOptions(params.options)) }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
function renderDiffCard(payload: DiffViewerPayload, anchorId?: string): string {
|
|
return `<section class="oc-diff-card"${anchorId ? ` id="${anchorId}"` : ""}>
|
|
<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>`;
|
|
}
|
|
|
|
type FileDiffStats = {
|
|
additions: number;
|
|
deletions: number;
|
|
};
|
|
|
|
type FileNavEntry = {
|
|
anchorId: string;
|
|
fileDiff: FileDiffMetadata;
|
|
stats: FileDiffStats;
|
|
};
|
|
|
|
// Hunk.additionLines/deletionLines count only +/- lines (not context), so the
|
|
// sums match the built-in per-file header counts rendered by @pierre/diffs.
|
|
function computeFileDiffStats(fileDiff: FileDiffMetadata): FileDiffStats {
|
|
let additions = 0;
|
|
let deletions = 0;
|
|
for (const hunk of fileDiff.hunks) {
|
|
additions += hunk.additionLines;
|
|
deletions += hunk.deletionLines;
|
|
}
|
|
return { additions, deletions };
|
|
}
|
|
|
|
function renderNavChangeBadge(changeType: FileDiffMetadata["type"]): string {
|
|
const label =
|
|
changeType === "new"
|
|
? "added"
|
|
: changeType === "deleted"
|
|
? "deleted"
|
|
: changeType === "rename-pure" || changeType === "rename-changed"
|
|
? "renamed"
|
|
: undefined;
|
|
return label ? `<span class="oc-diff-nav-badge" data-change="${label}">${label}</span>` : "";
|
|
}
|
|
|
|
function renderNavStats(stats: FileDiffStats): string {
|
|
return `<span class="oc-diff-nav-stats"><span class="oc-diff-nav-additions">+${stats.additions}</span><span class="oc-diff-nav-deletions">-${stats.deletions}</span></span>`;
|
|
}
|
|
|
|
function renderNavEntryName(fileDiff: FileDiffMetadata): string {
|
|
const renamed = fileDiff.prevName && fileDiff.prevName !== fileDiff.name;
|
|
return renamed
|
|
? `${escapeHtml(fileDiff.prevName ?? "")} → ${escapeHtml(fileDiff.name)}`
|
|
: escapeHtml(fileDiff.name);
|
|
}
|
|
|
|
// Multi-file patches render as stacked cards; this summary card gives per-file
|
|
// stats plus anchor links so long diffs stay navigable without extra JS.
|
|
function renderFileSummaryNav(entries: ReadonlyArray<FileNavEntry>): string {
|
|
const totals = entries.reduce<FileDiffStats>(
|
|
(sum, entry) => ({
|
|
additions: sum.additions + entry.stats.additions,
|
|
deletions: sum.deletions + entry.stats.deletions,
|
|
}),
|
|
{ additions: 0, deletions: 0 },
|
|
);
|
|
const items = entries
|
|
.map(
|
|
(entry) =>
|
|
`<li><a href="#${entry.anchorId}"><code>${renderNavEntryName(entry.fileDiff)}</code></a>${renderNavChangeBadge(entry.fileDiff.type)}${renderNavStats(entry.stats)}</li>`,
|
|
)
|
|
.join("\n ");
|
|
return `<nav class="oc-diff-card oc-diff-nav" aria-label="Changed files">
|
|
<p class="oc-diff-nav-summary">${entries.length} changed files${renderNavStats(totals)}</p>
|
|
<ol class="oc-diff-nav-list">
|
|
${items}
|
|
</ol>
|
|
</nav>`;
|
|
}
|
|
|
|
function buildHtmlDocument(params: {
|
|
title: string;
|
|
bodyHtml: string;
|
|
theme: DiffRenderOptions["presentation"]["theme"];
|
|
imageMaxWidth: number;
|
|
imageTypography: {
|
|
fontSize: number;
|
|
lineHeight: number;
|
|
};
|
|
runtimeMode: "viewer" | "image";
|
|
viewerRuntime: "base" | "language-pack";
|
|
}): string {
|
|
const viewerLoaderPath =
|
|
params.viewerRuntime === "language-pack"
|
|
? LANGUAGE_PACK_VIEWER_LOADER_DOCUMENT_PATH
|
|
: VIEWER_LOADER_DOCUMENT_PATH;
|
|
const imageTypographyCss =
|
|
params.runtimeMode === "image"
|
|
? `
|
|
.oc-frame[data-render-mode="image"] .oc-diff-host {
|
|
--diffs-font-size: ${params.imageTypography.fontSize}px;
|
|
--diffs-line-height: ${params.imageTypography.lineHeight}px;
|
|
}
|
|
`
|
|
: "";
|
|
return `<!doctype html>
|
|
<html lang="en">
|
|
<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;
|
|
scroll-behavior: smooth;
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
html {
|
|
scroll-behavior: auto;
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
${imageTypographyCss}
|
|
|
|
[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-diff-nav {
|
|
padding: 14px 18px;
|
|
font-size: 13px;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.oc-diff-nav-summary {
|
|
display: flex;
|
|
align-items: center;
|
|
margin: 0 0 10px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.oc-diff-nav-list {
|
|
display: grid;
|
|
gap: 4px;
|
|
margin: 0;
|
|
padding: 0;
|
|
list-style: none;
|
|
}
|
|
|
|
.oc-diff-nav-list li {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.oc-diff-nav-list code {
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.oc-diff-nav-list a {
|
|
color: inherit;
|
|
text-decoration: none;
|
|
min-width: 0;
|
|
}
|
|
|
|
.oc-diff-nav-list a:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.oc-diff-nav-stats {
|
|
display: inline-flex;
|
|
gap: 8px;
|
|
margin-inline-start: auto;
|
|
padding-inline-start: 12px;
|
|
font-variant-numeric: tabular-nums;
|
|
font-weight: 600;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.oc-diff-nav-summary .oc-diff-nav-stats {
|
|
margin-inline-start: 10px;
|
|
padding-inline-start: 0;
|
|
}
|
|
|
|
.oc-diff-nav-additions {
|
|
color: #4ade80;
|
|
}
|
|
|
|
.oc-diff-nav-deletions {
|
|
color: #f87171;
|
|
}
|
|
|
|
.oc-diff-nav-badge {
|
|
flex: 0 0 auto;
|
|
padding: 1px 7px;
|
|
border-radius: 999px;
|
|
border: 1px solid rgba(148, 163, 184, 0.32);
|
|
font-size: 11px;
|
|
opacity: 0.85;
|
|
}
|
|
|
|
body[data-theme="light"] .oc-diff-nav-additions {
|
|
color: #15803d;
|
|
}
|
|
|
|
body[data-theme="light"] .oc-diff-nav-deletions {
|
|
color: #b91c1c;
|
|
}
|
|
|
|
/* Nav summary cards are short; the diff-card floor would pad them with
|
|
empty space in static PNG/PDF captures. */
|
|
.oc-frame[data-render-mode="image"] .oc-diff-card:not(.oc-diff-nav) {
|
|
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>
|
|
<script type="module" src="${viewerLoaderPath}"></script>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
type RenderedSection = {
|
|
viewer?: string;
|
|
image?: string;
|
|
usesLanguagePack?: boolean;
|
|
};
|
|
|
|
function payloadUsesLanguagePack(payload: DiffViewerPayload | undefined): boolean {
|
|
return payload?.langs.some((lang) => !isBaseDiffViewerLanguage(lang)) ?? false;
|
|
}
|
|
|
|
function buildRenderedSection(params: {
|
|
viewerPayload?: DiffViewerPayload;
|
|
imagePayload?: DiffViewerPayload;
|
|
anchorId?: string;
|
|
}): RenderedSection {
|
|
return {
|
|
...(params.viewerPayload
|
|
? { viewer: renderDiffCard(params.viewerPayload, params.anchorId) }
|
|
: {}),
|
|
...(params.imagePayload ? { image: renderDiffCard(params.imagePayload, params.anchorId) } : {}),
|
|
usesLanguagePack:
|
|
payloadUsesLanguagePack(params.viewerPayload) || payloadUsesLanguagePack(params.imagePayload),
|
|
};
|
|
}
|
|
|
|
function buildRenderedBodies(
|
|
sections: ReadonlyArray<RenderedSection>,
|
|
leadingHtml?: string,
|
|
): {
|
|
viewerBodyHtml?: string;
|
|
imageBodyHtml?: string;
|
|
} {
|
|
const lead = leadingHtml ? [leadingHtml] : [];
|
|
const viewerSections = sections.flatMap((section) => (section.viewer ? [section.viewer] : []));
|
|
const imageSections = sections.flatMap((section) => (section.image ? [section.image] : []));
|
|
return {
|
|
...(viewerSections.length > 0
|
|
? { viewerBodyHtml: [...lead, ...viewerSections].join("\n") }
|
|
: {}),
|
|
...(imageSections.length > 0 ? { imageBodyHtml: [...lead, ...imageSections].join("\n") } : {}),
|
|
};
|
|
}
|
|
|
|
async function renderBeforeAfterDiff(
|
|
input: Extract<DiffInput, { kind: "before_after" }>,
|
|
options: DiffRenderOptions,
|
|
target: DiffRenderTarget,
|
|
): Promise<{
|
|
viewerBodyHtml?: string;
|
|
imageBodyHtml?: string;
|
|
fileCount: number;
|
|
usesLanguagePack: boolean;
|
|
}> {
|
|
const languagePackAvailable = options.languagePackAvailable === true;
|
|
const lang = await normalizeSupportedLanguageHint(input.lang, { languagePackAvailable });
|
|
const fileName = resolveBeforeAfterFileName({ 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, target });
|
|
const preloadOptions = viewerOptions ?? imageOptions;
|
|
if (!preloadOptions) {
|
|
throw new Error(`Unsupported diff render target: ${target}`);
|
|
}
|
|
const preloadResult = await preloadMultiFileDiffWithFallback({
|
|
oldFile,
|
|
newFile,
|
|
options: preloadOptions,
|
|
});
|
|
const normalizedPayload = await normalizeDiffViewerPayloadLanguages(
|
|
{
|
|
prerenderedHTML: preloadResult.prerenderedHTML,
|
|
oldFile: preloadResult.oldFile,
|
|
newFile: preloadResult.newFile,
|
|
options: preloadOptions,
|
|
langs: collectDiffPayloadLanguageHints({
|
|
oldFile: preloadResult.oldFile,
|
|
newFile: preloadResult.newFile,
|
|
}),
|
|
},
|
|
{ languagePackAvailable },
|
|
);
|
|
const viewerPayload = viewerOptions
|
|
? { ...normalizedPayload, options: viewerOptions }
|
|
: undefined;
|
|
const imagePayload = imageOptions ? { ...normalizedPayload, options: imageOptions } : undefined;
|
|
const section = buildRenderedSection({
|
|
...(viewerPayload ? { viewerPayload } : {}),
|
|
...(imagePayload ? { imagePayload } : {}),
|
|
});
|
|
|
|
return {
|
|
...buildRenderedBodies([section]),
|
|
fileCount: 1,
|
|
usesLanguagePack: section.usesLanguagePack === true,
|
|
};
|
|
}
|
|
|
|
async function renderPatchDiff(
|
|
input: Extract<DiffInput, { kind: "patch" }>,
|
|
options: DiffRenderOptions,
|
|
target: DiffRenderTarget,
|
|
): Promise<{
|
|
viewerBodyHtml?: string;
|
|
imageBodyHtml?: string;
|
|
fileCount: number;
|
|
usesLanguagePack: boolean;
|
|
}> {
|
|
const languagePackAvailable = options.languagePackAvailable === true;
|
|
const files = await Promise.all(
|
|
parsePatchFiles(input.patch)
|
|
.flatMap((entry) => entry.files ?? [])
|
|
.map((fileDiff) => normalizePatchFileLanguage(fileDiff, { languagePackAvailable })),
|
|
);
|
|
if (files.length === 0) {
|
|
throw new DiffRenderInputError("Patch input did not contain any file diffs.");
|
|
}
|
|
if (files.length > MAX_PATCH_FILE_COUNT) {
|
|
throw new DiffRenderInputError(
|
|
`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 DiffRenderInputError(
|
|
`Patch input is too large to render (max ${MAX_PATCH_TOTAL_LINES} lines).`,
|
|
);
|
|
}
|
|
|
|
const { viewerOptions, imageOptions } = buildRenderVariants({ options, target });
|
|
const preloadOptions = viewerOptions ?? imageOptions;
|
|
if (!preloadOptions) {
|
|
throw new Error(`Unsupported diff render target: ${target}`);
|
|
}
|
|
const navEntries: FileNavEntry[] = files.map((fileDiff, index) => ({
|
|
anchorId: `oc-diff-file-${index + 1}`,
|
|
fileDiff,
|
|
stats: computeFileDiffStats(fileDiff),
|
|
}));
|
|
const sections = await Promise.all(
|
|
files.map(async (fileDiff, index) => {
|
|
const preloadResult = await preloadFileDiffWithFallback({
|
|
fileDiff,
|
|
options: preloadOptions,
|
|
});
|
|
const normalizedPayload = await normalizeDiffViewerPayloadLanguages(
|
|
{
|
|
prerenderedHTML: preloadResult.prerenderedHTML,
|
|
fileDiff: preloadResult.fileDiff,
|
|
options: preloadOptions,
|
|
langs: collectDiffPayloadLanguageHints({ fileDiff: preloadResult.fileDiff }),
|
|
},
|
|
{ languagePackAvailable },
|
|
);
|
|
const viewerPayload = viewerOptions
|
|
? { ...normalizedPayload, options: viewerOptions }
|
|
: undefined;
|
|
const imagePayload = imageOptions
|
|
? { ...normalizedPayload, options: imageOptions }
|
|
: undefined;
|
|
|
|
return buildRenderedSection({
|
|
...(viewerPayload ? { viewerPayload } : {}),
|
|
...(imagePayload ? { imagePayload } : {}),
|
|
anchorId: navEntries[index]?.anchorId,
|
|
});
|
|
}),
|
|
);
|
|
// Single-file patches skip the summary card; one file needs no navigation.
|
|
const navHtml = files.length > 1 ? renderFileSummaryNav(navEntries) : undefined;
|
|
|
|
return {
|
|
...buildRenderedBodies(sections, navHtml),
|
|
fileCount: files.length,
|
|
usesLanguagePack: sections.some((section) => section.usesLanguagePack === true),
|
|
};
|
|
}
|
|
|
|
async function normalizePatchFileLanguage(
|
|
fileDiff: FileDiffMetadata,
|
|
options: { languagePackAvailable: boolean },
|
|
): Promise<FileDiffMetadata> {
|
|
const lang = await normalizeSupportedLanguageHint(fileDiff.lang, options);
|
|
if (lang === fileDiff.lang) {
|
|
return fileDiff;
|
|
}
|
|
return {
|
|
...fileDiff,
|
|
...(lang ? { lang } : { lang: "text" }),
|
|
};
|
|
}
|
|
|
|
export async function renderDiffDocument(
|
|
input: DiffInput,
|
|
options: DiffRenderOptions,
|
|
target: DiffRenderTarget = "both",
|
|
): Promise<RenderedDiffDocument> {
|
|
const title = buildDiffTitle(input);
|
|
const rendered =
|
|
input.kind === "before_after"
|
|
? await renderBeforeAfterDiff(input, options, target)
|
|
: await renderPatchDiff(input, options, target);
|
|
const viewerRuntime = rendered.usesLanguagePack ? "language-pack" : "base";
|
|
const imageTypography = resolveDiffTypography(buildImageRenderOptions(options).presentation);
|
|
|
|
return {
|
|
...(rendered.viewerBodyHtml
|
|
? {
|
|
html: buildHtmlDocument({
|
|
title,
|
|
bodyHtml: rendered.viewerBodyHtml,
|
|
theme: options.presentation.theme,
|
|
imageMaxWidth: options.image.maxWidth,
|
|
imageTypography,
|
|
runtimeMode: "viewer",
|
|
viewerRuntime,
|
|
}),
|
|
}
|
|
: {}),
|
|
...(rendered.imageBodyHtml
|
|
? {
|
|
imageHtml: buildHtmlDocument({
|
|
title,
|
|
bodyHtml: rendered.imageBodyHtml,
|
|
theme: options.presentation.theme,
|
|
imageMaxWidth: options.image.maxWidth,
|
|
imageTypography,
|
|
runtimeMode: "image",
|
|
viewerRuntime,
|
|
}),
|
|
}
|
|
: {}),
|
|
title,
|
|
fileCount: rendered.fileCount,
|
|
inputKind: input.kind,
|
|
viewerRuntime,
|
|
};
|
|
}
|
|
|
|
type PreloadedFileDiffResult = Awaited<ReturnType<typeof preloadFileDiff>>;
|
|
type PreloadedMultiFileDiffResult = Awaited<ReturnType<typeof preloadMultiFileDiff>>;
|
|
|
|
function shouldFallbackToClientHydration(error: unknown): boolean {
|
|
return (
|
|
error instanceof TypeError &&
|
|
error.message.includes('needs an import attribute of "type: json"')
|
|
);
|
|
}
|
|
|
|
async function preloadFileDiffWithFallback(params: {
|
|
fileDiff: FileDiffMetadata;
|
|
options: DiffViewerOptions;
|
|
}): Promise<PreloadedFileDiffResult> {
|
|
try {
|
|
return await preloadFileDiff(params);
|
|
} catch (error) {
|
|
if (!shouldFallbackToClientHydration(error)) {
|
|
throw error;
|
|
}
|
|
return {
|
|
fileDiff: params.fileDiff,
|
|
prerenderedHTML: "",
|
|
};
|
|
}
|
|
}
|
|
|
|
async function preloadMultiFileDiffWithFallback(params: {
|
|
oldFile: FileContents;
|
|
newFile: FileContents;
|
|
options: DiffViewerOptions;
|
|
}): Promise<PreloadedMultiFileDiffResult> {
|
|
try {
|
|
return await preloadMultiFileDiff(params);
|
|
} catch (error) {
|
|
if (!shouldFallbackToClientHydration(error)) {
|
|
throw error;
|
|
}
|
|
return {
|
|
oldFile: params.oldFile,
|
|
newFile: params.newFile,
|
|
prerenderedHTML: "",
|
|
};
|
|
}
|
|
}
|