Side-by-side rich diff viewer for Markdown files. Compare documents with intelligent block-level and character-level diffing, rendered in an interactive browser UI.
- Smart Block Matching - Uses similarity-based LCS algorithm to match paragraphs, even when they've been moved or partially edited
- Character-Level Diffs - Highlights exact changes within words (e.g., "Oxytocin" → "oxytocin" shows only the case change)
- Minor Change Detection - Subtle styling for case-only and punctuation-only changes
- Stop-Word Absorption - Prevents noise from isolated articles ("the", "a", "of") appearing as changes
- Git Integration - Compare files between any git refs (commits, branches, tags)
- Multi-File Diffs - Diff all changed markdown files between git refs at once
- Two Themes - Dark mode and Solar (warm sepia) themes with toggle
- Synchronized Scrolling - Left and right panes scroll together
- Keyboard Navigation - Alt+Up/Down to switch between files in multi-file mode
# Clone and install
git clone https://github.com/PiotrTrzpil/markdown-diff-viewer.git
cd markdown-diff-viewer
pnpm install
# Build
pnpm build
# Link globally (optional)
pnpm link --globalUse the engine without the CLI HTML shell (browser-safe):
import {
diffMarkdown,
toHostPayload,
changedBlocks,
isModifiedBlock,
} from "markdown-diff-viewer";
import { planLayout } from "markdown-diff-viewer/layout"; // optional subpath
const result = diffMarkdown(oldMd, newMd, {
matchLevel: "normal", // "strict" | "normal" | "loose" | "inline"
});
// result.schemaVersion — 1 (bump on incompatible JSON shape changes)
// result.document — DiffBlock[] (markdown + inline parts)
// result.layout — side-by-side vs stacked groups
// result.stats — block/word counts
// Wire / IPC: drop internal mdast pairs
const payload = toHostPayload(result);
// Host helpers
for (const block of changedBlocks(result.document)) {
if (isModifiedBlock(block)) {
// block.inline — character/word-level parts
}
}documentis the preferred host contract (JSON-serializable; no mdast).- Browser purity is enforced:
pnpm check:browser(nocli/git/ui/ Node core in the entry graph). - Node-only git helpers:
import { getGitFileContent } from "markdown-diff-viewer/git". - Architecture notes:
docs/ARCHITECTURE.md.
Tab completion is available for Bash, Zsh, and Fish. Completions support:
- Git shortcuts (
@main,@~1,@origin/develop) - CLI options (
--theme,--debug, etc.) - Fuzzy file search (type partial filename to match anywhere in path)
The fuzzy file search uses fd if installed, falling back to git ls-files. Both respect .gitignore automatically.
Add to ~/.bashrc:
eval "$(md-diff --completions bash)"Generate the completion file (run once):
md-diff --completions zsh > ~/.zsh/completions/_md-diffMake sure ~/.zsh/completions is in your fpath (add to ~/.zshrc before compinit):
fpath=(~/.zsh/completions $fpath)
autoload -Uz compinit && compinitmd-diff --completions fish > ~/.config/fish/completions/md-diff.fishmd-diff before.md after.mdOpens the diff in your default browser.
Compare a file between two git refs (commits, branches, tags):
# Compare between commits
md-diff --git HEAD~1 HEAD README.md
# Compare between branches
md-diff --git main feature-branch docs/api.md
# Compare with a specific commit
md-diff --git abc123 def456 notes.mdOmit the filename to diff all changed .md files between refs:
# All markdown changes in the last commit
md-diff --git HEAD~1 HEAD
# All markdown changes between branches
md-diff --git main developThis creates a multi-file viewer with a dropdown to switch between files.
# Write to a specific file instead of temp
md-diff before.md after.md --out diff.html
# Write to stdout (for piping)
md-diff before.md after.md --out -
# Don't auto-open browser
md-diff before.md after.md --no-open# Use solar (warm sepia) theme
md-diff before.md after.md --theme solar
# Use dark theme (default)
md-diff before.md after.md --theme darkThe theme can also be toggled in the viewer using the button in the header.
- Parses both Markdown files into AST (Abstract Syntax Tree) using remark
- Extracts top-level blocks (paragraphs, headings, code blocks, lists, etc.)
- Computes similarity between all block pairs using Dice coefficient on character bigrams
- Finds optimal block alignment using LCS (Longest Common Subsequence) weighted by similarity
- Blocks above the
matchLevelsimilarity bar (strict70%,normal60%,loose40%) are matched as "modified"; others as added/removed.inlinealso zips leftover adjacent delete/add paragraphs and always inline-diffs them.
For modified blocks:
- Tokenizes text into words with preserved whitespace
- Finds contiguous matching runs of 3+ words to anchor the diff
- Refines remaining segments with character-level diff
- Detects minor changes (case-only, punctuation-only) for subtle highlighting
- Absorbs isolated stop words to reduce noise
- Side-by-side: Blocks with shared content shown in two columns
- Stacked: Completely different blocks shown as removed block(s), then added block(s)
- Uses semantic HTML (
<ins>,<del>) with CSS styling - Supports full GitHub Flavored Markdown (tables, task lists, strikethrough, etc.)
| Content Type | Display |
|---|---|
| Identical blocks | Side-by-side, no highlighting |
| Modified with shared text | Side-by-side with inline highlighting |
| Completely different | Stacked (removed first, then added) |
| Removed only | Left side only, right side empty |
| Added only | Right side only, left side empty |
When comparing:
The "Sacred Canopy" protects us.to:
The sacred canopy protects us.The viewer shows subtle underlines on the changed characters ("S → s, C → c, removed quotes) rather than highlighting entire words.
When a paragraph is completely rewritten, the viewer stacks the old version (red) above the new version (green) instead of showing confusing side-by-side comparisons.
# Install dependencies
pnpm install
# Build TypeScript
pnpm build
# Watch mode
pnpm dev
# Run tests
pnpm test
# Run CLI directly
node dist/cli.js before.md after.md
# Corpus quality checker (rebuilds dist)
just check-corpus
just check-corpus --git ~/path/to/repo HEAD~1 '**/*.md'scripts/check-corpus.mjs walks markdown pairs through the built engine and reports pairing/inline quality bugs: unlike paragraphs glued into one modified block, HTML comments zipped onto prose, bibliography lines with different cite keys treated as one edit, reconstructed sides that drop source text, and leaked protect-markdown markers. With no arguments it runs the paired files under test/fixtures. --against old/dist/index.js also prints pairing and reconstructed-side diffs versus another build. The process exits 1 only when the current engine has quality findings.
src/
├── index.ts # Public library entry (diffMarkdown, model, layout)
├── api.ts # Engine composition root
├── config.ts # Constants + re-entrant DiffContext
├── model/ # DiffDocument, DiffStats (serializable)
├── layout/ # Host-agnostic layout plan
├── core/ # Block matching, inline diff, pipeline
├── text/ # Parse, tokens, protect, similarity
├── render/ # HTML row adapter (CLI)
├── ui/ # Standalone browser shell
├── git/ # Node git helpers
└── cli/ # commander entry + output
- unified - Markdown processing pipeline
- remark - Markdown parser
- remark-gfm - GitHub Flavored Markdown
- diff - Character-level diffing
- @kitajs/html - JSX to HTML (no React)
- open - Open URLs in browser
MIT
