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

15
.forgejo/workflows/ci.yml Normal file
View File

@@ -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

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
*.log
.DS_Store

85
README.md Normal file
View File

@@ -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 `<pre>`, 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/<you>/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
```

BIN
icons/icon128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 B

BIN
icons/icon16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 B

BIN
icons/icon32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 B

BIN
icons/icon48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 B

580
package-lock.json generated Normal file
View File

@@ -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"
}
}
}

21
package.json Normal file
View File

@@ -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"
}
}

35
samples/example.diff Normal file
View File

@@ -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.

57
scripts/build.mjs Normal file
View File

@@ -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));
}

91
scripts/make-icons.mjs Normal file
View File

@@ -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));

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;
}
}

22
tsconfig.json Normal file
View File

@@ -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"]
}