| 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("<", "<") |
| .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(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, |
| }; |
| } |
|
|