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"; 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): 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 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(); 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 `
`; } function renderStaticDiffCard(prerenderedHTML: string): string { return `
`; } function buildHtmlDocument(params: { title: string; bodyHtml: string; theme: DiffRenderOptions["presentation"]["theme"]; runtimeMode: "viewer" | "image"; }): string { return ` ${escapeHtml(params.title)}
${params.bodyHtml}
${params.runtimeMode === "viewer" ? `` : ""} `; } async function renderBeforeAfterDiff( input: Extract, 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 viewerPayloadOptions = buildDiffOptions(options); const imagePayloadOptions = buildDiffOptions(buildImageRenderOptions(options)); const [viewerResult, imageResult] = await Promise.all([ preloadMultiFileDiff({ oldFile, newFile, options: viewerPayloadOptions, }), preloadMultiFileDiff({ oldFile, newFile, options: imagePayloadOptions, }), ]); return { viewerBodyHtml: renderDiffCard({ prerenderedHTML: viewerResult.prerenderedHTML, oldFile: viewerResult.oldFile, newFile: viewerResult.newFile, options: viewerPayloadOptions, langs: buildPayloadLanguages({ oldFile: viewerResult.oldFile, newFile: viewerResult.newFile, }), }), imageBodyHtml: renderStaticDiffCard(imageResult.prerenderedHTML), fileCount: 1, }; } async function renderPatchDiff( input: Extract, 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."); } const viewerPayloadOptions = buildDiffOptions(options); const imagePayloadOptions = buildDiffOptions(buildImageRenderOptions(options)); const sections = await Promise.all( files.map(async (fileDiff) => { const [viewerResult, imageResult] = await Promise.all([ preloadFileDiff({ fileDiff, options: viewerPayloadOptions, }), preloadFileDiff({ fileDiff, options: imagePayloadOptions, }), ]); return { viewer: renderDiffCard({ prerenderedHTML: viewerResult.prerenderedHTML, fileDiff: viewerResult.fileDiff, options: viewerPayloadOptions, langs: buildPayloadLanguages({ fileDiff: viewerResult.fileDiff }), }), image: renderStaticDiffCard(imageResult.prerenderedHTML), }; }), ); return { viewerBodyHtml: sections.map((section) => section.viewer).join("\n"), imageBodyHtml: sections.map((section) => section.image).join("\n"), fileCount: files.length, }; } export async function renderDiffDocument( input: DiffInput, options: DiffRenderOptions, ): Promise { 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, runtimeMode: "viewer", }), imageHtml: buildHtmlDocument({ title, bodyHtml: rendered.imageBodyHtml, theme: options.presentation.theme, runtimeMode: "image", }), title, fileCount: rendered.fileCount, inputKind: input.kind, }; }