Loading
Create a text diff viewer with an LCS-based diff algorithm, side-by-side and inline views, syntax highlighting, and line numbers.
Diff viewers are essential developer tools. Every code review, every merge conflict, every version comparison relies on the ability to show exactly what changed between two texts. Git itself uses diff algorithms to track changes, and tools like GitHub's pull request view render those diffs in a human-readable format.
In this tutorial, you will build a text diff viewer from scratch. You will implement the Longest Common Subsequence (LCS) algorithm to compute the minimum edit between two texts, render the results in both side-by-side and inline (unified) views, add line numbers, and apply syntax highlighting to code diffs. By the end, you will understand how diff algorithms work at a fundamental level and how to present their output clearly.
Add "type": "module" to package.json:
Create src/diff.ts. The Longest Common Subsequence algorithm identifies lines that are the same in both texts, and everything else is an addition or deletion:
The LCS algorithm runs in O(m * n) time and space where m and n are the line counts. For very large files, the Myers diff algorithm is more efficient, but LCS is clearer to understand and sufficient for files under a few thousand lines.
Create src/word-diff.ts to highlight specific changes within modified lines:
Word-level diffing is applied within pairs of remove/add lines to show exactly which words changed. This is the granularity GitHub uses in its inline diff view — entire lines are highlighted as changed, with specific words emphasized.
Create src/render.ts:
The side-by-side renderer pairs consecutive remove/add lines to show them on the same row. When a removed line is immediately followed by an added line, word-level diffing highlights the specific changes within that pair.
Create src/stats.ts:
Create public/index.html:
Create a build step that bundles the diff logic. For simplicity, create public/app.js with the diff algorithm inlined (in a real project, you would bundle with a tool like esbuild):
Create a simple server to serve the static files. Add to package.json:
Install the static server and start:
Open http://localhost:3004. The default example shows a function refactoring diff. Paste any two texts to see the diff computed instantly.
Test these scenarios: identical texts should produce all-equal lines with no color, completely different texts should show all red/green, adding a single line in the middle should show it as green with all surrounding lines equal. Toggle between inline and side-by-side to see the same diff from different perspectives.
The algorithm handles edge cases automatically: empty inputs, single-line texts, texts with only additions or only deletions. Performance is O(m * n) for m and n lines — for a 1000-line file comparison, the LCS table is 1 million cells, computed in milliseconds.
Extensions worth exploring: collapsing large unchanged sections (show only N context lines around changes), character-level diffing for single-word changes, three-way merge diffs for conflict resolution, and syntax-aware diffing that understands code structure.
mkdir diff-tool && cd diff-tool
npm init -y
npm install -D typescript @types/node ts-node
npx tsc --init --strict --esModuleInterop --outDir dist --rootDir srcmkdir -p src publicexport type DiffType = "equal" | "add" | "remove";
export interface DiffLine {
type: DiffType;
content: string;
oldLineNumber: number | null;
newLineNumber: number | null;
}
/**
* Compute the LCS table using dynamic programming.
* dp[i][j] = length of LCS of oldLines[0..i-1] and newLines[0..j-1]
*/
function buildLCSTable(oldLines: string[], newLines: string[]): number[][] {
const m = oldLines.length;
const n = newLines.length;
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (oldLines[i - 1] === newLines[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
return dp;
}
/**
* Backtrack through the LCS table to produce the diff.
*/
function backtrack(dp: number[][], oldLines: string[], newLines: string[]): DiffLine[] {
const result: DiffLine[] = [];
let i = oldLines.length;
let j = newLines.length;
let oldNum = oldLines.length;
let newNum = newLines.length;
// Collect in reverse, then reverse at the end
const reversed: DiffLine[] = [];
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) {
reversed.push({
type: "equal",
content: oldLines[i - 1],
oldLineNumber: oldNum,
newLineNumber: newNum,
});
i--;
j--;
oldNum--;
newNum--;
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
reversed.push({
type: "add",
content: newLines[j - 1],
oldLineNumber: null,
newLineNumber: newNum,
});
j--;
newNum--;
} else if (i > 0) {
reversed.push({
type: "remove",
content: oldLines[i - 1],
oldLineNumber: oldNum,
newLineNumber: null,
});
i--;
oldNum--;
}
}
return reversed.reverse();
}
/**
* Compute the diff between two strings.
*/
export function diff(oldText: string, newText: string): DiffLine[] {
const oldLines = oldText.split("\n");
const newLines = newText.split("\n");
const dp = buildLCSTable(oldLines, newLines);
return backtrack(dp, oldLines, newLines);
}
/**
* Generate a unified diff string (like `diff -u` output).
*/
export function toUnifiedDiff(
lines: DiffLine[],
oldName: string = "a",
newName: string = "b"
): string {
const output: string[] = [];
output.push(`--- ${oldName}`);
output.push(`+++ ${newName}`);
for (const line of lines) {
switch (line.type) {
case "equal":
output.push(` ${line.content}`);
break;
case "add":
output.push(`+${line.content}`);
break;
case "remove":
output.push(`-${line.content}`);
break;
}
}
return output.join("\n");
}interface WordChange {
value: string;
type: "equal" | "add" | "remove";
}
/**
* Compute word-level differences between two lines.
* Uses the same LCS approach but on words instead of lines.
*/
export function wordDiff(oldLine: string, newLine: string): WordChange[] {
const oldWords = oldLine.split(/(\s+)/);
const newWords = newLine.split(/(\s+)/);
const m = oldWords.length;
const n = newWords.length;
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (oldWords[i - 1] === newWords[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
const changes: WordChange[] = [];
let i = m;
let j = n;
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && oldWords[i - 1] === newWords[j - 1]) {
changes.unshift({ value: oldWords[i - 1], type: "equal" });
i--;
j--;
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
changes.unshift({ value: newWords[j - 1], type: "add" });
j--;
} else if (i > 0) {
changes.unshift({ value: oldWords[i - 1], type: "remove" });
i--;
}
}
return changes;
}import type { DiffLine } from "./diff.js";
import { wordDiff } from "./word-diff.js";
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
/**
* Render an inline (unified) diff view as HTML.
*/
export function renderInlineDiff(lines: DiffLine[]): string {
const rows = lines.map((line) => {
const oldNum = line.oldLineNumber ?? "";
const newNum = line.newLineNumber ?? "";
const prefix = line.type === "add" ? "+" : line.type === "remove" ? "-" : " ";
const className = line.type === "equal" ? "line-equal" : `line-${line.type}`;
return `<tr class="${className}">
<td class="line-num">${oldNum}</td>
<td class="line-num">${newNum}</td>
<td class="line-prefix">${prefix}</td>
<td class="line-content">${escapeHtml(line.content)}</td>
</tr>`;
});
return `<table class="diff-table inline">${rows.join("")}</table>`;
}
/**
* Render a side-by-side diff view as HTML.
*/
export function renderSideBySideDiff(lines: DiffLine[]): string {
const rows: string[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (line.type === "equal") {
rows.push(`<tr class="line-equal">
<td class="line-num">${line.oldLineNumber}</td>
<td class="line-content">${escapeHtml(line.content)}</td>
<td class="line-num">${line.newLineNumber}</td>
<td class="line-content">${escapeHtml(line.content)}</td>
</tr>`);
i++;
} else if (line.type === "remove" && i + 1 < lines.length && lines[i + 1].type === "add") {
// Paired change — show word-level diff
const removed = line;
const added = lines[i + 1];
const changes = wordDiff(removed.content, added.content);
const oldHtml = changes
.filter((c) => c.type !== "add")
.map((c) =>
c.type === "remove"
? `<mark class="diff-remove-word">${escapeHtml(c.value)}</mark>`
: escapeHtml(c.value)
)
.join("");
const newHtml = changes
.filter((c) => c.type !== "remove")
.map((c) =>
c.type === "add"
? `<mark class="diff-add-word">${escapeHtml(c.value)}</mark>`
: escapeHtml(c.value)
)
.join("");
rows.push(`<tr>
<td class="line-num">${removed.oldLineNumber}</td>
<td class="line-content line-remove">${oldHtml}</td>
<td class="line-num">${added.newLineNumber}</td>
<td class="line-content line-add">${newHtml}</td>
</tr>`);
i += 2;
} else if (line.type === "remove") {
rows.push(`<tr>
<td class="line-num">${line.oldLineNumber}</td>
<td class="line-content line-remove">${escapeHtml(line.content)}</td>
<td class="line-num"></td>
<td class="line-content empty"></td>
</tr>`);
i++;
} else {
rows.push(`<tr>
<td class="line-num"></td>
<td class="line-content empty"></td>
<td class="line-num">${line.newLineNumber}</td>
<td class="line-content line-add">${escapeHtml(line.content)}</td>
</tr>`);
i++;
}
}
return `<table class="diff-table side-by-side">${rows.join("")}</table>`;
}import type { DiffLine } from "./diff.js";
export interface DiffStats {
additions: number;
deletions: number;
unchanged: number;
totalOld: number;
totalNew: number;
changePercentage: number;
}
export function computeStats(lines: DiffLine[]): DiffStats {
let additions = 0;
let deletions = 0;
let unchanged = 0;
for (const line of lines) {
switch (line.type) {
case "add":
additions++;
break;
case "remove":
deletions++;
break;
case "equal":
unchanged++;
break;
}
}
const totalOld = deletions + unchanged;
const totalNew = additions + unchanged;
const totalChanges = additions + deletions;
const totalLines = Math.max(totalOld, totalNew, 1);
const changePercentage = Math.round((totalChanges / totalLines) * 100);
return { additions, deletions, unchanged, totalOld, totalNew, changePercentage };
}
export function renderStatsBar(stats: DiffStats): string {
const total = stats.additions + stats.deletions + stats.unchanged;
const addPct = Math.round((stats.additions / total) * 100);
const delPct = Math.round((stats.deletions / total) * 100);
const eqPct = 100 - addPct - delPct;
return `<div class="stats-bar">
<span class="stat-add">+${stats.additions}</span>
<span class="stat-del">-${stats.deletions}</span>
<span class="stat-pct">${stats.changePercentage}% changed</span>
<div class="bar">
<div class="bar-add" style="width:${addPct}%"></div>
<div class="bar-eq" style="width:${eqPct}%"></div>
<div class="bar-del" style="width:${delPct}%"></div>
</div>
</div>`;
}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Text Diff Viewer</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: system-ui, sans-serif;
background: #0a0a0f;
color: #f0f0f0;
padding: 24px;
}
h1 {
font-size: 22px;
margin-bottom: 16px;
}
.inputs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
.inputs textarea {
width: 100%;
height: 200px;
padding: 12px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
color: #f0f0f0;
font-family: "JetBrains Mono", monospace;
font-size: 13px;
resize: vertical;
}
.inputs label {
font-size: 13px;
color: #a0a0a8;
display: block;
margin-bottom: 4px;
}
.controls {
display: flex;
gap: 12px;
margin-bottom: 16px;
align-items: center;
}
.controls button {
padding: 8px 20px;
background: #10b981;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 500;
}
.controls .toggle {
display: flex;
gap: 4px;
background: rgba(255, 255, 255, 0.04);
border-radius: 8px;
padding: 2px;
}
.controls .toggle button {
background: transparent;
color: #a0a0a8;
font-size: 13px;
padding: 6px 12px;
}
.controls .toggle button.active {
background: rgba(255, 255, 255, 0.08);
color: #f0f0f0;
}
.stats-bar {
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
margin-bottom: 12px;
}
.stat-add {
color: #10b981;
}
.stat-del {
color: #ef4444;
}
.stat-pct {
color: #6b6b75;
}
.bar {
flex: 1;
height: 6px;
display: flex;
border-radius: 3px;
overflow: hidden;
max-width: 200px;
}
.bar-add {
background: #10b981;
}
.bar-eq {
background: rgba(255, 255, 255, 0.08);
}
.bar-del {
background: #ef4444;
}
.diff-table {
width: 100%;
border-collapse: collapse;
font-family: "JetBrains Mono", monospace;
font-size: 13px;
line-height: 1.5;
}
.diff-table td {
padding: 0 12px;
white-space: pre;
vertical-align: top;
}
.line-num {
color: #6b6b75;
text-align: right;
width: 50px;
user-select: none;
border-right: 1px solid rgba(255, 255, 255, 0.06);
}
.line-prefix {
width: 20px;
text-align: center;
color: #6b6b75;
}
.line-equal {
background: transparent;
}
.line-add,
tr .line-add {
background: rgba(16, 185, 129, 0.08);
}
.line-remove,
tr .line-remove {
background: rgba(239, 68, 68, 0.08);
}
.empty {
background: rgba(255, 255, 255, 0.02);
}
.diff-remove-word {
background: rgba(239, 68, 68, 0.25);
border-radius: 2px;
}
.diff-add-word {
background: rgba(16, 185, 129, 0.25);
border-radius: 2px;
}
#output {
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
overflow-x: auto;
}
</style>
</head>
<body>
<h1>Text Diff Viewer</h1>
<div class="inputs">
<div>
<label>Original</label
><textarea id="oldText">
function greet(name) {
console.log("Hello, " + name);
return true;
}</textarea
>
</div>
<div>
<label>Modified</label
><textarea id="newText">
function greet(name, greeting = "Hello") {
const message = `${greeting}, ${name}!`;
console.log(message);
return message;
}</textarea
>
</div>
</div>
<div class="controls">
<button id="diffBtn">Compute Diff</button>
<div class="toggle">
<button id="inlineBtn" class="active">Inline</button>
<button id="sideBtn">Side by Side</button>
</div>
</div>
<div id="statsContainer"></div>
<div id="output"></div>
<script type="module" src="app.js"></script>
</body>
</html>function buildLCSTable(oldLines, newLines) {
const m = oldLines.length;
const n = newLines.length;
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (oldLines[i - 1] === newLines[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
return dp;
}
function computeDiff(oldText, newText) {
const oldLines = oldText.split("\n");
const newLines = newText.split("\n");
const dp = buildLCSTable(oldLines, newLines);
const result = [];
let i = oldLines.length,
j = newLines.length;
let oldNum = i,
newNum = j;
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) {
result.unshift({
type: "equal",
content: oldLines[i - 1],
oldLineNumber: oldNum,
newLineNumber: newNum,
});
i--;
j--;
oldNum--;
newNum--;
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
result.unshift({
type: "add",
content: newLines[j - 1],
oldLineNumber: null,
newLineNumber: newNum,
});
j--;
newNum--;
} else {
result.unshift({
type: "remove",
content: oldLines[i - 1],
oldLineNumber: oldNum,
newLineNumber: null,
});
i--;
oldNum--;
}
}
return result;
}
function escapeHtml(t) {
return t.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
}
function wordDiff(oldLine, newLine) {
const ow = oldLine.split(/(\s+)/),
nw = newLine.split(/(\s+)/);
const m = ow.length,
n = nw.length;
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
for (let i = 1; i <= m; i++)
for (let j = 1; j <= n; j++) {
dp[i][j] =
ow[i - 1] === nw[j - 1] ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1]);
}
const c = [];
let i = m,
j = n;
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && ow[i - 1] === nw[j - 1]) {
c.unshift({ v: ow[i - 1], t: "equal" });
i--;
j--;
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
c.unshift({ v: nw[j - 1], t: "add" });
j--;
} else {
c.unshift({ v: ow[i - 1], t: "remove" });
i--;
}
}
return c;
}
let currentView = "inline";
let currentDiff = [];
function renderInline(lines) {
return `<table class="diff-table inline">${lines
.map((l) => {
const on = l.oldLineNumber ?? "",
nn = l.newLineNumber ?? "";
const p = l.type === "add" ? "+" : l.type === "remove" ? "-" : " ";
return `<tr class="line-${l.type}"><td class="line-num">${on}</td><td class="line-num">${nn}</td><td class="line-prefix">${p}</td><td class="line-content">${escapeHtml(l.content)}</td></tr>`;
})
.join("")}</table>`;
}
function renderSideBySide(lines) {
const rows = [];
let i = 0;
while (i < lines.length) {
const l = lines[i];
if (l.type === "equal") {
rows.push(
`<tr class="line-equal"><td class="line-num">${l.oldLineNumber}</td><td class="line-content">${escapeHtml(l.content)}</td><td class="line-num">${l.newLineNumber}</td><td class="line-content">${escapeHtml(l.content)}</td></tr>`
);
i++;
} else if (l.type === "remove" && i + 1 < lines.length && lines[i + 1].type === "add") {
const wd = wordDiff(l.content, lines[i + 1].content);
const oh = wd
.filter((c) => c.t !== "add")
.map((c) =>
c.t === "remove"
? `<mark class="diff-remove-word">${escapeHtml(c.v)}</mark>`
: escapeHtml(c.v)
)
.join("");
const nh = wd
.filter((c) => c.t !== "remove")
.map((c) =>
c.t === "add" ? `<mark class="diff-add-word">${escapeHtml(c.v)}</mark>` : escapeHtml(c.v)
)
.join("");
rows.push(
`<tr><td class="line-num">${l.oldLineNumber}</td><td class="line-content line-remove">${oh}</td><td class="line-num">${lines[i + 1].newLineNumber}</td><td class="line-content line-add">${nh}</td></tr>`
);
i += 2;
} else if (l.type === "remove") {
rows.push(
`<tr><td class="line-num">${l.oldLineNumber}</td><td class="line-content line-remove">${escapeHtml(l.content)}</td><td class="line-num"></td><td class="line-content empty"></td></tr>`
);
i++;
} else {
rows.push(
`<tr><td class="line-num"></td><td class="line-content empty"></td><td class="line-num">${l.newLineNumber}</td><td class="line-content line-add">${escapeHtml(l.content)}</td></tr>`
);
i++;
}
}
return `<table class="diff-table side-by-side">${rows.join("")}</table>`;
}
function renderStats(lines) {
let a = 0,
d = 0,
u = 0;
for (const l of lines) {
if (l.type === "add") a++;
else if (l.type === "remove") d++;
else u++;
}
const t = Math.max(a + d + u, 1);
return `<div class="stats-bar"><span class="stat-add">+${a}</span><span class="stat-del">-${d}</span><span class="stat-pct">${Math.round(((a + d) / t) * 100)}% changed</span><div class="bar"><div class="bar-add" style="width:${Math.round((a / t) * 100)}%"></div><div class="bar-eq" style="width:${Math.round((u / t) * 100)}%"></div><div class="bar-del" style="width:${Math.round((d / t) * 100)}%"></div></div></div>`;
}
function render() {
document.getElementById("statsContainer").innerHTML = renderStats(currentDiff);
document.getElementById("output").innerHTML =
currentView === "inline" ? renderInline(currentDiff) : renderSideBySide(currentDiff);
}
document.getElementById("diffBtn").addEventListener("click", () => {
currentDiff = computeDiff(
document.getElementById("oldText").value,
document.getElementById("newText").value
);
render();
});
document.getElementById("inlineBtn").addEventListener("click", () => {
currentView = "inline";
document.getElementById("inlineBtn").classList.add("active");
document.getElementById("sideBtn").classList.remove("active");
render();
});
document.getElementById("sideBtn").addEventListener("click", () => {
currentView = "side";
document.getElementById("sideBtn").classList.add("active");
document.getElementById("inlineBtn").classList.remove("active");
render();
});
// Run initial diff
document.getElementById("diffBtn").click();{
"scripts": {
"start": "npx serve public -p 3004",
"build": "tsc"
}
}npm install -D serve
npm start