Something went wrong. Try again.
🌿 Collaborative wiki on ATProto forked from juprodh.me/lichen.wiki
Something went wrong. Try again.
5.4 kB · 178 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179import type * as DiffLib from "diff";import DiffMatchPatch from "diff-match-patch";import { escapeHtml } from "./html.ts";import { normalizeLF } from "./normalize.ts";
const dmp = new DiffMatchPatch();
// Lazy-loaded — only history pages need it.let diffLib: typeof DiffLib | null = null;function getDiff(): typeof DiffLib { if (!diffLib) diffLib = require("diff") as typeof DiffLib; return diffLib;}
const INSERT = 1;const DELETE = -1;const EQUAL = 0;
function renderEqual(text: string): string { return `<span class="text-[var(--text-muted)]">${escapeHtml(text)}</span>`;}
// Keep ins/del backgrounds off edge whitespace, else padding shows as a stray colored gap.function splitWhitespaceEdges(text: string): { lead: string; body: string; trail: string;} { const lead = text.match(/^\s*/)?.[0] ?? ""; const trail = text.match(/\s*$/)?.[0] ?? ""; const body = text.slice(lead.length, text.length - trail.length); return { lead, body, trail };}
function renderOne([op, text]: [number, string]): string { if (op !== INSERT && op !== DELETE) return renderEqual(text); if (text.trim().length === 0) return "";
const { lead, body, trail } = splitWhitespaceEdges(text); const tag = op === INSERT ? `<ins class="bg-[var(--accent-soft)] text-[var(--accent-hover)] no-underline px-0.5 rounded-sm">${escapeHtml(body)}</ins>` : `<del class="bg-[var(--danger-bg)] text-[var(--danger-text)] px-0.5 rounded-sm">${escapeHtml(body)}</del>`; return `${escapeHtml(lead)}${tag}${escapeHtml(trail)}`;}
const CONTEXT_LINES = 2;const HUNK_SEPARATOR = `<div class="text-xs text-[var(--text-muted)] font-mono py-1 select-none">⋯</div>`;
// Collapse long equal regions to ±CONTEXT_LINES of context, git-style.function renderDiffs(diffs: [number, string][]): string { const out: string[] = [];
for (let i = 0; i < diffs.length; i++) { const diff = diffs[i]; if (!diff) continue; const [op, text] = diff; if (op !== EQUAL) { out.push(renderOne(diff)); continue; } const lines = text.split("\n"); const isFirst = i === 0; const isLast = i === diffs.length - 1;
if (isFirst && isLast) { out.push(renderEqual(text)); } else if (isFirst) { if (lines.length <= CONTEXT_LINES) { out.push(renderEqual(text)); } else { out.push(HUNK_SEPARATOR); out.push(renderEqual(lines.slice(-CONTEXT_LINES).join("\n"))); } } else if (isLast) { if (lines.length <= CONTEXT_LINES) { out.push(renderEqual(text)); } else { out.push(renderEqual(lines.slice(0, CONTEXT_LINES).join("\n"))); out.push(HUNK_SEPARATOR); } } else { if (lines.length <= CONTEXT_LINES * 2) { out.push(renderEqual(text)); } else { out.push(renderEqual(lines.slice(0, CONTEXT_LINES).join("\n"))); out.push(HUNK_SEPARATOR); out.push(renderEqual(lines.slice(-CONTEXT_LINES).join("\n"))); } } }
return `<div class="font-mono text-sm leading-relaxed whitespace-pre-wrap py-2">${out.join("")}</div>`;}
function toTuples(changes: DiffLib.Change[]): [number, string][] { return changes.map((c) => { const op = c.added ? INSERT : c.removed ? DELETE : EQUAL; return [op, c.value]; });}
// Below this shared-content ratio, word-level refinement turns a rewrite into zebra striping.const REFINE_SIMILARITY = 0.4;
function shouldRefine( before: string, after: string, lib: typeof DiffLib,): boolean { const inner = lib.diffWordsWithSpace(before, after); let equal = 0; for (const c of inner) { if (!c.added && !c.removed) equal += c.value.length; } return equal / (before.length + after.length) >= REFINE_SIMILARITY;}
// Pair removed+added lines, then word-diff inside each pair when similar enough to read as an edit.function lineThenWord( before: string, after: string, lib: typeof DiffLib,): [number, string][] { const lines = lib.diffLines(before, after); const out: [number, string][] = []; for (let i = 0; i < lines.length; i++) { const cur = lines[i]; const next = lines[i + 1]; if (!cur) continue; if (cur.removed && next?.added) { if (shouldRefine(cur.value, next.value, lib)) { out.push(...toTuples(lib.diffWordsWithSpace(cur.value, next.value))); } else { out.push([DELETE, cur.value], [INSERT, next.value]); } i++; } else { out.push(...toTuples([cur])); } } return out;}
export function renderContentDiff(before: string, after: string): string { const beforeLF = normalizeLF(before); const afterLF = normalizeLF(after); if (beforeLF === afterLF) return ""; return renderDiffs(lineThenWord(beforeLF, afterLF, getDiff()));}
// Fallback for revisions older than the snapshot cap: render a stored dmp patch directly.export function renderDiffInline(patchText: string): string { if (!patchText.trim()) return ""; let patches: { diffs: [number, string][] }[]; try { patches = dmp.patch_fromText(patchText) as unknown as { diffs: [number, string][]; }[]; } catch { return `<pre class="text-xs whitespace-pre-wrap">${escapeHtml(patchText)}</pre>`; }
const hunks = patches.map((patch) => { const diffs = patch.diffs.map(([op, text]) => [op, text]) as [ number, string, ][]; dmp.diff_cleanupSemantic( diffs as unknown as Parameters<typeof dmp.diff_cleanupSemantic>[0], ); return renderDiffs(diffs); });
return hunks.join( `<div class="border-t border-[var(--border-subtle)] my-1"></div>`, );}