From 2319efb9df35b759e079df69e5c2c3282576adaf Mon Sep 17 00:00:00 2001 From: Francis Breidenbach <8886566+cheefbird@users.noreply.github.com> Date: Sat, 14 Mar 2026 20:34:40 -0700 Subject: [PATCH 1/8] refactor: scaffold feature architecture foundation - add FeatureDefinition and PageContext types in src/lib/feature-types.ts - rename github.content to grody.content entry point - add page-context module with URL parsing and route predicates - add 19 tests for buildPageContext and all predicates --- .../StatusBanner.svelte | 0 .../StatusBannerHost.svelte | 0 .../StatusPopover.svelte | 0 .../StatusStrip.svelte | 0 .../WorkflowFilter.svelte | 0 .../features/status-indicator.ts | 0 .../features/workflow-filter.test.ts | 0 .../features/workflow-filter.ts | 0 .../index.ts | 0 .../grody.content/page-context.test.ts | 115 ++++++++++++++++++ src/entrypoints/grody.content/page-context.ts | 25 ++++ src/lib/feature-types.ts | 19 +++ 12 files changed, 159 insertions(+) rename src/entrypoints/{github.content => grody.content}/StatusBanner.svelte (100%) rename src/entrypoints/{github.content => grody.content}/StatusBannerHost.svelte (100%) rename src/entrypoints/{github.content => grody.content}/StatusPopover.svelte (100%) rename src/entrypoints/{github.content => grody.content}/StatusStrip.svelte (100%) rename src/entrypoints/{github.content => grody.content}/WorkflowFilter.svelte (100%) rename src/entrypoints/{github.content => grody.content}/features/status-indicator.ts (100%) rename src/entrypoints/{github.content => grody.content}/features/workflow-filter.test.ts (100%) rename src/entrypoints/{github.content => grody.content}/features/workflow-filter.ts (100%) rename src/entrypoints/{github.content => grody.content}/index.ts (100%) create mode 100644 src/entrypoints/grody.content/page-context.test.ts create mode 100644 src/entrypoints/grody.content/page-context.ts create mode 100644 src/lib/feature-types.ts diff --git a/src/entrypoints/github.content/StatusBanner.svelte b/src/entrypoints/grody.content/StatusBanner.svelte similarity index 100% rename from src/entrypoints/github.content/StatusBanner.svelte rename to src/entrypoints/grody.content/StatusBanner.svelte diff --git a/src/entrypoints/github.content/StatusBannerHost.svelte b/src/entrypoints/grody.content/StatusBannerHost.svelte similarity index 100% rename from src/entrypoints/github.content/StatusBannerHost.svelte rename to src/entrypoints/grody.content/StatusBannerHost.svelte diff --git a/src/entrypoints/github.content/StatusPopover.svelte b/src/entrypoints/grody.content/StatusPopover.svelte similarity index 100% rename from src/entrypoints/github.content/StatusPopover.svelte rename to src/entrypoints/grody.content/StatusPopover.svelte diff --git a/src/entrypoints/github.content/StatusStrip.svelte b/src/entrypoints/grody.content/StatusStrip.svelte similarity index 100% rename from src/entrypoints/github.content/StatusStrip.svelte rename to src/entrypoints/grody.content/StatusStrip.svelte diff --git a/src/entrypoints/github.content/WorkflowFilter.svelte b/src/entrypoints/grody.content/WorkflowFilter.svelte similarity index 100% rename from src/entrypoints/github.content/WorkflowFilter.svelte rename to src/entrypoints/grody.content/WorkflowFilter.svelte diff --git a/src/entrypoints/github.content/features/status-indicator.ts b/src/entrypoints/grody.content/features/status-indicator.ts similarity index 100% rename from src/entrypoints/github.content/features/status-indicator.ts rename to src/entrypoints/grody.content/features/status-indicator.ts diff --git a/src/entrypoints/github.content/features/workflow-filter.test.ts b/src/entrypoints/grody.content/features/workflow-filter.test.ts similarity index 100% rename from src/entrypoints/github.content/features/workflow-filter.test.ts rename to src/entrypoints/grody.content/features/workflow-filter.test.ts diff --git a/src/entrypoints/github.content/features/workflow-filter.ts b/src/entrypoints/grody.content/features/workflow-filter.ts similarity index 100% rename from src/entrypoints/github.content/features/workflow-filter.ts rename to src/entrypoints/grody.content/features/workflow-filter.ts diff --git a/src/entrypoints/github.content/index.ts b/src/entrypoints/grody.content/index.ts similarity index 100% rename from src/entrypoints/github.content/index.ts rename to src/entrypoints/grody.content/index.ts diff --git a/src/entrypoints/grody.content/page-context.test.ts b/src/entrypoints/grody.content/page-context.test.ts new file mode 100644 index 0000000..dd6dc89 --- /dev/null +++ b/src/entrypoints/grody.content/page-context.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; +import { + buildPageContext, + isActionsPage, + isIssuePage, + isPRPage, + isRepoPage, +} from "./page-context"; +import type { PageContext } from "@/lib/feature-types"; + +/** Helper: build a PageContext from a pathname. */ +function ctx(pathname: string): PageContext { + return buildPageContext(new URL(`https://github.com${pathname}`)); +} + +describe("buildPageContext", () => { + it("parses owner and repo from a repo URL", () => { + const result = ctx("/owner/repo/actions"); + expect(result.owner).toBe("owner"); + expect(result.repo).toBe("repo"); + expect(result.pathname).toBe("/owner/repo/actions"); + }); + + it("returns undefined owner/repo for root path", () => { + const result = ctx("/"); + expect(result.owner).toBeUndefined(); + expect(result.repo).toBeUndefined(); + }); + + it("returns undefined owner/repo for single-segment path", () => { + const result = ctx("/settings"); + expect(result.owner).toBeUndefined(); + expect(result.repo).toBeUndefined(); + }); + + it("handles trailing slashes on repo path", () => { + const result = ctx("/owner/repo/"); + expect(result.owner).toBe("owner"); + expect(result.repo).toBe("repo"); + }); + + it("parses bare /owner/repo without trailing slash or subpath", () => { + const result = ctx("/owner/repo"); + expect(result.owner).toBe("owner"); + expect(result.repo).toBe("repo"); + }); + + it("preserves the full URL object", () => { + const url = new URL("https://github.com/owner/repo?tab=actions"); + const result = buildPageContext(url); + expect(result.url).toBe(url); + }); +}); + +describe("isActionsPage", () => { + it("matches /owner/repo/actions", () => { + expect(isActionsPage(ctx("/owner/repo/actions"))).toBe(true); + }); + + it("matches /owner/repo/actions/", () => { + expect(isActionsPage(ctx("/owner/repo/actions/"))).toBe(true); + }); + + it("matches /owner/repo/actions/workflows/ci.yml", () => { + expect(isActionsPage(ctx("/owner/repo/actions/workflows/ci.yml"))).toBe( + true, + ); + }); + + it("does not match /owner/repo/pulls", () => { + expect(isActionsPage(ctx("/owner/repo/pulls"))).toBe(false); + }); + + it("does not match /owner/repo/action (no trailing s)", () => { + expect(isActionsPage(ctx("/owner/repo/action"))).toBe(false); + }); +}); + +describe("isRepoPage", () => { + it("matches when owner and repo are present", () => { + expect(isRepoPage(ctx("/owner/repo"))).toBe(true); + }); + + it("does not match root path", () => { + expect(isRepoPage(ctx("/"))).toBe(false); + }); + + it("does not match single-segment path", () => { + expect(isRepoPage(ctx("/owner"))).toBe(false); + }); +}); + +describe("isPRPage", () => { + it("matches /owner/repo/pull/123", () => { + expect(isPRPage(ctx("/owner/repo/pull/123"))).toBe(true); + }); + + it("matches PR subpages like /files", () => { + expect(isPRPage(ctx("/owner/repo/pull/42/files"))).toBe(true); + }); + + it("does not match pulls list", () => { + expect(isPRPage(ctx("/owner/repo/pulls"))).toBe(false); + }); +}); + +describe("isIssuePage", () => { + it("matches /owner/repo/issues/123", () => { + expect(isIssuePage(ctx("/owner/repo/issues/123"))).toBe(true); + }); + + it("does not match issues list", () => { + expect(isIssuePage(ctx("/owner/repo/issues"))).toBe(false); + }); +}); diff --git a/src/entrypoints/grody.content/page-context.ts b/src/entrypoints/grody.content/page-context.ts new file mode 100644 index 0000000..f0e325f --- /dev/null +++ b/src/entrypoints/grody.content/page-context.ts @@ -0,0 +1,25 @@ +import type { PageContext } from "@/lib/feature-types"; + +const REPO_PATTERN = /^\/([^/]+)\/([^/]+)/; + +export function buildPageContext(url: URL): PageContext { + const match = url.pathname.match(REPO_PATTERN); + return { + url, + pathname: url.pathname, + owner: match?.[1], + repo: match?.[2], + }; +} + +export const isActionsPage = (ctx: PageContext) => + /^\/[^/]+\/[^/]+\/actions(\/|$)/.test(ctx.pathname); + +export const isRepoPage = (ctx: PageContext) => + ctx.owner !== undefined && ctx.repo !== undefined; + +export const isPRPage = (ctx: PageContext) => + /^\/[^/]+\/[^/]+\/pull\/\d+/.test(ctx.pathname); + +export const isIssuePage = (ctx: PageContext) => + /^\/[^/]+\/[^/]+\/issues\/\d+/.test(ctx.pathname); diff --git a/src/lib/feature-types.ts b/src/lib/feature-types.ts new file mode 100644 index 0000000..bfc49c6 --- /dev/null +++ b/src/lib/feature-types.ts @@ -0,0 +1,19 @@ +import type { ContentScriptContext } from "wxt/utils/content-script-context"; + +export interface PageContext { + url: URL; + pathname: string; + owner?: string; + repo?: string; +} + +export interface FeatureDefinition { + id: string; + include?: Array<(ctx: PageContext) => boolean>; + exclude?: Array<(ctx: PageContext) => boolean>; + reinitOnNavigation: boolean; + init: ( + ctx: ContentScriptContext, + signal: AbortSignal, + ) => void | Promise; +} From cbe2161baea30f71d97e2c6426d2e0cce166fdd5 Mon Sep 17 00:00:00 2001 From: Francis Breidenbach <8886566+cheefbird@users.noreply.github.com> Date: Sat, 14 Mar 2026 21:05:47 -0700 Subject: [PATCH 2/8] refactor: add feature manager with lifecycle and error isolation - include/exclude predicates, per-feature AbortControllers, SPA nav reinit - teardown wraps abort() in try/catch so one feature can't break others - 13 tests for run, onNavigate, invalidation, teardown isolation - test-setup handles node deferring abort listener errors via nextTick --- .../grody.content/feature-manager.test.ts | 278 ++++++++++++++++++ .../grody.content/feature-manager.ts | 69 +++++ .../grody.content/page-context.test.ts | 2 +- src/test-setup.ts | 7 + vitest.config.ts | 3 + 5 files changed, 358 insertions(+), 1 deletion(-) create mode 100644 src/entrypoints/grody.content/feature-manager.test.ts create mode 100644 src/entrypoints/grody.content/feature-manager.ts create mode 100644 src/test-setup.ts diff --git a/src/entrypoints/grody.content/feature-manager.test.ts b/src/entrypoints/grody.content/feature-manager.test.ts new file mode 100644 index 0000000..6215d73 --- /dev/null +++ b/src/entrypoints/grody.content/feature-manager.test.ts @@ -0,0 +1,278 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ContentScriptContext } from "wxt/utils/content-script-context"; +import type { FeatureDefinition } from "@/lib/feature-types"; +import { createFeatureManager } from "./feature-manager"; + +// Mock buildPageContext so we control the PageContext in tests +vi.mock("./page-context", () => ({ + buildPageContext: vi.fn((url: URL) => ({ + url, + pathname: url.pathname, + owner: url.pathname.split("/")[1] || undefined, + repo: url.pathname.split("/")[2] || undefined, + })), +})); + +function makeFeature( + overrides: Partial = {}, +): FeatureDefinition { + return { + id: "test-feature", + reinitOnNavigation: false, + init: vi.fn(), + ...overrides, + }; +} + +// Minimal mock for ContentScriptContext +function mockCtx() { + const callbacks: Array<() => void> = []; + return { + onInvalidated: (cb: () => void) => { + callbacks.push(cb); + }, + _invalidate: () => { + for (const cb of callbacks) cb(); + }, + } as unknown as ContentScriptContext & { _invalidate: () => void }; +} + +const ACTIONS_URL = new URL("https://github.com/owner/repo/actions"); +const HOME_URL = new URL("https://github.com/"); + +describe("createFeatureManager", () => { + describe("run", () => { + it("initializes features with no include/exclude on any page", async () => { + const feature = makeFeature(); + const ctx = mockCtx(); + const manager = createFeatureManager([feature], ctx); + + await manager.run(); + + expect(feature.init).toHaveBeenCalledOnce(); + }); + + it("does not initialize features when include predicate does not match", async () => { + const isActions = (ctx: { pathname: string }) => + ctx.pathname.includes("/actions"); + const feature = makeFeature({ + id: "actions-only", + include: [isActions], + }); + const ctx = mockCtx(); + const manager = createFeatureManager([feature], ctx); + + // window.location.href in test env is something like http://localhost:3000/ + // So include: [isActions] won't match + await manager.run(); + + expect(feature.init).not.toHaveBeenCalled(); + }); + + it("skips features when exclude predicate matches", async () => { + const always = () => true; + const feature = makeFeature({ + id: "excluded", + exclude: [always], + }); + const ctx = mockCtx(); + const manager = createFeatureManager([feature], ctx); + + await manager.run(); + + expect(feature.init).not.toHaveBeenCalled(); + }); + + it("exclude takes precedence over include", async () => { + const always = () => true; + const feature = makeFeature({ + id: "both", + include: [always], + exclude: [always], + }); + const ctx = mockCtx(); + const manager = createFeatureManager([feature], ctx); + + await manager.run(); + + expect(feature.init).not.toHaveBeenCalled(); + }); + + it("isolates errors — one feature throwing does not prevent others", async () => { + const badFeature = makeFeature({ + id: "bad", + init: vi.fn().mockRejectedValue(new Error("boom")), + }); + const goodFeature = makeFeature({ id: "good" }); + const ctx = mockCtx(); + const manager = createFeatureManager([badFeature, goodFeature], ctx); + + await manager.run(); + + expect(goodFeature.init).toHaveBeenCalledOnce(); + }); + + it("initializes features when run() URL matches include predicate", async () => { + vi.stubGlobal( + "location", + new URL("https://github.com/owner/repo/actions"), + ); + const isActions = (ctx: { pathname: string }) => + ctx.pathname.includes("/actions"); + const feature = makeFeature({ + id: "actions-only", + include: [isActions], + }); + const ctx = mockCtx(); + const manager = createFeatureManager([feature], ctx); + + await manager.run(); + + expect(feature.init).toHaveBeenCalledOnce(); + vi.unstubAllGlobals(); + }); + + it("provides an AbortSignal to each feature init", async () => { + const feature = makeFeature({ + init: vi.fn((_ctx, signal) => { + expect(signal).toBeInstanceOf(AbortSignal); + expect(signal.aborted).toBe(false); + }), + }); + const ctx = mockCtx(); + const manager = createFeatureManager([feature], ctx); + + await manager.run(); + + expect(feature.init).toHaveBeenCalledOnce(); + }); + }); + + describe("onNavigate", () => { + it("re-initializes features with reinitOnNavigation: true", async () => { + const feature = makeFeature({ + id: "reinit", + reinitOnNavigation: true, + }); + const ctx = mockCtx(); + const manager = createFeatureManager([feature], ctx); + + await manager.run(); + expect(feature.init).toHaveBeenCalledOnce(); + + await manager.onNavigate(ACTIONS_URL); + expect(feature.init).toHaveBeenCalledTimes(2); + }); + + it("does NOT re-initialize features with reinitOnNavigation: false", async () => { + let capturedSignal: AbortSignal | null = null; + const feature = makeFeature({ + id: "no-reinit", + reinitOnNavigation: false, + init: vi.fn((_ctx, signal) => { + capturedSignal = signal; + }), + }); + const ctx = mockCtx(); + const manager = createFeatureManager([feature], ctx); + + await manager.run(); + expect(feature.init).toHaveBeenCalledOnce(); + expect(capturedSignal).not.toBeNull(); + const signal = capturedSignal as unknown as AbortSignal; + + await manager.onNavigate(ACTIONS_URL); + expect(feature.init).toHaveBeenCalledOnce(); // still 1 + expect(signal.aborted).toBe(false); // signal preserved + }); + + it("aborts the previous signal before re-initializing", async () => { + let capturedSignal: AbortSignal | null = null; + const feature = makeFeature({ + id: "signal-test", + reinitOnNavigation: true, + init: vi.fn((_ctx, signal) => { + capturedSignal = signal; + }), + }); + const ctx = mockCtx(); + const manager = createFeatureManager([feature], ctx); + + await manager.run(); + expect(capturedSignal).not.toBeNull(); + const firstSignal = capturedSignal as unknown as AbortSignal; + expect(firstSignal.aborted).toBe(false); + + await manager.onNavigate(ACTIONS_URL); + expect(firstSignal.aborted).toBe(true); + }); + + it("evaluates include/exclude against the new URL", async () => { + const isActions = (ctx: { pathname: string }) => + ctx.pathname.includes("/actions"); + const feature = makeFeature({ + id: "actions-only", + include: [isActions], + reinitOnNavigation: true, + }); + const ctx = mockCtx(); + const manager = createFeatureManager([feature], ctx); + + // Navigate to actions page — should init + await manager.onNavigate(ACTIONS_URL); + expect(feature.init).toHaveBeenCalledOnce(); + + // Navigate to home — should NOT init (teardown only) + await manager.onNavigate(HOME_URL); + expect(feature.init).toHaveBeenCalledOnce(); // still 1 + }); + }); + + describe("invalidation", () => { + it("aborts all active signals on context invalidation", async () => { + let capturedSignal: AbortSignal | null = null; + const feature = makeFeature({ + init: vi.fn((_ctx, signal) => { + capturedSignal = signal; + }), + }); + const ctx = mockCtx(); + const manager = createFeatureManager([feature], ctx); + + await manager.run(); + expect(capturedSignal).not.toBeNull(); + const signal = capturedSignal as unknown as AbortSignal; + expect(signal.aborted).toBe(false); + + ctx._invalidate(); + expect(signal.aborted).toBe(true); + }); + }); + + describe("teardown error isolation", () => { + it("does not break other features when an abort handler throws", async () => { + const badFeature = makeFeature({ + id: "bad-teardown", + reinitOnNavigation: true, + init: vi.fn((_ctx, signal) => { + signal.addEventListener("abort", () => { + throw new Error("teardown boom"); + }); + }), + }); + const goodFeature = makeFeature({ + id: "good", + reinitOnNavigation: true, + }); + const ctx = mockCtx(); + const manager = createFeatureManager([badFeature, goodFeature], ctx); + + await manager.run(); + // Navigate — triggers teardown of bad feature, then re-init of both + await manager.onNavigate(ACTIONS_URL); + + // Good feature should still have been re-initialized + expect(goodFeature.init).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/src/entrypoints/grody.content/feature-manager.ts b/src/entrypoints/grody.content/feature-manager.ts new file mode 100644 index 0000000..d020c6b --- /dev/null +++ b/src/entrypoints/grody.content/feature-manager.ts @@ -0,0 +1,69 @@ +import type { ContentScriptContext } from "wxt/utils/content-script-context"; +import type { FeatureDefinition, PageContext } from "@/lib/feature-types"; +import { buildPageContext } from "./page-context"; + +export function createFeatureManager( + features: FeatureDefinition[], + ctx: ContentScriptContext, +) { + const activeControllers = new Map(); + + function shouldRun( + feature: FeatureDefinition, + pageCtx: PageContext, + ): boolean { + if (feature.exclude?.some((fn) => fn(pageCtx))) return false; + if (feature.include && !feature.include.some((fn) => fn(pageCtx))) + return false; + return true; + } + + function teardown(featureId: string) { + try { + activeControllers.get(featureId)?.abort(); + } catch (err) { + console.error(`[grody] feature "${featureId}" teardown error:`, err); + } + activeControllers.delete(featureId); + } + + async function initFeature(feature: FeatureDefinition, pageCtx: PageContext) { + teardown(feature.id); + + if (!shouldRun(feature, pageCtx)) return; + + const controller = new AbortController(); + activeControllers.set(feature.id, controller); + + try { + await feature.init(ctx, controller.signal); + } catch (err) { + console.error(`[grody] feature "${feature.id}" failed to init:`, err); + teardown(feature.id); + } + } + + async function run() { + const href = + typeof location !== "undefined" ? location.href : "http://localhost/"; + const pageCtx = buildPageContext(new URL(href)); + await Promise.allSettled(features.map((f) => initFeature(f, pageCtx))); + } + + async function onNavigate(url: URL) { + const pageCtx = buildPageContext(url); + await Promise.allSettled( + features + .filter((f) => f.reinitOnNavigation) + .map((f) => initFeature(f, pageCtx)), + ); + } + + ctx.onInvalidated(() => { + for (const id of [...activeControllers.keys()]) { + teardown(id); + } + }); + + return { run, onNavigate }; +} diff --git a/src/entrypoints/grody.content/page-context.test.ts b/src/entrypoints/grody.content/page-context.test.ts index dd6dc89..268caa3 100644 --- a/src/entrypoints/grody.content/page-context.test.ts +++ b/src/entrypoints/grody.content/page-context.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import type { PageContext } from "@/lib/feature-types"; import { buildPageContext, isActionsPage, @@ -6,7 +7,6 @@ import { isPRPage, isRepoPage, } from "./page-context"; -import type { PageContext } from "@/lib/feature-types"; /** Helper: build a PageContext from a pathname. */ function ctx(pathname: string): PageContext { diff --git a/src/test-setup.ts b/src/test-setup.ts new file mode 100644 index 0000000..47ecbff --- /dev/null +++ b/src/test-setup.ts @@ -0,0 +1,7 @@ +// node defers errors from abort signal listeners via nextTick instead of +// rethrowing synchronously (browsers rethrow). suppress these so teardown +// isolation tests pass cleanly. rethrow anything else. +process.on("uncaughtException", (err) => { + if (err instanceof Error && err.stack?.includes("abort_controller")) return; + throw err; +}); diff --git a/vitest.config.ts b/vitest.config.ts index 9b324c7..6efdd3f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,4 +3,7 @@ import { WxtVitest } from "wxt/testing/vitest-plugin"; export default defineConfig({ plugins: [WxtVitest()], + test: { + setupFiles: ["./src/test-setup.ts"], + }, }); From 255fea32f18f4956f223d430605592f76ee4af70 Mon Sep 17 00:00:00 2001 From: Francis Breidenbach <8886566+cheefbird@users.noreply.github.com> Date: Sat, 14 Mar 2026 21:15:04 -0700 Subject: [PATCH 3/8] refactor: migrate features to FeatureDefinition with auto-discovery - co-locate svelte components inside their feature directories - rewrite feature init files as typed FeatureDefinition exports - entry point uses import.meta.glob for zero-touch feature registration --- .../features/status-indicator.ts | 39 ---------- .../status-indicator}/StatusBanner.svelte | 0 .../status-indicator}/StatusBannerHost.svelte | 0 .../status-indicator}/StatusPopover.svelte | 0 .../status-indicator}/StatusStrip.svelte | 0 .../features/status-indicator/index.ts | 45 ++++++++++++ .../grody.content/features/workflow-filter.ts | 73 ------------------- .../workflow-filter}/WorkflowFilter.svelte | 0 .../features/workflow-filter/index.ts | 66 +++++++++++++++++ src/entrypoints/grody.content/index.ts | 22 ++++-- 10 files changed, 126 insertions(+), 119 deletions(-) delete mode 100644 src/entrypoints/grody.content/features/status-indicator.ts rename src/entrypoints/grody.content/{ => features/status-indicator}/StatusBanner.svelte (100%) rename src/entrypoints/grody.content/{ => features/status-indicator}/StatusBannerHost.svelte (100%) rename src/entrypoints/grody.content/{ => features/status-indicator}/StatusPopover.svelte (100%) rename src/entrypoints/grody.content/{ => features/status-indicator}/StatusStrip.svelte (100%) create mode 100644 src/entrypoints/grody.content/features/status-indicator/index.ts delete mode 100644 src/entrypoints/grody.content/features/workflow-filter.ts rename src/entrypoints/grody.content/{ => features/workflow-filter}/WorkflowFilter.svelte (100%) create mode 100644 src/entrypoints/grody.content/features/workflow-filter/index.ts diff --git a/src/entrypoints/grody.content/features/status-indicator.ts b/src/entrypoints/grody.content/features/status-indicator.ts deleted file mode 100644 index 7c78b05..0000000 --- a/src/entrypoints/grody.content/features/status-indicator.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { mount, unmount } from "svelte"; -import type { ContentScriptContext } from "#imports"; -import { enabledStorage } from "@/lib/github-status"; -import StatusBannerHost from "../StatusBannerHost.svelte"; - -function findHeader(): HTMLElement | null { - return document.querySelector( - "header.AppHeader, header[class*='appHeader'], header.HeaderMktg, div.Header", - ); -} - -export async function initStatusIndicator(ctx: ContentScriptContext) { - const enabled = await enabledStorage.getValue(); - if (!enabled) return; - - const header = findHeader(); - if (!header) return; - - const existing = document.getElementById("grody-github-status"); - if (existing) existing.remove(); - - const container = document.createElement("div"); - container.id = "grody-github-status"; - container.style.position = "relative"; - container.style.zIndex = "33"; - header.after(container); - - let app: ReturnType | null = mount(StatusBannerHost, { - target: container, - }); - - ctx.onInvalidated(() => { - if (app) { - unmount(app); - app = null; - } - container.remove(); - }); -} diff --git a/src/entrypoints/grody.content/StatusBanner.svelte b/src/entrypoints/grody.content/features/status-indicator/StatusBanner.svelte similarity index 100% rename from src/entrypoints/grody.content/StatusBanner.svelte rename to src/entrypoints/grody.content/features/status-indicator/StatusBanner.svelte diff --git a/src/entrypoints/grody.content/StatusBannerHost.svelte b/src/entrypoints/grody.content/features/status-indicator/StatusBannerHost.svelte similarity index 100% rename from src/entrypoints/grody.content/StatusBannerHost.svelte rename to src/entrypoints/grody.content/features/status-indicator/StatusBannerHost.svelte diff --git a/src/entrypoints/grody.content/StatusPopover.svelte b/src/entrypoints/grody.content/features/status-indicator/StatusPopover.svelte similarity index 100% rename from src/entrypoints/grody.content/StatusPopover.svelte rename to src/entrypoints/grody.content/features/status-indicator/StatusPopover.svelte diff --git a/src/entrypoints/grody.content/StatusStrip.svelte b/src/entrypoints/grody.content/features/status-indicator/StatusStrip.svelte similarity index 100% rename from src/entrypoints/grody.content/StatusStrip.svelte rename to src/entrypoints/grody.content/features/status-indicator/StatusStrip.svelte diff --git a/src/entrypoints/grody.content/features/status-indicator/index.ts b/src/entrypoints/grody.content/features/status-indicator/index.ts new file mode 100644 index 0000000..d656ec8 --- /dev/null +++ b/src/entrypoints/grody.content/features/status-indicator/index.ts @@ -0,0 +1,45 @@ +import { mount, unmount } from "svelte"; +import type { FeatureDefinition } from "@/lib/feature-types"; +import { enabledStorage } from "@/lib/github-status"; +import StatusBannerHost from "./StatusBannerHost.svelte"; + +const definition: FeatureDefinition = { + id: "status-indicator", + reinitOnNavigation: false, + async init(_ctx, signal) { + const enabled = await enabledStorage.getValue(); + if (!enabled || signal.aborted) return; + + const header = document.querySelector( + "header.AppHeader, header[class*='appHeader'], header.HeaderMktg, div.Header", + ); + if (!header) return; + + const existing = document.getElementById("grody-github-status"); + if (existing) existing.remove(); + + const container = document.createElement("div"); + container.id = "grody-github-status"; + container.style.position = "relative"; + container.style.zIndex = "33"; + header.after(container); + + let app: ReturnType | null = mount(StatusBannerHost, { + target: container, + }); + + signal.addEventListener("abort", () => { + try { + if (app) { + unmount(app); + app = null; + } + } catch (err) { + console.error("[grody] status-indicator unmount error:", err); + } + container.remove(); + }); + }, +}; + +export default definition; diff --git a/src/entrypoints/grody.content/features/workflow-filter.ts b/src/entrypoints/grody.content/features/workflow-filter.ts deleted file mode 100644 index 10c66cc..0000000 --- a/src/entrypoints/grody.content/features/workflow-filter.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { mount, unmount } from "svelte"; -import type { ContentScriptContext } from "#imports"; -import WorkflowFilter from "../WorkflowFilter.svelte"; - -const SIDEBAR_NAV_SELECTOR = 'nav[aria-label="Actions Workflows"] ul'; -const SHOW_MORE_SELECTOR = '[data-action*="nav-list-group#showMore"]'; -export const ACTIONS_PATTERN = /^\/[^/]+\/[^/]+\/actions(\/|$)/; - -export function parseRepo( - pathname: string = location.pathname, -): { owner: string; repo: string } | null { - const parts = pathname.split("/").filter(Boolean); - if (parts.length < 2) return null; - return { owner: parts[0], repo: parts[1] }; -} - -function findNavList(): HTMLElement | null { - if (!ACTIONS_PATTERN.test(location.pathname)) return null; - - const navList = document.querySelector(SIDEBAR_NAV_SELECTOR); - if (!navList) return null; - const showMore = navList - .closest("nav") - ?.querySelector(SHOW_MORE_SELECTOR); - if (!showMore) return null; - const totalPages = Number(showMore.dataset.totalPages ?? "1"); - if (totalPages <= 1) return null; - return navList; -} - -let currentApp: ReturnType | null = null; -let currentContainer: HTMLElement | null = null; - -function teardown() { - if (currentApp) { - unmount(currentApp); - currentApp = null; - } - if (currentContainer) { - currentContainer.remove(); - currentContainer = null; - } -} - -export function initWorkflowFilter(_ctx: ContentScriptContext) { - teardown(); - - const navList = findNavList(); - if (!navList) return; - - const repoInfo = parseRepo(); - if (!repoInfo) return; - - // Find the workflows section to insert before it - const workflowsSection = navList.querySelector( - ":scope > li:has(nav-list-group)", - ); - if (!workflowsSection) return; - - // Mount directly into the DOM at the right position - const container = document.createElement("div"); - workflowsSection.before(container); - currentContainer = container; - - currentApp = mount(WorkflowFilter, { - target: container, - props: { - owner: repoInfo.owner, - repo: repoInfo.repo, - navList, - }, - }); -} diff --git a/src/entrypoints/grody.content/WorkflowFilter.svelte b/src/entrypoints/grody.content/features/workflow-filter/WorkflowFilter.svelte similarity index 100% rename from src/entrypoints/grody.content/WorkflowFilter.svelte rename to src/entrypoints/grody.content/features/workflow-filter/WorkflowFilter.svelte diff --git a/src/entrypoints/grody.content/features/workflow-filter/index.ts b/src/entrypoints/grody.content/features/workflow-filter/index.ts new file mode 100644 index 0000000..4152479 --- /dev/null +++ b/src/entrypoints/grody.content/features/workflow-filter/index.ts @@ -0,0 +1,66 @@ +import { mount, unmount } from "svelte"; +import type { FeatureDefinition } from "@/lib/feature-types"; +import { isActionsPage } from "../../page-context"; +import WorkflowFilter from "./WorkflowFilter.svelte"; + +const SIDEBAR_NAV_SELECTOR = 'nav[aria-label="Actions Workflows"] ul'; +const SHOW_MORE_SELECTOR = '[data-action*="nav-list-group#showMore"]'; + +function findNavList(): HTMLElement | null { + const navList = document.querySelector(SIDEBAR_NAV_SELECTOR); + if (!navList) return null; + const showMore = navList + .closest("nav") + ?.querySelector(SHOW_MORE_SELECTOR); + if (!showMore) return null; + const totalPages = Number(showMore.dataset.totalPages ?? "1"); + if (totalPages <= 1) return null; + return navList; +} + +function parseRepo(): { owner: string; repo: string } | null { + const parts = location.pathname.split("/").filter(Boolean); + if (parts.length < 2) return null; + return { owner: parts[0], repo: parts[1] }; +} + +const definition: FeatureDefinition = { + id: "workflow-filter", + include: [isActionsPage], + reinitOnNavigation: true, + init(_ctx, signal) { + const navList = findNavList(); + if (!navList) return; + + const repoInfo = parseRepo(); + if (!repoInfo) return; + + const workflowsSection = navList.querySelector( + ":scope > li:has(nav-list-group)", + ); + if (!workflowsSection) return; + + const container = document.createElement("div"); + workflowsSection.before(container); + + const app = mount(WorkflowFilter, { + target: container, + props: { + owner: repoInfo.owner, + repo: repoInfo.repo, + navList, + }, + }); + + signal.addEventListener("abort", () => { + try { + unmount(app); + } catch (err) { + console.error("[grody] workflow-filter unmount error:", err); + } + container.remove(); + }); + }, +}; + +export default definition; diff --git a/src/entrypoints/grody.content/index.ts b/src/entrypoints/grody.content/index.ts index 71cf434..ea01321 100644 --- a/src/entrypoints/grody.content/index.ts +++ b/src/entrypoints/grody.content/index.ts @@ -1,15 +1,23 @@ -import { initStatusIndicator } from "./features/status-indicator"; -import { initWorkflowFilter } from "./features/workflow-filter"; +import type { FeatureDefinition } from "@/lib/feature-types"; +import { createFeatureManager } from "./feature-manager"; + +const modules = import.meta.glob<{ default: FeatureDefinition }>( + "./features/*/index.ts", + { eager: true }, +); + +const features = Object.values(modules).map((m) => m.default); export default defineContentScript({ matches: ["*://github.com/*"], - main(ctx) { - initStatusIndicator(ctx); + async main(ctx) { + const manager = createFeatureManager(features, ctx); + + await manager.run(); - initWorkflowFilter(ctx); - ctx.addEventListener(window, "wxt:locationchange", () => { - initWorkflowFilter(ctx); + ctx.addEventListener(window, "wxt:locationchange", ({ newUrl }) => { + manager.onNavigate(newUrl).catch(console.error); }); }, }); From f93ba2a8b24ba1c51a4f6f90675b07a4fe886558 Mon Sep 17 00:00:00 2001 From: Francis Breidenbach <8886566+cheefbird@users.noreply.github.com> Date: Sat, 14 Mar 2026 21:23:13 -0700 Subject: [PATCH 4/8] chore: clean up comments, try/catch and delete migrated test file - remove redundant try/catch around unmount in feature abort handlers - delete old workflow-filter.test.ts (migrated to page-context) --- .../grody.content/feature-manager.test.ts | 8 --- .../features/status-indicator/index.ts | 10 ++-- .../features/workflow-filter.test.ts | 51 ------------------- .../features/workflow-filter/index.ts | 6 +-- .../grody.content/page-context.test.ts | 1 - 5 files changed, 4 insertions(+), 72 deletions(-) delete mode 100644 src/entrypoints/grody.content/features/workflow-filter.test.ts diff --git a/src/entrypoints/grody.content/feature-manager.test.ts b/src/entrypoints/grody.content/feature-manager.test.ts index 6215d73..168fb26 100644 --- a/src/entrypoints/grody.content/feature-manager.test.ts +++ b/src/entrypoints/grody.content/feature-manager.test.ts @@ -3,7 +3,6 @@ import type { ContentScriptContext } from "wxt/utils/content-script-context"; import type { FeatureDefinition } from "@/lib/feature-types"; import { createFeatureManager } from "./feature-manager"; -// Mock buildPageContext so we control the PageContext in tests vi.mock("./page-context", () => ({ buildPageContext: vi.fn((url: URL) => ({ url, @@ -24,7 +23,6 @@ function makeFeature( }; } -// Minimal mock for ContentScriptContext function mockCtx() { const callbacks: Array<() => void> = []; return { @@ -62,8 +60,6 @@ describe("createFeatureManager", () => { const ctx = mockCtx(); const manager = createFeatureManager([feature], ctx); - // window.location.href in test env is something like http://localhost:3000/ - // So include: [isActions] won't match await manager.run(); expect(feature.init).not.toHaveBeenCalled(); @@ -218,11 +214,9 @@ describe("createFeatureManager", () => { const ctx = mockCtx(); const manager = createFeatureManager([feature], ctx); - // Navigate to actions page — should init await manager.onNavigate(ACTIONS_URL); expect(feature.init).toHaveBeenCalledOnce(); - // Navigate to home — should NOT init (teardown only) await manager.onNavigate(HOME_URL); expect(feature.init).toHaveBeenCalledOnce(); // still 1 }); @@ -268,10 +262,8 @@ describe("createFeatureManager", () => { const manager = createFeatureManager([badFeature, goodFeature], ctx); await manager.run(); - // Navigate — triggers teardown of bad feature, then re-init of both await manager.onNavigate(ACTIONS_URL); - // Good feature should still have been re-initialized expect(goodFeature.init).toHaveBeenCalledTimes(2); }); }); diff --git a/src/entrypoints/grody.content/features/status-indicator/index.ts b/src/entrypoints/grody.content/features/status-indicator/index.ts index d656ec8..4a26f43 100644 --- a/src/entrypoints/grody.content/features/status-indicator/index.ts +++ b/src/entrypoints/grody.content/features/status-indicator/index.ts @@ -29,13 +29,9 @@ const definition: FeatureDefinition = { }); signal.addEventListener("abort", () => { - try { - if (app) { - unmount(app); - app = null; - } - } catch (err) { - console.error("[grody] status-indicator unmount error:", err); + if (app) { + unmount(app); + app = null; } container.remove(); }); diff --git a/src/entrypoints/grody.content/features/workflow-filter.test.ts b/src/entrypoints/grody.content/features/workflow-filter.test.ts deleted file mode 100644 index 26e0015..0000000 --- a/src/entrypoints/grody.content/features/workflow-filter.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -// Mock the Svelte component import so the module can be loaded in Node -vi.mock("../WorkflowFilter.svelte", () => ({ default: {} })); - -import { ACTIONS_PATTERN, parseRepo } from "./workflow-filter"; - -describe("ACTIONS_PATTERN", () => { - it("matches /owner/repo/actions", () => { - expect(ACTIONS_PATTERN.test("/owner/repo/actions")).toBe(true); - }); - - it("matches /owner/repo/actions/", () => { - expect(ACTIONS_PATTERN.test("/owner/repo/actions/")).toBe(true); - }); - - it("matches /owner/repo/actions/workflows/ci.yml", () => { - expect(ACTIONS_PATTERN.test("/owner/repo/actions/workflows/ci.yml")).toBe( - true, - ); - }); - - it("does not match /owner/repo/pulls", () => { - expect(ACTIONS_PATTERN.test("/owner/repo/pulls")).toBe(false); - }); - - it("does not match /owner/repo/action (no trailing s)", () => { - expect(ACTIONS_PATTERN.test("/owner/repo/action")).toBe(false); - }); -}); - -describe("parseRepo", () => { - it("parses /owner/repo/actions correctly", () => { - expect(parseRepo("/owner/repo/actions")).toEqual({ - owner: "owner", - repo: "repo", - }); - }); - - it("handles trailing slashes", () => { - expect(parseRepo("/owner/repo/")).toEqual({ owner: "owner", repo: "repo" }); - }); - - it("returns null for paths with fewer than 2 segments", () => { - expect(parseRepo("/owner")).toBeNull(); - }); - - it("returns null for root path", () => { - expect(parseRepo("/")).toBeNull(); - }); -}); diff --git a/src/entrypoints/grody.content/features/workflow-filter/index.ts b/src/entrypoints/grody.content/features/workflow-filter/index.ts index 4152479..7cc57a6 100644 --- a/src/entrypoints/grody.content/features/workflow-filter/index.ts +++ b/src/entrypoints/grody.content/features/workflow-filter/index.ts @@ -53,11 +53,7 @@ const definition: FeatureDefinition = { }); signal.addEventListener("abort", () => { - try { - unmount(app); - } catch (err) { - console.error("[grody] workflow-filter unmount error:", err); - } + unmount(app); container.remove(); }); }, diff --git a/src/entrypoints/grody.content/page-context.test.ts b/src/entrypoints/grody.content/page-context.test.ts index 268caa3..83665e9 100644 --- a/src/entrypoints/grody.content/page-context.test.ts +++ b/src/entrypoints/grody.content/page-context.test.ts @@ -8,7 +8,6 @@ import { isRepoPage, } from "./page-context"; -/** Helper: build a PageContext from a pathname. */ function ctx(pathname: string): PageContext { return buildPageContext(new URL(`https://github.com${pathname}`)); } From 10d160e2127a7fdd76c3ca8ae6b5403cabf43d80 Mon Sep 17 00:00:00 2001 From: Francis Breidenbach <8886566+cheefbird@users.noreply.github.com> Date: Sat, 14 Mar 2026 22:14:05 -0700 Subject: [PATCH 5/8] fix: tighten error handling - scope test-setup handler to only suppress "teardown boom" by message - add [grody] prefix to onNavigate catch for log attribution - add null guard on workflow-filter unmount to match status-indicator pattern - add dev-only duplicate feature ID detection in feature manager - remove location fallback from production code, stub in tests instead --- .../grody.content/feature-manager.test.ts | 11 +++++++++-- src/entrypoints/grody.content/feature-manager.ts | 12 +++++++++--- .../grody.content/features/workflow-filter/index.ts | 7 +++++-- src/entrypoints/grody.content/index.ts | 4 +++- src/test-setup.ts | 8 ++++---- 5 files changed, 30 insertions(+), 12 deletions(-) diff --git a/src/entrypoints/grody.content/feature-manager.test.ts b/src/entrypoints/grody.content/feature-manager.test.ts index 168fb26..306ef90 100644 --- a/src/entrypoints/grody.content/feature-manager.test.ts +++ b/src/entrypoints/grody.content/feature-manager.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ContentScriptContext } from "wxt/utils/content-script-context"; import type { FeatureDefinition } from "@/lib/feature-types"; import { createFeatureManager } from "./feature-manager"; @@ -39,6 +39,14 @@ const ACTIONS_URL = new URL("https://github.com/owner/repo/actions"); const HOME_URL = new URL("https://github.com/"); describe("createFeatureManager", () => { + beforeEach(() => { + vi.stubGlobal("location", HOME_URL); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + describe("run", () => { it("initializes features with no include/exclude on any page", async () => { const feature = makeFeature(); @@ -125,7 +133,6 @@ describe("createFeatureManager", () => { await manager.run(); expect(feature.init).toHaveBeenCalledOnce(); - vi.unstubAllGlobals(); }); it("provides an AbortSignal to each feature init", async () => { diff --git a/src/entrypoints/grody.content/feature-manager.ts b/src/entrypoints/grody.content/feature-manager.ts index d020c6b..aa21f0a 100644 --- a/src/entrypoints/grody.content/feature-manager.ts +++ b/src/entrypoints/grody.content/feature-manager.ts @@ -6,6 +6,14 @@ export function createFeatureManager( features: FeatureDefinition[], ctx: ContentScriptContext, ) { + if (import.meta.env.DEV) { + const ids = features.map((f) => f.id); + const dupes = ids.filter((id, i) => ids.indexOf(id) !== i); + if (dupes.length > 0) { + console.error(`[grody] duplicate feature IDs: ${dupes.join(", ")}`); + } + } + const activeControllers = new Map(); function shouldRun( @@ -44,9 +52,7 @@ export function createFeatureManager( } async function run() { - const href = - typeof location !== "undefined" ? location.href : "http://localhost/"; - const pageCtx = buildPageContext(new URL(href)); + const pageCtx = buildPageContext(new URL(location.href)); await Promise.allSettled(features.map((f) => initFeature(f, pageCtx))); } diff --git a/src/entrypoints/grody.content/features/workflow-filter/index.ts b/src/entrypoints/grody.content/features/workflow-filter/index.ts index 7cc57a6..bd057a4 100644 --- a/src/entrypoints/grody.content/features/workflow-filter/index.ts +++ b/src/entrypoints/grody.content/features/workflow-filter/index.ts @@ -43,7 +43,7 @@ const definition: FeatureDefinition = { const container = document.createElement("div"); workflowsSection.before(container); - const app = mount(WorkflowFilter, { + let app: ReturnType | null = mount(WorkflowFilter, { target: container, props: { owner: repoInfo.owner, @@ -53,7 +53,10 @@ const definition: FeatureDefinition = { }); signal.addEventListener("abort", () => { - unmount(app); + if (app) { + unmount(app); + app = null; + } container.remove(); }); }, diff --git a/src/entrypoints/grody.content/index.ts b/src/entrypoints/grody.content/index.ts index ea01321..d0fbcfa 100644 --- a/src/entrypoints/grody.content/index.ts +++ b/src/entrypoints/grody.content/index.ts @@ -17,7 +17,9 @@ export default defineContentScript({ await manager.run(); ctx.addEventListener(window, "wxt:locationchange", ({ newUrl }) => { - manager.onNavigate(newUrl).catch(console.error); + manager.onNavigate(newUrl).catch((err) => { + console.error("[grody] navigation handler failed:", err); + }); }); }, }); diff --git a/src/test-setup.ts b/src/test-setup.ts index 47ecbff..f619717 100644 --- a/src/test-setup.ts +++ b/src/test-setup.ts @@ -1,7 +1,7 @@ -// node defers errors from abort signal listeners via nextTick instead of -// rethrowing synchronously (browsers rethrow). suppress these so teardown -// isolation tests pass cleanly. rethrow anything else. +// node defers abort listener errors via nextTick, so they fire after test +// lifecycle hooks complete. suppress only the intentional "teardown boom" +// error from the feature-manager teardown isolation test. process.on("uncaughtException", (err) => { - if (err instanceof Error && err.stack?.includes("abort_controller")) return; + if (err instanceof Error && err.message === "teardown boom") return; throw err; }); From b2644b35d2a77bcd5f70c86345b73c3258eec2e3 Mon Sep 17 00:00:00 2001 From: Francis Breidenbach <8886566+cheefbird@users.noreply.github.com> Date: Sat, 14 Mar 2026 22:46:41 -0700 Subject: [PATCH 6/8] feat(github-status): disable because feature is optional --- src/lib/github-status.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/github-status.ts b/src/lib/github-status.ts index a4164a4..47fd38a 100644 --- a/src/lib/github-status.ts +++ b/src/lib/github-status.ts @@ -46,7 +46,7 @@ export const pollIntervalStorage = storage.defineItem( export const enabledStorage = storage.defineItem( "local:github-status:enabled", - { fallback: true }, + { fallback: false }, ); export const collapsedStorage = storage.defineItem( From 53a15bfea3123d9820edf42aad01a4f6caa7f538 Mon Sep 17 00:00:00 2001 From: Francis Breidenbach <8886566+cheefbird@users.noreply.github.com> Date: Sat, 14 Mar 2026 22:47:05 -0700 Subject: [PATCH 7/8] docs: update readme with latest info --- README.md | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7a98509..057cd31 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,9 @@ GitHub's UI is fine. Until it isn't. If you've ever scrolled through a sidebar o Grody GitHub is a Chrome extension that bolts small, targeted fixes onto GitHub's interface. One feature at a time. No bloat. -## Features +## Core Features + +Core features are enabled by default and may/may not have an option to disable. Requests to change this are always welcome, just create a discussion first. ### Workflow Sidebar Filter @@ -25,9 +27,22 @@ Adds a search box to the Actions sidebar so you can actually find your workflows - Works with dark theme - Handles repos with hundreds of workflows without breaking a sweat -## Install +### Optional Features + +Optional features are opt-in only. You've gotta go to the options to turn it on. + +### GitHub Incident Status + +GitHub is basically always having an incident these days. This puts a banner below the header so you know before you start blaming your code. + +- Opt-in by enabling from the options screen +- Polls GitHub's status API in the background — defaults to 15 min, but you can change it in options +- Color-coded severity with a details popover showing per-component breakdown +- Dismissible, remembers your preference, auto-clears on resolve + +## Install Locally -Not on the Chrome Web Store yet. For now, sideload it: +If you want to contribute, or just build the extension from source for whatever reason, here you go: ```bash git clone https://github.com/cheefbird/grody-github.git @@ -41,7 +56,7 @@ Then in Chrome: 1. Go to `chrome://extensions` 2. Enable **Developer mode** (top right) 3. Click **Load unpacked** -4. Select the `.output/chrome-mv3` directory +4. Select the `~/path/to/grody-github/.output/chrome-mv3` directory from the repo root on your machine ## Setup @@ -58,6 +73,9 @@ Then in Chrome: ```bash pnpm dev # Dev server with hot reload (Chrome) pnpm dev:firefox # Dev server (Firefox) +pnpm format # Biome formatter +pnpm lint # Biome linter +pnpm biocheck # Biome check (format + lint) pnpm check # Svelte type checking ``` From 9da83b1d97787706a891c15452cef513f25b0e17 Mon Sep 17 00:00:00 2001 From: Francis Breidenbach <8886566+cheefbird@users.noreply.github.com> Date: Sat, 14 Mar 2026 23:01:12 -0700 Subject: [PATCH 8/8] chore: docs cleanup --- CLAUDE.md | 97 ------------------------------------------------------- 1 file changed, 97 deletions(-) delete mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index cf50fd2..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,97 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -Browser extension built with **WXT 0.20** (extension framework) + **Svelte 5** + **TypeScript**, targeting Chrome MV3 with Firefox support. - -## Commands - -```bash -pnpm dev # Dev server with hot reload (Chrome) -pnpm dev:firefox # Dev server (Firefox) -pnpm build # Production build (Chrome) -pnpm build:firefox # Production build (Firefox) -pnpm zip # Create distribution ZIP -pnpm format # Biome formatter -pnpm lint # Biome linter -pnpm check # Svelte type checking (svelte-check) -pnpm test # Run tests (vitest) -pnpm test:watch # Run tests in watch mode -``` - -## Architecture - -### Entry Points - -WXT auto-discovers entry points from `src/entrypoints/`. Each file/directory becomes part of the extension manifest automatically: - -- **`background.ts`** — Service worker. Uses `defineBackground()` wrapper. Handles message passing from content scripts. -- **`github.content/`** — Content script for GitHub pages. Uses `defineContentScript()` with match patterns. Re-runs features on SPA navigation via `wxt:locationchange`. - - **`features/`** — Each feature exports an `init()` function that checks page context and mounts Svelte components as needed. -- **`popup/`** — Browser action popup UI. `index.html` → `main.ts` → mounts Svelte `App.svelte`. -- **`options/`** — Extension options page. Same pattern as popup. - -### Shared Code - -- `src/lib/` — Shared utilities (`github-api.ts`, `storage.ts`, `messages.ts`, `types.ts`) and Svelte components - -### Global Imports - -WXT auto-injects these globally (no import needed): - -- `browser` — WebExtensions API -- `storage` — WXT storage utilities -- `defineBackground`, `defineContentScript` — Entry point wrappers -- `createShadowRootUi`, `createIntegratedUi`, `createIframeUi` — UI helpers for content scripts - -### Path Aliases - -- `@` / `~` → `src/` -- `@@` / `~~` → project root - -### Environment Variables - -Available via `import.meta.env`: - -- `MANIFEST_VERSION` (2 | 3), `BROWSER`, `CHROME`, `FIREFOX`, `SAFARI`, `EDGE`, `OPERA`, `COMMAND` ("build" | "serve"), `ENTRYPOINT` - -## Testing - -- **Vitest** with `WxtVitest()` plugin for browser API mocking -- Test files co-located with source: `*.test.ts` (not in `__tests__/` directories) -- `fakeBrowser` from `wxt/testing` for storage/runtime mocks — call `fakeBrowser.reset()` in `beforeEach` -- `vi.stubGlobal("fetch", ...)` for API tests -- Svelte components are mocked in unit tests to avoid jsdom dependency - -## Code Style - -- **Biome** for formatting and linting (not ESLint/Prettier) -- Double quotes, `organizeImports` enabled -- Svelte files have `noUnusedVariables` turned off (Svelte runes trigger false positives) - -## Patterns - -- **Result types**: Discriminated unions for success/failure (`{ ok: true; data } | { ok: false; reason }`) instead of thrown errors -- **Storage**: `storage.defineItem()` with typed cache keys (`` `local:${string}` ``), 24-hour TTL on cached data -- **Svelte fragments**: `svelte.config.js` sets `compilerOptions.fragments: "tree"` for CSP compliance (avoids innerHTML) - -## Key Files - -- `wxt.config.ts` — WXT config (srcDir, modules, Firefox manifest overrides) -- `svelte.config.js` — Svelte compiler options (fragments mode) -- `biome.json` — Formatter and linter config -- `.wxt/` — Auto-generated types and config (don't edit) -- `.output/` — Build output (load as unpacked extension for dev) -- `public/` — Static assets copied to build (icons, etc.) - -## Conventions - -- Package manager: **pnpm** -- Node 22 (pinned in `.nvmrc`) -- ES modules (`"type": "module"`) -- Svelte 5 runes syntax (`$state`, `$derived`, `$effect`, `$props`) -- MV3-first design; Firefox builds use MV2 compatibility layer via WXT -- Markdownlint is active in the IDE — prefer pure markdown over inline HTML where possible -- GitHub repo: `cheefbird/grody-github`