diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b052b1a..871e993 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -5,8 +5,13 @@ on:
branches: [main]
pull_request:
+concurrency:
+ group: ci-${{ github.ref }}
+ cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
+
jobs:
- build:
+ quality:
+ name: Lint, typecheck, test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -25,5 +30,27 @@ jobs:
- name: Typecheck
run: bun run compile
+ - name: Test
+ run: bun run test
+
+ build:
+ name: Build (${{ matrix.browser }})
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - browser: chrome
+ command: build
+ - browser: firefox
+ command: build:firefox
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: oven-sh/setup-bun@v2
+
+ - name: Install dependencies
+ run: bun install --frozen-lockfile
+
- name: Build extension
- run: bun run build
+ run: bun run ${{ matrix.command }}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 1579b0f..8bbd3df 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -25,9 +25,15 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile
+ - name: Lint
+ run: bun run check
+
- name: Typecheck
run: bun run compile
+ - name: Test
+ run: bun run test
+
- name: Zip extension
run: bun run zip
diff --git a/README.md b/README.md
index 568860f..f242baa 100644
--- a/README.md
+++ b/README.md
@@ -24,4 +24,15 @@ Know another tracker that works with the Steam profile URL trick? [Open a tracke
## Developers
-Trackers live in `src/trackers/catalog.ts`. PRs welcome.
+The source layout follows the features of the extension:
+
+- `src/trackers/` — tracker catalog, URL building, and preferences. New trackers go in `src/trackers/catalog.ts`.
+- `src/tracker-menu/` — the menu injected into Steam profile sidebars.
+- `src/popup/` — the toolbar popup (React).
+- `src/steam/` — Steam profile URL parsing and match patterns.
+- `src/i18n/` — supported locales and runtime translations.
+- `src/entrypoints/` — thin WXT wiring for the background, content script, and popup.
+
+Common commands: `bun run dev` (live reload), `bun run test` (Vitest), `bun run check` (lint), `bun run compile` (typecheck).
+
+PRs welcome.
diff --git a/biome.jsonc b/biome.jsonc
index c497c91..b549c11 100644
--- a/biome.jsonc
+++ b/biome.jsonc
@@ -8,17 +8,5 @@
"defineBackground",
"defineContentScript"
]
- },
- "overrides": [
- {
- "includes": ["src/**"],
- "linter": {
- "rules": {
- "style": {
- "useFilenamingConvention": "off"
- }
- }
- }
- }
- ]
+ }
}
diff --git a/bun.lock b/bun.lock
index b650d79..a8181c7 100644
--- a/bun.lock
+++ b/bun.lock
@@ -16,6 +16,7 @@
"@types/sharp": "^0.32.0",
"@wxt-dev/module-react": "^1.1.5",
"bumpp": "^11.1.0",
+ "happy-dom": "^20.10.2",
"husky": "^9.1.7",
"postcss-rem-to-responsive-pixel": "^7.0.4",
"sharp": "^0.34.5",
@@ -23,6 +24,7 @@
"tailwindcss": "^4.3.0",
"typescript": "^5.9.3",
"ultracite": "7.8.2",
+ "vitest": "^4.1.8",
"wxt": "^0.20.26",
},
},
@@ -234,6 +236,8 @@
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
+ "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
+
"@tailwindcss/node": ["@tailwindcss/node@4.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.0", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.0", "@tailwindcss/oxide-darwin-arm64": "4.3.0", "@tailwindcss/oxide-darwin-x64": "4.3.0", "@tailwindcss/oxide-freebsd-x64": "4.3.0", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", "@tailwindcss/oxide-linux-x64-musl": "4.3.0", "@tailwindcss/oxide-wasm32-wasi": "4.3.0", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" } }, "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg=="],
@@ -266,6 +270,10 @@
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
+ "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
+
+ "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
+
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/filesystem": ["@types/filesystem@0.0.36", "", { "dependencies": { "@types/filewriter": "*" } }, "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA=="],
@@ -286,8 +294,26 @@
"@types/webextension-polyfill": ["@types/webextension-polyfill@0.12.5", "", {}, "sha512-uKSAv6LgcVdINmxXMKBuVIcg/2m5JZugoZO8x20g7j2bXJkPIl/lVGQcDlbV+aXAiTyXT2RA5U5mI4IGCDMQeg=="],
+ "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
+
+ "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
+
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.2", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.0" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg=="],
+ "@vitest/expect": ["@vitest/expect@4.1.8", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.8", "@vitest/utils": "4.1.8", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ=="],
+
+ "@vitest/mocker": ["@vitest/mocker@4.1.8", "", { "dependencies": { "@vitest/spy": "4.1.8", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw=="],
+
+ "@vitest/pretty-format": ["@vitest/pretty-format@4.1.8", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA=="],
+
+ "@vitest/runner": ["@vitest/runner@4.1.8", "", { "dependencies": { "@vitest/utils": "4.1.8", "pathe": "^2.0.3" } }, "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg=="],
+
+ "@vitest/snapshot": ["@vitest/snapshot@4.1.8", "", { "dependencies": { "@vitest/pretty-format": "4.1.8", "@vitest/utils": "4.1.8", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ=="],
+
+ "@vitest/spy": ["@vitest/spy@4.1.8", "", {}, "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA=="],
+
+ "@vitest/utils": ["@vitest/utils@4.1.8", "", { "dependencies": { "@vitest/pretty-format": "4.1.8", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg=="],
+
"@webext-core/fake-browser": ["@webext-core/fake-browser@1.5.2", "", { "dependencies": { "@types/webextension-polyfill": ">=0.10.5", "lodash.merge": "^4.6.2" } }, "sha512-nkDQwOJ23X5Q7cEtN6LRuBtVFf1KVOFi5GoQAro0lzqdh59F5E+K350j1isbnqYbzsXRh1NJtboudIcHfZtvOQ=="],
"@webext-core/isolated-element": ["@webext-core/isolated-element@1.1.5", "", { "dependencies": { "is-potential-custom-element-name": "^1.0.1" } }, "sha512-4m6oP8Vzm/68YO1QmkUOZqqUcmyBtA53tji2g00/nYXE3E3IceYgeub7eIqvXDV2Z7xU6cm6qO1IMt4XFVwtvQ=="],
@@ -318,6 +344,8 @@
"array-union": ["array-union@3.0.1", "", {}, "sha512-1OvF9IbWwaeiM9VhzYXVQacMibxpXOMYVNIvMtKRyX9SImBXpKcFr8XvFDeEslCyuH/t6KRt7HEO94AlP8Iatw=="],
+ "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
+
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
"async-mutex": ["async-mutex@0.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA=="],
@@ -340,6 +368,8 @@
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
+ "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="],
+
"bumpp": ["bumpp@11.1.0", "", { "dependencies": { "args-tokenizer": "^0.3.0", "cac": "^7.0.0", "jsonc-parser": "^3.3.1", "package-manager-detector": "^1.6.0", "semver": "^7.7.4", "tinyexec": "^1.1.2", "tinyglobby": "^0.2.16", "unconfig": "^7.5.0", "yaml": "^2.8.4" }, "bin": { "bumpp": "bin/bumpp.mjs" } }, "sha512-jdwOGMyX8JIqpQ0N2RMRR87DHZaoJnUtui5lU9LqFfFK5JC0H8qY9uWqXoa+dEWt/K7rOmmsoyiZB8RBM7RPBQ=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
@@ -350,6 +380,8 @@
"camelcase": ["camelcase@8.0.0", "", {}, "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA=="],
+ "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
+
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
@@ -386,6 +418,8 @@
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
+ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
+
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
@@ -466,6 +500,8 @@
"eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
+ "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
+
"exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="],
"fast-redact": ["fast-redact@3.5.0", "", {}, "sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A=="],
@@ -514,6 +550,8 @@
"growly": ["growly@1.3.0", "", {}, "sha512-+xGQY0YyAWCnqy7Cd++hc2JqMYzlm0dG30Jd0beaA64sROr8C4nt8Yc9V5Ro3avlSUDTN0ulqP/VBKi1/lLygw=="],
+ "happy-dom": ["happy-dom@20.10.2", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-5p9Sxis3eowDJKqx90QCsgbNA02XXqJ59NOHvD4V6cxp+rP4d/xOyVx7uY3hS8hiUbY1VeiFH8lbJ81AyuDVLQ=="],
+
"hookable": ["hookable@6.1.1", "", {}, "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ=="],
"html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="],
@@ -814,6 +852,8 @@
"shellwords": ["shellwords@0.1.1", "", {}, "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww=="],
+ "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
+
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
@@ -838,6 +878,10 @@
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
+ "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
+
+ "std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="],
+
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
@@ -862,10 +906,14 @@
"through": ["through@2.3.8", "", {}, "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="],
+ "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
+
"tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
+ "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
+
"tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
@@ -906,24 +954,32 @@
"vite-node": ["vite-node@6.0.0", "", { "dependencies": { "cac": "^7.0.0", "es-module-lexer": "^2.0.0", "obug": "^2.1.1", "pathe": "^2.0.3", "vite": "^8.0.0" }, "bin": { "vite-node": "dist/cli.mjs" } }, "sha512-oj4PVrT+pDh6GYf5wfUXkcZyekYS8kKPfLPXVl8qe324Ec6l4K2DUKNadRbZ3LQl0qGcDz+PyOo7ZAh00Y+JjQ=="],
+ "vitest": ["vitest@4.1.8", "", { "dependencies": { "@vitest/expect": "4.1.8", "@vitest/mocker": "4.1.8", "@vitest/pretty-format": "4.1.8", "@vitest/runner": "4.1.8", "@vitest/snapshot": "4.1.8", "@vitest/spy": "4.1.8", "@vitest/utils": "4.1.8", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.8", "@vitest/browser-preview": "4.1.8", "@vitest/browser-webdriverio": "4.1.8", "@vitest/coverage-istanbul": "4.1.8", "@vitest/coverage-v8": "4.1.8", "@vitest/ui": "4.1.8", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig=="],
+
"watchpack": ["watchpack@2.4.4", "", { "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" } }, "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA=="],
"web-ext-run": ["web-ext-run@0.2.4", "", { "dependencies": { "@babel/runtime": "7.28.2", "@devicefarmer/adbkit": "3.3.8", "chrome-launcher": "1.2.0", "debounce": "1.2.1", "es6-error": "4.1.1", "firefox-profile": "4.7.0", "fx-runner": "1.4.0", "multimatch": "6.0.0", "node-notifier": "10.0.1", "parse-json": "7.1.1", "pino": "9.7.0", "promise-toolbox": "0.21.0", "set-value": "4.1.0", "source-map-support": "0.5.21", "strip-bom": "5.0.0", "strip-json-comments": "5.0.2", "tmp": "0.2.5", "update-notifier": "7.3.1", "watchpack": "2.4.4", "zip-dir": "2.0.0" } }, "sha512-rQicL7OwuqWdQWI33JkSXKcp7cuv1mJG8u3jRQwx/8aDsmhbTHs9ZRmNYOL+LX0wX8edIEQX8jj4bB60GoXtKA=="],
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
+ "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
+
"when": ["when@3.7.7", "", {}, "sha512-9lFZp/KHoqH6bPKjbWqa+3Dg/K/r2v0X/3/G2x4DBGchVS2QX2VXL3cZV994WQVnTM1/PD71Az25nAzryEUugw=="],
"when-exit": ["when-exit@2.1.5", "", {}, "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
+ "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
+
"widest-line": ["widest-line@5.0.0", "", { "dependencies": { "string-width": "^7.0.0" } }, "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA=="],
"winreg": ["winreg@0.0.12", "", {}, "sha512-typ/+JRmi7RqP1NanzFULK36vczznSNN8kWVA9vIqXyv8GhghUlwhGp1Xj3Nms1FsPcNnsQrJOR10N58/nQ9hQ=="],
"wrap-ansi": ["wrap-ansi@10.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0", "strip-ansi": "^7.1.2" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="],
+ "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
+
"wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="],
"wxt": ["wxt@0.20.26", "", { "dependencies": { "@1natsu/wait-element": "^4.1.2", "@aklinker1/rollup-plugin-visualizer": "5.12.0", "@webext-core/fake-browser": "^1.3.4", "@webext-core/isolated-element": "^1.1.3", "@webext-core/match-patterns": "^1.0.3", "@wxt-dev/browser": "^0.1.42", "@wxt-dev/storage": "^1.0.0", "async-mutex": "^0.5.0", "c12": "^3.3.3", "cac": "^6.7.14 || ^7.0.0", "chokidar": "^5.0.0", "ci-info": "^4.4.0", "consola": "^3.4.2", "defu": "^6.1.4", "dotenv-expand": "^12.0.3", "esbuild": "^0.27.1", "filesize": "^11.0.15", "get-port-please": "^3.2.0", "giget": "^1.2.3 || ^2.0.0 || ^3.0.0", "hookable": "^6.1.0", "import-meta-resolve": "^4.2.0", "is-wsl": "^3.1.1", "json5": "^2.2.3", "jszip": "^3.10.1", "linkedom": "^0.18.12", "magicast": "^0.5.2", "nano-spawn": "^2.0.0", "nanospinner": "^1.2.2", "normalize-path": "^3.0.0", "nypm": "^0.6.5", "ohash": "^2.0.11", "open": "^11.0.0", "perfect-debounce": "^2.1.0", "picomatch": "^4.0.3", "prompts": "^2.4.2", "publish-browser-extension": "^2.3.0 || ^3.0.2 || ^4.0.5", "scule": "^1.3.0", "tinyglobby": "^0.2.15", "unimport": "^3.13.1 || ^4.0.0 || ^5.0.0 || ^6.0.0", "vite": "^5.4.19 || ^6.3.4 || ^7.0.0 || ^8.0.0-0", "vite-node": "^3.2.4 || ^5.0.0 || ^6.0.0", "web-ext-run": "^0.2.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0" }, "optionalPeers": ["eslint"], "bin": { "wxt": "bin/wxt.mjs", "wxt-publish-extension": "bin/wxt-publish-extension.mjs" } }, "sha512-PMGz7sAlONJgwBkOriInXOoEU6/jlGKrhSFvZfiBPHZocyYPfnw1lod9rGDra957H83WO+TnGjYwJiGYciSIqA=="],
diff --git a/package.json b/package.json
index dfa29f4..db29815 100644
--- a/package.json
+++ b/package.json
@@ -24,6 +24,8 @@
"json:sort": "bun run scripts/json-sort.ts",
"prepare": "husky",
"release": "bumpp",
+ "test": "vitest run",
+ "test:watch": "vitest",
"zip": "wxt zip",
"zip:firefox": "wxt zip -b firefox"
},
@@ -39,6 +41,7 @@
"@types/sharp": "^0.32.0",
"@wxt-dev/module-react": "^1.1.5",
"bumpp": "^11.1.0",
+ "happy-dom": "^20.10.2",
"husky": "^9.1.7",
"postcss-rem-to-responsive-pixel": "^7.0.4",
"sharp": "^0.34.5",
@@ -46,6 +49,7 @@
"tailwindcss": "^4.3.0",
"typescript": "^5.9.3",
"ultracite": "7.8.2",
+ "vitest": "^4.1.8",
"wxt": "^0.20.26"
}
}
diff --git a/public/_locales/de/messages.json b/public/_locales/de/messages.json
index 0ff6594..cc08ce9 100644
--- a/public/_locales/de/messages.json
+++ b/public/_locales/de/messages.json
@@ -5,9 +5,6 @@
"extDescription": {
"message": "CS2-Statistik-Tracker von Steam-Profilen öffnen"
},
- "popupSubtitle": {
- "message": "Wähle, welche Statistik-Seiten auf Steam-Profilen erscheinen."
- },
"openSource": {
"message": "Open Source"
},
diff --git a/public/_locales/en/messages.json b/public/_locales/en/messages.json
index daab94b..801e8d2 100644
--- a/public/_locales/en/messages.json
+++ b/public/_locales/en/messages.json
@@ -5,9 +5,6 @@
"extDescription": {
"message": "Open CS2 stat trackers from Steam profiles"
},
- "popupSubtitle": {
- "message": "Choose which stat sites appear on Steam profiles."
- },
"openSource": {
"message": "Open source"
},
diff --git a/public/_locales/es/messages.json b/public/_locales/es/messages.json
index f9c03ed..1ce3230 100644
--- a/public/_locales/es/messages.json
+++ b/public/_locales/es/messages.json
@@ -5,9 +5,6 @@
"extDescription": {
"message": "Abre rastreadores de estadísticas de CS2 desde perfiles de Steam"
},
- "popupSubtitle": {
- "message": "Elige qué sitios de estadísticas aparecen en los perfiles de Steam."
- },
"openSource": {
"message": "Código abierto"
},
diff --git a/public/_locales/fr/messages.json b/public/_locales/fr/messages.json
index 028cfd8..a8b0419 100644
--- a/public/_locales/fr/messages.json
+++ b/public/_locales/fr/messages.json
@@ -5,9 +5,6 @@
"extDescription": {
"message": "Ouvrir les trackers de stats CS2 depuis les profils Steam"
},
- "popupSubtitle": {
- "message": "Choisissez quels sites de stats apparaissent sur les profils Steam."
- },
"openSource": {
"message": "Open source"
},
diff --git a/public/_locales/pt_BR/messages.json b/public/_locales/pt_BR/messages.json
index b457be7..52ae103 100644
--- a/public/_locales/pt_BR/messages.json
+++ b/public/_locales/pt_BR/messages.json
@@ -5,9 +5,6 @@
"extDescription": {
"message": "Abra rastreadores de estatísticas do CS2 a partir de perfis Steam"
},
- "popupSubtitle": {
- "message": "Escolha quais sites de estatísticas aparecem nos perfis Steam."
- },
"openSource": {
"message": "Código aberto"
},
diff --git a/public/_locales/ru/messages.json b/public/_locales/ru/messages.json
index 0fb5062..7a1ef76 100644
--- a/public/_locales/ru/messages.json
+++ b/public/_locales/ru/messages.json
@@ -5,9 +5,6 @@
"extDescription": {
"message": "Открывайте CS2-трекеры статистики со страниц профилей Steam"
},
- "popupSubtitle": {
- "message": "Выберите, какие сайты статистики показывать в профилях Steam."
- },
"openSource": {
"message": "Открытый код"
},
diff --git a/public/_locales/zh_CN/messages.json b/public/_locales/zh_CN/messages.json
index c48b98c..5b6504c 100644
--- a/public/_locales/zh_CN/messages.json
+++ b/public/_locales/zh_CN/messages.json
@@ -5,9 +5,6 @@
"extDescription": {
"message": "从 Steam 个人资料打开 CS2 战绩追踪网站"
},
- "popupSubtitle": {
- "message": "选择在 Steam 个人资料中显示哪些战绩网站。"
- },
"openSource": {
"message": "开源"
},
diff --git a/public/wxt.svg b/public/wxt.svg
deleted file mode 100644
index 0e76320..0000000
--- a/public/wxt.svg
+++ /dev/null
@@ -1,15 +0,0 @@
-
diff --git a/scripts/check-locales.ts b/scripts/check-locales.ts
index 74c9572..b0eb6fa 100644
--- a/scripts/check-locales.ts
+++ b/scripts/check-locales.ts
@@ -1,40 +1,66 @@
+#!/usr/bin/env bun
+/** Verify every locale has the same keys and $n placeholders as the base. */
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
const LOCALES_DIR = "public/_locales";
const BASE_LOCALE = "en";
+const SUBSTITUTION_RE = /\$\d+/g;
-const baseMessages = JSON.parse(
- await readFile(join(LOCALES_DIR, BASE_LOCALE, "messages.json"), "utf8")
-) as Record;
+type Messages = Record;
+async function loadMessages(locale: string): Promise {
+ const raw = await readFile(
+ join(LOCALES_DIR, locale, "messages.json"),
+ "utf8"
+ );
+ return JSON.parse(raw) as Messages;
+}
+
+function substitutionTokens(message: string): string {
+ return [...message.matchAll(SUBSTITUTION_RE)]
+ .map((match) => match[0])
+ .sort()
+ .join(",");
+}
+
+const baseMessages = await loadMessages(BASE_LOCALE);
const baseKeys = new Set(Object.keys(baseMessages));
-const locales = await readdir(LOCALES_DIR);
+const locales = (await readdir(LOCALES_DIR)).sort();
let failed = false;
-for (const locale of locales.sort()) {
+function fail(message: string): void {
+ console.error(message);
+ failed = true;
+}
+
+for (const locale of locales) {
if (locale === BASE_LOCALE) {
continue;
}
- const messages = JSON.parse(
- await readFile(join(LOCALES_DIR, locale, "messages.json"), "utf8")
- ) as Record;
-
+ const messages = await loadMessages(locale);
const keys = new Set(Object.keys(messages));
for (const key of baseKeys) {
if (!keys.has(key)) {
- console.error(`${locale}: missing key "${key}"`);
- failed = true;
+ fail(`${locale}: missing key "${key}"`);
+ continue;
+ }
+
+ const expected = substitutionTokens(baseMessages[key].message);
+ const actual = substitutionTokens(messages[key].message);
+ if (expected !== actual) {
+ fail(
+ `${locale}: key "${key}" placeholders [${actual}] do not match ${BASE_LOCALE} [${expected}]`
+ );
}
}
for (const key of keys) {
if (!baseKeys.has(key)) {
- console.error(`${locale}: extra key "${key}"`);
- failed = true;
+ fail(`${locale}: extra key "${key}"`);
}
}
}
@@ -43,4 +69,6 @@ if (failed) {
process.exit(1);
}
-console.log(`All ${locales.length - 1} locale files match ${BASE_LOCALE} keys`);
+console.log(
+ `All ${locales.length - 1} locale files match ${BASE_LOCALE} keys and placeholders`
+);
diff --git a/scripts/json-sort.ts b/scripts/json-sort.ts
index 7896584..8037b86 100644
--- a/scripts/json-sort.ts
+++ b/scripts/json-sort.ts
@@ -1,47 +1,16 @@
#!/usr/bin/env bun
-/**
- * Sort all workspace package.json files.
- * Discovers packages/* and apps/* dynamically.
- * Usage: bun run scripts/json-sort.ts
- */
-import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
-import { dirname, join, resolve } from "node:path";
-import { fileURLToPath } from "node:url";
+/** Sort package.json with sort-package-json. */
+import { readFileSync, writeFileSync } from "node:fs";
import sortPackageJson from "sort-package-json";
+import { fromRoot } from "./lib/paths.ts";
-const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+const file = fromRoot("package.json");
+const original = readFileSync(file, "utf8");
+const sorted = sortPackageJson(original);
-function packageJsonPaths(base: string): string[] {
- const paths = [join(base, "package.json")];
- for (const dir of ["packages", "apps"]) {
- const dirPath = join(base, dir);
- if (!existsSync(dirPath)) {
- continue;
- }
- for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
- if (entry.isDirectory()) {
- paths.push(join(dirPath, entry.name, "package.json"));
- }
- }
- }
- return paths;
+if (sorted === original) {
+ console.log("package.json already sorted");
+} else {
+ writeFileSync(file, sorted);
+ console.log("package.json sorted");
}
-
-let exitCode = 0;
-for (const file of packageJsonPaths(root)) {
- try {
- const original = readFileSync(file, "utf8");
- const sorted = sortPackageJson(original);
- if (sorted === original) {
- console.log(`${file} was already sorted.`);
- } else {
- writeFileSync(file, sorted);
- console.log(`${file} is sorted!`);
- }
- } catch (error) {
- console.error(`Failed to sort ${file}:`, error);
- exitCode = 1;
- }
-}
-
-process.exit(exitCode);
diff --git a/src/assets/react.svg b/src/assets/react.svg
deleted file mode 100644
index 8e0e0f1..0000000
--- a/src/assets/react.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/src/entrypoints/background.ts b/src/entrypoints/background.ts
index 6172578..a27ebdd 100644
--- a/src/entrypoints/background.ts
+++ b/src/entrypoints/background.ts
@@ -1,7 +1,16 @@
-import { handleExtensionInstalled } from "@/preferences/storage";
+import {
+ normalizeStoredPreferences,
+ seedDefaultPreferences,
+} from "@/trackers/preferences";
export default defineBackground(() => {
browser.runtime.onInstalled.addListener(({ reason }) => {
- handleExtensionInstalled(reason);
+ if (reason === "install") {
+ seedDefaultPreferences();
+ return;
+ }
+ if (reason === "update") {
+ normalizeStoredPreferences();
+ }
});
});
diff --git a/src/entrypoints/popup/main.tsx b/src/entrypoints/popup/main.tsx
index e9d6e82..1c9b1c5 100644
--- a/src/entrypoints/popup/main.tsx
+++ b/src/entrypoints/popup/main.tsx
@@ -1,6 +1,6 @@
import React from "react";
import ReactDOM from "react-dom/client";
-import App from "./App.tsx";
+import { PopupApp } from "@/popup/app";
import "@/assets/tailwind.css";
document.documentElement.classList.add("bg-black");
@@ -13,6 +13,6 @@ if (!root) {
ReactDOM.createRoot(root).render(
-
+
);
diff --git a/src/entrypoints/steam.content/index.ts b/src/entrypoints/steam.content/index.ts
index 18ae768..ca4becd 100644
--- a/src/entrypoints/steam.content/index.ts
+++ b/src/entrypoints/steam.content/index.ts
@@ -1,6 +1,8 @@
-import { LOCALE_KEY, SETTINGS_KEY } from "@/preferences/keys";
+import type { Browser } from "wxt/browser";
+import { LOCALE_KEY } from "@/i18n/preference";
import { STEAM_PROFILE_MATCHES } from "@/steam/matches";
-import { createTrackerDropdownController } from "@/steam-ui/controller";
+import { createTrackerMenuController } from "@/tracker-menu/controller";
+import { TRACKER_PREFERENCES_KEY } from "@/trackers/preferences";
import "./style.css";
export default defineContentScript({
@@ -8,23 +10,31 @@ export default defineContentScript({
runAt: "document_idle",
main(ctx) {
- const dropdown = createTrackerDropdownController(ctx);
+ const menu = createTrackerMenuController(ctx);
- dropdown.sync();
+ menu.sync();
ctx.addEventListener(window, "popstate", () => {
- dropdown.invalidate();
- dropdown.sync();
+ menu.invalidate();
+ menu.sync();
});
- browser.storage.onChanged.addListener((changes, area) => {
+ const onStorageChanged = (
+ changes: Record,
+ area: string
+ ) => {
if (area !== "local") {
return;
}
- if (changes[SETTINGS_KEY] || changes[LOCALE_KEY]) {
- dropdown.invalidate();
- dropdown.sync();
+ if (changes[TRACKER_PREFERENCES_KEY] || changes[LOCALE_KEY]) {
+ menu.invalidate();
+ menu.sync();
}
+ };
+
+ browser.storage.onChanged.addListener(onStorageChanged);
+ ctx.onInvalidated(() => {
+ browser.storage.onChanged.removeListener(onStorageChanged);
});
},
});
diff --git a/src/i18n/messages.test.ts b/src/i18n/messages.test.ts
new file mode 100644
index 0000000..95f525f
--- /dev/null
+++ b/src/i18n/messages.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from "vitest";
+import { applySubstitutions, flattenMessages } from "./messages";
+
+describe("applySubstitutions", () => {
+ it("returns the template when there are no substitutions", () => {
+ expect(applySubstitutions("Trackers ($1)")).toBe("Trackers ($1)");
+ });
+
+ it("substitutes a single value", () => {
+ expect(applySubstitutions("Trackers ($1)", "3")).toBe("Trackers (3)");
+ });
+
+ it("substitutes multiple positional values", () => {
+ expect(applySubstitutions("$1 and $2", ["a", "b"])).toBe("a and b");
+ });
+
+ it("keeps placeholders without a matching value", () => {
+ expect(applySubstitutions("$1 and $2", ["a"])).toBe("a and $2");
+ });
+});
+
+describe("flattenMessages", () => {
+ it("maps raw chrome i18n entries to plain strings", () => {
+ expect(
+ flattenMessages({
+ extName: { message: "Trackeroo" },
+ trackersWithCount: {
+ message: "Trackers ($1)",
+ placeholders: { count: { content: "$1" } },
+ },
+ })
+ ).toEqual({
+ extName: "Trackeroo",
+ trackersWithCount: "Trackers ($1)",
+ });
+ });
+});
diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts
new file mode 100644
index 0000000..7b31cea
--- /dev/null
+++ b/src/i18n/messages.ts
@@ -0,0 +1,30 @@
+export type RawMessages = Record<
+ string,
+ {
+ message: string;
+ placeholders?: Record;
+ }
+>;
+
+const SUBSTITUTION_RE = /\$(\d+)/g;
+
+export function applySubstitutions(
+ template: string,
+ substitutions?: string | string[]
+): string {
+ if (!substitutions) {
+ return template;
+ }
+
+ const values = Array.isArray(substitutions) ? substitutions : [substitutions];
+ return template.replace(SUBSTITUTION_RE, (_, token: string) => {
+ const index = Number(token) - 1;
+ return values[index] ?? `$${token}`;
+ });
+}
+
+export function flattenMessages(raw: RawMessages): Record {
+ return Object.fromEntries(
+ Object.entries(raw).map(([key, entry]) => [key, entry.message])
+ );
+}
diff --git a/src/i18n/preference.test.ts b/src/i18n/preference.test.ts
new file mode 100644
index 0000000..3a0803c
--- /dev/null
+++ b/src/i18n/preference.test.ts
@@ -0,0 +1,45 @@
+import { beforeEach, describe, expect, it } from "vitest";
+import { fakeBrowser } from "wxt/testing";
+import {
+ getStoredLocale,
+ LOCALE_KEY,
+ normalizeStoredLocale,
+ setStoredLocale,
+} from "./preference";
+
+beforeEach(() => {
+ fakeBrowser.reset();
+});
+
+describe("normalizeStoredLocale", () => {
+ it("accepts supported locales", () => {
+ expect(normalizeStoredLocale("de")).toBe("de");
+ expect(normalizeStoredLocale("pt_BR")).toBe("pt_BR");
+ });
+
+ it("keeps the system sentinel", () => {
+ expect(normalizeStoredLocale("system")).toBe("system");
+ });
+
+ it("falls back to system for unknown values", () => {
+ expect(normalizeStoredLocale("xx")).toBe("system");
+ expect(normalizeStoredLocale(42)).toBe("system");
+ expect(normalizeStoredLocale(undefined)).toBe("system");
+ });
+});
+
+describe("locale storage", () => {
+ it("defaults to system when nothing is stored", async () => {
+ expect(await getStoredLocale()).toBe("system");
+ });
+
+ it("round-trips a stored locale", async () => {
+ await setStoredLocale("fr");
+ expect(await getStoredLocale()).toBe("fr");
+ });
+
+ it("normalizes corrupt stored values", async () => {
+ await fakeBrowser.storage.local.set({ [LOCALE_KEY]: "not-a-locale" });
+ expect(await getStoredLocale()).toBe("system");
+ });
+});
diff --git a/src/i18n/preference.ts b/src/i18n/preference.ts
index 32c7507..c6e11c1 100644
--- a/src/i18n/preference.ts
+++ b/src/i18n/preference.ts
@@ -1,8 +1,9 @@
-import { LOCALE_KEY } from "@/preferences/keys";
-import type { LocaleId, StoredLocale } from "./locales";
-import { isLocaleId, LOCALE_IDS } from "./locales";
+import type { StoredLocale } from "./locales";
+import { isLocaleId } from "./locales";
-function normalizeStoredLocale(value: unknown): StoredLocale {
+export const LOCALE_KEY = "locale";
+
+export function normalizeStoredLocale(value: unknown): StoredLocale {
if (value === "system") {
return "system";
}
@@ -25,22 +26,3 @@ export async function getStoredLocale(): Promise {
export async function setStoredLocale(locale: StoredLocale): Promise {
await browser.storage.local.set({ [LOCALE_KEY]: locale });
}
-
-export function resolveLocale(stored: StoredLocale): LocaleId | null {
- if (stored !== "system") {
- return stored;
- }
-
- const uiLocale = browser.i18n.getMessage("@@ui_locale").replace("-", "_");
- if (isLocaleId(uiLocale)) {
- return uiLocale;
- }
-
- const base = uiLocale.split("_")[0];
- if (base && isLocaleId(base)) {
- return base;
- }
-
- const match = LOCALE_IDS.find((id) => id.startsWith(`${base}_`));
- return match ?? null;
-}
diff --git a/src/i18n/runtime.ts b/src/i18n/runtime.ts
index 8fe6037..89d1d76 100644
--- a/src/i18n/runtime.ts
+++ b/src/i18n/runtime.ts
@@ -1,34 +1,16 @@
import { browser } from "wxt/browser";
import type { LocaleId } from "./locales";
+import {
+ applySubstitutions,
+ flattenMessages,
+ type RawMessages,
+} from "./messages";
import { getStoredLocale } from "./preference";
type MessageName = Parameters[0];
-type RawMessages = Record<
- string,
- {
- message: string;
- placeholders?: Record;
- }
->;
-
let activeMessages: Record | null = null;
-function applySubstitutions(
- template: string,
- substitutions?: string | string[]
-): string {
- if (!substitutions) {
- return template;
- }
-
- const values = Array.isArray(substitutions) ? substitutions : [substitutions];
- return template.replace(/\$(\d+)/g, (_, token) => {
- const index = Number(token) - 1;
- return values[index] ?? `$${token}`;
- });
-}
-
async function loadMessages(locale: LocaleId): Promise> {
const url = browser.runtime.getURL(`/_locales/${locale}/messages.json`);
const response = await fetch(url);
@@ -37,10 +19,7 @@ async function loadMessages(locale: LocaleId): Promise> {
throw new Error(`Failed to load locale: ${locale}`);
}
- const raw = (await response.json()) as RawMessages;
- return Object.fromEntries(
- Object.entries(raw).map(([key, entry]) => [key, entry.message])
- );
+ return flattenMessages((await response.json()) as RawMessages);
}
export async function initI18n(): Promise {
diff --git a/src/hooks/useLocale.ts b/src/i18n/use-locale.ts
similarity index 78%
rename from src/hooks/useLocale.ts
rename to src/i18n/use-locale.ts
index 36c43d7..d6b2138 100644
--- a/src/hooks/useLocale.ts
+++ b/src/i18n/use-locale.ts
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
-import type { StoredLocale } from "@/i18n/locales";
-import { getStoredLocale, setStoredLocale } from "@/i18n/preference";
-import { initI18n } from "@/i18n/runtime";
+import type { StoredLocale } from "./locales";
+import { getStoredLocale, setStoredLocale } from "./preference";
+import { initI18n } from "./runtime";
export function useLocale() {
const [ready, setReady] = useState(false);
diff --git a/src/meta/links.ts b/src/meta/links.ts
index 59eee84..ed5140c 100644
--- a/src/meta/links.ts
+++ b/src/meta/links.ts
@@ -1,5 +1,3 @@
export const GITHUB_URL = "https://github.com/percdotdev/trackeroo";
-export const GITHUB_ISSUES_URL =
- "https://github.com/percdotdev/trackeroo/issues";
export const GITHUB_TRACKER_REQUEST_URL =
"https://github.com/percdotdev/trackeroo/issues/new?template=tracker-request.yml";
diff --git a/src/entrypoints/popup/App.tsx b/src/popup/app.tsx
similarity index 67%
rename from src/entrypoints/popup/App.tsx
rename to src/popup/app.tsx
index 799e92d..8fa31e3 100644
--- a/src/entrypoints/popup/App.tsx
+++ b/src/popup/app.tsx
@@ -1,13 +1,13 @@
import { useState } from "react";
-import { PopupShell } from "@/components/popup/PopupShell";
-import type { PopupTab } from "@/components/popup/PopupTabs";
-import { SettingsTab } from "@/components/popup/SettingsTab";
-import { TrackersTab } from "@/components/popup/TrackersTab";
-import { useLocale } from "@/hooks/useLocale";
-import { useTrackerPreferences } from "@/hooks/useTrackerPreferences";
+import { useLocale } from "@/i18n/use-locale";
import { TRACKERS } from "@/trackers/catalog";
+import { useTrackerPreferences } from "@/trackers/use-tracker-preferences";
+import { PopupShell } from "./components/popup-shell";
+import type { PopupTab } from "./components/popup-tabs";
+import { SettingsTab } from "./components/settings-tab";
+import { TrackersTab } from "./components/trackers-tab";
-function App() {
+export function PopupApp() {
const { ready, locale, setLocale } = useLocale();
const { preferences, toggle, setAll } = useTrackerPreferences();
const [activeTab, setActiveTab] = useState("trackers");
@@ -39,5 +39,3 @@ function App() {
);
}
-
-export default App;
diff --git a/src/components/popup/LanguagePicker.tsx b/src/popup/components/language-picker.tsx
similarity index 100%
rename from src/components/popup/LanguagePicker.tsx
rename to src/popup/components/language-picker.tsx
diff --git a/src/components/popup/PopupShell.tsx b/src/popup/components/popup-shell.tsx
similarity index 93%
rename from src/components/popup/PopupShell.tsx
rename to src/popup/components/popup-shell.tsx
index af5b5d9..fbecab8 100644
--- a/src/components/popup/PopupShell.tsx
+++ b/src/popup/components/popup-shell.tsx
@@ -1,6 +1,6 @@
import type { ReactNode } from "react";
-import { type PopupTab, PopupTabs } from "@/components/popup/PopupTabs";
import { t } from "@/i18n/runtime";
+import { type PopupTab, PopupTabs } from "./popup-tabs";
interface PopupShellProps {
activeTab: PopupTab;
diff --git a/src/components/popup/PopupTabs.tsx b/src/popup/components/popup-tabs.tsx
similarity index 100%
rename from src/components/popup/PopupTabs.tsx
rename to src/popup/components/popup-tabs.tsx
diff --git a/src/components/popup/SettingsTab.tsx b/src/popup/components/settings-tab.tsx
similarity index 94%
rename from src/components/popup/SettingsTab.tsx
rename to src/popup/components/settings-tab.tsx
index fb9d5a0..588fded 100644
--- a/src/components/popup/SettingsTab.tsx
+++ b/src/popup/components/settings-tab.tsx
@@ -1,7 +1,7 @@
-import { LanguagePicker } from "@/components/popup/LanguagePicker";
import type { StoredLocale } from "@/i18n/locales";
import { t } from "@/i18n/runtime";
import { GITHUB_TRACKER_REQUEST_URL, GITHUB_URL } from "@/meta/links";
+import { LanguagePicker } from "./language-picker";
interface SettingsTabProps {
locale: StoredLocale;
diff --git a/src/components/popup/Toggle.tsx b/src/popup/components/toggle.tsx
similarity index 100%
rename from src/components/popup/Toggle.tsx
rename to src/popup/components/toggle.tsx
diff --git a/src/components/popup/TrackersTab.tsx b/src/popup/components/trackers-tab.tsx
similarity index 92%
rename from src/components/popup/TrackersTab.tsx
rename to src/popup/components/trackers-tab.tsx
index b55804d..b16faf7 100644
--- a/src/components/popup/TrackersTab.tsx
+++ b/src/popup/components/trackers-tab.tsx
@@ -1,8 +1,7 @@
-import { Toggle } from "@/components/popup/Toggle";
import { t } from "@/i18n/runtime";
-import type { TrackerPreferences } from "@/preferences/types";
import { getTrackerHost, TRACKERS } from "@/trackers/catalog";
-import type { TrackerId } from "@/trackers/types";
+import type { TrackerId, TrackerPreferences } from "@/trackers/types";
+import { Toggle } from "./toggle";
interface TrackersTabProps {
onSetAll: (enabled: boolean) => void;
diff --git a/src/preferences/keys.ts b/src/preferences/keys.ts
deleted file mode 100644
index 5e8bf2b..0000000
--- a/src/preferences/keys.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export const SETTINGS_KEY = "trackerPreferences";
-export const LOCALE_KEY = "locale";
diff --git a/src/preferences/storage.ts b/src/preferences/storage.ts
deleted file mode 100644
index 2d8d462..0000000
--- a/src/preferences/storage.ts
+++ /dev/null
@@ -1,74 +0,0 @@
-import { SETTINGS_KEY } from "@/preferences/keys";
-import { TRACKER_IDS } from "@/trackers/catalog";
-import type { TrackerId } from "@/trackers/types";
-import type { TrackerPreferences } from "./types";
-
-export function getDefaultPreferences(): TrackerPreferences {
- return Object.fromEntries(
- TRACKER_IDS.map((id) => [id, true])
- ) as TrackerPreferences;
-}
-
-function normalizePreferences(stored: unknown): TrackerPreferences {
- const source =
- stored && typeof stored === "object"
- ? (stored as Partial)
- : {};
-
- return Object.fromEntries(
- TRACKER_IDS.map((id) => [id, source[id] ?? true])
- ) as TrackerPreferences;
-}
-
-async function readPreferences(): Promise {
- const { [SETTINGS_KEY]: stored } =
- await browser.storage.local.get(SETTINGS_KEY);
- const preferences = normalizePreferences(stored);
-
- if (!stored) {
- await browser.storage.local.set({ [SETTINGS_KEY]: preferences });
- }
-
- return preferences;
-}
-
-export async function getTrackerPreferences(): Promise {
- try {
- return await readPreferences();
- } catch {
- return getDefaultPreferences();
- }
-}
-
-export async function setTrackerPreference(
- id: TrackerId,
- enabled: boolean
-): Promise {
- const preferences = await readPreferences();
- preferences[id] = enabled;
- await browser.storage.local.set({ [SETTINGS_KEY]: preferences });
-}
-
-export async function setAllTrackerPreferences(
- enabled: boolean
-): Promise {
- const preferences = Object.fromEntries(
- TRACKER_IDS.map((id) => [id, enabled])
- ) as TrackerPreferences;
- await browser.storage.local.set({ [SETTINGS_KEY]: preferences });
- return preferences;
-}
-
-export async function handleExtensionInstalled(reason: string): Promise {
- if (reason === "install") {
- await browser.storage.local.set({
- [SETTINGS_KEY]: getDefaultPreferences(),
- });
- return;
- }
-
- if (reason === "update") {
- const preferences = await getTrackerPreferences();
- await browser.storage.local.set({ [SETTINGS_KEY]: preferences });
- }
-}
diff --git a/src/preferences/types.ts b/src/preferences/types.ts
deleted file mode 100644
index d9f7790..0000000
--- a/src/preferences/types.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-import type { TrackerId } from "@/trackers/types";
-
-export type TrackerPreferences = Record;
diff --git a/src/steam/profile-url.test.ts b/src/steam/profile-url.test.ts
new file mode 100644
index 0000000..7675cab
--- /dev/null
+++ b/src/steam/profile-url.test.ts
@@ -0,0 +1,74 @@
+import { describe, expect, it } from "vitest";
+import { getSteamProfileBaseUrl, parseSteamProfilePath } from "./profile-url";
+
+describe("parseSteamProfilePath", () => {
+ it("parses vanity profile urls", () => {
+ expect(parseSteamProfilePath("https://steamcommunity.com/id/gaben")).toBe(
+ "id/gaben"
+ );
+ });
+
+ it("parses steam64 profile urls", () => {
+ expect(
+ parseSteamProfilePath(
+ "https://steamcommunity.com/profiles/76561197960287930"
+ )
+ ).toBe("profiles/76561197960287930");
+ });
+
+ it("strips sub-pages, queries, and hashes", () => {
+ expect(
+ parseSteamProfilePath(
+ "https://steamcommunity.com/id/gaben/games/?tab=all#section"
+ )
+ ).toBe("id/gaben");
+ });
+
+ it("handles trailing slashes", () => {
+ expect(parseSteamProfilePath("https://steamcommunity.com/id/gaben/")).toBe(
+ "id/gaben"
+ );
+ });
+
+ it("accepts http urls", () => {
+ expect(parseSteamProfilePath("http://steamcommunity.com/id/gaben")).toBe(
+ "id/gaben"
+ );
+ });
+
+ it("rejects non-numeric steam64 ids", () => {
+ expect(
+ parseSteamProfilePath("https://steamcommunity.com/profiles/notanid")
+ ).toBeNull();
+ });
+
+ it("rejects other steamcommunity pages", () => {
+ expect(
+ parseSteamProfilePath("https://steamcommunity.com/market/listings/730")
+ ).toBeNull();
+ });
+
+ it("rejects other hosts", () => {
+ expect(parseSteamProfilePath("https://example.com/id/gaben")).toBeNull();
+ expect(
+ parseSteamProfilePath("https://fakesteamcommunity.com/id/gaben")
+ ).toBeNull();
+ });
+
+ it("rejects invalid urls", () => {
+ expect(parseSteamProfilePath("not a url")).toBeNull();
+ expect(parseSteamProfilePath("")).toBeNull();
+ });
+});
+
+describe("getSteamProfileBaseUrl", () => {
+ it("normalizes to a canonical https base url", () => {
+ expect(
+ getSteamProfileBaseUrl("http://steamcommunity.com/id/gaben/badges?l=en")
+ ).toBe("https://steamcommunity.com/id/gaben");
+ });
+
+ it("returns null for non-profile urls", () => {
+ expect(getSteamProfileBaseUrl("https://steamcommunity.com/")).toBeNull();
+ });
+});
diff --git a/src/steam-ui/anchors.ts b/src/tracker-menu/anchors.ts
similarity index 100%
rename from src/steam-ui/anchors.ts
rename to src/tracker-menu/anchors.ts
diff --git a/src/steam-ui/constants.ts b/src/tracker-menu/constants.ts
similarity index 100%
rename from src/steam-ui/constants.ts
rename to src/tracker-menu/constants.ts
diff --git a/src/steam-ui/controller.ts b/src/tracker-menu/controller.ts
similarity index 90%
rename from src/steam-ui/controller.ts
rename to src/tracker-menu/controller.ts
index c537531..7ddac59 100644
--- a/src/steam-ui/controller.ts
+++ b/src/tracker-menu/controller.ts
@@ -1,12 +1,12 @@
import type { ContentScriptContext } from "#imports";
import { initI18n } from "@/i18n/runtime";
import { getSteamProfileBaseUrl } from "@/steam/profile-url";
-import { getEnabledTrackers } from "@/trackers/enabled";
+import { getEnabledTrackers } from "@/trackers/preferences";
import { resolveSidebarAnchor } from "./anchors";
import { TRACKEROO_ROOT_ATTR } from "./constants";
-import { mountTrackerUi } from "./mount-ui";
+import { mountTrackerUi } from "./mount";
-export function createTrackerDropdownController(ctx: ContentScriptContext) {
+export function createTrackerMenuController(ctx: ContentScriptContext) {
let ui: ReturnType | null = null;
let mountedFor: string | null = null;
let mountedKey = "";
diff --git a/src/steam-ui/mount-dropdown.ts b/src/tracker-menu/dropdown.ts
similarity index 97%
rename from src/steam-ui/mount-dropdown.ts
rename to src/tracker-menu/dropdown.ts
index f470a00..4dd903f 100644
--- a/src/steam-ui/mount-dropdown.ts
+++ b/src/tracker-menu/dropdown.ts
@@ -1,6 +1,6 @@
import type { ContentScriptContext } from "#imports";
import type { Tracker } from "@/trackers/types";
-import { createDropdownMenu, createDropdownTrigger } from "./create-dropdown";
+import { createDropdownMenu, createDropdownTrigger } from "./elements";
interface DropdownControls {
close: () => void;
diff --git a/src/tracker-menu/elements.test.ts b/src/tracker-menu/elements.test.ts
new file mode 100644
index 0000000..f2cf89a
--- /dev/null
+++ b/src/tracker-menu/elements.test.ts
@@ -0,0 +1,102 @@
+import { describe, expect, it, vi } from "vitest";
+import type { Tracker } from "@/trackers/types";
+import {
+ createDirectLink,
+ createDropdownMenu,
+ createDropdownTrigger,
+ createEmptyState,
+} from "./elements";
+
+vi.mock("@/i18n/runtime", () => ({
+ t: (messageName: string, substitutions?: string | string[]) => {
+ const values = Array.isArray(substitutions)
+ ? substitutions.join(",")
+ : substitutions;
+ return values ? `${messageName}:${values}` : messageName;
+ },
+}));
+
+const PROFILE_URL = "https://steamcommunity.com/id/gaben";
+
+const trackers: Tracker[] = [
+ {
+ id: "csstats",
+ homeUrl: "https://csstats.gg",
+ transform: { type: "prefix", value: "x" },
+ },
+ {
+ id: "leetify",
+ homeUrl: "https://leetify.com",
+ transform: { type: "tld", value: "gg" },
+ },
+];
+
+describe("createDropdownMenu", () => {
+ it("renders a menu item per tracker", () => {
+ const menu = createDropdownMenu(trackers, PROFILE_URL);
+ const items = [...menu.querySelectorAll(".trackeroo-menu-item")];
+
+ expect(menu.getAttribute("role")).toBe("menu");
+ expect(menu.hidden).toBe(true);
+ expect(items).toHaveLength(2);
+
+ const [first, second] = items as HTMLAnchorElement[];
+ expect(first.href).toBe("https://xsteamcommunity.com/id/gaben");
+ expect(first.textContent).toBe("csstats.gg");
+ expect(first.getAttribute("role")).toBe("menuitem");
+ expect(first.target).toBe("_blank");
+ expect(first.rel).toBe("noopener noreferrer");
+ expect(second.href).toBe("https://steamcommunity.gg/id/gaben");
+ });
+
+ it("skips trackers when the page url is not a profile", () => {
+ const menu = createDropdownMenu(trackers, "https://example.com");
+ expect(menu.querySelectorAll(".trackeroo-menu-item")).toHaveLength(0);
+ });
+});
+
+describe("createDropdownTrigger", () => {
+ it("renders an accessible trigger with a count label", () => {
+ const trigger = createDropdownTrigger(2);
+
+ expect(trigger.getAttribute("role")).toBe("button");
+ expect(trigger.getAttribute("aria-haspopup")).toBe("true");
+ expect(trigger.getAttribute("aria-expanded")).toBe("false");
+ expect(trigger.querySelector(".count_link_label")?.textContent).toBe(
+ "trackersWithCount:2"
+ );
+ });
+
+ it("omits the count when no trackers are enabled", () => {
+ const trigger = createDropdownTrigger(0);
+ expect(trigger.querySelector(".count_link_label")?.textContent).toBe(
+ "trackers"
+ );
+ });
+});
+
+describe("createDirectLink", () => {
+ it("links straight to the tracker", () => {
+ const link = createDirectLink(trackers[0], PROFILE_URL);
+
+ expect(link?.href).toBe("https://xsteamcommunity.com/id/gaben");
+ expect(link?.querySelector(".count_link_label")?.textContent).toBe(
+ "csstats.gg"
+ );
+ });
+
+ it("returns null for non-profile urls", () => {
+ expect(createDirectLink(trackers[0], "https://example.com")).toBeNull();
+ });
+});
+
+describe("createEmptyState", () => {
+ it("explains how to enable trackers", () => {
+ const empty = createEmptyState();
+
+ expect(empty.title).toBe("emptyStateTitle");
+ expect(empty.querySelector(".count_link_label")?.textContent).toBe(
+ "noTrackersEnabled"
+ );
+ });
+});
diff --git a/src/steam-ui/create-dropdown.ts b/src/tracker-menu/elements.ts
similarity index 63%
rename from src/steam-ui/create-dropdown.ts
rename to src/tracker-menu/elements.ts
index 0837b5e..d952f64 100644
--- a/src/steam-ui/create-dropdown.ts
+++ b/src/tracker-menu/elements.ts
@@ -3,6 +3,22 @@ import { buildTrackerUrl } from "@/trackers/build-url";
import { getTrackerHost } from "@/trackers/catalog";
import type { Tracker } from "@/trackers/types";
+function createCountLabel(text: string): HTMLSpanElement {
+ const label = document.createElement("span");
+ label.className = "count_link_label";
+ label.textContent = text;
+ return label;
+}
+
+function createTrackerAnchor(tracker: Tracker, url: string): HTMLAnchorElement {
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.target = "_blank";
+ anchor.rel = "noopener noreferrer";
+ anchor.textContent = getTrackerHost(tracker);
+ return anchor;
+}
+
export function createDropdownMenu(
enabledTrackers: Tracker[],
pageUrl: string
@@ -18,13 +34,9 @@ export function createDropdownMenu(
continue;
}
- const item = document.createElement("a");
+ const item = createTrackerAnchor(tracker, url);
item.className = "trackeroo-menu-item";
- item.href = url;
- item.target = "_blank";
- item.rel = "noopener noreferrer";
item.setAttribute("role", "menuitem");
- item.textContent = getTrackerHost(tracker);
menu.append(item);
}
@@ -39,13 +51,19 @@ export function createDropdownTrigger(count: number): HTMLDivElement {
trigger.setAttribute("aria-expanded", "false");
trigger.setAttribute("aria-haspopup", "true");
+ const link = document.createElement("a");
+ link.href = "#";
+ link.className = "trackeroo-trigger-link";
+ link.tabIndex = -1;
+
const label =
count > 0 ? t("trackersWithCount", String(count)) : t("trackers");
- trigger.innerHTML =
- '' +
- `${label} ` +
- '▾' +
- "";
+ const caret = document.createElement("span");
+ caret.className = "profile_count_link_total";
+ caret.textContent = "▾";
+
+ link.append(createCountLabel(label), "\u00a0", caret);
+ trigger.append(link);
return trigger;
}
@@ -59,12 +77,9 @@ export function createDirectLink(
return null;
}
- const link = document.createElement("a");
+ const link = createTrackerAnchor(tracker, url);
link.className = "profile_count_link ellipsis trackeroo-direct-link";
- link.href = url;
- link.target = "_blank";
- link.rel = "noopener noreferrer";
- link.innerHTML = `${getTrackerHost(tracker)}`;
+ link.replaceChildren(createCountLabel(getTrackerHost(tracker)));
return link;
}
@@ -72,6 +87,6 @@ export function createEmptyState(): HTMLDivElement {
const el = document.createElement("div");
el.className = "profile_count_link ellipsis trackeroo-empty";
el.title = t("emptyStateTitle");
- el.innerHTML = `${t("noTrackersEnabled")}`;
+ el.append(createCountLabel(t("noTrackersEnabled")));
return el;
}
diff --git a/src/tracker-menu/mount.test.ts b/src/tracker-menu/mount.test.ts
new file mode 100644
index 0000000..da73ca7
--- /dev/null
+++ b/src/tracker-menu/mount.test.ts
@@ -0,0 +1,134 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { ContentScriptContext } from "#imports";
+import type { Tracker } from "@/trackers/types";
+import { mountTrackerUi } from "./mount";
+
+vi.mock("@/i18n/runtime", () => ({
+ t: (messageName: string) => messageName,
+}));
+
+const PROFILE_URL = "https://steamcommunity.com/id/gaben";
+
+const trackers: Tracker[] = [
+ {
+ id: "csstats",
+ homeUrl: "https://csstats.gg",
+ transform: { type: "prefix", value: "x" },
+ },
+ {
+ id: "leetify",
+ homeUrl: "https://leetify.com",
+ transform: { type: "tld", value: "gg" },
+ },
+];
+
+// Forwards listener registration like the real context, minus auto-cleanup.
+const ctx = {
+ addEventListener: (
+ target: EventTarget,
+ type: string,
+ handler: EventListenerOrEventListenerObject
+ ) => {
+ target.addEventListener(type, handler);
+ },
+} as unknown as ContentScriptContext;
+
+function mount(enabledTrackers: Tracker[]): HTMLElement {
+ const container = document.createElement("div");
+ document.body.append(container);
+ mountTrackerUi(ctx, container, enabledTrackers, PROFILE_URL);
+ return container;
+}
+
+beforeEach(() => {
+ document.body.replaceChildren();
+});
+
+describe("mountTrackerUi", () => {
+ it("shows the empty state when no trackers are enabled", () => {
+ const container = mount([]);
+
+ expect(container.className).toContain("trackeroo-root--empty");
+ expect(container.querySelector(".trackeroo-empty")).not.toBeNull();
+ });
+
+ it("renders a direct link for a single enabled tracker", () => {
+ const container = mount([trackers[0]]);
+
+ const link = container.querySelector(
+ ".trackeroo-direct-link"
+ );
+ expect(container.className).toContain("trackeroo-root--direct");
+ expect(link?.href).toBe("https://xsteamcommunity.com/id/gaben");
+ });
+
+ it("renders a dropdown for multiple enabled trackers", () => {
+ const container = mount(trackers);
+
+ expect(container.querySelector(".trackeroo-trigger")).not.toBeNull();
+ expect(
+ container.querySelector(".trackeroo-menu")?.hidden
+ ).toBe(true);
+ });
+});
+
+describe("dropdown interaction", () => {
+ it("opens on trigger click and closes on escape", () => {
+ const container = mount(trackers);
+ const trigger = container.querySelector(".trackeroo-trigger");
+ const menu = container.querySelector(".trackeroo-menu");
+
+ trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ expect(menu?.hidden).toBe(false);
+ expect(trigger?.getAttribute("aria-expanded")).toBe("true");
+ expect(container.classList.contains("is-open")).toBe(true);
+
+ document.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true })
+ );
+ expect(menu?.hidden).toBe(true);
+ expect(trigger?.getAttribute("aria-expanded")).toBe("false");
+ });
+
+ it("closes when clicking outside", () => {
+ const container = mount(trackers);
+ const trigger = container.querySelector(".trackeroo-trigger");
+ const menu = container.querySelector(".trackeroo-menu");
+
+ trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ expect(menu?.hidden).toBe(false);
+
+ document.body.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ expect(menu?.hidden).toBe(true);
+ });
+
+ it("supports keyboard navigation between menu items", () => {
+ const container = mount(trackers);
+ const trigger = container.querySelector(".trackeroo-trigger");
+ const menu = container.querySelector(".trackeroo-menu");
+ const items = [
+ ...container.querySelectorAll(".trackeroo-menu-item"),
+ ];
+
+ trigger?.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })
+ );
+ expect(menu?.hidden).toBe(false);
+ expect(document.activeElement).toBe(items[0]);
+
+ menu?.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })
+ );
+ expect(document.activeElement).toBe(items[1]);
+
+ menu?.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })
+ );
+ expect(document.activeElement).toBe(items[0]);
+
+ menu?.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "End", bubbles: true })
+ );
+ expect(document.activeElement).toBe(items.at(-1));
+ });
+});
diff --git a/src/steam-ui/mount-ui.ts b/src/tracker-menu/mount.ts
similarity index 86%
rename from src/steam-ui/mount-ui.ts
rename to src/tracker-menu/mount.ts
index acb3035..ba09f71 100644
--- a/src/steam-ui/mount-ui.ts
+++ b/src/tracker-menu/mount.ts
@@ -1,7 +1,7 @@
import type { ContentScriptContext } from "#imports";
import type { Tracker } from "@/trackers/types";
-import { createDirectLink, createEmptyState } from "./create-dropdown";
-import { mountDropdown } from "./mount-dropdown";
+import { mountDropdown } from "./dropdown";
+import { createDirectLink, createEmptyState } from "./elements";
export function mountTrackerUi(
ctx: ContentScriptContext,
diff --git a/src/trackers/build-url.test.ts b/src/trackers/build-url.test.ts
new file mode 100644
index 0000000..477655e
--- /dev/null
+++ b/src/trackers/build-url.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it } from "vitest";
+import { buildTrackerUrl } from "./build-url";
+import type { Tracker } from "./types";
+
+const PROFILE_URL = "https://steamcommunity.com/id/gaben";
+
+const prefixTracker: Tracker = {
+ id: "csstats",
+ homeUrl: "https://csstats.gg",
+ transform: { type: "prefix", value: "x" },
+};
+
+const tldTracker: Tracker = {
+ id: "leetify",
+ homeUrl: "https://leetify.com",
+ transform: { type: "tld", value: "gg" },
+};
+
+describe("buildTrackerUrl", () => {
+ it("applies prefix transforms to the steam hostname", () => {
+ expect(buildTrackerUrl(PROFILE_URL, prefixTracker)).toBe(
+ "https://xsteamcommunity.com/id/gaben"
+ );
+ });
+
+ it("applies tld transforms to the steam hostname", () => {
+ expect(buildTrackerUrl(PROFILE_URL, tldTracker)).toBe(
+ "https://steamcommunity.gg/id/gaben"
+ );
+ });
+
+ it("works for steam64 profile urls", () => {
+ expect(
+ buildTrackerUrl(
+ "https://steamcommunity.com/profiles/76561197960287930",
+ tldTracker
+ )
+ ).toBe("https://steamcommunity.gg/profiles/76561197960287930");
+ });
+
+ it("normalizes sub-pages before transforming", () => {
+ expect(
+ buildTrackerUrl(`${PROFILE_URL}/games/?tab=all`, prefixTracker)
+ ).toBe("https://xsteamcommunity.com/id/gaben");
+ });
+
+ it("returns null for non-profile urls", () => {
+ expect(
+ buildTrackerUrl("https://steamcommunity.com/market", prefixTracker)
+ ).toBeNull();
+ expect(buildTrackerUrl("not a url", prefixTracker)).toBeNull();
+ });
+});
diff --git a/src/trackers/catalog.test.ts b/src/trackers/catalog.test.ts
new file mode 100644
index 0000000..743bba2
--- /dev/null
+++ b/src/trackers/catalog.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from "vitest";
+import { buildTrackerUrl } from "./build-url";
+import { getTrackerHost, TRACKER_IDS, TRACKERS } from "./catalog";
+
+const PROFILE_URL = "https://steamcommunity.com/id/gaben";
+
+describe("tracker catalog", () => {
+ it("has unique tracker ids", () => {
+ expect(new Set(TRACKER_IDS).size).toBe(TRACKERS.length);
+ });
+
+ it("produces a valid url for every tracker", () => {
+ for (const tracker of TRACKERS) {
+ const url = buildTrackerUrl(PROFILE_URL, tracker);
+ expect(url, tracker.id).not.toBeNull();
+ expect(() => new URL(url as string), tracker.id).not.toThrow();
+ expect(url, tracker.id).not.toBe(PROFILE_URL);
+ }
+ });
+
+ it("uses valid home urls", () => {
+ for (const tracker of TRACKERS) {
+ expect(() => new URL(tracker.homeUrl), tracker.id).not.toThrow();
+ }
+ });
+});
+
+describe("getTrackerHost", () => {
+ it("returns the bare hostname", () => {
+ expect(
+ getTrackerHost({
+ id: "csstats",
+ homeUrl: "https://csstats.gg",
+ transform: { type: "prefix", value: "x" },
+ })
+ ).toBe("csstats.gg");
+ });
+
+ it("strips a www prefix", () => {
+ expect(
+ getTrackerHost({
+ id: "leetify",
+ homeUrl: "https://www.leetify.com/path",
+ transform: { type: "tld", value: "gg" },
+ })
+ ).toBe("leetify.com");
+ });
+});
diff --git a/src/trackers/enabled.ts b/src/trackers/enabled.ts
deleted file mode 100644
index 1f045b6..0000000
--- a/src/trackers/enabled.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import { getTrackerPreferences } from "@/preferences/storage";
-import { TRACKERS } from "./catalog";
-import type { Tracker } from "./types";
-
-export async function getEnabledTrackers(): Promise {
- const preferences = await getTrackerPreferences();
- return TRACKERS.filter((tracker) => preferences[tracker.id]);
-}
diff --git a/src/trackers/preferences.test.ts b/src/trackers/preferences.test.ts
new file mode 100644
index 0000000..d7a3eba
--- /dev/null
+++ b/src/trackers/preferences.test.ts
@@ -0,0 +1,117 @@
+import { beforeEach, describe, expect, it } from "vitest";
+import { fakeBrowser } from "wxt/testing";
+import { TRACKER_IDS, TRACKERS } from "./catalog";
+import {
+ getDefaultPreferences,
+ getEnabledTrackers,
+ getTrackerPreferences,
+ normalizeStoredPreferences,
+ seedDefaultPreferences,
+ setAllTrackerPreferences,
+ setTrackerPreference,
+ TRACKER_PREFERENCES_KEY,
+} from "./preferences";
+
+beforeEach(() => {
+ fakeBrowser.reset();
+});
+
+describe("getTrackerPreferences", () => {
+ it("defaults every tracker to enabled when nothing is stored", async () => {
+ expect(await getTrackerPreferences()).toEqual(getDefaultPreferences());
+ });
+
+ it("merges partial stored preferences with defaults", async () => {
+ await fakeBrowser.storage.local.set({
+ [TRACKER_PREFERENCES_KEY]: { csstats: false },
+ });
+
+ const preferences = await getTrackerPreferences();
+ expect(preferences.csstats).toBe(false);
+ expect(preferences.leetify).toBe(true);
+ });
+
+ it("ignores corrupt stored values", async () => {
+ await fakeBrowser.storage.local.set({
+ [TRACKER_PREFERENCES_KEY]: "garbage",
+ });
+
+ expect(await getTrackerPreferences()).toEqual(getDefaultPreferences());
+ });
+
+ it("drops keys for removed trackers", async () => {
+ await fakeBrowser.storage.local.set({
+ [TRACKER_PREFERENCES_KEY]: { ghostTracker: true, csstats: false },
+ });
+
+ const preferences = await getTrackerPreferences();
+ expect(Object.keys(preferences).sort()).toEqual([...TRACKER_IDS].sort());
+ });
+});
+
+describe("setTrackerPreference", () => {
+ it("persists a single toggle", async () => {
+ await setTrackerPreference("csstats", false);
+
+ const preferences = await getTrackerPreferences();
+ expect(preferences.csstats).toBe(false);
+ expect(preferences.csrep).toBe(true);
+ });
+
+ it("does not clobber concurrent writes", async () => {
+ await Promise.all([
+ setTrackerPreference("csstats", false),
+ setTrackerPreference("csrep", false),
+ setTrackerPreference("leetify", false),
+ ]);
+
+ const preferences = await getTrackerPreferences();
+ expect(preferences.csstats).toBe(false);
+ expect(preferences.csrep).toBe(false);
+ expect(preferences.leetify).toBe(false);
+ });
+});
+
+describe("setAllTrackerPreferences", () => {
+ it("turns every tracker off", async () => {
+ await setAllTrackerPreferences(false);
+
+ const preferences = await getTrackerPreferences();
+ expect(Object.values(preferences).every((value) => !value)).toBe(true);
+ });
+});
+
+describe("getEnabledTrackers", () => {
+ it("returns only enabled trackers in catalog order", async () => {
+ await setTrackerPreference("csstats", false);
+
+ const enabled = await getEnabledTrackers();
+ expect(enabled.map((tracker) => tracker.id)).toEqual(
+ TRACKERS.filter((tracker) => tracker.id !== "csstats").map(
+ (tracker) => tracker.id
+ )
+ );
+ });
+});
+
+describe("install lifecycle", () => {
+ it("seeds defaults on install", async () => {
+ await seedDefaultPreferences();
+
+ const { [TRACKER_PREFERENCES_KEY]: stored } =
+ await fakeBrowser.storage.local.get(TRACKER_PREFERENCES_KEY);
+ expect(stored).toEqual(getDefaultPreferences());
+ });
+
+ it("reconciles stored preferences on update", async () => {
+ await fakeBrowser.storage.local.set({
+ [TRACKER_PREFERENCES_KEY]: { ghostTracker: false, csstats: false },
+ });
+
+ await normalizeStoredPreferences();
+
+ const { [TRACKER_PREFERENCES_KEY]: stored } =
+ await fakeBrowser.storage.local.get(TRACKER_PREFERENCES_KEY);
+ expect(stored).toEqual({ ...getDefaultPreferences(), csstats: false });
+ });
+});
diff --git a/src/trackers/preferences.ts b/src/trackers/preferences.ts
new file mode 100644
index 0000000..58fae43
--- /dev/null
+++ b/src/trackers/preferences.ts
@@ -0,0 +1,97 @@
+import { TRACKER_IDS, TRACKERS } from "./catalog";
+import type { Tracker, TrackerId, TrackerPreferences } from "./types";
+
+export const TRACKER_PREFERENCES_KEY = "trackerPreferences";
+
+// Serializes read-modify-write cycles so concurrent toggles (e.g. rapid
+// clicks in the popup) cannot clobber each other's writes.
+let pendingWrite: Promise = Promise.resolve();
+
+function enqueueWrite(task: () => Promise): Promise {
+ const run = pendingWrite.then(task);
+ pendingWrite = run.catch(() => undefined);
+ return run;
+}
+
+export function getDefaultPreferences(): TrackerPreferences {
+ return Object.fromEntries(
+ TRACKER_IDS.map((id) => [id, true])
+ ) as TrackerPreferences;
+}
+
+function normalizePreferences(stored: unknown): TrackerPreferences {
+ const source =
+ stored && typeof stored === "object"
+ ? (stored as Partial)
+ : {};
+
+ return Object.fromEntries(
+ TRACKER_IDS.map((id) => [id, source[id] ?? true])
+ ) as TrackerPreferences;
+}
+
+async function readPreferences(): Promise {
+ const { [TRACKER_PREFERENCES_KEY]: stored } = await browser.storage.local.get(
+ TRACKER_PREFERENCES_KEY
+ );
+ return normalizePreferences(stored);
+}
+
+export async function getTrackerPreferences(): Promise {
+ try {
+ return await readPreferences();
+ } catch {
+ return getDefaultPreferences();
+ }
+}
+
+export async function getEnabledTrackers(): Promise {
+ const preferences = await getTrackerPreferences();
+ return TRACKERS.filter((tracker) => preferences[tracker.id]);
+}
+
+export function setTrackerPreference(
+ id: TrackerId,
+ enabled: boolean
+): Promise {
+ return enqueueWrite(async () => {
+ const preferences = await readPreferences();
+ preferences[id] = enabled;
+ await browser.storage.local.set({
+ [TRACKER_PREFERENCES_KEY]: preferences,
+ });
+ });
+}
+
+export function setAllTrackerPreferences(
+ enabled: boolean
+): Promise {
+ return enqueueWrite(async () => {
+ const preferences = Object.fromEntries(
+ TRACKER_IDS.map((id) => [id, enabled])
+ ) as TrackerPreferences;
+ await browser.storage.local.set({
+ [TRACKER_PREFERENCES_KEY]: preferences,
+ });
+ return preferences;
+ });
+}
+
+/** Write defaults on first install so the content script sees a full record. */
+export function seedDefaultPreferences(): Promise {
+ return enqueueWrite(async () => {
+ await browser.storage.local.set({
+ [TRACKER_PREFERENCES_KEY]: getDefaultPreferences(),
+ });
+ });
+}
+
+/** Re-persist stored preferences so added/removed trackers are reconciled. */
+export function normalizeStoredPreferences(): Promise {
+ return enqueueWrite(async () => {
+ const preferences = await readPreferences();
+ await browser.storage.local.set({
+ [TRACKER_PREFERENCES_KEY]: preferences,
+ });
+ });
+}
diff --git a/src/trackers/types.ts b/src/trackers/types.ts
index 41e2428..b959e15 100644
--- a/src/trackers/types.ts
+++ b/src/trackers/types.ts
@@ -16,3 +16,5 @@ export interface Tracker {
id: TrackerId;
transform: HostTransform;
}
+
+export type TrackerPreferences = Record;
diff --git a/src/hooks/useTrackerPreferences.ts b/src/trackers/use-tracker-preferences.ts
similarity index 87%
rename from src/hooks/useTrackerPreferences.ts
rename to src/trackers/use-tracker-preferences.ts
index 509d835..f49c33e 100644
--- a/src/hooks/useTrackerPreferences.ts
+++ b/src/trackers/use-tracker-preferences.ts
@@ -4,9 +4,8 @@ import {
getTrackerPreferences,
setAllTrackerPreferences,
setTrackerPreference,
-} from "@/preferences/storage";
-import type { TrackerPreferences } from "@/preferences/types";
-import type { TrackerId } from "@/trackers/types";
+} from "./preferences";
+import type { TrackerId, TrackerPreferences } from "./types";
export function useTrackerPreferences() {
const [preferences, setPreferences] = useState(
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..373553f
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from "vitest/config";
+import { WxtVitest } from "wxt/testing";
+
+export default defineConfig({
+ plugins: [WxtVitest()],
+ test: {
+ environment: "happy-dom",
+ },
+});