From b885a25b5860a2154e00850baad8cbe5c2d48cd7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 10 Jun 2026 00:58:57 +0000 Subject: [PATCH 1/9] refactor(arch): reorganize source into feature-first modules Replace technical-layer folders (components/, hooks/, preferences/, steam-ui/) with feature modules that reflect what the extension does: - src/trackers/ tracker catalog, URL building, preferences + hook - src/tracker-menu/ menu injected into Steam profile sidebars - src/popup/ toolbar popup app and components - src/steam/ Steam profile URL parsing and match patterns - src/i18n/ locales, stored preference, runtime + hook Entrypoints are now thin wiring only. Storage keys live with their domains (TRACKER_PREFERENCES_KEY, LOCALE_KEY), install/update lifecycle branching moved to the background entrypoint, pure message helpers extracted to i18n/messages.ts, and all filenames are kebab-case so the useFilenamingConvention override is no longer needed. --- biome.jsonc | 14 +--- src/entrypoints/background.ts | 13 ++- src/entrypoints/popup/main.tsx | 4 +- src/entrypoints/steam.content/index.ts | 19 ++--- src/i18n/messages.ts | 30 +++++++ src/i18n/preference.ts | 5 +- src/i18n/runtime.ts | 33 ++------ .../useLocale.ts => i18n/use-locale.ts} | 6 +- src/meta/links.ts | 2 - .../popup/App.tsx => popup/app.tsx} | 16 ++-- .../components/language-picker.tsx} | 0 .../components/popup-shell.tsx} | 2 +- .../components/popup-tabs.tsx} | 0 .../components/settings-tab.tsx} | 2 +- .../components/toggle.tsx} | 0 .../components/trackers-tab.tsx} | 5 +- src/preferences/keys.ts | 2 - src/preferences/storage.ts | 74 ----------------- src/preferences/types.ts | 3 - src/{steam-ui => tracker-menu}/anchors.ts | 0 src/{steam-ui => tracker-menu}/constants.ts | 0 src/{steam-ui => tracker-menu}/controller.ts | 6 +- .../dropdown.ts} | 2 +- .../elements.ts} | 0 .../mount-ui.ts => tracker-menu/mount.ts} | 4 +- src/trackers/enabled.ts | 8 -- src/trackers/preferences.ts | 79 +++++++++++++++++++ src/trackers/types.ts | 2 + .../use-tracker-preferences.ts} | 5 +- 29 files changed, 166 insertions(+), 170 deletions(-) create mode 100644 src/i18n/messages.ts rename src/{hooks/useLocale.ts => i18n/use-locale.ts} (78%) rename src/{entrypoints/popup/App.tsx => popup/app.tsx} (67%) rename src/{components/popup/LanguagePicker.tsx => popup/components/language-picker.tsx} (100%) rename src/{components/popup/PopupShell.tsx => popup/components/popup-shell.tsx} (93%) rename src/{components/popup/PopupTabs.tsx => popup/components/popup-tabs.tsx} (100%) rename src/{components/popup/SettingsTab.tsx => popup/components/settings-tab.tsx} (94%) rename src/{components/popup/Toggle.tsx => popup/components/toggle.tsx} (100%) rename src/{components/popup/TrackersTab.tsx => popup/components/trackers-tab.tsx} (92%) delete mode 100644 src/preferences/keys.ts delete mode 100644 src/preferences/storage.ts delete mode 100644 src/preferences/types.ts rename src/{steam-ui => tracker-menu}/anchors.ts (100%) rename src/{steam-ui => tracker-menu}/constants.ts (100%) rename src/{steam-ui => tracker-menu}/controller.ts (90%) rename src/{steam-ui/mount-dropdown.ts => tracker-menu/dropdown.ts} (97%) rename src/{steam-ui/create-dropdown.ts => tracker-menu/elements.ts} (100%) rename src/{steam-ui/mount-ui.ts => tracker-menu/mount.ts} (86%) delete mode 100644 src/trackers/enabled.ts create mode 100644 src/trackers/preferences.ts rename src/{hooks/useTrackerPreferences.ts => trackers/use-tracker-preferences.ts} (87%) 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/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..6b5666b 100644 --- a/src/entrypoints/steam.content/index.ts +++ b/src/entrypoints/steam.content/index.ts @@ -1,6 +1,7 @@ -import { LOCALE_KEY, SETTINGS_KEY } from "@/preferences/keys"; +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,22 +9,22 @@ 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) => { 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(); } }); }, 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.ts b/src/i18n/preference.ts index 32c7507..845afa4 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"; -function normalizeStoredLocale(value: unknown): StoredLocale { +export const LOCALE_KEY = "locale"; + +export function normalizeStoredLocale(value: unknown): StoredLocale { if (value === "system") { return "system"; } 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-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/steam-ui/create-dropdown.ts b/src/tracker-menu/elements.ts similarity index 100% rename from src/steam-ui/create-dropdown.ts rename to src/tracker-menu/elements.ts 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/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.ts b/src/trackers/preferences.ts new file mode 100644 index 0000000..f10f749 --- /dev/null +++ b/src/trackers/preferences.ts @@ -0,0 +1,79 @@ +import { TRACKER_IDS, TRACKERS } from "./catalog"; +import type { Tracker, TrackerId, TrackerPreferences } from "./types"; + +export const TRACKER_PREFERENCES_KEY = "trackerPreferences"; + +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 async function setTrackerPreference( + id: TrackerId, + enabled: boolean +): Promise { + const preferences = await readPreferences(); + preferences[id] = enabled; + await browser.storage.local.set({ + [TRACKER_PREFERENCES_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({ + [TRACKER_PREFERENCES_KEY]: preferences, + }); + return preferences; +} + +/** Write defaults on first install so the content script sees a full record. */ +export async function seedDefaultPreferences(): Promise { + await browser.storage.local.set({ + [TRACKER_PREFERENCES_KEY]: getDefaultPreferences(), + }); +} + +/** Re-persist stored preferences so added/removed trackers are reconciled. */ +export async function normalizeStoredPreferences(): Promise { + 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( From 00ee5ec393f3151daa784f38bed6a4238daef9cf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 10 Jun 2026 00:59:07 +0000 Subject: [PATCH 2/9] fix(trackers): serialize preference writes to prevent lost updates setTrackerPreference does a read-modify-write of the whole preferences record. Two rapid toggles in the popup could both read the same snapshot and the second write would silently revert the first one. All writes now go through a queue so each read sees the previous write's result. --- src/trackers/preferences.ts | 56 ++++++++++++++++++++++++------------- 1 file changed, 37 insertions(+), 19 deletions(-) diff --git a/src/trackers/preferences.ts b/src/trackers/preferences.ts index f10f749..58fae43 100644 --- a/src/trackers/preferences.ts +++ b/src/trackers/preferences.ts @@ -3,6 +3,16 @@ 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]) @@ -40,40 +50,48 @@ export async function getEnabledTrackers(): Promise { return TRACKERS.filter((tracker) => preferences[tracker.id]); } -export async function setTrackerPreference( +export function setTrackerPreference( id: TrackerId, enabled: boolean ): Promise { - const preferences = await readPreferences(); - preferences[id] = enabled; - await browser.storage.local.set({ - [TRACKER_PREFERENCES_KEY]: preferences, + return enqueueWrite(async () => { + const preferences = await readPreferences(); + preferences[id] = enabled; + await browser.storage.local.set({ + [TRACKER_PREFERENCES_KEY]: preferences, + }); }); } -export async function setAllTrackerPreferences( +export function setAllTrackerPreferences( enabled: boolean ): Promise { - const preferences = Object.fromEntries( - TRACKER_IDS.map((id) => [id, enabled]) - ) as TrackerPreferences; - await browser.storage.local.set({ - [TRACKER_PREFERENCES_KEY]: preferences, + 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; }); - return preferences; } /** Write defaults on first install so the content script sees a full record. */ -export async function seedDefaultPreferences(): Promise { - await browser.storage.local.set({ - [TRACKER_PREFERENCES_KEY]: getDefaultPreferences(), +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 async function normalizeStoredPreferences(): Promise { - const preferences = await readPreferences(); - await browser.storage.local.set({ - [TRACKER_PREFERENCES_KEY]: preferences, +export function normalizeStoredPreferences(): Promise { + return enqueueWrite(async () => { + const preferences = await readPreferences(); + await browser.storage.local.set({ + [TRACKER_PREFERENCES_KEY]: preferences, + }); }); } From 67afe295f0731e05baa81520582f5992869baed4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 10 Jun 2026 00:59:17 +0000 Subject: [PATCH 3/9] fix(content): remove storage listener when context is invalidated browser.storage.onChanged listeners are not cleaned up by WXT's content script context. After an extension update or reload the orphaned listener kept firing against an invalidated context. Register a named handler and remove it via ctx.onInvalidated. --- src/entrypoints/steam.content/index.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/entrypoints/steam.content/index.ts b/src/entrypoints/steam.content/index.ts index 6b5666b..ca4becd 100644 --- a/src/entrypoints/steam.content/index.ts +++ b/src/entrypoints/steam.content/index.ts @@ -1,3 +1,4 @@ +import type { Browser } from "wxt/browser"; import { LOCALE_KEY } from "@/i18n/preference"; import { STEAM_PROFILE_MATCHES } from "@/steam/matches"; import { createTrackerMenuController } from "@/tracker-menu/controller"; @@ -18,7 +19,10 @@ export default defineContentScript({ menu.sync(); }); - browser.storage.onChanged.addListener((changes, area) => { + const onStorageChanged = ( + changes: Record, + area: string + ) => { if (area !== "local") { return; } @@ -26,6 +30,11 @@ export default defineContentScript({ menu.invalidate(); menu.sync(); } + }; + + browser.storage.onChanged.addListener(onStorageChanged); + ctx.onInvalidated(() => { + browser.storage.onChanged.removeListener(onStorageChanged); }); }, }); From e60fc1adfe91d47029b85b22dde13859d41a5062 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 10 Jun 2026 00:59:17 +0000 Subject: [PATCH 4/9] refactor(tracker-menu): build menu DOM without innerHTML Construct trigger, direct link, and empty state with createElement and textContent instead of interpolating translated strings into innerHTML. Hardens against markup injection from locale files and deduplicates the count_link_label markup. --- src/tracker-menu/elements.ts | 47 ++++++++++++++++++++++++------------ 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/src/tracker-menu/elements.ts b/src/tracker-menu/elements.ts index 0837b5e..d952f64 100644 --- a/src/tracker-menu/elements.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; } From 11ca959fb87539fbad884b989a44ebc8ef336c31 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 10 Jun 2026 00:59:26 +0000 Subject: [PATCH 5/9] chore: remove dead code and unused assets - resolveLocale was never called; locale resolution is handled by initI18n falling back to browser.i18n for the system setting - GITHUB_ISSUES_URL constant was unreferenced - popupSubtitle message key was unused in all seven locales - wxt.svg shipped in the extension bundle and react.svg were template leftovers --- public/_locales/de/messages.json | 3 --- public/_locales/en/messages.json | 3 --- public/_locales/es/messages.json | 3 --- public/_locales/fr/messages.json | 3 --- public/_locales/pt_BR/messages.json | 3 --- public/_locales/ru/messages.json | 3 --- public/_locales/zh_CN/messages.json | 3 --- public/wxt.svg | 15 --------------- src/assets/react.svg | 1 - src/i18n/preference.ts | 23 ++--------------------- 10 files changed, 2 insertions(+), 58 deletions(-) delete mode 100644 public/wxt.svg delete mode 100644 src/assets/react.svg 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/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/i18n/preference.ts b/src/i18n/preference.ts index 845afa4..c6e11c1 100644 --- a/src/i18n/preference.ts +++ b/src/i18n/preference.ts @@ -1,5 +1,5 @@ -import type { LocaleId, StoredLocale } from "./locales"; -import { isLocaleId, LOCALE_IDS } from "./locales"; +import type { StoredLocale } from "./locales"; +import { isLocaleId } from "./locales"; export const LOCALE_KEY = "locale"; @@ -26,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; -} From 4882bfdf9c57afae73782626680fa83867498b45 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 10 Jun 2026 00:59:39 +0000 Subject: [PATCH 6/9] test: add vitest setup and unit tests Vitest with WXT's testing plugin (fakeBrowser for storage-backed code) and happy-dom for DOM assertions. Covers Steam profile URL parsing, tracker URL transforms, catalog invariants, preference storage (including a regression test for the concurrent-write race), locale storage normalization, i18n substitutions, and the injected menu's rendering, a11y attributes, and keyboard navigation. Run with bun run test / bun run test:watch. --- bun.lock | 56 +++++++++++++ package.json | 4 + src/i18n/messages.test.ts | 37 +++++++++ src/i18n/preference.test.ts | 45 ++++++++++ src/steam/profile-url.test.ts | 74 +++++++++++++++++ src/tracker-menu/elements.test.ts | 102 +++++++++++++++++++++++ src/tracker-menu/mount.test.ts | 134 ++++++++++++++++++++++++++++++ src/trackers/build-url.test.ts | 53 ++++++++++++ src/trackers/catalog.test.ts | 48 +++++++++++ src/trackers/preferences.test.ts | 117 ++++++++++++++++++++++++++ vitest.config.ts | 9 ++ 11 files changed, 679 insertions(+) create mode 100644 src/i18n/messages.test.ts create mode 100644 src/i18n/preference.test.ts create mode 100644 src/steam/profile-url.test.ts create mode 100644 src/tracker-menu/elements.test.ts create mode 100644 src/tracker-menu/mount.test.ts create mode 100644 src/trackers/build-url.test.ts create mode 100644 src/trackers/catalog.test.ts create mode 100644 src/trackers/preferences.test.ts create mode 100644 vitest.config.ts 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/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/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/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/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/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/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/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/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", + }, +}); From b5737c922d65a7c0faf2785e6b4a7e6df27bc97d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 10 Jun 2026 00:59:40 +0000 Subject: [PATCH 7/9] ci: run tests and build both browsers Split CI into a quality job (lint, locale check, typecheck, test) and a build matrix (chrome, firefox). Cancel superseded runs on PR branches. Release workflow now lints and tests before zipping. --- .github/workflows/ci.yml | 31 +++++++++++++++++++++++++++++-- .github/workflows/release.yml | 6 ++++++ 2 files changed, 35 insertions(+), 2 deletions(-) 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 From 0d622cef661ea12b0f2a161524fad0ea7152d7e6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 10 Jun 2026 00:59:40 +0000 Subject: [PATCH 8/9] chore(scripts): simplify json-sort and validate locale placeholders json-sort carried monorepo package discovery (packages/*, apps/*) from a template; this repo has a single package.json. check-locales now also verifies $n substitution tokens match the base locale per key. --- scripts/check-locales.ts | 56 ++++++++++++++++++++++++++++++---------- scripts/json-sort.ts | 53 ++++++++----------------------------- 2 files changed, 53 insertions(+), 56 deletions(-) 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); From a2df1c10bd39090e44fb34852a9f37ce62c4d069 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 10 Jun 2026 00:59:40 +0000 Subject: [PATCH 9/9] docs(readme): document feature-first layout and dev commands --- README.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) 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.