commit 314c34fe52c95816b0b987d9d69ec3e95e7b3166 Author: Tilo Klarenbeek Date: Tue Jun 16 13:01:06 2026 +0200 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) diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..5808688 --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,15 @@ +name: build +on: [push, pull_request] + +jobs: + build: + # On Codeberg's hosted runner use a label such as `codeberg-tiny`. + runs-on: docker + container: + image: node:22-bookworm + steps: + # Forgejo resolves actions from data.forgejo.org, hence the full URL. + - uses: https://data.forgejo.org/actions/checkout@v4 + - run: npm ci + - run: npm run typecheck + - run: npm run build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd6e803 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.log +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..cadf96c --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +# Diff Highlighter + +A Chrome (Manifest V3) extension that highlights `.diff` and `.patch` files in the +browser: **additions green, deletions red**, with hunk/file headers styled and the +underlying code **syntax-highlighted** by language. Works on GitHub/Bitbucket PR +`.diff`/`.patch` URLs, raw files, and local `file://` diffs. Light/dark aware, fast, +and fully toggleable. + +## How it works + +- A content script runs only on `text/plain` pages whose URL ends in `.diff`/`.patch` + (the `include_globs` matcher covers query strings too, e.g. `…/123.diff?token=…`). +- It reads the browser-rendered `
`, parses the unified-diff structure, and:
+  1. **Instantly** repaints every line with its diff color (green/red/context/headers).
+  2. **Progressively** layers syntax highlighting via [highlight.js](https://highlightjs.org/),
+     processed in idle-time chunks so large diffs stay responsive. Above ~50k highlightable
+     lines, syntax is skipped to preserve speed (diff colors remain).
+- Toggling is instant and reload-free: the popup writes `chrome.storage.sync`, and the
+  content script re-renders on change. `Alt+D` flips the master toggle from anywhere.
+- Only the `storage` permission is requested — declarative content scripts need no host
+  permissions.
+
+## Toggles
+
+| Control | Effect |
+| --- | --- |
+| **Highlighting** (popup) | Master switch for the green/red diff layer. |
+| **Syntax highlighting** (popup) | Layer language-aware token colors on top. |
+| `Alt+D` | Flip the master switch (rebind at `chrome://extensions/shortcuts`). |
+
+## Build
+
+Requires Node 20+.
+
+```sh
+npm ci
+npm run build      # type-checks, then bundles to dist/
+npm run watch      # rebuild on change (dev)
+```
+
+`dist/` is the unpacked extension (git-ignored).
+
+## Load in Chrome
+
+1. `chrome://extensions` → enable **Developer mode**.
+2. **Load unpacked** → select the `dist/` folder.
+3. For local diffs, open the extension's **Details** page and enable
+   **“Allow access to file URLs.”** Then try `samples/example.diff`.
+
+## Continuous integration (Codeberg / Forgejo Actions)
+
+CI lives in [`.forgejo/workflows/ci.yml`](.forgejo/workflows/ci.yml) and runs
+`npm ci → typecheck → build` in a `node:22-bookworm` container — the same steps as a
+local build, so a green local build means green CI.
+
+To wire it up on Codeberg:
+
+1. Push this repo to Codeberg:
+   ```sh
+   git remote add origin https://codeberg.org//diff-highlighter.git
+   git push -u origin main
+   ```
+2. Connect a runner: repo **Settings → Actions → Runners** (or use Codeberg's hosted
+   runners). If using a hosted runner, change `runs-on: docker` to its label
+   (e.g. `codeberg-tiny`).
+
+## Scope
+
+Triggers on URLs/files whose path ends in `.diff`/`.patch` and that render as plain text.
+In-app **HTML** diff views (GitHub's "Files changed", Bitbucket's `/diff` endpoint) are out
+of scope — those are full HTML pages, not raw text.
+
+## Layout
+
+```
+src/
+  manifest.json      MV3 manifest
+  content.ts         detect + render + live toggle
+  diff-parser.ts     pure diff parsing / language detection
+  background.ts      defaults + Alt+D command
+  popup/             popup UI
+  styles/            diff.css, hljs-theme.css, popup.css (all light/dark)
+scripts/             build.mjs (esbuild), make-icons.mjs
+.forgejo/workflows/  CI
+```
diff --git a/icons/icon128.png b/icons/icon128.png
new file mode 100644
index 0000000..19071f4
Binary files /dev/null and b/icons/icon128.png differ
diff --git a/icons/icon16.png b/icons/icon16.png
new file mode 100644
index 0000000..f6c8172
Binary files /dev/null and b/icons/icon16.png differ
diff --git a/icons/icon32.png b/icons/icon32.png
new file mode 100644
index 0000000..1c74f12
Binary files /dev/null and b/icons/icon32.png differ
diff --git a/icons/icon48.png b/icons/icon48.png
new file mode 100644
index 0000000..3fd9bf9
Binary files /dev/null and b/icons/icon48.png differ
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..87bcbd9
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,580 @@
+{
+  "name": "diff-highlighter",
+  "version": "1.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "diff-highlighter",
+      "version": "1.0.0",
+      "dependencies": {
+        "highlight.js": "^11.10.0"
+      },
+      "devDependencies": {
+        "@types/chrome": "^0.0.287",
+        "@types/node": "^22.10.0",
+        "esbuild": "^0.28.1",
+        "typescript": "^5.7.2"
+      }
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
+      "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
+      "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
+      "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
+      "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
+      "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
+      "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
+      "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
+      "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
+      "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
+      "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
+      "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
+      "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
+      "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
+      "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
+      "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
+      "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
+      "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
+      "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
+      "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
+      "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
+      "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openharmony-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
+      "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
+      "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
+      "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
+      "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
+      "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@types/chrome": {
+      "version": "0.0.287",
+      "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.287.tgz",
+      "integrity": "sha512-wWhBNPNXZHwycHKNYnexUcpSbrihVZu++0rdp6GEk5ZgAglenLx+RwdEouh6FrHS0XQiOxSd62yaujM1OoQlZQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/filesystem": "*",
+        "@types/har-format": "*"
+      }
+    },
+    "node_modules/@types/filesystem": {
+      "version": "0.0.36",
+      "resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz",
+      "integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/filewriter": "*"
+      }
+    },
+    "node_modules/@types/filewriter": {
+      "version": "0.0.33",
+      "resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz",
+      "integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/har-format": {
+      "version": "1.2.16",
+      "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz",
+      "integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/node": {
+      "version": "22.19.21",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz",
+      "integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": "~6.21.0"
+      }
+    },
+    "node_modules/esbuild": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
+      "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.28.1",
+        "@esbuild/android-arm": "0.28.1",
+        "@esbuild/android-arm64": "0.28.1",
+        "@esbuild/android-x64": "0.28.1",
+        "@esbuild/darwin-arm64": "0.28.1",
+        "@esbuild/darwin-x64": "0.28.1",
+        "@esbuild/freebsd-arm64": "0.28.1",
+        "@esbuild/freebsd-x64": "0.28.1",
+        "@esbuild/linux-arm": "0.28.1",
+        "@esbuild/linux-arm64": "0.28.1",
+        "@esbuild/linux-ia32": "0.28.1",
+        "@esbuild/linux-loong64": "0.28.1",
+        "@esbuild/linux-mips64el": "0.28.1",
+        "@esbuild/linux-ppc64": "0.28.1",
+        "@esbuild/linux-riscv64": "0.28.1",
+        "@esbuild/linux-s390x": "0.28.1",
+        "@esbuild/linux-x64": "0.28.1",
+        "@esbuild/netbsd-arm64": "0.28.1",
+        "@esbuild/netbsd-x64": "0.28.1",
+        "@esbuild/openbsd-arm64": "0.28.1",
+        "@esbuild/openbsd-x64": "0.28.1",
+        "@esbuild/openharmony-arm64": "0.28.1",
+        "@esbuild/sunos-x64": "0.28.1",
+        "@esbuild/win32-arm64": "0.28.1",
+        "@esbuild/win32-ia32": "0.28.1",
+        "@esbuild/win32-x64": "0.28.1"
+      }
+    },
+    "node_modules/highlight.js": {
+      "version": "11.11.1",
+      "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
+      "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=12.0.0"
+      }
+    },
+    "node_modules/typescript": {
+      "version": "5.9.3",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+      "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/undici-types": {
+      "version": "6.21.0",
+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+      "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+      "dev": true,
+      "license": "MIT"
+    }
+  }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..015e436
--- /dev/null
+++ b/package.json
@@ -0,0 +1,21 @@
+{
+  "name": "diff-highlighter",
+  "version": "1.0.0",
+  "description": "Chrome extension that highlights .diff and .patch files — additions green, deletions red, with toggleable syntax highlighting.",
+  "private": true,
+  "type": "module",
+  "scripts": {
+    "typecheck": "tsc --noEmit",
+    "build": "tsc --noEmit && node scripts/build.mjs",
+    "watch": "node scripts/build.mjs --watch"
+  },
+  "dependencies": {
+    "highlight.js": "^11.10.0"
+  },
+  "devDependencies": {
+    "@types/chrome": "^0.0.287",
+    "@types/node": "^22.10.0",
+    "esbuild": "^0.28.1",
+    "typescript": "^5.7.2"
+  }
+}
diff --git a/samples/example.diff b/samples/example.diff
new file mode 100644
index 0000000..51f083e
--- /dev/null
+++ b/samples/example.diff
@@ -0,0 +1,35 @@
+diff --git a/src/greet.ts b/src/greet.ts
+index 1a2b3c4..5d6e7f8 100644
+--- a/src/greet.ts
++++ b/src/greet.ts
+@@ -1,8 +1,9 @@
+ // Greeting helpers
+-export function greet(name) {
+-  return "Hello " + name;
++export function greet(name: string): string {
++  const trimmed = name.trim();
++  return `Hello, ${trimmed || "world"}!`;
+ }
+
+-console.log(greet("there"));
++console.log(greet("  there  "));
+diff --git a/scripts/run.py b/scripts/run.py
+index aaaaaaa..bbbbbbb 100644
+--- a/scripts/run.py
++++ b/scripts/run.py
+@@ -1,4 +1,5 @@
+ import sys
+
+-def main():
+-    print("hi")
++def main() -> int:
++    print("hello from python")
++    return 0
+diff --git a/README.md b/README.md
+new file mode 100644
+index 0000000..ccccccc
+--- /dev/null
++++ b/README.md
+@@ -0,0 +1,2 @@
++# Demo
++A tiny sample used to exercise the highlighter.
diff --git a/scripts/build.mjs b/scripts/build.mjs
new file mode 100644
index 0000000..ee0bf8e
--- /dev/null
+++ b/scripts/build.mjs
@@ -0,0 +1,57 @@
+// Builds the extension into dist/: bundles the TypeScript entry points with
+// esbuild and copies the static assets (manifest, popup HTML, CSS, icons).
+// Run `node scripts/build.mjs` for a one-shot build, or with `--watch` for dev.
+import * as esbuild from 'esbuild';
+import { cp, mkdir, rm } from 'node:fs/promises';
+import { existsSync } from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const dist = path.join(root, 'dist');
+const watch = process.argv.includes('--watch');
+
+/** Copy a file or directory from a repo-relative path into dist/ (flattened). */
+async function copy(from, to) {
+  const src = path.join(root, from);
+  if (!existsSync(src)) return;
+  await cp(src, path.join(dist, to), { recursive: true });
+}
+
+async function copyStatic() {
+  await copy('src/manifest.json', 'manifest.json');
+  await copy('src/popup/popup.html', 'popup.html');
+  await copy('src/styles/diff.css', 'diff.css');
+  await copy('src/styles/hljs-theme.css', 'hljs-theme.css');
+  await copy('src/styles/popup.css', 'popup.css');
+  await copy('icons', 'icons');
+}
+
+const options = {
+  entryPoints: {
+    content: path.join(root, 'src/content.ts'),
+    background: path.join(root, 'src/background.ts'),
+    popup: path.join(root, 'src/popup/popup.ts'),
+  },
+  outdir: dist,
+  entryNames: '[name]',
+  bundle: true,
+  format: 'iife',
+  target: ['chrome120'],
+  minify: !watch,
+  sourcemap: watch ? 'inline' : false,
+  logLevel: 'info',
+};
+
+await rm(dist, { recursive: true, force: true });
+await mkdir(dist, { recursive: true });
+await copyStatic();
+
+if (watch) {
+  const ctx = await esbuild.context(options);
+  await ctx.watch();
+  console.log('watching for changes…');
+} else {
+  await esbuild.build(options);
+  console.log('built ->', path.relative(root, dist));
+}
diff --git a/scripts/make-icons.mjs b/scripts/make-icons.mjs
new file mode 100644
index 0000000..72cb2f2
--- /dev/null
+++ b/scripts/make-icons.mjs
@@ -0,0 +1,91 @@
+// Generates the extension icons (green "added" band over a red "removed" band)
+// as PNGs, with zero dependencies — just Node's zlib. Run: node scripts/make-icons.mjs
+import zlib from 'node:zlib';
+import { writeFileSync, mkdirSync } from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const outDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../icons');
+
+const CRC_TABLE = (() => {
+  const t = new Uint32Array(256);
+  for (let n = 0; n < 256; n++) {
+    let c = n;
+    for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
+    t[n] = c >>> 0;
+  }
+  return t;
+})();
+
+function crc32(buf) {
+  let c = 0xffffffff;
+  for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
+  return (c ^ 0xffffffff) >>> 0;
+}
+
+function chunk(type, data) {
+  const len = Buffer.alloc(4);
+  len.writeUInt32BE(data.length, 0);
+  const typeBuf = Buffer.from(type, 'ascii');
+  const crc = Buffer.alloc(4);
+  crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
+  return Buffer.concat([len, typeBuf, data, crc]);
+}
+
+function png(size, rgba) {
+  const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
+  const ihdr = Buffer.alloc(13);
+  ihdr.writeUInt32BE(size, 0);
+  ihdr.writeUInt32BE(size, 4);
+  ihdr[8] = 8; // bit depth
+  ihdr[9] = 6; // color type RGBA
+  const stride = size * 4;
+  const raw = Buffer.alloc((stride + 1) * size);
+  for (let y = 0; y < size; y++) {
+    raw[y * (stride + 1)] = 0; // filter: none
+    rgba.copy(raw, y * (stride + 1) + 1, y * stride, y * stride + stride);
+  }
+  const idat = zlib.deflateSync(raw, { level: 9 });
+  return Buffer.concat([sig, chunk('IHDR', ihdr), chunk('IDAT', idat), chunk('IEND', Buffer.alloc(0))]);
+}
+
+function insideRounded(x, y, x0, y0, x1, y1, r) {
+  const cxL = x0 + r, cxR = x1 - r, cyT = y0 + r, cyB = y1 - r;
+  let cx = null, cy = null;
+  if (x < cxL && y < cyT) { cx = cxL; cy = cyT; }
+  else if (x > cxR && y < cyT) { cx = cxR; cy = cyT; }
+  else if (x < cxL && y > cyB) { cx = cxL; cy = cyB; }
+  else if (x > cxR && y > cyB) { cx = cxR; cy = cyB; }
+  if (cx === null) return true;
+  const dx = x - cx, dy = y - cy;
+  return dx * dx + dy * dy <= r * r;
+}
+
+function makeIcon(size) {
+  const px = Buffer.alloc(size * size * 4); // transparent
+  const green = [46, 160, 67, 255];
+  const red = [215, 58, 73, 255];
+  const inset = Math.max(1, Math.round(size * 0.06));
+  const r = Math.round(size * 0.2);
+  const x0 = inset, y0 = inset, x1 = size - 1 - inset, y1 = size - 1 - inset;
+  const mid = Math.floor(size / 2);
+  const gap = Math.max(1, Math.round(size * 0.04));
+  const set = (x, y, c) => {
+    const i = (y * size + x) * 4;
+    px[i] = c[0]; px[i + 1] = c[1]; px[i + 2] = c[2]; px[i + 3] = c[3];
+  };
+  for (let y = y0; y <= y1; y++) {
+    for (let x = x0; x <= x1; x++) {
+      if (!insideRounded(x, y, x0, y0, x1, y1, r)) continue;
+      if (Math.abs(y - mid) < gap) continue; // divider between bands
+      set(x, y, y < mid ? green : red);
+    }
+  }
+  return png(size, px);
+}
+
+mkdirSync(outDir, { recursive: true });
+for (const size of [16, 32, 48, 128]) {
+  writeFileSync(path.join(outDir, `icon${size}.png`), makeIcon(size));
+}
+console.log('icons written ->', path.relative(process.cwd(), outDir));
diff --git a/src/background.ts b/src/background.ts
new file mode 100644
index 0000000..ef552e1
--- /dev/null
+++ b/src/background.ts
@@ -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 });
+  });
+});
diff --git a/src/content.ts b/src/content.ts
new file mode 100644
index 0000000..268f216
--- /dev/null
+++ b/src/content.ts
@@ -0,0 +1,136 @@
+// Runs on text/plain pages whose URL ends in .diff/.patch. Replaces the raw
+// 
 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('body > pre') ??
