Initial commit: Diff Highlighter Chrome extension (MV3, TypeScript)

Highlights .diff/.patch files inline — additions green, deletions red,
hunk/file headers styled, with toggleable highlight.js syntax highlighting.

- MV3, content script gated on text/plain + URL ending in .diff/.patch
- Two-phase render: instant diff colors, then chunked idle-time syntax
- Popup switches + Alt+D shortcut, persisted in chrome.storage.sync, live re-render
- Light/dark via prefers-color-scheme; only the `storage` permission
- TypeScript + esbuild build; Forgejo Actions CI (typecheck + build)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tilo Klarenbeek
2026-06-16 13:01:06 +02:00
commit 314c34fe52
24 changed files with 1564 additions and 0 deletions

18
src/background.ts Normal file
View File

@@ -0,0 +1,18 @@
// Service worker: seeds default settings on install and handles the keyboard
// shortcut by flipping the master toggle. Content scripts react via
// chrome.storage.onChanged, so no direct messaging is needed.
import { DEFAULT_SETTINGS } from './settings';
import type { Settings } from './settings';
chrome.runtime.onInstalled.addListener(() => {
chrome.storage.sync.get(DEFAULT_SETTINGS, (current) => {
chrome.storage.sync.set(current as Settings); // persist resolved defaults
});
});
chrome.commands.onCommand.addListener((command) => {
if (command !== 'toggle-highlighting') return;
chrome.storage.sync.get(DEFAULT_SETTINGS, (s) => {
chrome.storage.sync.set({ enabled: !(s as Settings).enabled });
});
});

136
src/content.ts Normal file
View File

@@ -0,0 +1,136 @@
// Runs on text/plain pages whose URL ends in .diff/.patch. Replaces the raw
// <pre> with a colored, optionally syntax-highlighted rendering, and reacts to
// the popup/keyboard toggles live (no reload).
import hljs from 'highlight.js/lib/common';
import { parseDiff } from './diff-parser';
import type { ParsedLine } from './diff-parser';
import { DEFAULT_SETTINGS } from './settings';
import type { Settings } from './settings';
const CHUNK = 300; // code lines highlighted per idle slice
const MAX_SYNTAX_LINES = 50_000; // above this, keep diff colors but skip syntax
// --- Guards: only touch browser-rendered plain-text diffs. -------------------
const pre =
document.contentType === 'text/plain'
? document.querySelector<HTMLPreElement>('body > pre') ??
document.querySelector<HTMLPreElement>('pre')
: null;
if (pre) run(pre);
function run(pre: HTMLPreElement): void {
const raw = pre.textContent ?? '';
// Parse + build the base HTML once; reused on every (re)render.
let baseHtml: string | null = null;
const ensureBaseHtml = (): string => (baseHtml ??= buildHtml(parseDiff(raw)));
let idleHandle: number | null = null;
const cancelHighlight = (): void => {
if (idleHandle !== null) {
cancelIdle(idleHandle);
idleHandle = null;
}
};
const restorePlain = (): void => {
cancelHighlight();
if (pre.classList.contains('dh-on')) {
pre.textContent = raw;
pre.classList.remove('dh-on');
}
};
const render = (settings: Settings): void => {
cancelHighlight();
pre.innerHTML = ensureBaseHtml();
pre.classList.add('dh-on');
if (settings.syntax) scheduleSyntax();
};
const scheduleSyntax = (): void => {
const nodes = Array.from(
pre.querySelectorAll<HTMLElement>('.dh-code[data-lang]'),
);
if (nodes.length > MAX_SYNTAX_LINES) {
console.info(
`[diff-highlighter] ${nodes.length} highlightable lines — skipping syntax for speed`,
);
return;
}
let i = 0;
const step = (): void => {
const end = Math.min(i + CHUNK, nodes.length);
for (; i < end; i++) {
const el = nodes[i]!;
const lang = el.dataset['lang']!;
try {
el.innerHTML = hljs.highlight(el.textContent ?? '', {
language: lang,
ignoreIllegals: true,
}).value;
} catch {
/* leave the escaped text in place */
}
}
idleHandle = i < nodes.length ? requestIdle(step) : null;
};
idleHandle = requestIdle(step);
};
const apply = (settings: Settings): void => {
if (settings.enabled) render(settings);
else restorePlain();
};
const loadAndApply = (): void => {
chrome.storage.sync.get(DEFAULT_SETTINGS, (s) => apply(s as Settings));
};
loadAndApply();
chrome.storage.onChanged.addListener((changes, area) => {
if (area !== 'sync') return;
if ('enabled' in changes || 'syntax' in changes) loadAndApply();
});
}
// --- Rendering helpers -------------------------------------------------------
function buildHtml(lines: ParsedLine[]): string {
let html = '';
for (const line of lines) {
const hasGutter = line.kind === 'add' || line.kind === 'del' || line.kind === 'ctx';
const gutter = hasGutter
? `<span class="dh-gutter">${escapeHtml(line.marker)}</span>`
: '';
const langAttr =
line.lang && hljs.getLanguage(line.lang) ? ` data-lang="${line.lang}"` : '';
html +=
`<span class="dh-line dh-${line.kind}">${gutter}` +
`<span class="dh-code"${langAttr}>${escapeHtml(line.code)}</span></span>`;
}
return html;
}
const ESCAPE: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
};
function escapeHtml(s: string): string {
return s.replace(/[&<>]/g, (c) => ESCAPE[c]!);
}
// --- Idle scheduling (with setTimeout fallback) ------------------------------
const requestIdle: (cb: () => void) => number =
typeof requestIdleCallback === 'function'
? (cb) => requestIdleCallback(cb)
: (cb) => setTimeout(cb, 0) as unknown as number;
const cancelIdle: (handle: number) => void =
typeof cancelIdleCallback === 'function'
? (handle) => cancelIdleCallback(handle)
: (handle) => clearTimeout(handle);

