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", + }, +});