+      document.querySelector('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('.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
+      ? `${escapeHtml(line.marker)}`
+      : '';
+    const langAttr =
+      line.lang && hljs.getLanguage(line.lang) ? ` data-lang="${line.lang}"` : '';
+    html +=
+      `${gutter}` +
+      `${escapeHtml(line.code)}`;
+  }
+  return html;
+}
+
+const ESCAPE: Record = {
+  '&': '&',
+  '<': '<',
+  '>': '>',
+};
+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);
diff --git a/src/diff-parser.ts b/src/diff-parser.ts
new file mode 100644
index 0000000..2d42a5b
--- /dev/null
+++ b/src/diff-parser.ts
@@ -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 = {
+  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;
+}
diff --git a/src/hljs-common.d.ts b/src/hljs-common.d.ts
new file mode 100644
index 0000000..00760a0
--- /dev/null
+++ b/src/hljs-common.d.ts
@@ -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;
+}
diff --git a/src/manifest.json b/src/manifest.json
new file mode 100644
index 0000000..c6873ca
--- /dev/null
+++ b/src/manifest.json
@@ -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"
+    }
+  }
+}
diff --git a/src/popup/popup.html b/src/popup/popup.html
new file mode 100644
index 0000000..adeafa0
--- /dev/null
+++ b/src/popup/popup.html
@@ -0,0 +1,32 @@
+
+
+  
+    
+    
+    
+  
+  
+    

