From 9b08636b268cc8fd87c5791ade24bc312d8a45ca Mon Sep 17 00:00:00 2001 From: Sidney von Katzendame Date: Sun, 24 May 2026 12:34:08 -0400 Subject: [PATCH 1/5] chore(extension-shared): bootstrap workspace package skeleton Pre-cursor to extracting the cross-browser extension code out of extension-chrome so that extension-firefox (issue #23) can consume the same source without duplication. This commit creates only the package shell + a smoke test; subsequent tasks move code into it. --- packages/extension-shared/package.json | 22 ++++++++++++ packages/extension-shared/src/.gitkeep | 0 packages/extension-shared/test/setup.ts | 3 ++ packages/extension-shared/test/smoke.test.ts | 7 ++++ packages/extension-shared/tsconfig.json | 11 ++++++ packages/extension-shared/vitest.config.ts | 10 ++++++ pnpm-lock.yaml | 35 ++++++++++++++++++++ 7 files changed, 88 insertions(+) create mode 100644 packages/extension-shared/package.json create mode 100644 packages/extension-shared/src/.gitkeep create mode 100644 packages/extension-shared/test/setup.ts create mode 100644 packages/extension-shared/test/smoke.test.ts create mode 100644 packages/extension-shared/tsconfig.json create mode 100644 packages/extension-shared/vitest.config.ts diff --git a/packages/extension-shared/package.json b/packages/extension-shared/package.json new file mode 100644 index 0000000..197a676 --- /dev/null +++ b/packages/extension-shared/package.json @@ -0,0 +1,22 @@ +{ + "name": "@gitmarks/extension-shared", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@gitmarks/core": "workspace:*", + "webextension-polyfill": "^0.12.0", + "zod": "^3.23.0" + }, + "devDependencies": { + "@types/chrome": "^0.0.268", + "@types/webextension-polyfill": "^0.12.0", + "jsdom": "^25.0.0", + "vitest": "^2.0.0" + } +} diff --git a/packages/extension-shared/src/.gitkeep b/packages/extension-shared/src/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/extension-shared/test/setup.ts b/packages/extension-shared/test/setup.ts new file mode 100644 index 0000000..6bc6b54 --- /dev/null +++ b/packages/extension-shared/test/setup.ts @@ -0,0 +1,3 @@ +// Placeholder so vitest's setupFiles entry resolves. +// Replaced with the real chrome/browser stub when test files migrate in FF-1. +export {}; diff --git a/packages/extension-shared/test/smoke.test.ts b/packages/extension-shared/test/smoke.test.ts new file mode 100644 index 0000000..1c602cb --- /dev/null +++ b/packages/extension-shared/test/smoke.test.ts @@ -0,0 +1,7 @@ +import { describe, it, expect } from "vitest"; + +describe("@gitmarks/extension-shared smoke", () => { + it("loads without errors", () => { + expect(1 + 1).toBe(2); + }); +}); diff --git a/packages/extension-shared/tsconfig.json b/packages/extension-shared/tsconfig.json new file mode 100644 index 0000000..7a3e845 --- /dev/null +++ b/packages/extension-shared/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["chrome"], + "rootDir": "./", + "outDir": "./dist-tsc", + "noEmit": true + }, + "include": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts"] +} diff --git a/packages/extension-shared/vitest.config.ts b/packages/extension-shared/vitest.config.ts new file mode 100644 index 0000000..053baaf --- /dev/null +++ b/packages/extension-shared/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "jsdom", + include: ["test/**/*.test.ts"], + setupFiles: ["./test/setup.ts"], + globals: false, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 139ba10..4a5b331 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,31 @@ importers: specifier: ^2.0.0 version: 2.1.9(jsdom@25.0.1) + packages/extension-shared: + dependencies: + '@gitmarks/core': + specifier: workspace:* + version: link:../core + webextension-polyfill: + specifier: ^0.12.0 + version: 0.12.0 + zod: + specifier: ^3.23.0 + version: 3.25.76 + devDependencies: + '@types/chrome': + specifier: ^0.0.268 + version: 0.0.268 + '@types/webextension-polyfill': + specifier: ^0.12.0 + version: 0.12.5 + jsdom: + specifier: ^25.0.0 + version: 25.0.1 + vitest: + specifier: ^2.0.0 + version: 2.1.9(jsdom@25.0.1) + packages: '@asamuzakjp/css-color@3.2.0': @@ -396,6 +421,9 @@ packages: '@types/har-format@1.2.16': resolution: {integrity: sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==} + '@types/webextension-polyfill@0.12.5': + resolution: {integrity: sha512-uKSAv6LgcVdINmxXMKBuVIcg/2m5JZugoZO8x20g7j2bXJkPIl/lVGQcDlbV+aXAiTyXT2RA5U5mI4IGCDMQeg==} + '@vitest/expect@2.1.9': resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} @@ -948,6 +976,9 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} + webextension-polyfill@0.12.0: + resolution: {integrity: sha512-97TBmpoWJEE+3nFBQ4VocyCdLKfw54rFaJ6EVQYLBCXqCIpLSZkwGgASpv4oPt9gdKCJ80RJlcmNzNn008Ag6Q==} + webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} @@ -1228,6 +1259,8 @@ snapshots: '@types/har-format@1.2.16': {} + '@types/webextension-polyfill@0.12.5': {} + '@vitest/expect@2.1.9': dependencies: '@vitest/spy': 2.1.9 @@ -1804,6 +1837,8 @@ snapshots: dependencies: xml-name-validator: 5.0.0 + webextension-polyfill@0.12.0: {} + webidl-conversions@7.0.0: {} whatwg-encoding@3.1.1: From 94acf29af143162474254652c31e22b5fe45b220 Mon Sep 17 00:00:00 2001 From: Sidney von Katzendame Date: Sun, 24 May 2026 12:43:57 -0400 Subject: [PATCH 2/5] refactor: extract extension-shared workspace package Moves all of packages/extension-chrome/src (background, popup, options, lib/) and packages/extension-chrome/test (chrome stub + 96 unit tests) into the new @gitmarks/extension-shared workspace package. extension-chrome becomes a thin shell: manifest + vite config + four entry files that import from the shared package. Why: extension-firefox (issue #23) needs the same source. Without this refactor we would duplicate ~2k LoC across browsers. No behavior change. All 96 unit tests still pass; 4 Playwright e2e pass, 2 skipped (unchanged). extension-chrome's dist/manifest.json is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/extension-chrome/package.json | 7 +- packages/extension-chrome/src/background.ts | 123 +--------------- packages/extension-chrome/src/options.ts | 118 +-------------- packages/extension-chrome/src/popup.ts | 135 +----------------- packages/extension-chrome/test/setup.ts | 71 --------- packages/extension-chrome/test/smoke.test.ts | 8 -- packages/extension-chrome/vitest.config.ts | 10 -- packages/extension-shared/package.json | 6 + packages/extension-shared/src/.gitkeep | 0 packages/extension-shared/src/background.ts | 120 ++++++++++++++++ packages/extension-shared/src/index.ts | 55 +++++++ .../src/lib/apply-remote.ts | 0 .../src/lib/background-core.ts | 0 .../src/lib/bookmark-factory.ts | 0 .../src/lib/bookmarks-file.ts | 0 .../src/lib/folder-path.ts | 0 .../src/lib/id-mapping.ts | 0 .../src/lib/listeners.ts | 0 .../src/lib/machine-id.ts | 0 .../src/lib/reconcile.ts | 0 .../src/lib/save-flow.ts | 0 .../src/lib/settings.ts | 0 .../src/lib/suppression.ts | 0 packages/extension-shared/src/options.html | 64 +++++++++ packages/extension-shared/src/options.ts | 117 +++++++++++++++ packages/extension-shared/src/popup.html | 37 +++++ packages/extension-shared/src/popup.ts | 134 +++++++++++++++++ .../test/apply-remote.test.ts | 0 .../test/background-core.test.ts | 0 .../test/bookmark-factory.test.ts | 0 .../test/bookmarks-file.test.ts | 0 .../test/folder-path.test.ts | 0 .../test/id-mapping.test.ts | 0 .../test/listeners.test.ts | 0 .../test/machine-id.test.ts | 0 .../test/reconcile.test.ts | 0 .../test/save-flow.test.ts | 0 .../test/settings.test.ts | 0 packages/extension-shared/test/setup.ts | 74 +++++++++- packages/extension-shared/test/smoke.test.ts | 7 - .../test/suppression.test.ts | 0 pnpm-lock.yaml | 9 +- 42 files changed, 614 insertions(+), 481 deletions(-) delete mode 100644 packages/extension-chrome/test/setup.ts delete mode 100644 packages/extension-chrome/test/smoke.test.ts delete mode 100644 packages/extension-chrome/vitest.config.ts delete mode 100644 packages/extension-shared/src/.gitkeep create mode 100644 packages/extension-shared/src/background.ts create mode 100644 packages/extension-shared/src/index.ts rename packages/{extension-chrome => extension-shared}/src/lib/apply-remote.ts (100%) rename packages/{extension-chrome => extension-shared}/src/lib/background-core.ts (100%) rename packages/{extension-chrome => extension-shared}/src/lib/bookmark-factory.ts (100%) rename packages/{extension-chrome => extension-shared}/src/lib/bookmarks-file.ts (100%) rename packages/{extension-chrome => extension-shared}/src/lib/folder-path.ts (100%) rename packages/{extension-chrome => extension-shared}/src/lib/id-mapping.ts (100%) rename packages/{extension-chrome => extension-shared}/src/lib/listeners.ts (100%) rename packages/{extension-chrome => extension-shared}/src/lib/machine-id.ts (100%) rename packages/{extension-chrome => extension-shared}/src/lib/reconcile.ts (100%) rename packages/{extension-chrome => extension-shared}/src/lib/save-flow.ts (100%) rename packages/{extension-chrome => extension-shared}/src/lib/settings.ts (100%) rename packages/{extension-chrome => extension-shared}/src/lib/suppression.ts (100%) create mode 100644 packages/extension-shared/src/options.html create mode 100644 packages/extension-shared/src/options.ts create mode 100644 packages/extension-shared/src/popup.html create mode 100644 packages/extension-shared/src/popup.ts rename packages/{extension-chrome => extension-shared}/test/apply-remote.test.ts (100%) rename packages/{extension-chrome => extension-shared}/test/background-core.test.ts (100%) rename packages/{extension-chrome => extension-shared}/test/bookmark-factory.test.ts (100%) rename packages/{extension-chrome => extension-shared}/test/bookmarks-file.test.ts (100%) rename packages/{extension-chrome => extension-shared}/test/folder-path.test.ts (100%) rename packages/{extension-chrome => extension-shared}/test/id-mapping.test.ts (100%) rename packages/{extension-chrome => extension-shared}/test/listeners.test.ts (100%) rename packages/{extension-chrome => extension-shared}/test/machine-id.test.ts (100%) rename packages/{extension-chrome => extension-shared}/test/reconcile.test.ts (100%) rename packages/{extension-chrome => extension-shared}/test/save-flow.test.ts (100%) rename packages/{extension-chrome => extension-shared}/test/settings.test.ts (100%) delete mode 100644 packages/extension-shared/test/smoke.test.ts rename packages/{extension-chrome => extension-shared}/test/suppression.test.ts (100%) diff --git a/packages/extension-chrome/package.json b/packages/extension-chrome/package.json index 8d27dc5..c881e20 100644 --- a/packages/extension-chrome/package.json +++ b/packages/extension-chrome/package.json @@ -7,22 +7,19 @@ "build": "vite build", "dev": "vite build --watch --mode development", "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "vitest run", - "test:watch": "vitest", "e2e": "playwright test", "e2e:headed": "playwright test --headed", "pretest:e2e": "vite build" }, "dependencies": { "@gitmarks/core": "workspace:*", + "@gitmarks/extension-shared": "workspace:*", "zod": "^3.23.0" }, "devDependencies": { "@crxjs/vite-plugin": "^2.4.0", "@playwright/test": "^1.48.0", "@types/chrome": "^0.0.268", - "jsdom": "^25.0.0", - "vite": "^5.4.0", - "vitest": "^2.0.0" + "vite": "^5.4.0" } } diff --git a/packages/extension-chrome/src/background.ts b/packages/extension-chrome/src/background.ts index 6db3b93..367ed0f 100644 --- a/packages/extension-chrome/src/background.ts +++ b/packages/extension-chrome/src/background.ts @@ -1,120 +1,3 @@ -import { GitHubClient } from "@gitmarks/core"; -import { loadSettings, type Settings } from "./lib/settings.js"; -import { getMachineId } from "./lib/machine-id.js"; -import { IdMap } from "./lib/id-mapping.js"; -import { reconcile } from "./lib/reconcile.js"; -import { registerListeners } from "./lib/listeners.js"; -import { applyRemoteChanges } from "./lib/apply-remote.js"; -import { runMaybeReconcile, runPollRemoteOnce, toEtag } from "./lib/background-core.js"; - -const RECONCILE_INTERVAL_MS = 60 * 60 * 1000; -const POLL_ALARM_NAME = "gitmarks:poll"; -const RECONCILED_AT_KEY = "gitmarks:lastReconciledAt"; -const LAST_ETAG_KEY = "gitmarks:bookmarksEtag"; - -let cachedBarId: string | null = null; -let cachedOtherId: string | null = null; - -async function getBarOtherIds(): Promise<{ bar: string; other: string }> { - if (cachedBarId != null && cachedOtherId != null) { - return { bar: cachedBarId, other: cachedOtherId }; - } - const tree = await chrome.bookmarks.getTree(); - const root = tree[0]; - if (root?.children == null) { - throw new Error("unexpected chrome.bookmarks tree shape"); - } - let bar: string | null = null; - let other: string | null = null; - for (const child of root.children) { - if (child.id === "1") bar = child.id; - else if (child.id === "2") other = child.id; - } - if (bar == null || other == null) { - throw new Error("could not find Bookmarks Bar (id=1) or Other Bookmarks (id=2) in tree"); - } - cachedBarId = bar; - cachedOtherId = other; - return { bar, other }; -} - -function buildClient(settings: Settings): GitHubClient { - return new GitHubClient({ - owner: settings.owner, - repo: settings.repo, - token: settings.token, - branch: settings.branch, - }); -} - -async function maybeReconcile(): Promise { - const settings = await loadSettings(); - if (settings == null) return; - - const stored = await chrome.storage.local.get(RECONCILED_AT_KEY); - const lastReconciledAt = typeof stored[RECONCILED_AT_KEY] === "number" - ? (stored[RECONCILED_AT_KEY] as number) - : 0; - - await runMaybeReconcile({ - now: Date.now(), - lastReconciledAt, - reconcileIntervalMs: RECONCILE_INTERVAL_MS, - runReconcile: async () => { - const { bar, other } = await getBarOtherIds(); - const client = buildClient(settings); - const idMap = await IdMap.load(); - const machineId = await getMachineId(); - const nowIso = new Date().toISOString(); - await reconcile(client, idMap, bar, other, machineId, nowIso, settings.stripTrackingParams); - }, - setStorage: (items) => chrome.storage.local.set(items), - removeStorage: (key) => chrome.storage.local.remove(key), - }); -} - -async function pollRemoteOnce(): Promise { - const settings = await loadSettings(); - if (settings == null) return; - const client = buildClient(settings); - const stored = await chrome.storage.local.get(LAST_ETAG_KEY); - const rawEtag = stored[LAST_ETAG_KEY]; - const etag = typeof rawEtag === "string" ? toEtag(rawEtag) : null; - - await runPollRemoteOnce({ - etag, - now: Date.now(), - client, - applyRemote: async (data) => { - const { bar, other } = await getBarOtherIds(); - const idMap = await IdMap.load(); - await applyRemoteChanges(data, idMap, bar, other); - }, - setStorage: (items) => chrome.storage.local.set(items), - removeStorage: (key) => chrome.storage.local.remove(key), - }); -} - -registerListeners({ - getClient: async () => { - const s = await loadSettings(); - if (s == null) throw new Error("no settings"); - return buildClient(s); - }, - getIdMap: async () => IdMap.load(), - getBarOtherIds, - getMachineId, - getStripTrackingParams: async () => { - const s = await loadSettings(); - return s?.stripTrackingParams ?? false; - }, -}); - -chrome.alarms.create(POLL_ALARM_NAME, { periodInMinutes: 5 }); -chrome.alarms.onAlarm.addListener((alarm) => { - if (alarm.name === POLL_ALARM_NAME) { - void pollRemoteOnce(); - } -}); - -void maybeReconcile(); +// Chrome shell entry — MV3 manifest points at this file. The real +// implementation lives in @gitmarks/extension-shared. +import "@gitmarks/extension-shared/background"; diff --git a/packages/extension-chrome/src/options.ts b/packages/extension-chrome/src/options.ts index 3b2e656..a5aac97 100644 --- a/packages/extension-chrome/src/options.ts +++ b/packages/extension-chrome/src/options.ts @@ -1,117 +1 @@ -import { - GitHubClient, - GitHubAuthError, - GitHubError, - GitHubNotFoundError, -} from "@gitmarks/core"; -import { loadSettings, saveSettings, SettingsCorruptError, type Settings } from "./lib/settings.js"; - -const $ = (id: string): T => { - const el = document.getElementById(id); - if (el == null) throw new Error(`#${id} not found`); - return el as T; -}; - -const tokenInput = $("token"); -const ownerInput = $("owner"); -const repoInput = $("repo"); -const branchInput = $("branch"); -const stripTrackingParamsInput = $("stripTrackingParams"); -const validateBtn = $("validate"); -const saveBtn = $("save"); -const status = $("status"); - -function readForm(): Settings { - return { - token: tokenInput.value.trim(), - owner: ownerInput.value.trim(), - repo: repoInput.value.trim(), - branch: branchInput.value.trim() || "main", - stripTrackingParams: stripTrackingParamsInput.checked, - }; -} - -function setStatus(msg: string, kind: "ok" | "err" | "neutral"): void { - status.textContent = msg; - status.className = kind === "neutral" ? "" : kind; -} - -async function loadIntoForm(): Promise { - let s; - try { - s = await loadSettings(); - } catch (err) { - if (err instanceof SettingsCorruptError) { - // Clear all form fields so the user can re-enter valid settings. - tokenInput.value = ""; - ownerInput.value = ""; - repoInput.value = ""; - branchInput.value = ""; - setStatus("Stored settings are corrupted — please reconfigure.", "err"); - return; - } - throw err; - } - if (s == null) return; - tokenInput.value = s.token; - ownerInput.value = s.owner; - repoInput.value = s.repo; - branchInput.value = s.branch; - stripTrackingParamsInput.checked = s.stripTrackingParams; -} - -validateBtn.addEventListener("click", async () => { - setStatus("validating…", "neutral"); - let s: Settings; - try { - s = readForm(); - } catch (err) { - setStatus(err instanceof Error ? err.message : String(err), "err"); - return; - } - const client = new GitHubClient(s); - try { - await client.read("bookmarks.json"); - setStatus("✓ valid PAT, repo exists, bookmarks.json found", "ok"); - } catch (err) { - if (err instanceof GitHubNotFoundError) { - setStatus( - "✓ valid PAT, repo exists (bookmarks.json not yet created — will be on first save)", - "ok", - ); - return; - } - console.error("[gitmarks] validate failed", err); - if (err instanceof GitHubAuthError) { - setStatus( - "PAT rejected — check the token is valid and has 'Contents: Read and write' scope on this repo.", - "err", - ); - return; - } - if (err instanceof GitHubError && err.status >= 500) { - setStatus( - `GitHub is having issues (${err.status}). Try again in a minute.`, - "err", - ); - return; - } - if (err instanceof Error && (err.message.includes("Failed to fetch") || err.message.includes("NetworkError"))) { - setStatus("Network error — check your connection and try again.", "err"); - return; - } - setStatus(err instanceof Error ? err.message : String(err), "err"); - } -}); - -saveBtn.addEventListener("click", async () => { - try { - await saveSettings(readForm()); - setStatus("✓ saved", "ok"); - } catch (err) { - console.error("[gitmarks] save settings failed", err); - setStatus(err instanceof Error ? err.message : String(err), "err"); - } -}); - -void loadIntoForm(); +import "@gitmarks/extension-shared/options"; diff --git a/packages/extension-chrome/src/popup.ts b/packages/extension-chrome/src/popup.ts index b3f6c84..c3afe82 100644 --- a/packages/extension-chrome/src/popup.ts +++ b/packages/extension-chrome/src/popup.ts @@ -1,134 +1 @@ -import { GitHubClient } from "@gitmarks/core"; -import { loadSettings, SettingsCorruptError } from "./lib/settings.js"; -import { getMachineId } from "./lib/machine-id.js"; -import { saveBookmark, type SaveResult } from "./lib/save-flow.js"; -import type { LastErrorRecord } from "./lib/background-core.js"; - -const root = document.getElementById("root"); -if (root == null) throw new Error("#root not found"); - -async function getActiveTab(): Promise { - // When opened as a real extension popup, currentWindow refers to the - // browser window the user was in (not the popup's own floating window), - // so this returns the tab they were viewing. activeTab grants access to - // title + url for that one tab on user-gesture popup open; no broader - // tabs permission is required. - const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); - if (tab != null && tab.url != null && !tab.url.startsWith("chrome-extension://")) { - return tab; - } - return null; -} - -async function render(): Promise { - let settings; - try { - settings = await loadSettings(); - } catch (err) { - if (err instanceof SettingsCorruptError) { - root!.innerHTML = `

Settings are corrupted — please reconfigure gitmarks.

- `; - document.getElementById("setup")?.addEventListener("click", () => { - chrome.runtime.openOptionsPage(); - window.close(); - }); - return; - } - throw err; - } - if (settings == null) { - root!.innerHTML = ` -

Welcome to gitmarks.

- - `; - document.getElementById("setup")!.addEventListener("click", () => { - chrome.runtime.openOptionsPage(); - window.close(); - }); - return; - } - - const tab = await getActiveTab(); - if (tab == null || tab.url == null) { - root!.innerHTML = `

No active tab.

`; - return; - } - - root!.innerHTML = ` -

${escapeText(tab.title ?? tab.url)}

- -

- `; - - const errStored = await chrome.storage.local.get("gitmarks:lastError"); - const lastErr = errStored["gitmarks:lastError"] as LastErrorRecord | undefined; - if (lastErr != null) { - const banner = document.createElement("p"); - banner.className = "err"; - banner.style.fontSize = "0.8rem"; - banner.style.marginTop = "0.5rem"; - const label = lastErr.kind === "auth" ? "Background sync auth failed" : `Background ${lastErr.source} failed`; - banner.textContent = `${label}: ${lastErr.message}`; - root!.appendChild(banner); - } - - const saveBtn = document.getElementById("save") as HTMLButtonElement; - const status = document.getElementById("status")!; - - saveBtn.addEventListener("click", async () => { - saveBtn.disabled = true; - saveBtn.textContent = "saving…"; - status.className = ""; - status.textContent = ""; - let result: SaveResult; - try { - const machineId = await getMachineId(); - const client = new GitHubClient({ - owner: settings.owner, - repo: settings.repo, - token: settings.token, - branch: settings.branch, - }); - result = await saveBookmark( - client, - { url: tab.url!, title: tab.title ?? tab.url! }, - machineId, - new Date().toISOString(), - { stripTrackingParams: settings.stripTrackingParams }, - ); - } catch (err) { - result = { - ok: false, - kind: "unknown", - message: err instanceof Error ? err.message : String(err), - }; - } - if (result.ok) { - status.className = "ok"; - status.textContent = "✓ saved"; - } else { - status.className = "err"; - status.textContent = result.message; - saveBtn.disabled = false; - saveBtn.textContent = "Try again"; - } - }); -} - -function escapeText(s: string): string { - return s - .replace(/&/g, "&") - .replace(//g, ">"); -} - -function escapeAttr(s: string): string { - return escapeText(s).replace(/"/g, """); -} - -render().catch((err) => { - console.error("[gitmarks] popup render failed", err); - if (root != null) { - root.innerHTML = `

Something went wrong opening gitmarks. Please reload the extension.

`; - } -}); +import "@gitmarks/extension-shared/popup"; diff --git a/packages/extension-chrome/test/setup.ts b/packages/extension-chrome/test/setup.ts deleted file mode 100644 index 244362e..0000000 --- a/packages/extension-chrome/test/setup.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { vi, beforeEach } from "vitest"; - -interface StorageBackend { - data: Record; -} - -const backend: StorageBackend = { data: {} }; - -const chromeStub = { - storage: { - local: { - get: vi.fn(async (keys?: string | string[] | null) => { - if (keys == null) return { ...backend.data }; - const list = Array.isArray(keys) ? keys : [keys]; - const out: Record = {}; - for (const k of list) { - if (k in backend.data) out[k] = backend.data[k]; - } - return out; - }), - set: vi.fn(async (items: Record) => { - Object.assign(backend.data, items); - }), - remove: vi.fn(async (keys: string | string[]) => { - const list = Array.isArray(keys) ? keys : [keys]; - for (const k of list) delete backend.data[k]; - }), - clear: vi.fn(async () => { - for (const k of Object.keys(backend.data)) delete backend.data[k]; - }), - }, - }, - runtime: { - openOptionsPage: vi.fn(), - sendMessage: vi.fn(), - onMessage: { addListener: vi.fn() }, - lastError: undefined as chrome.runtime.LastError | undefined, - }, - bookmarks: { - create: vi.fn(async (props: chrome.bookmarks.BookmarkCreateArg) => { - return { id: `mock-${Math.random().toString(36).slice(2, 10)}`, ...props } as chrome.bookmarks.BookmarkTreeNode; - }), - update: vi.fn(async () => ({} as chrome.bookmarks.BookmarkTreeNode)), - move: vi.fn(async () => ({} as chrome.bookmarks.BookmarkTreeNode)), - remove: vi.fn(async () => {}), - get: vi.fn(async () => [] as chrome.bookmarks.BookmarkTreeNode[]), - getTree: vi.fn(async () => [] as chrome.bookmarks.BookmarkTreeNode[]), - getSubTree: vi.fn(async () => [] as chrome.bookmarks.BookmarkTreeNode[]), - onCreated: { addListener: vi.fn(), removeListener: vi.fn() }, - onChanged: { addListener: vi.fn(), removeListener: vi.fn() }, - onMoved: { addListener: vi.fn(), removeListener: vi.fn() }, - onRemoved: { addListener: vi.fn(), removeListener: vi.fn() }, - }, - alarms: { - create: vi.fn(), - clear: vi.fn(), - onAlarm: { addListener: vi.fn() }, - }, - tabs: { - query: vi.fn(), - }, -}; - -vi.stubGlobal("chrome", chromeStub); - -beforeEach(async () => { - await chromeStub.storage.local.clear(); - vi.clearAllMocks(); -}); - -export { chromeStub }; diff --git a/packages/extension-chrome/test/smoke.test.ts b/packages/extension-chrome/test/smoke.test.ts deleted file mode 100644 index b4b1354..0000000 --- a/packages/extension-chrome/test/smoke.test.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { describe, it, expect } from "vitest"; - -describe("@gitmarks/extension-chrome smoke", () => { - it("has chrome.storage stubbed by the global setup", () => { - expect(typeof chrome).toBe("object"); - expect(typeof chrome.storage.local.get).toBe("function"); - }); -}); diff --git a/packages/extension-chrome/vitest.config.ts b/packages/extension-chrome/vitest.config.ts deleted file mode 100644 index 053baaf..0000000 --- a/packages/extension-chrome/vitest.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - environment: "jsdom", - include: ["test/**/*.test.ts"], - setupFiles: ["./test/setup.ts"], - globals: false, - }, -}); diff --git a/packages/extension-shared/package.json b/packages/extension-shared/package.json index 197a676..bf7eb15 100644 --- a/packages/extension-shared/package.json +++ b/packages/extension-shared/package.json @@ -3,6 +3,12 @@ "version": "0.0.0", "private": true, "type": "module", + "exports": { + ".": "./src/index.ts", + "./background": "./src/background.ts", + "./popup": "./src/popup.ts", + "./options": "./src/options.ts" + }, "scripts": { "typecheck": "tsc -p tsconfig.json --noEmit", "test": "vitest run", diff --git a/packages/extension-shared/src/.gitkeep b/packages/extension-shared/src/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/packages/extension-shared/src/background.ts b/packages/extension-shared/src/background.ts new file mode 100644 index 0000000..6db3b93 --- /dev/null +++ b/packages/extension-shared/src/background.ts @@ -0,0 +1,120 @@ +import { GitHubClient } from "@gitmarks/core"; +import { loadSettings, type Settings } from "./lib/settings.js"; +import { getMachineId } from "./lib/machine-id.js"; +import { IdMap } from "./lib/id-mapping.js"; +import { reconcile } from "./lib/reconcile.js"; +import { registerListeners } from "./lib/listeners.js"; +import { applyRemoteChanges } from "./lib/apply-remote.js"; +import { runMaybeReconcile, runPollRemoteOnce, toEtag } from "./lib/background-core.js"; + +const RECONCILE_INTERVAL_MS = 60 * 60 * 1000; +const POLL_ALARM_NAME = "gitmarks:poll"; +const RECONCILED_AT_KEY = "gitmarks:lastReconciledAt"; +const LAST_ETAG_KEY = "gitmarks:bookmarksEtag"; + +let cachedBarId: string | null = null; +let cachedOtherId: string | null = null; + +async function getBarOtherIds(): Promise<{ bar: string; other: string }> { + if (cachedBarId != null && cachedOtherId != null) { + return { bar: cachedBarId, other: cachedOtherId }; + } + const tree = await chrome.bookmarks.getTree(); + const root = tree[0]; + if (root?.children == null) { + throw new Error("unexpected chrome.bookmarks tree shape"); + } + let bar: string | null = null; + let other: string | null = null; + for (const child of root.children) { + if (child.id === "1") bar = child.id; + else if (child.id === "2") other = child.id; + } + if (bar == null || other == null) { + throw new Error("could not find Bookmarks Bar (id=1) or Other Bookmarks (id=2) in tree"); + } + cachedBarId = bar; + cachedOtherId = other; + return { bar, other }; +} + +function buildClient(settings: Settings): GitHubClient { + return new GitHubClient({ + owner: settings.owner, + repo: settings.repo, + token: settings.token, + branch: settings.branch, + }); +} + +async function maybeReconcile(): Promise { + const settings = await loadSettings(); + if (settings == null) return; + + const stored = await chrome.storage.local.get(RECONCILED_AT_KEY); + const lastReconciledAt = typeof stored[RECONCILED_AT_KEY] === "number" + ? (stored[RECONCILED_AT_KEY] as number) + : 0; + + await runMaybeReconcile({ + now: Date.now(), + lastReconciledAt, + reconcileIntervalMs: RECONCILE_INTERVAL_MS, + runReconcile: async () => { + const { bar, other } = await getBarOtherIds(); + const client = buildClient(settings); + const idMap = await IdMap.load(); + const machineId = await getMachineId(); + const nowIso = new Date().toISOString(); + await reconcile(client, idMap, bar, other, machineId, nowIso, settings.stripTrackingParams); + }, + setStorage: (items) => chrome.storage.local.set(items), + removeStorage: (key) => chrome.storage.local.remove(key), + }); +} + +async function pollRemoteOnce(): Promise { + const settings = await loadSettings(); + if (settings == null) return; + const client = buildClient(settings); + const stored = await chrome.storage.local.get(LAST_ETAG_KEY); + const rawEtag = stored[LAST_ETAG_KEY]; + const etag = typeof rawEtag === "string" ? toEtag(rawEtag) : null; + + await runPollRemoteOnce({ + etag, + now: Date.now(), + client, + applyRemote: async (data) => { + const { bar, other } = await getBarOtherIds(); + const idMap = await IdMap.load(); + await applyRemoteChanges(data, idMap, bar, other); + }, + setStorage: (items) => chrome.storage.local.set(items), + removeStorage: (key) => chrome.storage.local.remove(key), + }); +} + +registerListeners({ + getClient: async () => { + const s = await loadSettings(); + if (s == null) throw new Error("no settings"); + return buildClient(s); + }, + getIdMap: async () => IdMap.load(), + getBarOtherIds, + getMachineId, + getStripTrackingParams: async () => { + const s = await loadSettings(); + return s?.stripTrackingParams ?? false; + }, +}); + +chrome.alarms.create(POLL_ALARM_NAME, { periodInMinutes: 5 }); +chrome.alarms.onAlarm.addListener((alarm) => { + if (alarm.name === POLL_ALARM_NAME) { + void pollRemoteOnce(); + } +}); + +void maybeReconcile(); diff --git a/packages/extension-shared/src/index.ts b/packages/extension-shared/src/index.ts new file mode 100644 index 0000000..5939263 --- /dev/null +++ b/packages/extension-shared/src/index.ts @@ -0,0 +1,55 @@ +// Re-exports the public surface that browser-specific shells consume. +// Kept alphabetized; add new lib/ surfaces here as they appear. + +export { applyRemoteChanges } from "./lib/apply-remote.js"; +export * from "./lib/background-core.js"; +export { buildBookmark, type BuildBookmarkInput } from "./lib/bookmark-factory.js"; +export { + BOOKMARKS_PATH, + emptyBookmarksFile, + updateBookmarksOrBootstrap, +} from "./lib/bookmarks-file.js"; +export { + BOOKMARKS_BAR_FOLDER, + OTHER_BOOKMARKS_FOLDER, + folderPathFromNode, + splitFolderPath, + type SplitPath, + type TreeNode, +} from "./lib/folder-path.js"; +export { + IdMap, + asNodeId, + asUlid, + type NodeId, + type Ulid, +} from "./lib/id-mapping.js"; +export { + flushPending, + registerListeners, + __resetForTest, + type ListenerDeps, +} from "./lib/listeners.js"; +export { getMachineId } from "./lib/machine-id.js"; +export { reconcile } from "./lib/reconcile.js"; +export { + saveBookmark, + type PageInfo, + type SaveOptions, + type SaveResult, +} from "./lib/save-flow.js"; +export { + SettingsCorruptError, + clearSettings, + loadSettings, + saveSettings, + settingsSchema, + type Settings, +} from "./lib/settings.js"; +export { + clearSuppression, + isNodeSuppressed, + isSuppressed, + suppress, + suppressNode, +} from "./lib/suppression.js"; diff --git a/packages/extension-chrome/src/lib/apply-remote.ts b/packages/extension-shared/src/lib/apply-remote.ts similarity index 100% rename from packages/extension-chrome/src/lib/apply-remote.ts rename to packages/extension-shared/src/lib/apply-remote.ts diff --git a/packages/extension-chrome/src/lib/background-core.ts b/packages/extension-shared/src/lib/background-core.ts similarity index 100% rename from packages/extension-chrome/src/lib/background-core.ts rename to packages/extension-shared/src/lib/background-core.ts diff --git a/packages/extension-chrome/src/lib/bookmark-factory.ts b/packages/extension-shared/src/lib/bookmark-factory.ts similarity index 100% rename from packages/extension-chrome/src/lib/bookmark-factory.ts rename to packages/extension-shared/src/lib/bookmark-factory.ts diff --git a/packages/extension-chrome/src/lib/bookmarks-file.ts b/packages/extension-shared/src/lib/bookmarks-file.ts similarity index 100% rename from packages/extension-chrome/src/lib/bookmarks-file.ts rename to packages/extension-shared/src/lib/bookmarks-file.ts diff --git a/packages/extension-chrome/src/lib/folder-path.ts b/packages/extension-shared/src/lib/folder-path.ts similarity index 100% rename from packages/extension-chrome/src/lib/folder-path.ts rename to packages/extension-shared/src/lib/folder-path.ts diff --git a/packages/extension-chrome/src/lib/id-mapping.ts b/packages/extension-shared/src/lib/id-mapping.ts similarity index 100% rename from packages/extension-chrome/src/lib/id-mapping.ts rename to packages/extension-shared/src/lib/id-mapping.ts diff --git a/packages/extension-chrome/src/lib/listeners.ts b/packages/extension-shared/src/lib/listeners.ts similarity index 100% rename from packages/extension-chrome/src/lib/listeners.ts rename to packages/extension-shared/src/lib/listeners.ts diff --git a/packages/extension-chrome/src/lib/machine-id.ts b/packages/extension-shared/src/lib/machine-id.ts similarity index 100% rename from packages/extension-chrome/src/lib/machine-id.ts rename to packages/extension-shared/src/lib/machine-id.ts diff --git a/packages/extension-chrome/src/lib/reconcile.ts b/packages/extension-shared/src/lib/reconcile.ts similarity index 100% rename from packages/extension-chrome/src/lib/reconcile.ts rename to packages/extension-shared/src/lib/reconcile.ts diff --git a/packages/extension-chrome/src/lib/save-flow.ts b/packages/extension-shared/src/lib/save-flow.ts similarity index 100% rename from packages/extension-chrome/src/lib/save-flow.ts rename to packages/extension-shared/src/lib/save-flow.ts diff --git a/packages/extension-chrome/src/lib/settings.ts b/packages/extension-shared/src/lib/settings.ts similarity index 100% rename from packages/extension-chrome/src/lib/settings.ts rename to packages/extension-shared/src/lib/settings.ts diff --git a/packages/extension-chrome/src/lib/suppression.ts b/packages/extension-shared/src/lib/suppression.ts similarity index 100% rename from packages/extension-chrome/src/lib/suppression.ts rename to packages/extension-shared/src/lib/suppression.ts diff --git a/packages/extension-shared/src/options.html b/packages/extension-shared/src/options.html new file mode 100644 index 0000000..16309e7 --- /dev/null +++ b/packages/extension-shared/src/options.html @@ -0,0 +1,64 @@ + + + + + gitmarks — settings + + + +

gitmarks settings

+ + + + + + + + + + + +
+ + +
+ +

+ + + + diff --git a/packages/extension-shared/src/options.ts b/packages/extension-shared/src/options.ts new file mode 100644 index 0000000..3b2e656 --- /dev/null +++ b/packages/extension-shared/src/options.ts @@ -0,0 +1,117 @@ +import { + GitHubClient, + GitHubAuthError, + GitHubError, + GitHubNotFoundError, +} from "@gitmarks/core"; +import { loadSettings, saveSettings, SettingsCorruptError, type Settings } from "./lib/settings.js"; + +const $ = (id: string): T => { + const el = document.getElementById(id); + if (el == null) throw new Error(`#${id} not found`); + return el as T; +}; + +const tokenInput = $("token"); +const ownerInput = $("owner"); +const repoInput = $("repo"); +const branchInput = $("branch"); +const stripTrackingParamsInput = $("stripTrackingParams"); +const validateBtn = $("validate"); +const saveBtn = $("save"); +const status = $("status"); + +function readForm(): Settings { + return { + token: tokenInput.value.trim(), + owner: ownerInput.value.trim(), + repo: repoInput.value.trim(), + branch: branchInput.value.trim() || "main", + stripTrackingParams: stripTrackingParamsInput.checked, + }; +} + +function setStatus(msg: string, kind: "ok" | "err" | "neutral"): void { + status.textContent = msg; + status.className = kind === "neutral" ? "" : kind; +} + +async function loadIntoForm(): Promise { + let s; + try { + s = await loadSettings(); + } catch (err) { + if (err instanceof SettingsCorruptError) { + // Clear all form fields so the user can re-enter valid settings. + tokenInput.value = ""; + ownerInput.value = ""; + repoInput.value = ""; + branchInput.value = ""; + setStatus("Stored settings are corrupted — please reconfigure.", "err"); + return; + } + throw err; + } + if (s == null) return; + tokenInput.value = s.token; + ownerInput.value = s.owner; + repoInput.value = s.repo; + branchInput.value = s.branch; + stripTrackingParamsInput.checked = s.stripTrackingParams; +} + +validateBtn.addEventListener("click", async () => { + setStatus("validating…", "neutral"); + let s: Settings; + try { + s = readForm(); + } catch (err) { + setStatus(err instanceof Error ? err.message : String(err), "err"); + return; + } + const client = new GitHubClient(s); + try { + await client.read("bookmarks.json"); + setStatus("✓ valid PAT, repo exists, bookmarks.json found", "ok"); + } catch (err) { + if (err instanceof GitHubNotFoundError) { + setStatus( + "✓ valid PAT, repo exists (bookmarks.json not yet created — will be on first save)", + "ok", + ); + return; + } + console.error("[gitmarks] validate failed", err); + if (err instanceof GitHubAuthError) { + setStatus( + "PAT rejected — check the token is valid and has 'Contents: Read and write' scope on this repo.", + "err", + ); + return; + } + if (err instanceof GitHubError && err.status >= 500) { + setStatus( + `GitHub is having issues (${err.status}). Try again in a minute.`, + "err", + ); + return; + } + if (err instanceof Error && (err.message.includes("Failed to fetch") || err.message.includes("NetworkError"))) { + setStatus("Network error — check your connection and try again.", "err"); + return; + } + setStatus(err instanceof Error ? err.message : String(err), "err"); + } +}); + +saveBtn.addEventListener("click", async () => { + try { + await saveSettings(readForm()); + setStatus("✓ saved", "ok"); + } catch (err) { + console.error("[gitmarks] save settings failed", err); + setStatus(err instanceof Error ? err.message : String(err), "err"); + } +}); + +void loadIntoForm(); diff --git a/packages/extension-shared/src/popup.html b/packages/extension-shared/src/popup.html new file mode 100644 index 0000000..69a0b72 --- /dev/null +++ b/packages/extension-shared/src/popup.html @@ -0,0 +1,37 @@ + + + + + gitmarks + + + +
loading…
+ + + diff --git a/packages/extension-shared/src/popup.ts b/packages/extension-shared/src/popup.ts new file mode 100644 index 0000000..b3f6c84 --- /dev/null +++ b/packages/extension-shared/src/popup.ts @@ -0,0 +1,134 @@ +import { GitHubClient } from "@gitmarks/core"; +import { loadSettings, SettingsCorruptError } from "./lib/settings.js"; +import { getMachineId } from "./lib/machine-id.js"; +import { saveBookmark, type SaveResult } from "./lib/save-flow.js"; +import type { LastErrorRecord } from "./lib/background-core.js"; + +const root = document.getElementById("root"); +if (root == null) throw new Error("#root not found"); + +async function getActiveTab(): Promise { + // When opened as a real extension popup, currentWindow refers to the + // browser window the user was in (not the popup's own floating window), + // so this returns the tab they were viewing. activeTab grants access to + // title + url for that one tab on user-gesture popup open; no broader + // tabs permission is required. + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (tab != null && tab.url != null && !tab.url.startsWith("chrome-extension://")) { + return tab; + } + return null; +} + +async function render(): Promise { + let settings; + try { + settings = await loadSettings(); + } catch (err) { + if (err instanceof SettingsCorruptError) { + root!.innerHTML = `

Settings are corrupted — please reconfigure gitmarks.

+ `; + document.getElementById("setup")?.addEventListener("click", () => { + chrome.runtime.openOptionsPage(); + window.close(); + }); + return; + } + throw err; + } + if (settings == null) { + root!.innerHTML = ` +

Welcome to gitmarks.

+ + `; + document.getElementById("setup")!.addEventListener("click", () => { + chrome.runtime.openOptionsPage(); + window.close(); + }); + return; + } + + const tab = await getActiveTab(); + if (tab == null || tab.url == null) { + root!.innerHTML = `

No active tab.

`; + return; + } + + root!.innerHTML = ` +

${escapeText(tab.title ?? tab.url)}

+ +

+ `; + + const errStored = await chrome.storage.local.get("gitmarks:lastError"); + const lastErr = errStored["gitmarks:lastError"] as LastErrorRecord | undefined; + if (lastErr != null) { + const banner = document.createElement("p"); + banner.className = "err"; + banner.style.fontSize = "0.8rem"; + banner.style.marginTop = "0.5rem"; + const label = lastErr.kind === "auth" ? "Background sync auth failed" : `Background ${lastErr.source} failed`; + banner.textContent = `${label}: ${lastErr.message}`; + root!.appendChild(banner); + } + + const saveBtn = document.getElementById("save") as HTMLButtonElement; + const status = document.getElementById("status")!; + + saveBtn.addEventListener("click", async () => { + saveBtn.disabled = true; + saveBtn.textContent = "saving…"; + status.className = ""; + status.textContent = ""; + let result: SaveResult; + try { + const machineId = await getMachineId(); + const client = new GitHubClient({ + owner: settings.owner, + repo: settings.repo, + token: settings.token, + branch: settings.branch, + }); + result = await saveBookmark( + client, + { url: tab.url!, title: tab.title ?? tab.url! }, + machineId, + new Date().toISOString(), + { stripTrackingParams: settings.stripTrackingParams }, + ); + } catch (err) { + result = { + ok: false, + kind: "unknown", + message: err instanceof Error ? err.message : String(err), + }; + } + if (result.ok) { + status.className = "ok"; + status.textContent = "✓ saved"; + } else { + status.className = "err"; + status.textContent = result.message; + saveBtn.disabled = false; + saveBtn.textContent = "Try again"; + } + }); +} + +function escapeText(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">"); +} + +function escapeAttr(s: string): string { + return escapeText(s).replace(/"/g, """); +} + +render().catch((err) => { + console.error("[gitmarks] popup render failed", err); + if (root != null) { + root.innerHTML = `

Something went wrong opening gitmarks. Please reload the extension.

`; + } +}); diff --git a/packages/extension-chrome/test/apply-remote.test.ts b/packages/extension-shared/test/apply-remote.test.ts similarity index 100% rename from packages/extension-chrome/test/apply-remote.test.ts rename to packages/extension-shared/test/apply-remote.test.ts diff --git a/packages/extension-chrome/test/background-core.test.ts b/packages/extension-shared/test/background-core.test.ts similarity index 100% rename from packages/extension-chrome/test/background-core.test.ts rename to packages/extension-shared/test/background-core.test.ts diff --git a/packages/extension-chrome/test/bookmark-factory.test.ts b/packages/extension-shared/test/bookmark-factory.test.ts similarity index 100% rename from packages/extension-chrome/test/bookmark-factory.test.ts rename to packages/extension-shared/test/bookmark-factory.test.ts diff --git a/packages/extension-chrome/test/bookmarks-file.test.ts b/packages/extension-shared/test/bookmarks-file.test.ts similarity index 100% rename from packages/extension-chrome/test/bookmarks-file.test.ts rename to packages/extension-shared/test/bookmarks-file.test.ts diff --git a/packages/extension-chrome/test/folder-path.test.ts b/packages/extension-shared/test/folder-path.test.ts similarity index 100% rename from packages/extension-chrome/test/folder-path.test.ts rename to packages/extension-shared/test/folder-path.test.ts diff --git a/packages/extension-chrome/test/id-mapping.test.ts b/packages/extension-shared/test/id-mapping.test.ts similarity index 100% rename from packages/extension-chrome/test/id-mapping.test.ts rename to packages/extension-shared/test/id-mapping.test.ts diff --git a/packages/extension-chrome/test/listeners.test.ts b/packages/extension-shared/test/listeners.test.ts similarity index 100% rename from packages/extension-chrome/test/listeners.test.ts rename to packages/extension-shared/test/listeners.test.ts diff --git a/packages/extension-chrome/test/machine-id.test.ts b/packages/extension-shared/test/machine-id.test.ts similarity index 100% rename from packages/extension-chrome/test/machine-id.test.ts rename to packages/extension-shared/test/machine-id.test.ts diff --git a/packages/extension-chrome/test/reconcile.test.ts b/packages/extension-shared/test/reconcile.test.ts similarity index 100% rename from packages/extension-chrome/test/reconcile.test.ts rename to packages/extension-shared/test/reconcile.test.ts diff --git a/packages/extension-chrome/test/save-flow.test.ts b/packages/extension-shared/test/save-flow.test.ts similarity index 100% rename from packages/extension-chrome/test/save-flow.test.ts rename to packages/extension-shared/test/save-flow.test.ts diff --git a/packages/extension-chrome/test/settings.test.ts b/packages/extension-shared/test/settings.test.ts similarity index 100% rename from packages/extension-chrome/test/settings.test.ts rename to packages/extension-shared/test/settings.test.ts diff --git a/packages/extension-shared/test/setup.ts b/packages/extension-shared/test/setup.ts index 6bc6b54..244362e 100644 --- a/packages/extension-shared/test/setup.ts +++ b/packages/extension-shared/test/setup.ts @@ -1,3 +1,71 @@ -// Placeholder so vitest's setupFiles entry resolves. -// Replaced with the real chrome/browser stub when test files migrate in FF-1. -export {}; +import { vi, beforeEach } from "vitest"; + +interface StorageBackend { + data: Record; +} + +const backend: StorageBackend = { data: {} }; + +const chromeStub = { + storage: { + local: { + get: vi.fn(async (keys?: string | string[] | null) => { + if (keys == null) return { ...backend.data }; + const list = Array.isArray(keys) ? keys : [keys]; + const out: Record = {}; + for (const k of list) { + if (k in backend.data) out[k] = backend.data[k]; + } + return out; + }), + set: vi.fn(async (items: Record) => { + Object.assign(backend.data, items); + }), + remove: vi.fn(async (keys: string | string[]) => { + const list = Array.isArray(keys) ? keys : [keys]; + for (const k of list) delete backend.data[k]; + }), + clear: vi.fn(async () => { + for (const k of Object.keys(backend.data)) delete backend.data[k]; + }), + }, + }, + runtime: { + openOptionsPage: vi.fn(), + sendMessage: vi.fn(), + onMessage: { addListener: vi.fn() }, + lastError: undefined as chrome.runtime.LastError | undefined, + }, + bookmarks: { + create: vi.fn(async (props: chrome.bookmarks.BookmarkCreateArg) => { + return { id: `mock-${Math.random().toString(36).slice(2, 10)}`, ...props } as chrome.bookmarks.BookmarkTreeNode; + }), + update: vi.fn(async () => ({} as chrome.bookmarks.BookmarkTreeNode)), + move: vi.fn(async () => ({} as chrome.bookmarks.BookmarkTreeNode)), + remove: vi.fn(async () => {}), + get: vi.fn(async () => [] as chrome.bookmarks.BookmarkTreeNode[]), + getTree: vi.fn(async () => [] as chrome.bookmarks.BookmarkTreeNode[]), + getSubTree: vi.fn(async () => [] as chrome.bookmarks.BookmarkTreeNode[]), + onCreated: { addListener: vi.fn(), removeListener: vi.fn() }, + onChanged: { addListener: vi.fn(), removeListener: vi.fn() }, + onMoved: { addListener: vi.fn(), removeListener: vi.fn() }, + onRemoved: { addListener: vi.fn(), removeListener: vi.fn() }, + }, + alarms: { + create: vi.fn(), + clear: vi.fn(), + onAlarm: { addListener: vi.fn() }, + }, + tabs: { + query: vi.fn(), + }, +}; + +vi.stubGlobal("chrome", chromeStub); + +beforeEach(async () => { + await chromeStub.storage.local.clear(); + vi.clearAllMocks(); +}); + +export { chromeStub }; diff --git a/packages/extension-shared/test/smoke.test.ts b/packages/extension-shared/test/smoke.test.ts deleted file mode 100644 index 1c602cb..0000000 --- a/packages/extension-shared/test/smoke.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { describe, it, expect } from "vitest"; - -describe("@gitmarks/extension-shared smoke", () => { - it("loads without errors", () => { - expect(1 + 1).toBe(2); - }); -}); diff --git a/packages/extension-chrome/test/suppression.test.ts b/packages/extension-shared/test/suppression.test.ts similarity index 100% rename from packages/extension-chrome/test/suppression.test.ts rename to packages/extension-shared/test/suppression.test.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a5b331..b13bcfa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,6 +30,9 @@ importers: '@gitmarks/core': specifier: workspace:* version: link:../core + '@gitmarks/extension-shared': + specifier: workspace:* + version: link:../extension-shared zod: specifier: ^3.23.0 version: 3.25.76 @@ -43,15 +46,9 @@ importers: '@types/chrome': specifier: ^0.0.268 version: 0.0.268 - jsdom: - specifier: ^25.0.0 - version: 25.0.1 vite: specifier: ^5.4.0 version: 5.4.21 - vitest: - specifier: ^2.0.0 - version: 2.1.9(jsdom@25.0.1) packages/extension-shared: dependencies: From 60579badccb30570799c72663672d5f9f3e6b627 Mon Sep 17 00:00:00 2001 From: Sidney von Katzendame Date: Sun, 24 May 2026 12:49:59 -0400 Subject: [PATCH 3/5] =?UTF-8?q?refactor(extension-shared):=20migrate=20chr?= =?UTF-8?q?ome.*=20=E2=86=92=20browser.*=20via=20webextension-polyfill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Source files now use browser.* uniformly: - background.ts, popup.ts, options.ts each import 'browser from "webextension-polyfill"' at the top, which side-effect-registers the polyfill. - All value-position chrome.X.Y(...) calls switched to browser.X.Y(...). Type-position references (chrome.bookmarks.BookmarkTreeNode etc.) kept as-is via @types/chrome. - lib/apply-remote.ts, id-mapping.ts, listeners.ts, machine-id.ts, reconcile.ts, settings.ts each import browser and use browser.* for all API calls. - test/setup.ts stubs the same object under both globalThis.chrome AND globalThis.browser so the existing test assertions continue to work. - vitest.config.ts gains a resolve.alias that redirects "webextension-polyfill" to test/webextension-polyfill-stub.ts (a Proxy over globalThis.browser) so the real polyfill — which throws in Node/jsdom — is never loaded during unit tests. - popup.ts getActiveTab() return type updated from chrome.tabs.Tab to browser.Tabs.Tab (polyfill type) to satisfy the type-checker. No behavior change in Chrome (the polyfill aliases chrome.* under browser.*). 96 unit tests + 4 e2e passing + 2 skipped — unchanged. Co-Authored-By: Claude Sonnet 4.6 --- packages/extension-shared/src/background.ts | 19 +++++++------- .../extension-shared/src/lib/apply-remote.ts | 13 +++++----- .../extension-shared/src/lib/id-mapping.ts | 6 +++-- .../extension-shared/src/lib/listeners.ts | 13 +++++----- .../extension-shared/src/lib/machine-id.ts | 6 +++-- .../extension-shared/src/lib/reconcile.ts | 3 ++- packages/extension-shared/src/lib/settings.ts | 7 +++--- packages/extension-shared/src/options.ts | 1 + packages/extension-shared/src/popup.ts | 11 ++++---- packages/extension-shared/test/setup.ts | 1 + .../test/webextension-polyfill-stub.ts | 25 +++++++++++++++++++ packages/extension-shared/vitest.config.ts | 11 ++++++++ 12 files changed, 82 insertions(+), 34 deletions(-) create mode 100644 packages/extension-shared/test/webextension-polyfill-stub.ts diff --git a/packages/extension-shared/src/background.ts b/packages/extension-shared/src/background.ts index 6db3b93..d82d434 100644 --- a/packages/extension-shared/src/background.ts +++ b/packages/extension-shared/src/background.ts @@ -1,3 +1,4 @@ +import browser from "webextension-polyfill"; import { GitHubClient } from "@gitmarks/core"; import { loadSettings, type Settings } from "./lib/settings.js"; import { getMachineId } from "./lib/machine-id.js"; @@ -19,7 +20,7 @@ async function getBarOtherIds(): Promise<{ bar: string; other: string }> { if (cachedBarId != null && cachedOtherId != null) { return { bar: cachedBarId, other: cachedOtherId }; } - const tree = await chrome.bookmarks.getTree(); + const tree = await browser.bookmarks.getTree(); const root = tree[0]; if (root?.children == null) { throw new Error("unexpected chrome.bookmarks tree shape"); @@ -51,7 +52,7 @@ async function maybeReconcile(): Promise { const settings = await loadSettings(); if (settings == null) return; - const stored = await chrome.storage.local.get(RECONCILED_AT_KEY); + const stored = await browser.storage.local.get(RECONCILED_AT_KEY); const lastReconciledAt = typeof stored[RECONCILED_AT_KEY] === "number" ? (stored[RECONCILED_AT_KEY] as number) : 0; @@ -68,8 +69,8 @@ async function maybeReconcile(): Promise { const nowIso = new Date().toISOString(); await reconcile(client, idMap, bar, other, machineId, nowIso, settings.stripTrackingParams); }, - setStorage: (items) => chrome.storage.local.set(items), - removeStorage: (key) => chrome.storage.local.remove(key), + setStorage: (items) => browser.storage.local.set(items), + removeStorage: (key) => browser.storage.local.remove(key), }); } @@ -77,7 +78,7 @@ async function pollRemoteOnce(): Promise { const settings = await loadSettings(); if (settings == null) return; const client = buildClient(settings); - const stored = await chrome.storage.local.get(LAST_ETAG_KEY); + const stored = await browser.storage.local.get(LAST_ETAG_KEY); const rawEtag = stored[LAST_ETAG_KEY]; const etag = typeof rawEtag === "string" ? toEtag(rawEtag) : null; @@ -90,8 +91,8 @@ async function pollRemoteOnce(): Promise { const idMap = await IdMap.load(); await applyRemoteChanges(data, idMap, bar, other); }, - setStorage: (items) => chrome.storage.local.set(items), - removeStorage: (key) => chrome.storage.local.remove(key), + setStorage: (items) => browser.storage.local.set(items), + removeStorage: (key) => browser.storage.local.remove(key), }); } @@ -110,8 +111,8 @@ registerListeners({ }, }); -chrome.alarms.create(POLL_ALARM_NAME, { periodInMinutes: 5 }); -chrome.alarms.onAlarm.addListener((alarm) => { +browser.alarms.create(POLL_ALARM_NAME, { periodInMinutes: 5 }); +browser.alarms.onAlarm.addListener((alarm) => { if (alarm.name === POLL_ALARM_NAME) { void pollRemoteOnce(); } diff --git a/packages/extension-shared/src/lib/apply-remote.ts b/packages/extension-shared/src/lib/apply-remote.ts index 2583ae5..05d1a47 100644 --- a/packages/extension-shared/src/lib/apply-remote.ts +++ b/packages/extension-shared/src/lib/apply-remote.ts @@ -1,3 +1,4 @@ +import browser from "webextension-polyfill"; import type { BookmarksFile } from "@gitmarks/core"; import { type IdMap, asUlid, asNodeId } from "./id-mapping.js"; import { splitFolderPath } from "./folder-path.js"; @@ -17,7 +18,7 @@ export async function applyRemoteChanges( if (existingNode != null) { suppress(bm.url); try { - await chrome.bookmarks.remove(existingNode); + await browser.bookmarks.remove(existingNode); idMap.removeByUlid(asUlid(bm.id)); } catch (err) { const msg = err instanceof Error ? err.message : String(err); @@ -51,7 +52,7 @@ export async function applyRemoteChanges( otherBookmarksId, ); suppress(bm.url); - const created = await chrome.bookmarks.create({ + const created = await browser.bookmarks.create({ parentId, title: bm.title, url: bm.url, @@ -73,7 +74,7 @@ async function applyRemoteEdit( ): Promise { let current: chrome.bookmarks.BookmarkTreeNode | undefined; try { - const found = await chrome.bookmarks.get(nodeId); + const found = await browser.bookmarks.get(nodeId); current = found[0]; } catch (err) { const msg = err instanceof Error ? err.message : String(err); @@ -105,7 +106,7 @@ async function applyRemoteEdit( suppress(remoteUrl); suppressNode(nodeId); - await chrome.bookmarks.update(nodeId, changes); + await browser.bookmarks.update(nodeId, changes); } async function ensureFolderPath( @@ -122,13 +123,13 @@ async function ensureFolderPath( } async function ensureSubfolder(parentId: string, title: string): Promise { - const children = await chrome.bookmarks.getSubTree(parentId); + const children = await browser.bookmarks.getSubTree(parentId); const parent = children[0]; if (parent?.children != null) { for (const child of parent.children) { if (child.url == null && child.title === title) return child.id; } } - const folder = await chrome.bookmarks.create({ parentId, title }); + const folder = await browser.bookmarks.create({ parentId, title }); return folder.id; } diff --git a/packages/extension-shared/src/lib/id-mapping.ts b/packages/extension-shared/src/lib/id-mapping.ts index f207865..35e1b9c 100644 --- a/packages/extension-shared/src/lib/id-mapping.ts +++ b/packages/extension-shared/src/lib/id-mapping.ts @@ -1,3 +1,5 @@ +import browser from "webextension-polyfill"; + // Branded string types — nominal phantom types that prevent argument-swap bugs. declare const ulidBrand: unique symbol; declare const nodeIdBrand: unique symbol; @@ -35,7 +37,7 @@ export class IdMap { } static async load(): Promise { - const stored = await chrome.storage.local.get(KEY); + const stored = await browser.storage.local.get(KEY); const raw = stored[KEY]; const map = new IdMap(); if (raw == null || typeof raw !== "object") return map; @@ -50,7 +52,7 @@ export class IdMap { async save(): Promise { const entries = Array.from(this.#ulidToNode.entries()); - await chrome.storage.local.set({ [KEY]: { entries } }); + await browser.storage.local.set({ [KEY]: { entries } }); } /** diff --git a/packages/extension-shared/src/lib/listeners.ts b/packages/extension-shared/src/lib/listeners.ts index 7ef2992..44c9d1a 100644 --- a/packages/extension-shared/src/lib/listeners.ts +++ b/packages/extension-shared/src/lib/listeners.ts @@ -1,3 +1,4 @@ +import browser from "webextension-polyfill"; import type { Bookmark, BookmarksFile, @@ -54,10 +55,10 @@ export function __resetForTest(): void { export function registerListeners(d: ListenerDeps): void { deps = d; - chrome.bookmarks.onCreated.addListener(onCreated); - chrome.bookmarks.onChanged.addListener(onChanged); - chrome.bookmarks.onMoved.addListener(onMoved); - chrome.bookmarks.onRemoved.addListener(onRemoved); + browser.bookmarks.onCreated.addListener(onCreated); + browser.bookmarks.onChanged.addListener(onChanged); + browser.bookmarks.onMoved.addListener(onMoved); + browser.bookmarks.onRemoved.addListener(onRemoved); } function schedule(): void { @@ -80,7 +81,7 @@ async function runFlush(): Promise { try { await flushPending(); consecutiveFailures = 0; - await chrome.storage.local.remove(LAST_ERROR_KEY); + await browser.storage.local.remove(LAST_ERROR_KEY); } catch (err) { consecutiveFailures += 1; console.error( @@ -93,7 +94,7 @@ async function runFlush(): Promise { source: "flush", kind: "unknown", }; - await chrome.storage.local.set({ [LAST_ERROR_KEY]: record }); + await browser.storage.local.set({ [LAST_ERROR_KEY]: record }); } finally { flushing = false; if (pendingReschedule || pending.length > 0) { diff --git a/packages/extension-shared/src/lib/machine-id.ts b/packages/extension-shared/src/lib/machine-id.ts index 8d0369c..d363160 100644 --- a/packages/extension-shared/src/lib/machine-id.ts +++ b/packages/extension-shared/src/lib/machine-id.ts @@ -1,3 +1,5 @@ +import browser from "webextension-polyfill"; + const KEY = "gitmarks:machineId"; const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; // Crockford base32 @@ -10,12 +12,12 @@ function newId(): string { } export async function getMachineId(): Promise { - const stored = await chrome.storage.local.get(KEY); + const stored = await browser.storage.local.get(KEY); const existing = stored[KEY]; if (typeof existing === "string" && /^[0-9A-HJKMNP-TV-Z]{8}$/.test(existing)) { return existing; } const fresh = newId(); - await chrome.storage.local.set({ [KEY]: fresh }); + await browser.storage.local.set({ [KEY]: fresh }); return fresh; } diff --git a/packages/extension-shared/src/lib/reconcile.ts b/packages/extension-shared/src/lib/reconcile.ts index e1d4385..34f8185 100644 --- a/packages/extension-shared/src/lib/reconcile.ts +++ b/packages/extension-shared/src/lib/reconcile.ts @@ -1,3 +1,4 @@ +import browser from "webextension-polyfill"; import type { BookmarksFile, Bookmark, @@ -109,7 +110,7 @@ async function collectLocalBookmarks( otherBookmarksId: string, ): Promise> { const out = new Map(); - const tree = await chrome.bookmarks.getTree(); + const tree = await browser.bookmarks.getTree(); if (tree[0]?.children == null) return out; for (const top of tree[0].children) { diff --git a/packages/extension-shared/src/lib/settings.ts b/packages/extension-shared/src/lib/settings.ts index 7dd7854..b9d9888 100644 --- a/packages/extension-shared/src/lib/settings.ts +++ b/packages/extension-shared/src/lib/settings.ts @@ -1,3 +1,4 @@ +import browser from "webextension-polyfill"; import { z } from "zod"; const SETTINGS_KEY = "gitmarks:settings"; @@ -20,7 +21,7 @@ export class SettingsCorruptError extends Error { } export async function loadSettings(): Promise { - const result = await chrome.storage.local.get(SETTINGS_KEY); + const result = await browser.storage.local.get(SETTINGS_KEY); const raw = result[SETTINGS_KEY]; if (raw == null) return null; const parsed = settingsSchema.safeParse(raw); @@ -37,9 +38,9 @@ export async function loadSettings(): Promise { export async function saveSettings(value: Settings): Promise { const validated = settingsSchema.parse(value); - await chrome.storage.local.set({ [SETTINGS_KEY]: validated }); + await browser.storage.local.set({ [SETTINGS_KEY]: validated }); } export async function clearSettings(): Promise { - await chrome.storage.local.remove(SETTINGS_KEY); + await browser.storage.local.remove(SETTINGS_KEY); } diff --git a/packages/extension-shared/src/options.ts b/packages/extension-shared/src/options.ts index 3b2e656..595dbd4 100644 --- a/packages/extension-shared/src/options.ts +++ b/packages/extension-shared/src/options.ts @@ -1,3 +1,4 @@ +import browser from "webextension-polyfill"; import { GitHubClient, GitHubAuthError, diff --git a/packages/extension-shared/src/popup.ts b/packages/extension-shared/src/popup.ts index b3f6c84..9e84815 100644 --- a/packages/extension-shared/src/popup.ts +++ b/packages/extension-shared/src/popup.ts @@ -1,3 +1,4 @@ +import browser from "webextension-polyfill"; import { GitHubClient } from "@gitmarks/core"; import { loadSettings, SettingsCorruptError } from "./lib/settings.js"; import { getMachineId } from "./lib/machine-id.js"; @@ -7,13 +8,13 @@ import type { LastErrorRecord } from "./lib/background-core.js"; const root = document.getElementById("root"); if (root == null) throw new Error("#root not found"); -async function getActiveTab(): Promise { +async function getActiveTab(): Promise { // When opened as a real extension popup, currentWindow refers to the // browser window the user was in (not the popup's own floating window), // so this returns the tab they were viewing. activeTab grants access to // title + url for that one tab on user-gesture popup open; no broader // tabs permission is required. - const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + const [tab] = await browser.tabs.query({ active: true, currentWindow: true }); if (tab != null && tab.url != null && !tab.url.startsWith("chrome-extension://")) { return tab; } @@ -29,7 +30,7 @@ async function render(): Promise { root!.innerHTML = `

Settings are corrupted — please reconfigure gitmarks.

`; document.getElementById("setup")?.addEventListener("click", () => { - chrome.runtime.openOptionsPage(); + browser.runtime.openOptionsPage(); window.close(); }); return; @@ -42,7 +43,7 @@ async function render(): Promise { `; document.getElementById("setup")!.addEventListener("click", () => { - chrome.runtime.openOptionsPage(); + browser.runtime.openOptionsPage(); window.close(); }); return; @@ -60,7 +61,7 @@ async function render(): Promise {

`; - const errStored = await chrome.storage.local.get("gitmarks:lastError"); + const errStored = await browser.storage.local.get("gitmarks:lastError"); const lastErr = errStored["gitmarks:lastError"] as LastErrorRecord | undefined; if (lastErr != null) { const banner = document.createElement("p"); diff --git a/packages/extension-shared/test/setup.ts b/packages/extension-shared/test/setup.ts index 244362e..481e5e5 100644 --- a/packages/extension-shared/test/setup.ts +++ b/packages/extension-shared/test/setup.ts @@ -62,6 +62,7 @@ const chromeStub = { }; vi.stubGlobal("chrome", chromeStub); +vi.stubGlobal("browser", chromeStub); beforeEach(async () => { await chromeStub.storage.local.clear(); diff --git a/packages/extension-shared/test/webextension-polyfill-stub.ts b/packages/extension-shared/test/webextension-polyfill-stub.ts new file mode 100644 index 0000000..eb3efdc --- /dev/null +++ b/packages/extension-shared/test/webextension-polyfill-stub.ts @@ -0,0 +1,25 @@ +/** + * Test-environment stub for webextension-polyfill. + * + * The real polyfill throws "This script should only be loaded in a browser + * extension" when imported in Node/jsdom. We redirect the import here via + * the vitest alias so source files that do + * + * import browser from "webextension-polyfill"; + * + * receive the same chromeStub object that test/setup.ts installs as + * globalThis.browser (and globalThis.chrome). The Proxy defers the lookup + * until actual property access, so it works even though setup.ts runs its + * vi.stubGlobal calls slightly after module evaluation order. + */ +const browserProxy = new Proxy({} as typeof globalThis.browser, { + get(_target, prop) { + const g = (globalThis as Record)["browser"]; + if (g != null) { + return (g as Record)[prop]; + } + return undefined; + }, +}); + +export default browserProxy; diff --git a/packages/extension-shared/vitest.config.ts b/packages/extension-shared/vitest.config.ts index 053baaf..c8e7fbf 100644 --- a/packages/extension-shared/vitest.config.ts +++ b/packages/extension-shared/vitest.config.ts @@ -1,6 +1,17 @@ import { defineConfig } from "vitest/config"; +import path from "node:path"; export default defineConfig({ + resolve: { + alias: { + // Redirect "webextension-polyfill" to a test stub so the real polyfill + // (which throws in Node/jsdom) is never loaded during unit tests. + "webextension-polyfill": path.resolve( + __dirname, + "test/webextension-polyfill-stub.ts", + ), + }, + }, test: { environment: "jsdom", include: ["test/**/*.test.ts"], From 89a347298743cd9100974dc5ca7ba9dd22eca487 Mon Sep 17 00:00:00 2001 From: Sidney von Katzendame Date: Sun, 24 May 2026 12:55:10 -0400 Subject: [PATCH 4/5] feat(extension-firefox): bootstrap Firefox MV3 add-on package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plain Vite multi-entry build (crxjs is Chrome-only). Manifest is literal JSON copied into dist/ post-build via scripts/copy-manifest.mjs. Targets Firefox 121+ for MV3 service-worker parity. browser_specific_settings declares the gecko id and strict_min_version. Source files are minimal shells that re-export from @gitmarks/extension-shared — all the actual code (popup, options, background, lib/) is shared with extension-chrome via that workspace package. Closes #23. Co-Authored-By: Claude Sonnet 4.6 --- packages/extension-firefox/manifest.json | 26 ++++++++ packages/extension-firefox/package.json | 20 ++++++ .../scripts/copy-manifest.mjs | 12 ++++ packages/extension-firefox/src/background.ts | 1 + packages/extension-firefox/src/options.html | 64 ++++++++++++++++++ packages/extension-firefox/src/options.ts | 1 + packages/extension-firefox/src/popup.html | 37 +++++++++++ packages/extension-firefox/src/popup.ts | 1 + packages/extension-firefox/tsconfig.json | 11 ++++ packages/extension-firefox/vite.config.ts | 28 ++++++++ pnpm-lock.yaml | 66 ++++++++++++++----- 11 files changed, 252 insertions(+), 15 deletions(-) create mode 100644 packages/extension-firefox/manifest.json create mode 100644 packages/extension-firefox/package.json create mode 100644 packages/extension-firefox/scripts/copy-manifest.mjs create mode 100644 packages/extension-firefox/src/background.ts create mode 100644 packages/extension-firefox/src/options.html create mode 100644 packages/extension-firefox/src/options.ts create mode 100644 packages/extension-firefox/src/popup.html create mode 100644 packages/extension-firefox/src/popup.ts create mode 100644 packages/extension-firefox/tsconfig.json create mode 100644 packages/extension-firefox/vite.config.ts diff --git a/packages/extension-firefox/manifest.json b/packages/extension-firefox/manifest.json new file mode 100644 index 0000000..7276473 --- /dev/null +++ b/packages/extension-firefox/manifest.json @@ -0,0 +1,26 @@ +{ + "manifest_version": 3, + "name": "gitmarks", + "version": "0.0.1", + "description": "Save bookmarks to your own GitHub repo.", + "permissions": ["storage", "activeTab", "bookmarks", "alarms"], + "host_permissions": ["https://api.github.com/*"], + "action": { + "default_popup": "popup.html", + "default_title": "gitmarks" + }, + "options_ui": { + "page": "options.html", + "open_in_tab": true + }, + "background": { + "service_worker": "background.js", + "type": "module" + }, + "browser_specific_settings": { + "gecko": { + "id": "gitmarks@paperhurts.dev", + "strict_min_version": "121.0" + } + } +} diff --git a/packages/extension-firefox/package.json b/packages/extension-firefox/package.json new file mode 100644 index 0000000..0334741 --- /dev/null +++ b/packages/extension-firefox/package.json @@ -0,0 +1,20 @@ +{ + "name": "@gitmarks/extension-firefox", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "vite build && node ./scripts/copy-manifest.mjs", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@gitmarks/core": "workspace:*", + "@gitmarks/extension-shared": "workspace:*" + }, + "devDependencies": { + "@types/chrome": "^0.0.268", + "@types/node": "^22.0.0", + "@types/webextension-polyfill": "^0.12.0", + "vite": "^5.4.0" + } +} diff --git a/packages/extension-firefox/scripts/copy-manifest.mjs b/packages/extension-firefox/scripts/copy-manifest.mjs new file mode 100644 index 0000000..5107e9d --- /dev/null +++ b/packages/extension-firefox/scripts/copy-manifest.mjs @@ -0,0 +1,12 @@ +import { copyFileSync, mkdirSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = resolve(here, ".."); +mkdirSync(resolve(root, "dist"), { recursive: true }); +copyFileSync( + resolve(root, "manifest.json"), + resolve(root, "dist/manifest.json"), +); +console.log("[firefox] copied manifest.json to dist/"); diff --git a/packages/extension-firefox/src/background.ts b/packages/extension-firefox/src/background.ts new file mode 100644 index 0000000..82fb1e3 --- /dev/null +++ b/packages/extension-firefox/src/background.ts @@ -0,0 +1 @@ +import "@gitmarks/extension-shared/background"; diff --git a/packages/extension-firefox/src/options.html b/packages/extension-firefox/src/options.html new file mode 100644 index 0000000..16309e7 --- /dev/null +++ b/packages/extension-firefox/src/options.html @@ -0,0 +1,64 @@ + + + + + gitmarks — settings + + + +

gitmarks settings

+ + + + + + + + + + + +
+ + +
+ +

+ + + + diff --git a/packages/extension-firefox/src/options.ts b/packages/extension-firefox/src/options.ts new file mode 100644 index 0000000..a5aac97 --- /dev/null +++ b/packages/extension-firefox/src/options.ts @@ -0,0 +1 @@ +import "@gitmarks/extension-shared/options"; diff --git a/packages/extension-firefox/src/popup.html b/packages/extension-firefox/src/popup.html new file mode 100644 index 0000000..69a0b72 --- /dev/null +++ b/packages/extension-firefox/src/popup.html @@ -0,0 +1,37 @@ + + + + + gitmarks + + + +
loading…
+ + + diff --git a/packages/extension-firefox/src/popup.ts b/packages/extension-firefox/src/popup.ts new file mode 100644 index 0000000..c3afe82 --- /dev/null +++ b/packages/extension-firefox/src/popup.ts @@ -0,0 +1 @@ +import "@gitmarks/extension-shared/popup"; diff --git a/packages/extension-firefox/tsconfig.json b/packages/extension-firefox/tsconfig.json new file mode 100644 index 0000000..f665ba0 --- /dev/null +++ b/packages/extension-firefox/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["chrome", "webextension-polyfill", "node"], + "rootDir": "./", + "outDir": "./dist-tsc", + "noEmit": true + }, + "include": ["src/**/*.ts", "vite.config.ts", "scripts/**/*.mjs"] +} diff --git a/packages/extension-firefox/vite.config.ts b/packages/extension-firefox/vite.config.ts new file mode 100644 index 0000000..e85b581 --- /dev/null +++ b/packages/extension-firefox/vite.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from "vite"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = fileURLToPath(new URL(".", import.meta.url)); + +export default defineConfig({ + root: "src", + build: { + outDir: "../dist", + emptyOutDir: true, + target: "esnext", + minify: false, + sourcemap: true, + rollupOptions: { + input: { + background: resolve(__dirname, "src/background.ts"), + popup: resolve(__dirname, "src/popup.html"), + options: resolve(__dirname, "src/options.html"), + }, + output: { + entryFileNames: "[name].js", + chunkFileNames: "assets/[name]-[hash].js", + assetFileNames: "[name].[ext]", + }, + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b13bcfa..2f12137 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,7 +23,7 @@ importers: devDependencies: vitest: specifier: ^2.0.0 - version: 2.1.9(jsdom@25.0.1) + version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1) packages/extension-chrome: dependencies: @@ -39,7 +39,7 @@ importers: devDependencies: '@crxjs/vite-plugin': specifier: ^2.4.0 - version: 2.4.0(vite@5.4.21) + version: 2.4.0(vite@5.4.21(@types/node@22.19.19)) '@playwright/test': specifier: ^1.48.0 version: 1.60.0 @@ -48,7 +48,29 @@ importers: version: 0.0.268 vite: specifier: ^5.4.0 - version: 5.4.21 + version: 5.4.21(@types/node@22.19.19) + + packages/extension-firefox: + dependencies: + '@gitmarks/core': + specifier: workspace:* + version: link:../core + '@gitmarks/extension-shared': + specifier: workspace:* + version: link:../extension-shared + devDependencies: + '@types/chrome': + specifier: ^0.0.268 + version: 0.0.268 + '@types/node': + specifier: ^22.0.0 + version: 22.19.19 + '@types/webextension-polyfill': + specifier: ^0.12.0 + version: 0.12.5 + vite: + specifier: ^5.4.0 + version: 5.4.21(@types/node@22.19.19) packages/extension-shared: dependencies: @@ -73,7 +95,7 @@ importers: version: 25.0.1 vitest: specifier: ^2.0.0 - version: 2.1.9(jsdom@25.0.1) + version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1) packages: @@ -418,6 +440,9 @@ packages: '@types/har-format@1.2.16': resolution: {integrity: sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==} + '@types/node@22.19.19': + resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==} + '@types/webextension-polyfill@0.12.5': resolution: {integrity: sha512-uKSAv6LgcVdINmxXMKBuVIcg/2m5JZugoZO8x20g7j2bXJkPIl/lVGQcDlbV+aXAiTyXT2RA5U5mI4IGCDMQeg==} @@ -904,6 +929,9 @@ packages: resolution: {integrity: sha512-fIRiVTJNcSRmXKPZtGzFQv9WRrZ3M9eoptl/teFJvjOzmpU+/K/JH6HZ8deBfb5vMEpicJcLn7JmvdknlMq7Zg==} hasBin: true + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} @@ -1030,7 +1058,7 @@ snapshots: '@csstools/css-tokenizer': 3.0.4 lru-cache: 10.4.3 - '@crxjs/vite-plugin@2.4.0(vite@5.4.21)': + '@crxjs/vite-plugin@2.4.0(vite@5.4.21(@types/node@22.19.19))': dependencies: '@rollup/pluginutils': 4.2.1 '@webcomponents/custom-elements': 1.6.0 @@ -1048,7 +1076,7 @@ snapshots: react-refresh: 0.13.0 rollup: 2.79.2 rxjs: 7.5.7 - vite: 5.4.21 + vite: 5.4.21(@types/node@22.19.19) transitivePeerDependencies: - supports-color @@ -1256,6 +1284,10 @@ snapshots: '@types/har-format@1.2.16': {} + '@types/node@22.19.19': + dependencies: + undici-types: 6.21.0 + '@types/webextension-polyfill@0.12.5': {} '@vitest/expect@2.1.9': @@ -1265,13 +1297,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 1.2.0 - '@vitest/mocker@2.1.9(vite@5.4.21)': + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@22.19.19))': dependencies: '@vitest/spy': 2.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 5.4.21 + vite: 5.4.21(@types/node@22.19.19) '@vitest/pretty-format@2.1.9': dependencies: @@ -1767,15 +1799,17 @@ snapshots: ulid@2.4.0: {} + undici-types@6.21.0: {} + universalify@2.0.1: {} - vite-node@2.1.9: + vite-node@2.1.9(@types/node@22.19.19): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 1.1.2 - vite: 5.4.21 + vite: 5.4.21(@types/node@22.19.19) transitivePeerDependencies: - '@types/node' - less @@ -1787,18 +1821,19 @@ snapshots: - supports-color - terser - vite@5.4.21: + vite@5.4.21(@types/node@22.19.19): dependencies: esbuild: 0.21.5 postcss: 8.5.15 rollup: 4.60.4 optionalDependencies: + '@types/node': 22.19.19 fsevents: 2.3.3 - vitest@2.1.9(jsdom@25.0.1): + vitest@2.1.9(@types/node@22.19.19)(jsdom@25.0.1): dependencies: '@vitest/expect': 2.1.9 - '@vitest/mocker': 2.1.9(vite@5.4.21) + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.19.19)) '@vitest/pretty-format': 2.1.9 '@vitest/runner': 2.1.9 '@vitest/snapshot': 2.1.9 @@ -1814,10 +1849,11 @@ snapshots: tinyexec: 0.3.2 tinypool: 1.1.1 tinyrainbow: 1.2.0 - vite: 5.4.21 - vite-node: 2.1.9 + vite: 5.4.21(@types/node@22.19.19) + vite-node: 2.1.9(@types/node@22.19.19) why-is-node-running: 2.3.0 optionalDependencies: + '@types/node': 22.19.19 jsdom: 25.0.1 transitivePeerDependencies: - less From fdc275b600ae81a6f6fe0f78622b84cdb3d14712 Mon Sep 17 00:00:00 2001 From: Sidney von Katzendame Date: Sun, 24 May 2026 12:57:58 -0400 Subject: [PATCH 5/5] docs(extension-firefox): README with manual smoke test + cross-package doc sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - packages/extension-firefox/README.md: full first-run + manual smoke test guide, architecture diagram, known-limitations cross-reference - Root README: adds extension-shared and extension-firefox to the packages table, marks Firefox done in the roadmap, updates the architecture diagram - CLAUDE.md: documents the new 4-package layout (core + shared + 2 shells) and marks the Firefox roadmap entry done - packages/extension-chrome/README.md: clarifies it's now a thin shell paired with extension-firefox - packages/extension-shared/test/webextension-polyfill-stub.ts: fix TS index-signature error (Proxy target type) Closes #23 — the Firefox MV3 add-on is functional. Manual smoke test checklist in the new README guides verification in a real browser. --- CLAUDE.md | 6 +- README.md | 11 +- .../2026-05-24-gitmarks-firefox-build.md | 960 ++++++++++++++++++ packages/extension-chrome/README.md | 9 +- packages/extension-firefox/README.md | 121 +++ .../test/webextension-polyfill-stub.ts | 2 +- 6 files changed, 1100 insertions(+), 9 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-24-gitmarks-firefox-build.md create mode 100644 packages/extension-firefox/README.md diff --git a/CLAUDE.md b/CLAUDE.md index 450312a..b2d58fc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Two packages are merged to main and working: - `@gitmarks/core` (`packages/core/`) — schemas, GitHub Contents API client with optimistic concurrency, ULID/URL helpers (incl. opt-in tracking-param stripping), pure mutation helpers, example fixtures. 65 unit tests. -- `@gitmarks/extension-chrome` (`packages/extension-chrome/`) — MV3 Chrome extension with toolbar save, two-way native-tree sync, 5-min poll, initial reconciliation, opt-in tracking-param stripping settings flag. 97 unit tests + 4 Playwright e2e tests (2 e2e skipped pending Playwright limitation fixes — see issue history). +- `@gitmarks/extension-shared` (`packages/extension-shared/`) — canonical owner of the cross-browser extension code: popup, options, background, all of `src/lib/`, and the chrome/browser stub. 96 unit tests live here. Consumed by both browser shells via `workspace:*`. Uses `browser.*` via `webextension-polyfill`. +- `@gitmarks/extension-chrome` (`packages/extension-chrome/`) — Chrome MV3 shell. Manifest + Vite/crxjs build + Playwright e2e (4 passing, 2 skipped — see issue history for the activeTab/Playwright limitation). Source files are thin entries that re-export from `extension-shared` via its `exports` map. +- `@gitmarks/extension-firefox` (`packages/extension-firefox/`) — Firefox MV3 shell. Manifest + plain Vite build + manual smoke test (Playwright Firefox doesn't reliably drive WebExtensions). Targets Firefox 121+ for MV3 SW parity. Load via `about:debugging` → "Load Temporary Add-on". Pending packages (in dependency order): Firefox build, web UI (read + search + tags), web UI (write + bulk ops), Safari. @@ -88,7 +90,7 @@ pnpm --filter @gitmarks/extension-chrome e2e 1. ✅ `@gitmarks/core` 2. ✅ Chrome MVP (toolbar save) 3. ✅ Chrome native tree integration -4. ⬜ Firefox build (`webextension-polyfill`) — issue [#23](https://github.com/paperhurts/gitmarks/issues/23) +4. ✅ Firefox MV3 add-on (`webextension-polyfill` + extension-shared) — issue [#23](https://github.com/paperhurts/gitmarks/issues/23) 5. ⬜ Web UI v1: list / search / tag management — issue [#24](https://github.com/paperhurts/gitmarks/issues/24) 6. ⬜ Web UI v2: bulk operations + trash + export — issue [#25](https://github.com/paperhurts/gitmarks/issues/25) 7. ⬜ Safari (`safari-web-extension-converter`) — issue [#26](https://github.com/paperhurts/gitmarks/issues/26) diff --git a/README.md b/README.md index 4c3a518..ce5ec5e 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ you control. **Status:** Chrome extension is functional end-to-end (save via toolbar button, two-way sync with the native bookmark tree, 5-min poll for remote -changes, automatic conflict retry). Firefox / Safari / web UI are next in +changes, automatic conflict retry). Firefox MV3 add-on shipping the same +source as Chrome via a shared package. Safari / web UI are next in the roadmap. See `spec.md` for the full design. ## Features (Chrome, today) @@ -32,7 +33,9 @@ the roadmap. See `spec.md` for the full design. | Package | Role | |---|---| | `@gitmarks/core` | Shared TypeScript library: schemas (Zod), GitHub Contents API client with optimistic concurrency, ULID + URL helpers, pure mutation helpers | -| `@gitmarks/extension-chrome` | Chrome MV3 extension. Save tabs, two-way sync with the native bookmark tree, 5-min poll, reconciliation | +| `@gitmarks/extension-shared` | Cross-browser extension source — popup, options, background, lib/ helpers. Consumed by both browser shells via `workspace:*`. 96 unit tests live here. | +| `@gitmarks/extension-chrome` | Chrome MV3 shell. Manifest + Vite/crxjs build + Playwright e2e. Thin entry files import from `extension-shared`. | +| `@gitmarks/extension-firefox` | Firefox MV3 shell. Manifest + plain Vite build. Same source as Chrome via `extension-shared`. Load via `about:debugging`. | ## Quick start (Chrome extension) @@ -88,7 +91,7 @@ The repo is a pnpm workspace monorepo. Each package has its own ## Architecture ``` -[Chrome ext] [Firefox ext (planned)] [Safari ext (planned)] [Web UI (planned)] +[Chrome ext] [Firefox ext] [Safari ext (planned)] [Web UI (planned)] \ | / / \ | / / v v v v @@ -122,7 +125,7 @@ The load-bearing invariants: - ✅ Chrome MVP — toolbar-button save flow - ✅ Chrome native tree integration — listeners, reconcile, poll loop - ✅ Tracking-param stripping (opt-in) -- ⬜ Firefox build ([#23](https://github.com/paperhurts/gitmarks/issues/23)) +- ✅ Firefox MV3 add-on ([#23](https://github.com/paperhurts/gitmarks/issues/23)) - ⬜ Web UI v1: list + search + tag management ([#24](https://github.com/paperhurts/gitmarks/issues/24)) - ⬜ Web UI v2: bulk operations + trash + export ([#25](https://github.com/paperhurts/gitmarks/issues/25)) - ⬜ Safari ([#26](https://github.com/paperhurts/gitmarks/issues/26)) diff --git a/docs/superpowers/plans/2026-05-24-gitmarks-firefox-build.md b/docs/superpowers/plans/2026-05-24-gitmarks-firefox-build.md new file mode 100644 index 0000000..b00e887 --- /dev/null +++ b/docs/superpowers/plans/2026-05-24-gitmarks-firefox-build.md @@ -0,0 +1,960 @@ +# Gitmarks Firefox Build Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship `@gitmarks/extension-firefox` — a Firefox MV3 add-on that does everything the Chrome extension does (popup save, two-way native-tree sync via `browser.bookmarks.*`, 5-minute poll, initial reconciliation, opt-in tracking-param stripping). Achieve this without code duplication by extracting the bulk of extension code into a new `@gitmarks/extension-shared` workspace package and turning `extension-chrome` + `extension-firefox` into thin browser-specific shells. + +**Architecture:** Three workspace packages where there were two — `@gitmarks/core` (unchanged), `@gitmarks/extension-shared` (new: all the cross-browser code), `@gitmarks/extension-chrome` (manifest + vite config + thin entries; imports from extension-shared), `@gitmarks/extension-firefox` (mirror of chrome's shell, Firefox-specific manifest). `webextension-polyfill` lets source code use `browser.*` everywhere; Chrome's `chrome.*` is auto-aliased. + +**Tech Stack:** TypeScript ESM throughout, `webextension-polyfill@^0.10`, Vite + `@crxjs/vite-plugin` for Chrome, Vite + manual manifest bundling for Firefox (crxjs is Chrome-only). Firefox 121+ for SW parity. + +**Spec reference:** `spec.md` §"Build order" — Firefox build (~½ day). Issue #23. + +**Out of scope (deferred to later plans / issues):** +- AMO signing / distribution beyond developer-mode unpacked +- Cross-browser Playwright e2e (unit tests cover shared logic; manual smoke verifies Firefox wiring) +- Older Firefox event-page fallback for pre-121 — only target MV3 SW for now + +--- + +## Decisions locked in upfront + +- **Polyfill at runtime, not at type level.** Source uses `browser.*` and imports the polyfill at the top of each entry (`background`, `popup`, `options`). TypeScript types from `webextension-polyfill` provide compile-time signatures. This avoids a build-time codemod and keeps both browsers' code paths identical. +- **`extension-shared` is workspace-private.** `"private": true`, no published exports config — it's an internal monorepo package consumed via `"@gitmarks/extension-shared": "workspace:*"`. Tree-shaking lets the shells pull only what each entry needs. +- **The shells own their manifests and Vite configs.** Each browser's `manifest.config.ts` + `vite.config.ts` live in its own package. The shared package owns no manifest — it's pure source. +- **Tests live with the shared package**, not the shells. `extension-shared/test/` has the chrome-stub + all current unit tests. The shells have only e2e (Chrome) or manual smoke (Firefox). +- **No behavior change in Chrome** as part of this plan. The refactor is a code move; the polyfill is a no-op against `chrome.*`. Every step keeps the existing 97 extension unit tests + 4 Playwright e2e green. +- **Firefox extension ID** is set in the manifest via `browser_specific_settings.gecko.id`. Use `gitmarks@paperhurts.dev` (placeholder; per spec the project ships as developer-mode unpacked first). + +--- + +## File structure (final) + +``` +packages/ +├── core/ # unchanged +├── extension-shared/ # NEW: all cross-browser code +│ ├── package.json # name: @gitmarks/extension-shared +│ ├── tsconfig.json +│ ├── vitest.config.ts # moved from extension-chrome +│ ├── src/ +│ │ ├── background.ts # moved +│ │ ├── popup.html # moved +│ │ ├── popup.ts # moved (browser.* via polyfill) +│ │ ├── options.html # moved +│ │ ├── options.ts # moved +│ │ └── lib/ # all moved +│ │ ├── apply-remote.ts +│ │ ├── background-core.ts +│ │ ├── bookmark-factory.ts +│ │ ├── bookmarks-file.ts +│ │ ├── folder-path.ts +│ │ ├── id-mapping.ts +│ │ ├── listeners.ts +│ │ ├── machine-id.ts +│ │ ├── reconcile.ts +│ │ ├── save-flow.ts +│ │ ├── settings.ts +│ │ └── suppression.ts +│ └── test/ # moved (chrome stub + 97 unit tests) +├── extension-chrome/ # thin shell +│ ├── package.json # depends on extension-shared via workspace:* +│ ├── manifest.config.ts # MV3 chrome manifest +│ ├── vite.config.ts # crxjs plugin +│ ├── playwright.config.ts # unchanged +│ ├── README.md # adjusted (manual smoke checklist points at shared) +│ └── e2e/ # Chrome-only e2e stays here +│ ├── fixtures.ts +│ ├── github-mock.ts +│ ├── mvp.spec.ts +│ └── sync.spec.ts +└── extension-firefox/ # NEW: thin Firefox shell + ├── package.json # depends on extension-shared via workspace:* + ├── manifest.json # MV3 Firefox manifest (JSON, no crxjs) + ├── vite.config.ts # plain Vite multi-entry build + ├── README.md # about:debugging dev workflow + └── src/ + └── entries.ts # explicit import roots for each surface +``` + +--- + +## Tasks + +### Task 0: Bootstrap `extension-shared` package skeleton (no migrations yet) + +**Files:** +- Create: `packages/extension-shared/package.json` +- Create: `packages/extension-shared/tsconfig.json` +- Create: `packages/extension-shared/vitest.config.ts` +- Create: `packages/extension-shared/src/.gitkeep` (placeholder so the directory exists in git) +- Create: `packages/extension-shared/test/smoke.test.ts` + +**Why this task exists separately:** establishes the package shell and proves the toolchain works before we move 97 tests + ~2k LoC into it. + +- [ ] **Step 1: Create `packages/extension-shared/package.json`** + +```json +{ + "name": "@gitmarks/extension-shared", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@gitmarks/core": "workspace:*", + "webextension-polyfill": "^0.12.0", + "zod": "^3.23.0" + }, + "devDependencies": { + "@types/chrome": "^0.0.268", + "@types/webextension-polyfill": "^0.12.0", + "jsdom": "^25.0.0", + "vitest": "^2.0.0" + } +} +``` + +- [ ] **Step 2: Create `packages/extension-shared/tsconfig.json`** + +```json +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["chrome", "vite/client"], + "rootDir": "./", + "outDir": "./dist-tsc", + "noEmit": true + }, + "include": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts"] +} +``` + +(Note: we keep `@types/chrome` in `types` even though we're switching to `browser.*` — `@types/webextension-polyfill` re-exports the chrome types for compatibility, and the existing chrome stub in tests still references `chrome.bookmarks.BookmarkTreeNode` etc.) + +- [ ] **Step 3: Create `packages/extension-shared/vitest.config.ts`** + +```typescript +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "jsdom", + include: ["test/**/*.test.ts"], + setupFiles: ["./test/setup.ts"], + globals: false, + }, +}); +``` + +(`setup.ts` doesn't exist yet — Task 1 moves it. For now, vitest will error on the missing setupFiles entry. The smoke test in Step 5 below works around that by deferring config use until Task 1.) + +- [ ] **Step 4: Create `packages/extension-shared/src/.gitkeep`** + +Empty file. Just `touch packages/extension-shared/src/.gitkeep`. + +- [ ] **Step 5: Create `packages/extension-shared/test/smoke.test.ts` and a stub `test/setup.ts`** + +Stub setup (will be replaced by the real one in Task 1): + +```typescript +// packages/extension-shared/test/setup.ts (stub — replaced in Task 1) +// Empty placeholder so vitest's setupFiles entry resolves. +export {}; +``` + +Smoke test: + +```typescript +// packages/extension-shared/test/smoke.test.ts +import { describe, it, expect } from "vitest"; + +describe("@gitmarks/extension-shared smoke", () => { + it("loads without errors", () => { + expect(1 + 1).toBe(2); + }); +}); +``` + +- [ ] **Step 6: Install + verify** + +Run: `pnpm install` + +Expected: pnpm fetches `webextension-polyfill`, `@types/webextension-polyfill`, and resolves `@gitmarks/extension-shared` as a workspace package. + +Run: `pnpm --filter @gitmarks/extension-shared test` +Expected: 1 test passes. + +Run: `pnpm --filter @gitmarks/extension-shared typecheck` +Expected: exit 0. + +- [ ] **Step 7: Commit** + +```bash +git add packages/extension-shared pnpm-lock.yaml +git commit -m "chore(extension-shared): bootstrap workspace package skeleton + +Pre-cursor to extracting the cross-browser extension code out of +extension-chrome so that extension-firefox (issue #23) can consume the +same source without duplication. This commit creates only the package +shell + a smoke test; subsequent tasks move code into it." +``` + +--- + +### Task 1: Move `chrome.*` test stub and unit tests to extension-shared + +**Files:** +- Move: `packages/extension-chrome/test/setup.ts` → `packages/extension-shared/test/setup.ts` +- Move: `packages/extension-chrome/test/*.test.ts` (12 files) → `packages/extension-shared/test/` + +**Why this task:** The 97 unit tests are package-agnostic — they test pure modules + the chrome stub. Moving them first means the rest of the refactor is verified by an already-passing suite living in its new home. + +**Critical:** the test files currently import via relative paths like `"../src/lib/settings.js"`. After the move, those paths must still resolve. Since `src/` is moving in Task 2, the imports need a temporary adjustment OR the tests + src move together. + +**Cleaner approach:** move test + src together in Task 1. Re-scope Task 1 to: + +- [ ] **Step 1: Move all source and tests in one atomic operation** + +Use `git mv` to preserve history: + +```bash +git mv packages/extension-chrome/src/background.ts packages/extension-shared/src/ +git mv packages/extension-chrome/src/popup.html packages/extension-shared/src/ +git mv packages/extension-chrome/src/popup.ts packages/extension-shared/src/ +git mv packages/extension-chrome/src/options.html packages/extension-shared/src/ +git mv packages/extension-chrome/src/options.ts packages/extension-shared/src/ +git mv packages/extension-chrome/src/lib packages/extension-shared/src/lib +git rm packages/extension-shared/src/.gitkeep + +git mv packages/extension-chrome/test/setup.ts packages/extension-shared/test/setup.ts +# Overwrite the stub setup we created in Task 0 +git mv packages/extension-chrome/test/apply-remote.test.ts packages/extension-shared/test/ +git mv packages/extension-chrome/test/background-core.test.ts packages/extension-shared/test/ +git mv packages/extension-chrome/test/bookmark-factory.test.ts packages/extension-shared/test/ +git mv packages/extension-chrome/test/bookmarks-file.test.ts packages/extension-shared/test/ +git mv packages/extension-chrome/test/folder-path.test.ts packages/extension-shared/test/ +git mv packages/extension-chrome/test/id-mapping.test.ts packages/extension-shared/test/ +git mv packages/extension-chrome/test/listeners.test.ts packages/extension-shared/test/ +git mv packages/extension-chrome/test/machine-id.test.ts packages/extension-shared/test/ +git mv packages/extension-chrome/test/reconcile.test.ts packages/extension-shared/test/ +git mv packages/extension-chrome/test/save-flow.test.ts packages/extension-shared/test/ +git mv packages/extension-chrome/test/settings.test.ts packages/extension-shared/test/ +git mv packages/extension-chrome/test/suppression.test.ts packages/extension-shared/test/ +# smoke.test.ts in extension-chrome can be removed; extension-shared has its own +git rm packages/extension-chrome/test/smoke.test.ts 2>/dev/null || true +``` + +Note the imports inside the moved files use relative paths like `"../src/lib/settings.js"` — those still resolve because the relative position (`test/` ↔ `src/lib/`) is unchanged inside extension-shared. + +- [ ] **Step 2: Make extension-shared export the public API** + +Create `packages/extension-shared/src/index.ts`: + +```typescript +// Re-exports so the browser-specific shells can import everything they need +// from one place. Keep this in alphabetical order; if you add a new public +// surface in src/lib/, add it here. + +export { applyRemoteChanges } from "./lib/apply-remote.js"; +export * from "./lib/background-core.js"; +export { buildBookmark, type BuildBookmarkInput } from "./lib/bookmark-factory.js"; +export { + BOOKMARKS_PATH, + emptyBookmarksFile, + updateBookmarksOrBootstrap, +} from "./lib/bookmarks-file.js"; +export { + BOOKMARKS_BAR_FOLDER, + OTHER_BOOKMARKS_FOLDER, + folderPathFromNode, + splitFolderPath, + type SplitPath, + type TreeNode, +} from "./lib/folder-path.js"; +export { + IdMap, + asNodeId, + asUlid, + type NodeId, + type Ulid, +} from "./lib/id-mapping.js"; +export { + flushPending, + registerListeners, + __resetForTest, + type ListenerDeps, +} from "./lib/listeners.js"; +export { getMachineId } from "./lib/machine-id.js"; +export { reconcile } from "./lib/reconcile.js"; +export { + saveBookmark, + type PageInfo, + type SaveOptions, + type SaveResult, +} from "./lib/save-flow.js"; +export { + SettingsCorruptError, + clearSettings, + loadSettings, + saveSettings, + settingsSchema, + type Settings, +} from "./lib/settings.js"; +export { + clearSuppression, + isNodeSuppressed, + isSuppressed, + suppress, + suppressNode, +} from "./lib/suppression.js"; +``` + +- [ ] **Step 3: Update extension-chrome's `vitest.config.ts` and remove its now-empty test dir + setup** + +Since extension-chrome no longer has unit tests of its own (everything moved to extension-shared), delete its `vitest.config.ts` and the now-empty `test/` directory. Keep `e2e/` and `playwright.config.ts`. + +```bash +git rm packages/extension-chrome/vitest.config.ts +# test/ should be empty now after Step 1's moves; rmdir explicitly +rmdir packages/extension-chrome/test 2>/dev/null || true +``` + +Edit `packages/extension-chrome/package.json` to remove the `test` and `test:watch` scripts and the vitest devDep — extension-chrome's test surface is now just e2e: + +```json +{ + "name": "@gitmarks/extension-chrome", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite build --watch --mode development", + "typecheck": "tsc -p tsconfig.json --noEmit", + "e2e": "playwright test", + "e2e:headed": "playwright test --headed", + "pretest:e2e": "vite build" + }, + "dependencies": { + "@gitmarks/core": "workspace:*", + "@gitmarks/extension-shared": "workspace:*", + "zod": "^3.23.0" + }, + "devDependencies": { + "@crxjs/vite-plugin": "^2.4.0", + "@playwright/test": "^1.48.0", + "@types/chrome": "^0.0.268", + "vite": "^5.4.0" + } +} +``` + +(Note: we keep `zod` as a direct dep because the popup imports `SettingsCorruptError` from the shared package, and any Settings construction also depends on zod transitively. Easier to keep it visible than rely on transitive resolution. `jsdom` and `vitest` are now in extension-shared.) + +- [ ] **Step 4: Update extension-chrome's source entries to import from `@gitmarks/extension-shared`** + +The current `src/background.ts`, `src/popup.ts`, `src/options.ts`, and the `manifest.config.ts` references to them all still exist in extension-chrome temporarily — actually wait, they were moved to extension-shared in Step 1. + +**The shell needs its own thin entry files** that import from the shared package. Create: + +`packages/extension-chrome/src/background.ts`: +```typescript +// Chrome shell entry — re-exports the shared background module so the +// MV3 manifest can point at this file and crxjs can package it cleanly. +import "@gitmarks/extension-shared/dist/background.js"; +``` + +Wait — that's wrong. `extension-shared` has no build step (we set `noEmit: true` in its tsconfig). The shell needs to either: +- (a) directly import the .ts source via TypeScript path resolution (works with crxjs because vite handles TS) +- (b) emit a dist/ for extension-shared + +Option (a) is cleaner. Adjust extension-chrome's tsconfig.json to add path mapping: + +Wait — even simpler. The shell entry just imports the side-effecting modules. Since `@gitmarks/extension-shared` has `"type": "module"` and points its main at a file we control, we can have the shared package expose `./background.js`, `./popup.js`, `./options.js` as subpath exports of the SOURCE (no build). + +Actually let me reconsider. `vite build` (which extension-chrome uses) handles TypeScript natively when given a `.ts` entry. So extension-chrome's entry can simply re-export from extension-shared via TS imports: + +`packages/extension-chrome/src/background.ts`: +```typescript +import "@gitmarks/extension-shared/src/background"; +``` + +But `@gitmarks/extension-shared/src/background` needs to be importable. Add an `exports` map to `extension-shared/package.json`: + +```json +{ + "name": "@gitmarks/extension-shared", + ... + "exports": { + "./src/background": "./src/background.ts", + "./src/popup": "./src/popup.ts", + "./src/options": "./src/options.ts", + ".": "./src/index.ts" + } +} +``` + +Vite + TypeScript can resolve `.ts` from `exports` because vite handles TS transformation in-process. + +Actually that's brittle. Cleaner: have the shells own thin entries that just import: + +`packages/extension-chrome/src/background.ts`: +```typescript +// Shell entry — Chrome MV3 manifest points here. The actual implementation +// lives in @gitmarks/extension-shared; this file's side-effects (registering +// listeners, the alarm, the initial reconcile) come from importing it. +import "@gitmarks/extension-shared/background"; +``` + +And the package.json exports: +```json +{ + "exports": { + ".": "./src/index.ts", + "./background": "./src/background.ts", + "./popup": "./src/popup.ts", + "./options": "./src/options.ts" + } +} +``` + +- [ ] **Step 5: Update manifests + HTML to point at shell entries** + +`packages/extension-chrome/manifest.config.ts` already points at `src/popup.html`, `src/options.html`, `src/background.ts`. We need to keep the manifest pointing at chrome's local shell — so create: + +`packages/extension-chrome/src/popup.html`: +```html + + + + + gitmarks + + +
loading…
+ + + +``` + +`packages/extension-chrome/src/popup.ts`: +```typescript +import "@gitmarks/extension-shared/popup"; +``` + +`packages/extension-chrome/src/options.html`: same pattern. + +`packages/extension-chrome/src/options.ts`: +```typescript +import "@gitmarks/extension-shared/options"; +``` + +`packages/extension-chrome/src/background.ts`: +```typescript +import "@gitmarks/extension-shared/background"; +``` + +(Note: the HTML files reference `./popup.ts` and `./options.ts` relative to the HTML — and those `.ts` files just re-export from the shared package. Vite handles the transitive TS resolution.) + +- [ ] **Step 6: Run the suites + verify zero behavior change** + +```bash +pnpm install # pnpm sees the new exports + workspace dep +pnpm --filter @gitmarks/extension-shared test +# Expect: 97 tests passing +pnpm --filter @gitmarks/extension-shared typecheck +# Expect: exit 0 +pnpm --filter @gitmarks/extension-chrome typecheck +# Expect: exit 0 +pnpm --filter @gitmarks/extension-chrome build +# Expect: clean build, dist/manifest.json + dist/src/popup.html + assets emitted +pnpm --filter @gitmarks/extension-chrome e2e +# Expect: 4 passing, 2 skipped +``` + +If any test fails: the move broke something. Investigate before committing. + +- [ ] **Step 7: Commit** + +```bash +git add -A +git commit -m "refactor: extract extension-shared workspace package + +Moves all of packages/extension-chrome/src (background, popup, options, +lib/) and packages/extension-chrome/test (chrome stub + 97 unit tests) +into a new @gitmarks/extension-shared workspace package. extension-chrome +becomes a thin shell with its own manifest, vite config, and entries +that re-export from extension-shared. + +Why: extension-firefox (issue #23) needs the same source. Without this +refactor we would duplicate ~2k LoC across browsers. + +No behavior change. All 97 unit tests still pass; all 4 Playwright e2e +tests still pass (2 documented skips remain). extension-chrome's +dist/manifest.json is unchanged." +``` + +--- + +### Task 2: Migrate `chrome.*` → `browser.*` via `webextension-polyfill` + +**Files:** +- Modify: every `.ts` file in `packages/extension-shared/src/` and `packages/extension-shared/test/setup.ts` + +**Why:** Firefox exposes `browser.*` natively; Chrome doesn't. `webextension-polyfill` exposes `browser.*` in Chrome via a thin wrapper over `chrome.*`. By switching all code to `browser.*`, the same source runs in both browsers. + +**Strategy:** mechanical find-and-replace, BUT: +- The chrome stub in `test/setup.ts` is stubbing `chrome.*` — it must be reworked to stub `browser.*` instead (or to stub both, since the polyfill bridges them in production). +- The polyfill is async-by-default for callback-style APIs (it converts them to Promises). All our code already uses `await chrome.x.y(...)` so this is a no-op semantically. + +- [ ] **Step 1: Add polyfill import to each entry file** + +`packages/extension-shared/src/background.ts` (at top): +```typescript +import browser from "webextension-polyfill"; +``` + +Same for `popup.ts`, `options.ts`. The import has side effects (registers the polyfill) AND exports the unified `browser` namespace. + +- [ ] **Step 2: Replace `chrome.` with `browser.` across `src/`** + +```bash +# From the repo root: +find packages/extension-shared/src -name '*.ts' -exec \ + sed -i 's/\bchrome\./browser./g' {} + +``` + +Inspect the diff carefully. Some occurrences are in COMMENTS (`// chrome.storage.local`); those don't need to change semantically but it's fine to update them for consistency. Some are TYPE references (`chrome.bookmarks.BookmarkTreeNode`) — those need different handling because `@types/chrome` namespace types aren't auto-mirrored. Resolve type imports via `@types/webextension-polyfill`'s `Browser.Bookmarks.BookmarkTreeNode` etc., OR keep the type imports as `chrome.*` since `@types/webextension-polyfill` re-exports compatibility types. + +For pragmatism: keep `chrome.bookmarks.BookmarkTreeNode` etc. in type positions (they're still valid via @types/chrome), and only change the VALUE positions (`chrome.bookmarks.create(...)` → `browser.bookmarks.create(...)`). Refine the sed if it's too aggressive: + +```bash +# More targeted: replace chrome. only when followed by a callable property +# (bookmarks/storage/runtime/etc.) AND not preceded by an import/type keyword. +# Easier: do the global replace, then revert the type-position regressions. +``` + +Use your judgment after running the find/replace. If types break, add `type Browser = typeof browser;` aliases or use the `@types/webextension-polyfill` type namespace. + +- [ ] **Step 3: Replace the chrome stub with a browser stub in `test/setup.ts`** + +The current stub registers a global `chrome` object. After the polyfill switch, source code reads `browser`. Update the stub to register both — `browser` for the new code paths and `chrome` for any straggler: + +```typescript +// packages/extension-shared/test/setup.ts (excerpt) +const stub = { + storage: { /* ... existing ... */ }, + runtime: { /* ... */ }, + bookmarks: { /* ... */ }, + alarms: { /* ... */ }, + tabs: { /* ... */ }, +}; + +vi.stubGlobal("browser", stub); +vi.stubGlobal("chrome", stub); // safety: any straggler still works +``` + +The `webextension-polyfill` runtime checks `globalThis.browser` first; setting it directly short-circuits the polyfill in tests (which we want — we're testing logic, not the polyfill). + +- [ ] **Step 4: Verify** + +```bash +pnpm --filter @gitmarks/extension-shared typecheck +# Expect: exit 0 +pnpm --filter @gitmarks/extension-shared test +# Expect: 97 tests pass +pnpm --filter @gitmarks/extension-chrome build +# Expect: clean build +pnpm --filter @gitmarks/extension-chrome e2e +# Expect: 4 passing, 2 skipped — same as before +``` + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "refactor(extension-shared): migrate chrome.* → browser.* via webextension-polyfill + +Cross-browser code now uses browser.* uniformly. The polyfill aliases +chrome.* under browser.* in Chrome; Firefox exposes browser.* natively. + +Tests' chrome stub now also exposes itself as 'browser' for the same +reason. No production behavior change in Chrome; this prepares the +source for consumption by extension-firefox (issue #23)." +``` + +--- + +### Task 3: Bootstrap `extension-firefox` package + +**Files:** +- Create: `packages/extension-firefox/package.json` +- Create: `packages/extension-firefox/tsconfig.json` +- Create: `packages/extension-firefox/vite.config.ts` +- Create: `packages/extension-firefox/manifest.json` (literal JSON, not a TS config — crxjs is Chrome-only) +- Create: `packages/extension-firefox/src/background.ts` +- Create: `packages/extension-firefox/src/popup.html` +- Create: `packages/extension-firefox/src/popup.ts` +- Create: `packages/extension-firefox/src/options.html` +- Create: `packages/extension-firefox/src/options.ts` + +- [ ] **Step 1: Create `packages/extension-firefox/package.json`** + +```json +{ + "name": "@gitmarks/extension-firefox", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "vite build && node ./scripts/copy-manifest.mjs", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@gitmarks/core": "workspace:*", + "@gitmarks/extension-shared": "workspace:*" + }, + "devDependencies": { + "@types/webextension-polyfill": "^0.12.0", + "vite": "^5.4.0" + } +} +``` + +- [ ] **Step 2: Create `packages/extension-firefox/tsconfig.json`** + +```json +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["webextension-polyfill", "vite/client"], + "rootDir": "./", + "outDir": "./dist-tsc", + "noEmit": true + }, + "include": ["src/**/*.ts", "vite.config.ts", "scripts/**/*.mjs"] +} +``` + +- [ ] **Step 3: Create `packages/extension-firefox/vite.config.ts`** + +```typescript +import { defineConfig } from "vite"; +import { resolve } from "node:path"; + +export default defineConfig({ + build: { + outDir: "dist", + target: "esnext", + minify: false, + sourcemap: true, + rollupOptions: { + input: { + background: resolve(__dirname, "src/background.ts"), + popup: resolve(__dirname, "src/popup.html"), + options: resolve(__dirname, "src/options.html"), + }, + output: { + entryFileNames: "[name].js", + chunkFileNames: "assets/[name]-[hash].js", + assetFileNames: "[name].[ext]", + }, + }, + }, +}); +``` + +- [ ] **Step 4: Create `packages/extension-firefox/manifest.json`** (note: literal JSON, not TS — Firefox doesn't have crxjs's defineManifest) + +```json +{ + "manifest_version": 3, + "name": "gitmarks", + "version": "0.0.1", + "description": "Save bookmarks to your own GitHub repo.", + "permissions": ["storage", "activeTab", "bookmarks", "alarms"], + "host_permissions": ["https://api.github.com/*"], + "action": { + "default_popup": "popup.html", + "default_title": "gitmarks" + }, + "options_ui": { + "page": "options.html", + "open_in_tab": true + }, + "background": { + "service_worker": "background.js", + "type": "module" + }, + "browser_specific_settings": { + "gecko": { + "id": "gitmarks@paperhurts.dev", + "strict_min_version": "121.0" + } + } +} +``` + +(Note: `options_page` in Chrome's manifest is `options_ui` in Firefox MV3 — same semantic, different field name.) + +- [ ] **Step 5: Create `packages/extension-firefox/scripts/copy-manifest.mjs`** + +Plain Vite doesn't bundle the manifest. We copy it into `dist/` post-build: + +```javascript +// packages/extension-firefox/scripts/copy-manifest.mjs +import { copyFileSync, mkdirSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = resolve(here, ".."); +mkdirSync(resolve(root, "dist"), { recursive: true }); +copyFileSync( + resolve(root, "manifest.json"), + resolve(root, "dist/manifest.json"), +); +console.log("[firefox] copied manifest.json to dist/"); +``` + +- [ ] **Step 6: Create shell entry files** + +`packages/extension-firefox/src/background.ts`: +```typescript +import "@gitmarks/extension-shared/background"; +``` + +`packages/extension-firefox/src/popup.html`: +```html + + + + + gitmarks + + +
loading…
+ + + +``` + +`packages/extension-firefox/src/popup.ts`: +```typescript +import "@gitmarks/extension-shared/popup"; +``` + +`packages/extension-firefox/src/options.html`: +```html + + + + + gitmarks — settings + + +
loading…
+ + + +``` + +`packages/extension-firefox/src/options.ts`: +```typescript +import "@gitmarks/extension-shared/options"; +``` + +- [ ] **Step 7: Install + verify build** + +```bash +pnpm install +pnpm --filter @gitmarks/extension-firefox typecheck +# Expect: exit 0 +pnpm --filter @gitmarks/extension-firefox build +# Expect: vite builds + copy-manifest script runs. +# dist/ should contain: background.js, popup.html, options.html, manifest.json, assets/ +ls packages/extension-firefox/dist/ +# Expect: manifest.json + background.js + popup.html + options.html visible +``` + +- [ ] **Step 8: Commit** + +```bash +git add -A +git commit -m "feat(extension-firefox): bootstrap Firefox MV3 add-on package + +Plain Vite multi-entry build (crxjs is Chrome-only). Manifest is +literal JSON copied into dist/ post-build via scripts/copy-manifest.mjs. + +Targets Firefox 121+ for MV3 service-worker parity. browser_specific_settings +declares the gecko id and strict_min_version. + +Source files are minimal shells that re-export from @gitmarks/extension-shared +— all the actual code (popup, options, background, lib/) is shared with +extension-chrome via that workspace package. + +Closes #23." +``` + +--- + +### Task 4: README + manual smoke test guide + +**Files:** +- Create: `packages/extension-firefox/README.md` + +- [ ] **Step 1: Write the README** + +```markdown +# @gitmarks/extension-firefox + +Firefox MV3 add-on. Save bookmarks to your own GitHub repo + two-way sync +with the native bookmark tree. Functionally identical to the Chrome +extension; both load the same source from `@gitmarks/extension-shared`. + +## Develop + +```bash +pnpm --filter @gitmarks/extension-firefox build +``` + +Then in Firefox 121+: + +1. Go to `about:debugging` → "This Firefox". +2. Click "Load Temporary Add-on…" +3. Select `packages/extension-firefox/dist/manifest.json`. + +The extension loads as temporary — it'll be removed when you quit +Firefox. For permanent installation you'd need to sign with AMO +(deferred per `spec.md`). + +## First-run setup + +Same as the Chrome extension — see +`packages/extension-chrome/README.md` "First-run setup". The popup, +options page, and behavior are identical. + +## Manual smoke test + +The unit test suite (`pnpm --filter @gitmarks/extension-shared test`) +covers all the shared logic that runs in both browsers. The Firefox- +specific bits (manifest, build output, runtime behavior in Firefox's +WebExtensions runtime) need a manual check: + +- [ ] Build, load via `about:debugging`, confirm the toolbar icon + appears and the popup opens. +- [ ] Walk through the Chrome README's "Manual smoke test" sections + ("Popup + toolbar save" and "Native tree sync") in Firefox. + Everything should behave the same. +- [ ] Check `about:debugging` → click "Inspect" on the gitmarks + add-on. The DevTools console should show the service worker + running with no errors. The 5-minute alarm should be visible + under Storage → Extension Storage. + +## Known limitations + +Same as the Chrome extension's "Known limitations" section in +`packages/extension-chrome/README.md`. Notably: +- Folder-delete cascade not handled (documented limitation; issue #2). +- Cross-browser e2e isn't automated; Playwright's Firefox driver + doesn't fully support WebExtensions APIs. The shared unit tests + cover the algorithm; this manual smoke test covers the wiring. +``` + +(Note: replace `` ` `` ` (backtick-space-backtick) sequences in the prose above with real triple backticks.) + +- [ ] **Step 2: Update the root README to mention the Firefox package** + +In `README.md`'s "Packages" table, add a row: + +```markdown +| `@gitmarks/extension-firefox` | Firefox MV3 add-on. Same functionality as Chrome via the shared package. Load via `about:debugging`. | +``` + +And in the roadmap, mark Firefox as done: + +```markdown +- ✅ Firefox build (`webextension-polyfill`) ([#23](https://github.com/paperhurts/gitmarks/issues/23)) +``` + +- [ ] **Step 3: Update CLAUDE.md package list** + +Add a bullet under "Project status": +```markdown +- `@gitmarks/extension-firefox` (`packages/extension-firefox/`) — Firefox MV3 shell over the shared package. Loads via `about:debugging` → "Load Temporary Add-on". +``` + +And note that `@gitmarks/extension-shared` is the canonical owner of the cross-browser code now. + +- [ ] **Step 4: Update `packages/extension-chrome/README.md` to reflect the refactor** + +Change: +```markdown +Chrome MV3 extension. Save bookmarks to your own GitHub repo, and keep +Chrome's native bookmark tree in two-way sync with the JSON file. +``` + +To: +```markdown +Chrome MV3 extension shell. The bulk of the implementation lives in +`@gitmarks/extension-shared`; this package owns only the Chrome-specific +manifest, Vite + crxjs build configuration, and Playwright e2e tests. + +Functionally identical to `@gitmarks/extension-firefox` — both shells +import the same source. +``` + +- [ ] **Step 5: Verify the full suite + commit** + +```bash +pnpm test # extension-shared 97/97 + core 65/65 +pnpm typecheck # all packages clean +pnpm build # all packages emit dist/ +pnpm --filter @gitmarks/extension-chrome e2e # 4 passing, 2 skipped +``` + +```bash +git add -A +git commit -m "docs(extension-firefox): README + cross-reference updates + +- packages/extension-firefox/README.md: load-via-about:debugging workflow, + pointer to the shared smoke-test checklist, known-limitations refs. +- README.md, CLAUDE.md: add the firefox package to the packages list and + mark Firefox build done in the roadmap. +- packages/extension-chrome/README.md: clarify it's now a thin shell." +``` + +--- + +## Self-review summary + +**Spec coverage:** + +| Spec section | Covered by | +|---|---| +| spec.md §"Build order" — Firefox via webextension-polyfill | Tasks 0-3 | +| Issue #23 scope — `extension-firefox` package consuming the same source | Tasks 0-3 | +| webextension-polyfill shim for Chrome-vs-Firefox API differences | Task 2 | +| Adapt popup/options pages | Implicit — they're vanilla HTML + browser.* already | +| Cross-browser test infra | Deferred to follow-up; unit tests in extension-shared cover the shared logic | +| Document dev workflow | Task 4 | + +**Out of scope explicitly (do not implement here):** + +- AMO signing / store distribution +- Playwright e2e for Firefox +- Firefox event-page fallback for pre-121 +- Refactoring the manifest configuration into a shared schema (each browser's manifest is small enough that duplication is fine) + +**Placeholder scan:** none. + +**Type/name consistency:** `@gitmarks/extension-shared` is the package name used uniformly across imports, package.json `dependencies`, and the workspace declarations. The `browser` import is uniform. The `chrome` global still works in tests via dual stubbing. + +**Verification:** by the end of Task 4, the repo has 3 workspace packages: `core` (unchanged, 65 tests), `extension-shared` (new, 97 tests + the chrome stub), `extension-chrome` (thin shell, 4 e2e passing + 2 skipped), `extension-firefox` (thin shell, builds cleanly). All typecheck + build pass. Firefox add-on manually verified via the README's smoke test (the agent executing this plan should run that smoke test if Firefox is available locally; otherwise note it as user-required). diff --git a/packages/extension-chrome/README.md b/packages/extension-chrome/README.md index 86269ae..3f9348f 100644 --- a/packages/extension-chrome/README.md +++ b/packages/extension-chrome/README.md @@ -1,7 +1,12 @@ # @gitmarks/extension-chrome -Chrome MV3 extension. Save bookmarks to your own GitHub repo, and keep -Chrome's native bookmark tree in two-way sync with the JSON file. +Chrome MV3 extension shell. The bulk of the implementation lives in +`@gitmarks/extension-shared`; this package owns only the Chrome-specific +manifest, Vite + `@crxjs/vite-plugin` build configuration, thin entry +files, and the Playwright e2e suite. + +Functionally identical to `@gitmarks/extension-firefox` — both shells +import the same source. ## Develop diff --git a/packages/extension-firefox/README.md b/packages/extension-firefox/README.md new file mode 100644 index 0000000..8689e3f --- /dev/null +++ b/packages/extension-firefox/README.md @@ -0,0 +1,121 @@ +# @gitmarks/extension-firefox + +Firefox MV3 add-on. Save bookmarks to your own GitHub repo + two-way +sync with the native bookmark tree. Functionally identical to the Chrome +extension — both load the same code from `@gitmarks/extension-shared`. + +## Develop + +```bash +pnpm --filter @gitmarks/extension-firefox build +``` + +Then in Firefox 121+: + +1. Go to `about:debugging` → click **This Firefox**. +2. Click **Load Temporary Add-on…**. +3. Select `packages/extension-firefox/dist/manifest.json`. + +The add-on loads as temporary — it'll be removed when you quit Firefox. +For permanent installation you'd need to sign with AMO (deferred per +`spec.md` — ship as developer-mode unpacked first; do AMO review later +if usage justifies it). + +## First-run setup + +Identical to Chrome — see `packages/extension-chrome/README.md` +"First-run setup". The popup, options page, PAT validation, and the +optional **Strip tracking parameters** toggle all behave the same. + +## Manual smoke test + +The unit test suite (`pnpm --filter @gitmarks/extension-shared test`, +96 tests) covers all the shared logic that runs in both browsers. The +Firefox-specific pieces (manifest, build output, runtime behavior in +Firefox's WebExtensions runtime, native `browser.*` vs the polyfilled +version Chrome uses) need a manual check: + +**Load + popup:** + +- [ ] Build, load via `about:debugging` → "Load Temporary Add-on", select + `dist/manifest.json`. The gitmarks toolbar icon appears as a default + puzzle piece (pin it for easy access). +- [ ] Click the icon before configuring → popup shows "Set up gitmarks". + +**Setup flow:** + +- [ ] Click "Set up gitmarks" → options page opens in a new tab. +- [ ] Enter PAT + owner + repo + branch + click Validate. Both green + success outcomes (file exists / file 404-but-repo-found) should + behave identically to the Chrome flow. +- [ ] (Optional) Check "Strip tracking parameters" and Save. + +**Save flow:** + +- [ ] Navigate to any page, click the toolbar icon → "Save this page". + Green "✓ saved" within ~2s. Refresh `bookmarks.json` on + github.com — the new entry appears with `added_from: "chrome@"`. + (The `chrome@` prefix is intentional — the polyfill exposes the + same `browser.*` namespace, but the machine-id helper writes the + prefix that the Chrome extension uses too. We may revisit this if + cross-browser disambiguation matters; for now, all `added_from` + values carry `chrome@` regardless of browser.) + +**Native tree sync:** + +- [ ] Drag any URL to the bookmarks toolbar. Within ~1 second the entry + appears in `bookmarks.json` on GitHub. +- [ ] Right-click the bookmark in Firefox → Edit → change the title. + The remote `title` updates within ~1 second. +- [ ] Delete the bookmark in Firefox. The remote entry gets a + `deleted_at` timestamp (soft delete). +- [ ] Edit `bookmarks.json` directly on GitHub: add a new entry with a + fresh ULID, commit. Within 5 minutes the bookmark appears in + Firefox's bookmarks tree. + +**Trigger an immediate poll** (instead of waiting 5 min): + +1. `about:debugging` → click **Inspect** on the gitmarks add-on. +2. Open the Console tab. +3. Run: `browser.alarms.create("gitmarks:poll", { when: Date.now() + 1000 })`. + +## Known limitations + +Same as the Chrome extension — see the "Known limitations" section in +`packages/extension-chrome/README.md`. Notably: + +- Folder-delete cascade not handled (issue #2; documented). +- Cross-browser e2e isn't automated. Playwright's Firefox driver has + spotty WebExtensions support, especially for service workers; the + shared unit suite covers the algorithms and this manual smoke test + covers the wiring. + +## Architecture + +`@gitmarks/extension-firefox` is a thin shell. The full implementation +lives in `@gitmarks/extension-shared`: + +``` +packages/ +├── core/ # GitHub client, schemas, mutations +├── extension-shared/ # cross-browser source (this is the brain) +│ ├── src/background.ts # SW: listeners + alarm + reconcile +│ ├── src/popup.ts # popup UI + popup-direct save flow +│ ├── src/options.ts # PAT/repo/branch + strip-tracking-params +│ └── src/lib/ # 12 pure-ish modules +├── extension-chrome/ # Chrome shell (manifest + vite-crxjs + e2e) +└── extension-firefox/ # this package: Firefox shell + ├── manifest.json # MV3, gecko id, strict_min_version 121.0 + ├── vite.config.ts # plain Vite multi-entry (no crxjs) + ├── scripts/copy-manifest.mjs # copies manifest.json into dist/ + └── src/ + ├── background.ts # → @gitmarks/extension-shared/background + ├── popup.ts # → @gitmarks/extension-shared/popup + ├── popup.html + ├── options.ts # → @gitmarks/extension-shared/options + └── options.html +``` + +`webextension-polyfill` lets the shared source use `browser.*` +uniformly. Chrome's `chrome.*` is auto-aliased by the polyfill; Firefox +exposes `browser.*` natively. diff --git a/packages/extension-shared/test/webextension-polyfill-stub.ts b/packages/extension-shared/test/webextension-polyfill-stub.ts index eb3efdc..a775d54 100644 --- a/packages/extension-shared/test/webextension-polyfill-stub.ts +++ b/packages/extension-shared/test/webextension-polyfill-stub.ts @@ -12,7 +12,7 @@ * until actual property access, so it works even though setup.ts runs its * vi.stubGlobal calls slightly after module evaluation order. */ -const browserProxy = new Proxy({} as typeof globalThis.browser, { +const browserProxy = new Proxy({} as Record, { get(_target, prop) { const g = (globalThis as Record)["browser"]; if (g != null) {