119
src/diff-parser.ts Normal file
View File

@@ -0,0 +1,119 @@
// Pure, DOM-free parsing of unified diff / patch text. Splitting this out keeps
// the logic testable and the content script focused on rendering.
export type LineKind = 'add' | 'del' | 'hunk' | 'file' | 'meta' | 'ctx';
export interface ParsedLine {
kind: LineKind;
/** Gutter content: '+', '-', ' ', or '' for header lines. */
marker: string;
/** The remainder of the line to render (and maybe syntax-highlight). */
code: string;
/** Detected highlight.js language for this line's code, when known. */
lang: string | null;
}
/** File-extension → highlight.js language id (limited to the common bundle). */
const EXT_TO_LANG: Record<string, string> = {
js: 'javascript', jsx: 'javascript', mjs: 'javascript', cjs: 'javascript',
ts: 'typescript', tsx: 'typescript', mts: 'typescript', cts: 'typescript',
py: 'python', pyw: 'python',
rb: 'ruby', go: 'go', rs: 'rust', java: 'java',
kt: 'kotlin', kts: 'kotlin',
c: 'c', h: 'c',
cc: 'cpp', cpp: 'cpp', cxx: 'cpp', hpp: 'cpp', hh: 'cpp', hxx: 'cpp',
cs: 'csharp', php: 'php', swift: 'swift',
sh: 'bash', bash: 'bash', zsh: 'bash',
json: 'json', yml: 'yaml', yaml: 'yaml',
xml: 'xml', html: 'xml', htm: 'xml', svg: 'xml', vue: 'xml', xhtml: 'xml',
css: 'css', scss: 'scss', sass: 'scss', less: 'less',
md: 'markdown', markdown: 'markdown',
sql: 'sql', lua: 'lua',
pl: 'perl', pm: 'perl',
r: 'r',
ini: 'ini', toml: 'ini', cfg: 'ini', conf: 'ini',
mk: 'makefile',
graphql: 'graphql', gql: 'graphql',
m: 'objectivec', mm: 'objectivec',
vb: 'vbnet',
};
const META_PREFIXES = [
'diff ', 'index ', 'new file', 'deleted file', 'old mode', 'new mode',
'rename ', 'copy ', 'similarity ', 'dissimilarity ', 'Binary files',
'GIT binary patch', '\\ No newline',
];
export function classifyLine(line: string): LineKind {
if (line.startsWith('@@')) return 'hunk';
// Must precede the +/- checks below.
if (line.startsWith('+++') || line.startsWith('---')) return 'file';
if (line.startsWith('+')) return 'add';
if (line.startsWith('-')) return 'del';
for (const p of META_PREFIXES) if (line.startsWith(p)) return 'meta';
return 'ctx';
}
function splitMarker(line: string, kind: LineKind): { marker: string; code: string } {
if (kind === 'add' || kind === 'del') return { marker: line[0]!, code: line.slice(1) };
if (kind === 'ctx') {
if (line.startsWith(' ')) return { marker: ' ', code: line.slice(1) };
return { marker: '', code: line }; // blank / no-prefix line
}
return { marker: '', code: line }; // hunk / file / meta shown whole
}
/** Map a file path to a highlight.js language id, or null if unknown. */
export function languageForPath(p: string): string | null {
const base = (p.split('/').pop() ?? p).trim();
if (base.toLowerCase() === 'makefile') return 'makefile';
if (base.toLowerCase() === 'dockerfile') return null;
const dot = base.lastIndexOf('.');
if (dot <= 0) return null;
return EXT_TO_LANG[base.slice(dot + 1).toLowerCase()] ?? null;
}
/** Extract the relevant path from a `diff --git`, `---`, or `+++` header line. */
function pathFromHeader(line: string): string | null {
if (line.startsWith('diff --git')) {
const m = line.match(/ b\/(.+)$/);
return m ? m[1]! : null;
}
if (line.startsWith('+++ ') || line.startsWith('--- ')) {
let p = line.slice(4).replace(/\t.*$/, '').trim(); // drop trailing tab+timestamp
if (p === '/dev/null') return null;
p = p.replace(/^[ab]\//, '');
return p || null;
}
return null;
}
/** Parse raw diff text into classified lines, threading the per-file language. */
export function parseDiff(raw: string): ParsedLine[] {
const out: ParsedLine[] = [];
let lang: string | null = null;
for (const line of raw.split('\n')) {
const kind = classifyLine(line);
if (kind === 'meta' && line.startsWith('diff --git')) {
// New file section in a git diff — reset the language (may be unknown).
const p = pathFromHeader(line);
lang = p ? languageForPath(p) : null;
} else if (kind === 'file') {
const p = pathFromHeader(line);
if (line.startsWith('--- ')) {
lang = p ? languageForPath(p) : null; // start of a plain unified-diff section
} else if (p) {
const refined = languageForPath(p); // +++ new path is most authoritative
if (refined) lang = refined;
}
}
const { marker, code } = splitMarker(line, kind);
const codeLang = kind === 'add' || kind === 'del' || kind === 'ctx' ? lang : null;
out.push({ kind, marker, code, lang: codeLang });
}
return out;
}

7
src/hljs-common.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
// The `highlight.js/lib/common` subpath (core + ~37 common languages) ships JS
// but no dedicated typings, so reuse the main package's API type.
declare module 'highlight.js/lib/common' {
import type { HLJSApi } from 'highlight.js';
const hljs: HLJSApi;
export default hljs;
}

41
src/manifest.json Normal file
View File

@@ -0,0 +1,41 @@
{
"manifest_version": 3,
"name": "Diff Highlighter",
"version": "1.0.0",
"description": "Highlights .diff and .patch files — additions green, deletions red, with toggleable syntax highlighting.",
"permissions": ["storage"],
"action": {
"default_popup": "popup.html",
"default_title": "Diff Highlighter",
"default_icon": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"icons": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"background": {
"service_worker": "background.js"
},
"content_scripts": [
{
"matches": ["https://*/*", "http://*/*", "file:///*"],
"include_globs": ["*.diff", "*.diff?*", "*.patch", "*.patch?*"],
"js": ["content.js"],
"css": ["diff.css", "hljs-theme.css"],
"run_at": "document_end"
}
],
"commands": {
"toggle-highlighting": {
"suggested_key": { "default": "Alt+D" },
"description": "Toggle diff highlighting"
}
}
}

32
src/popup/popup.html Normal file
View File

@@ -0,0 +1,32 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="popup.css" />
</head>
<body>
<h1>Diff Highlighter</h1>
<label class="row">
<span class="label">Highlighting</span>
<input type="checkbox" id="enabled" class="switch" />
</label>
<label class="row">
<span class="label">Syntax highlighting</span>
<input type="checkbox" id="syntax" class="switch" />
</label>
<p class="hint">
Toggle anywhere with <kbd>Alt</kbd>+<kbd>D</kbd>
(rebind at <code>chrome://extensions/shortcuts</code>).
</p>
<p class="hint">
For local <code>file://</code> diffs, enable
<em>“Allow access to file URLs”</em> on the extensions details page.
</p>
<script src="popup.js"></script>
</body>
</html>

29
src/popup/popup.ts Normal file
View File

@@ -0,0 +1,29 @@
// Drives the popup switches and persists them. The content script reads the
// same chrome.storage.sync keys and re-renders the open diff live.
import { DEFAULT_SETTINGS } from '../settings';
import type { Settings } from '../settings';
const enabledEl = document.getElementById('enabled') as HTMLInputElement;
const syntaxEl = document.getElementById('syntax') as HTMLInputElement;
function reflectDisabled(enabled: boolean): void {
// Syntax highlighting only makes sense while the master toggle is on.
syntaxEl.disabled = !enabled;
syntaxEl.closest('.row')?.classList.toggle('muted', !enabled);
}
chrome.storage.sync.get(DEFAULT_SETTINGS, (s) => {
const settings = s as Settings;
enabledEl.checked = settings.enabled;
syntaxEl.checked = settings.syntax;
reflectDisabled(settings.enabled);
});
enabledEl.addEventListener('change', () => {
chrome.storage.sync.set({ enabled: enabledEl.checked });
reflectDisabled(enabledEl.checked);
});
syntaxEl.addEventListener('change', () => {
chrome.storage.sync.set({ syntax: syntaxEl.checked });
});

12
src/settings.ts Normal file
View File

@@ -0,0 +1,12 @@
/** User-facing toggles, persisted in chrome.storage.sync. */
export interface Settings {
/** Master switch for the green/red diff layer. */
enabled: boolean;
/** Layer language-aware syntax highlighting on top of the diff colors. */
syntax: boolean;
}
export const DEFAULT_SETTINGS: Settings = {
enabled: true,
syntax: true,
};

82
src/styles/diff.css Normal file
View File

@@ -0,0 +1,82 @@
/* Diff layout + line colors. Everything is scoped under `pre.dh-on` so toggling
the extension off (which removes that class) fully restores the plain view. */
pre.dh-on {
margin: 0;
padding: 12px 0;
min-height: 100vh;
box-sizing: border-box;
tab-size: 4;
font: 12px/1.5 ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
"Liberation Mono", monospace;
background: #ffffff;
color: #1f2328;
}
pre.dh-on .dh-line {
display: block;
white-space: pre;
padding: 0 12px;
}
pre.dh-on .dh-gutter {
display: inline-block;
width: 2ch;
text-align: center;
user-select: none;
opacity: 0.55;
}
pre.dh-on .dh-add {
background: #e6ffec;
}
pre.dh-on .dh-del {
background: #ffebe9;
}
pre.dh-on .dh-add .dh-gutter {
color: #1a7f37;
opacity: 1;
}
pre.dh-on .dh-del .dh-gutter {
color: #cf222e;
opacity: 1;
}
pre.dh-on .dh-hunk {
color: #8250df;
background: #f1eeff;
}
pre.dh-on .dh-file {
color: #57606a;
font-weight: 600;
}
pre.dh-on .dh-meta {
color: #57606a;
}
@media (prefers-color-scheme: dark) {
pre.dh-on {
background: #0d1117;
color: #e6edf3;
}
pre.dh-on .dh-add {
background: rgba(46, 160, 67, 0.15);
}
pre.dh-on .dh-del {
background: rgba(248, 81, 73, 0.15);
}
pre.dh-on .dh-add .dh-gutter {
color: #3fb950;
}
pre.dh-on .dh-del .dh-gutter {
color: #f85149;
}
pre.dh-on .dh-hunk {
color: #d2a8ff;
background: rgba(56, 139, 253, 0.1);
}
pre.dh-on .dh-file,
pre.dh-on .dh-meta {
color: #8b949e;
}
}

103
src/styles/hljs-theme.css Normal file
View File

@@ -0,0 +1,103 @@
/* Compact highlight.js token theme (GitHub-like), light + dark. Scoped to the
diff view; the diff line background sits behind these foreground colors. */
pre.dh-on .hljs-comment,
pre.dh-on .hljs-quote {
color: #6e7781;
font-style: italic;
}
pre.dh-on .hljs-keyword,
pre.dh-on .hljs-selector-tag,
pre.dh-on .hljs-literal,
pre.dh-on .hljs-doctag,
pre.dh-on .hljs-meta .hljs-keyword {
color: #cf222e;
}
pre.dh-on .hljs-string,
pre.dh-on .hljs-regexp,
pre.dh-on .hljs-addition,
pre.dh-on .hljs-symbol,
pre.dh-on .hljs-bullet,
pre.dh-on .hljs-link {
color: #0a3069;
}
pre.dh-on .hljs-number,
pre.dh-on .hljs-variable,
pre.dh-on .hljs-template-variable,
pre.dh-on .hljs-attr,
pre.dh-on .hljs-attribute,
pre.dh-on .hljs-property {
color: #0550ae;
}
pre.dh-on .hljs-title,
pre.dh-on .hljs-title.function_,
pre.dh-on .hljs-section {
color: #8250df;
}
pre.dh-on .hljs-type,
pre.dh-on .hljs-class .hljs-title,
pre.dh-on .hljs-title.class_,
pre.dh-on .hljs-built_in {
color: #953800;
}
pre.dh-on .hljs-name,
pre.dh-on .hljs-tag {
color: #116329;
}
pre.dh-on .hljs-meta {
color: #6e7781;
}
pre.dh-on .hljs-emphasis {
font-style: italic;
}
pre.dh-on .hljs-strong {
font-weight: 600;
}
@media (prefers-color-scheme: dark) {
pre.dh-on .hljs-comment,
pre.dh-on .hljs-quote {
color: #8b949e;
}
pre.dh-on .hljs-keyword,
pre.dh-on .hljs-selector-tag,
pre.dh-on .hljs-literal,
pre.dh-on .hljs-doctag,
pre.dh-on .hljs-meta .hljs-keyword {
color: #ff7b72;
}
pre.dh-on .hljs-string,
pre.dh-on .hljs-regexp,
pre.dh-on .hljs-addition,
pre.dh-on .hljs-symbol,
pre.dh-on .hljs-bullet,
pre.dh-on .hljs-link {
color: #a5d6ff;
}
pre.dh-on .hljs-number,
pre.dh-on .hljs-variable,
pre.dh-on .hljs-template-variable,
pre.dh-on .hljs-attr,
pre.dh-on .hljs-attribute,
pre.dh-on .hljs-property {
color: #79c0ff;
}
pre.dh-on .hljs-title,
pre.dh-on .hljs-title.function_,
pre.dh-on .hljs-section {
color: #d2a8ff;
}
pre.dh-on .hljs-type,
pre.dh-on .hljs-class .hljs-title,
pre.dh-on .hljs-title.class_,
pre.dh-on .hljs-built_in {
color: #ffa657;
}
pre.dh-on .hljs-name,
pre.dh-on .hljs-tag {
color: #7ee787;
}
pre.dh-on .hljs-meta {
color: #8b949e;
}
}

75
src/styles/popup.css Normal file
View File

@@ -0,0 +1,75 @@
:root {
color-scheme: light dark;
}
body {
width: 260px;
margin: 0;
padding: 14px 16px;
font: 13px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: #ffffff;
color: #1f2328;
}
h1 {
font-size: 14px;
margin: 0 0 10px;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 7px 0;
}
.row .label {
font-weight: 500;
}
.row.muted {
opacity: 0.5;
}
.switch {
width: 18px;
height: 18px;
margin: 0;
accent-color: #1a7f37;
cursor: pointer;
}
.hint {
margin: 10px 0 0;
font-size: 11px;
color: #57606a;
}
kbd {
font-family: ui-monospace, monospace;
font-size: 10px;
background: #f3f4f6;
border: 1px solid #d0d7de;
border-bottom-width: 2px;
border-radius: 4px;
padding: 0 4px;
}
code {
font-family: ui-monospace, monospace;
font-size: 11px;
}
@media (prefers-color-scheme: dark) {
body {
background: #1c2128;
color: #e6edf3;
}
.hint {
color: #8b949e;
}
kbd {
background: #2d333b;
border-color: #444c56;
}
}