Diff Highlighter

+ + + + + +

+ Toggle anywhere with Alt+D + (rebind at chrome://extensions/shortcuts). +

+

+ For local file:// diffs, enable + “Allow access to file URLs” on the extension’s details page. +

+ + + + diff --git a/src/popup/popup.ts b/src/popup/popup.ts new file mode 100644 index 0000000..24b7f84 --- /dev/null +++ b/src/popup/popup.ts @@ -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 }); +}); diff --git a/src/settings.ts b/src/settings.ts new file mode 100644 index 0000000..9aa641c --- /dev/null +++ b/src/settings.ts @@ -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, +}; diff --git a/src/styles/diff.css b/src/styles/diff.css new file mode 100644 index 0000000..269c315 --- /dev/null +++ b/src/styles/diff.css @@ -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; + } +} diff --git a/src/styles/hljs-theme.css b/src/styles/hljs-theme.css new file mode 100644 index 0000000..ad6230e --- /dev/null +++ b/src/styles/hljs-theme.css @@ -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; + } +} diff --git a/src/styles/popup.css b/src/styles/popup.css new file mode 100644 index 0000000..fc97aa6 --- /dev/null +++ b/src/styles/popup.css @@ -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; + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b684e99 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["chrome", "node"], + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "esModuleInterop": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true + }, + "include": ["src"] +}