diff --git a/.github/prompts/add-search-engine.prompt.md b/.github/prompts/add-search-engine.prompt.md new file mode 100644 index 0000000..7283fb1 --- /dev/null +++ b/.github/prompts/add-search-engine.prompt.md @@ -0,0 +1,62 @@ +# Add a new search engine + +Add support for a new search engine to the SearchEngineSwitcher extension. + +## Information needed before starting + +Please provide the following before making any changes: + +1. **Engine name** — display name (e.g. "Brave Search") and the constant name to use (e.g. `BraveSearch` / `"BRAVESEARCH"`) +2. **Search URL** — the base URL including the query parameter, e.g. `https://search.brave.com/search?q=` +3. **Icon URL** — a stable URL to a favicon/icon (SVG or PNG). Note which domain it is served from, as it may differ from the search domain and require an additional `web_accessible_resources` entry in the manifest. +4. **Form action suffix** — inspect the search results page and find what `document.forms[n].action` ends with (e.g. `/search`, `/`). Run `Array.from(document.forms).map(f => f.action)` in DevTools to find it. +5. **DOM depth** — the number of `.parentElement` calls needed from the `input[name="q"]` to reach the container element that should be wrapped. Run the following in DevTools on a results page to find it: + ```js + const input = document.querySelector('input[name="q"]'); + let el = input; + for (let i = 0; i < 5; i++) { + el = el?.parentElement; + console.log(i + 1, el); + } + ``` +6. **Does the page re-render the search bar after initial load?** — if yes, a `MutationObserver` is required to re-inject icons after they are removed. You can check by searching, watching DevTools Elements panel for mutations around the search input, or simply noticing that icons appear then disappear. +7. **Any special layout needs?** — e.g. does the search bar shrink when icons are added? Does the wrapper need `inline-flex` instead of `flex`? Does the icons container need `alignItems: center`? + +## What will be changed + +The following files require edits — do them all in one pass: + +- `source/utils/searchEngineConfig.ts` — add exported constant, expand `SearchEngineName` union, add config entry +- `source/types/storage.ts` — import new constant, add to `searchEngineSettings` and `searchEngineOrder` in `defaultStorage` +- `source/manifest.json` — add URL pattern to `host_permissions`, `content_scripts.matches`, and `web_accessible_resources.matches` (if the icon is on a different domain, add that domain to `web_accessible_resources.matches` too) +- `source/ContentScript/index.ts` — import new constant, add `embedHtmlOn` function, add `case` to `injectHtml` +- `source/Popup/features/searchEngines/useSearchEngineSettings.ts` — add new engine to the `useState` initial value (must stay in sync with `defaultStorage`) +- `README.md` — add the new engine to the supported search engines list + +## MutationObserver guidance + +Only add a `MutationObserver` if the page re-renders the search bar after load (question 6 above). If one is needed, use this pattern (see `embedHtmlOnBrave` or `embedHtmlOnEcosia` for reference): + +```ts +const applyDOMChanges = () => { + // ... find form, input, container + if (searchAreaContainer.parentElement?.id !== 'searchengineswitcher-container') { + // ... inject wrapper and icons + } +}; + +const mutationObserver = new MutationObserver(() => { + if (!document.getElementById('searchengineswitcher-container')) { + applyDOMChanges(); + } +}); + +applyDOMChanges(); +mutationObserver.observe(document.body, {childList: true, subtree: true}); +``` + +If no observer is needed (Google, Bing, DDG pattern), inject directly without any guard or observer. + +## After implementing + +Run `npm run build` to verify both Chrome and Firefox builds succeed before committing. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1865de6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,122 @@ +# AGENTS.md + +## Project Overview + +SearchEngineSwitcher is a cross-browser web extension (Chrome & Firefox) that lets users quickly switch between search engines by embedding clickable icons next to the search bar on supported search engine pages. + +Supported search engines: **Google**, **DuckDuckGo**, **Ecosia**, **Bing**. + +The extension uses Manifest V3, is built with Vite + React + TypeScript, and targets both Chrome and Firefox from a single codebase. + +## Repository Structure + +``` +source/ # All source code +├── manifest.json # Browser extension manifest (uses __chrome__/__firefox__ prefixes for browser-specific keys) +├── Background/index.ts # Background script / service worker — seeds default settings on install +├── ContentScript/index.ts # Content script — detects the current search engine and injects links to the others +├── Popup/ # Extension popup UI (React) +│ ├── popup.html # Popup entry HTML +│ ├── index.tsx # React root mount +│ ├── App.tsx # Main popup component (Grommet UI) +│ ├── useMediaPreference.ts # Hook for dark/light mode detection +│ └── features/searchEngines/ # Search engine toggle list components + hook +├── types/storage.ts # StorageSchema type and defaults +├── utils/ +│ ├── searchEngineConfig.ts # Search engine definitions (URLs, icons, IDs) +│ └── storage.ts # Typed wrappers around browser.storage.local +└── public/assets/icons/ # Extension icons (favicon-*.png) + +extension/ # Build output (git-tracked for deployment) +├── chrome/ # Chrome build artifacts +├── firefox/ # Firefox build artifacts +├── chrome.zip # Production Chrome package +└── firefox.xpi # Production Firefox package + +.github/workflows/build.yml # CI: build on push/PR to main, deploy on version tags +``` + +## Tech Stack + +- **Language:** TypeScript (strict, ESNext target) +- **UI Framework:** React 19 with Grommet component library and styled-components +- **Build Tool:** Vite 7 with custom plugins for IIFE content scripts, manifest generation, and zip packaging +- **Browser API:** `webextension-polyfill` for cross-browser compatibility +- **Linting:** ESLint 9 (flat config) with TypeScript, React, and Prettier plugins +- **Node version:** 20+ (see `.nvmrc` for exact version) + +## Development + +### Setup + +```sh +npm install +``` + +### Dev (watch mode) + +```sh +npm run dev:chrome # Build Chrome extension with file watching +npm run dev:firefox # Build Firefox extension with file watching +``` + +Then load the extension locally: +- **Chrome:** Load `extension/chrome` as an unpacked extension at `chrome://extensions` +- **Firefox:** Load `extension/firefox/manifest.json` as a temporary add-on at `about:debugging` + +### Production Build + +```sh +npm run build # Builds both Chrome and Firefox +npm run build:chrome # Chrome only → extension/chrome.zip +npm run build:firefox # Firefox only → extension/firefox.xpi +``` + +### Linting + +```sh +npm run lint # Check for lint errors +npm run lint:fix # Auto-fix lint errors +``` + +## Architecture Notes + +### Content Script (IIFE) + +The content script (`source/ContentScript/index.ts`) is built as an **IIFE** bundle via a custom Vite plugin (`buildIIFEScripts` in `vite.config.ts`) because Manifest V3 content scripts injected via the manifest cannot use ES module imports. This is separate from the main Vite build. + +### Manifest Generation + +The manifest at `source/manifest.json` uses `__chrome__` and `__firefox__` prefixes for browser-specific keys (e.g., `__chrome__service_worker`, `__firefox__scripts`). The `vite-plugin-wext-manifest` plugin strips these prefixes to produce the correct manifest for each target browser. The version is pulled from `package.json` automatically. + +### Storage + +Settings are stored in `browser.storage.local`. The schema is defined in `source/types/storage.ts` and consists of: +- `searchEngineSettings` — a record of each search engine's enabled/disabled status +- `searchEngineOrder` — the display order of search engines + +The background script seeds defaults on first install. The typed `getStorage`/`setStorage` helpers in `source/utils/storage.ts` always merge with defaults to handle missing keys gracefully. + +### Adding a New Search Engine + +1. Add the new engine constant and config entry in `source/utils/searchEngineConfig.ts` (baseUrl, iconUrl, id, displayName). +2. Add the engine to `SearchEngineName` union type and the `defaultStorage` in `source/types/storage.ts`. +3. Add the engine's URL pattern to `host_permissions` and `content_scripts.matches` in `source/manifest.json`. +4. Implement an `embedHtmlOn()` function in `source/ContentScript/index.ts` and wire it into `injectHtml()`. + +### Popup UI + +The popup uses React with Grommet and automatically respects the user's dark/light mode preference. The `useSearchEngineSettings` hook loads settings from storage and provides a `toggleEngine` callback that persists changes immediately. + +## CI/CD + +The GitHub Actions workflow (`.github/workflows/build.yml`): +- **On push/PR to `main`:** Builds both browser extensions and uploads them as artifacts. +- **On version tags (`v*.*.*`):** Also deploys the `extension/` directory to the `extension` branch via GitHub Pages. + +## Code Style + +- 2-space indentation, LF line endings (see `.editorconfig`) +- ESLint flat config with TypeScript, React, and Prettier rules +- `console` statements are allowed (`no-console: off`) +- Prefer named exports for types and constants; default exports for React components and config objects diff --git a/README.md b/README.md index bd2bf17..56d6a46 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ The following search engines are supported currently: - DuckDuckGo - Ecosia - Bing + - Brave Search It contains a content_script, responsible for embedding links to the other enabled search engines when the user is browsing on one of the enabled search engines. Here's a picture of what the embedded links look like on Google, as an example. The extension adds clickable icons to the other search engines to the right of the search area. diff --git a/source/ContentScript/index.ts b/source/ContentScript/index.ts index 3dd076d..4b3f414 100644 --- a/source/ContentScript/index.ts +++ b/source/ContentScript/index.ts @@ -6,6 +6,7 @@ import config, { Ecosia, DuckDuckGo, Bing, + BraveSearch, } from '../utils/searchEngineConfig'; import {getStorage} from '../utils/storage'; @@ -53,6 +54,16 @@ function createFlexContainer() { return flexContainer; } +function findSearchInput( + form: HTMLFormElement +): HTMLInputElement | HTMLTextAreaElement | undefined { + return ( + form.querySelector( + 'input[name="q"], textarea[name="q"]' + ) ?? undefined + ); +} + // --------------------------------------------------------------------------- // Per-engine DOM injection // --------------------------------------------------------------------------- @@ -63,9 +74,7 @@ function embedHtmlOnGoogle(searchLinks: HTMLAnchorElement[]) { 'Cannot find Google search form' ); const searchInput = assertExists( - Array.from(searchForm.getElementsByTagName('input')).find( - (i) => i.name === 'q' - ), + findSearchInput(searchForm), 'Cannot find Google search input' ); const searchAreaContainer = assertExists( @@ -74,6 +83,7 @@ function embedHtmlOnGoogle(searchLinks: HTMLAnchorElement[]) { ); const searchAreaContainerWrapper = createFlexContainer(); + searchAreaContainerWrapper.id = 'searchengineswitcher-container'; wrap(searchAreaContainer, searchAreaContainerWrapper); for (const anchor of searchLinks) { @@ -87,30 +97,13 @@ function embedHtmlOnEcosia(searchLinks: HTMLAnchorElement[]) { const getSearchForm = () => Array.from(document.forms).find((f) => f.action.endsWith('/search')); - const mutationObserver = new MutationObserver((mutationsList) => { - const searchForm = getSearchForm(); - for (const mutation of mutationsList) { - if ( - mutation.type === 'childList' && - searchForm?.parentElement?.contains(mutation.target) - ) { - applyDOMChanges(); - } - } - }); - - mutationObserver.observe(document.body, {childList: true, subtree: true}); - const applyDOMChanges = () => { const searchForm = assertExists( getSearchForm(), 'Cannot find Ecosia search form' ); - const searchInput = assertExists( - Array.from(searchForm.getElementsByTagName('input')).find( - (i) => i.name === 'q' - ), + findSearchInput(searchForm), 'Cannot find Ecosia search input' ); const searchAreaContainer = assertExists( @@ -118,24 +111,35 @@ function embedHtmlOnEcosia(searchLinks: HTMLAnchorElement[]) { 'Cannot find Ecosia search input container' ); - if ( - searchAreaContainer.parentElement?.id !== - 'searchengineswitcher-container' - ) { + if (searchAreaContainer.parentElement?.id !== 'searchengineswitcher-container') { const searchAreaContainerWrapper = document.createElement('div'); searchAreaContainerWrapper.style.display = 'inline-flex'; + searchAreaContainerWrapper.style.alignItems = 'center'; + searchAreaContainerWrapper.style.width = '100%'; searchAreaContainerWrapper.id = 'searchengineswitcher-container'; wrap(searchAreaContainer, searchAreaContainerWrapper); + // Prevent the search bar from shrinking to accommodate icons + searchAreaContainer.style.flexShrink = '0'; + searchAreaContainer.style.flexGrow = '1'; + for (const anchor of searchLinks) { anchor.style.margin = 'auto'; anchor.style.padding = '4px 4px 0px 16px'; + anchor.style.flexShrink = '0'; searchAreaContainerWrapper.appendChild(anchor); } } }; + const mutationObserver = new MutationObserver(() => { + if (!document.getElementById('searchengineswitcher-container')) { + applyDOMChanges(); + } + }); + applyDOMChanges(); + mutationObserver.observe(document.body, {childList: true, subtree: true}); } function embedHtmlOnBing(searchLinks: HTMLAnchorElement[]) { @@ -144,9 +148,7 @@ function embedHtmlOnBing(searchLinks: HTMLAnchorElement[]) { 'Cannot find Bing search form' ); const searchInput = assertExists( - Array.from(searchForm.getElementsByTagName('input')).find( - (i) => i.name === 'q' - ), + findSearchInput(searchForm), 'Cannot find Bing search input' ); const searchAreaContainer = assertExists( @@ -155,7 +157,9 @@ function embedHtmlOnBing(searchLinks: HTMLAnchorElement[]) { ); const searchAreaContainerWrapper = createFlexContainer(); + searchAreaContainerWrapper.id = 'searchengineswitcher-container'; wrap(searchAreaContainer, searchAreaContainerWrapper); + const iconsContainer = createFlexContainer(); searchAreaContainerWrapper.appendChild(iconsContainer); @@ -166,15 +170,58 @@ function embedHtmlOnBing(searchLinks: HTMLAnchorElement[]) { } } +function embedHtmlOnBrave(searchLinks: HTMLAnchorElement[]) { + const getSearchForm = () => + Array.from(document.forms).find((f) => f.action.endsWith('/search')); + + const applyDOMChanges = () => { + const searchForm = assertExists( + getSearchForm(), + 'Cannot find Brave Search form' + ); + const searchInput = assertExists( + findSearchInput(searchForm), + 'Cannot find Brave Search input' + ); + const searchAreaContainer = assertExists( + searchInput.parentElement?.parentElement, + 'Cannot find Brave Search input container' + ); + + if (searchAreaContainer.parentElement?.id !== 'searchengineswitcher-container') { + const searchAreaContainerWrapper = createFlexContainer(); + searchAreaContainerWrapper.id = 'searchengineswitcher-container'; + wrap(searchAreaContainer, searchAreaContainerWrapper); + + const iconsContainer = createFlexContainer(); + iconsContainer.style.alignItems = 'center'; + searchAreaContainerWrapper.appendChild(iconsContainer); + + for (const anchor of searchLinks) { + anchor.style.margin = 'auto'; + anchor.style.padding = '4px 4px 0px 16px'; + iconsContainer.appendChild(anchor); + } + } + }; + + const mutationObserver = new MutationObserver(() => { + if (!document.getElementById('searchengineswitcher-container')) { + applyDOMChanges(); + } + }); + + applyDOMChanges(); + mutationObserver.observe(document.body, {childList: true, subtree: true}); +} + function embedHtmlOnDDG(searchLinks: HTMLAnchorElement[]) { const searchForm = assertExists( Array.from(document.forms).find((f) => f.action.endsWith('/')), 'Cannot find DuckDuckGo search form' ); const searchInput = assertExists( - Array.from(searchForm.getElementsByTagName('input')).find( - (i) => i.name === 'q' - ), + findSearchInput(searchForm), 'Cannot find DuckDuckGo search input' ); const searchAreaContainer = assertExists( @@ -184,9 +231,11 @@ function embedHtmlOnDDG(searchLinks: HTMLAnchorElement[]) { searchAreaContainer.style.paddingRight = 'unset'; const searchAreaContainerWrapper = createFlexContainer(); + searchAreaContainerWrapper.id = 'searchengineswitcher-container'; wrap(searchAreaContainer, searchAreaContainerWrapper); // https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Flexible_Box_Layout/Controlling_Ratios_of_Flex_Items_Along_the_Main_Ax searchAreaContainer.style.flex = '1 0 0'; + const iconsContainer = createFlexContainer(); searchAreaContainerWrapper.appendChild(iconsContainer); @@ -230,6 +279,8 @@ function injectHtml( return embedHtmlOnDDG(searchLinks); case Bing: return embedHtmlOnBing(searchLinks); + case BraveSearch: + return embedHtmlOnBrave(searchLinks); } } diff --git a/source/Popup/features/searchEngines/useSearchEngineSettings.ts b/source/Popup/features/searchEngines/useSearchEngineSettings.ts index 20c1e34..5735c6b 100644 --- a/source/Popup/features/searchEngines/useSearchEngineSettings.ts +++ b/source/Popup/features/searchEngines/useSearchEngineSettings.ts @@ -1,7 +1,7 @@ import {useCallback, useEffect, useState} from 'react'; import {getStorage, setStorage} from '../../../utils/storage'; import type {SearchEngineName} from '../../../utils/searchEngineConfig'; -import type {SearchEngineStatus} from '../../../types/storage'; +import {defaultStorage, type SearchEngineStatus} from '../../../types/storage'; export interface SearchEngineSettings { settings: Record; @@ -14,13 +14,8 @@ export function useSearchEngineSettings(): SearchEngineSettings & { } { const [settings, setSettings] = useState< Record - >({ - GOOGLE: {enabled: true}, - BING: {enabled: true}, - ECOSIA: {enabled: true}, - DUCKDUCKGO: {enabled: true}, - }); - const [order, setOrder] = useState([]); + >(defaultStorage.searchEngineSettings); + const [order, setOrder] = useState(defaultStorage.searchEngineOrder); const [loading, setLoading] = useState(true); useEffect(() => { diff --git a/source/manifest.json b/source/manifest.json index bcd7d22..ef484b0 100644 --- a/source/manifest.json +++ b/source/manifest.json @@ -21,7 +21,8 @@ "*://*.duckduckgo.com/*", "*://*.google.com/search*", "*://*.ecosia.org/search*", - "*://*.bing.com/search*" + "*://*.bing.com/search*", + "*://*.search.brave.com/search*" ], "content_security_policy": { @@ -68,7 +69,8 @@ "*://*.duckduckgo.com/*", "*://*.google.com/search*", "*://*.ecosia.org/search*", - "*://*.bing.com/search*" + "*://*.bing.com/search*", + "*://*.search.brave.com/search*" ], "css": [], "js": ["assets/js/contentScript.bundle.js"] @@ -82,7 +84,8 @@ "*://*.duckduckgo.com/*", "*://*.google.com/*", "*://*.ecosia.org/*", - "*://*.bing.com/*" + "*://*.bing.com/*", + "*://*.brave.com/*" ] } ] diff --git a/source/types/storage.ts b/source/types/storage.ts index 69d1f87..09d5403 100644 --- a/source/types/storage.ts +++ b/source/types/storage.ts @@ -1,4 +1,4 @@ -import {Google, DuckDuckGo, Bing, Ecosia} from '../utils/searchEngineConfig'; +import {Google, DuckDuckGo, Bing, Ecosia, BraveSearch} from '../utils/searchEngineConfig'; import type {SearchEngineName} from '../utils/searchEngineConfig'; export interface SearchEngineStatus { @@ -16,6 +16,7 @@ export const defaultStorage: StorageSchema = { BING: {enabled: true}, ECOSIA: {enabled: true}, DUCKDUCKGO: {enabled: true}, + BRAVESEARCH: {enabled: true}, }, - searchEngineOrder: [Google, DuckDuckGo, Bing, Ecosia], + searchEngineOrder: [Google, DuckDuckGo, Bing, Ecosia, BraveSearch], }; diff --git a/source/utils/searchEngineConfig.ts b/source/utils/searchEngineConfig.ts index 5c6dd5c..7cba202 100644 --- a/source/utils/searchEngineConfig.ts +++ b/source/utils/searchEngineConfig.ts @@ -9,8 +9,9 @@ export const Google = "GOOGLE" as const; export const Ecosia = "ECOSIA" as const; export const Bing = "BING" as const; export const DuckDuckGo = "DUCKDUCKGO" as const; +export const BraveSearch = "BRAVESEARCH" as const; -export type SearchEngineName = typeof Google | typeof Ecosia | typeof Bing | typeof DuckDuckGo; +export type SearchEngineName = typeof Google | typeof Ecosia | typeof Bing | typeof DuckDuckGo | typeof BraveSearch; const config: Record = { GOOGLE: { @@ -37,6 +38,12 @@ const config: Record = { id: "f3dfab76-5b65-4604-95ef-9e3cb7a8a59f", displayName: "DuckDuckGo", }, + BRAVESEARCH: { + baseUrl: new URL("https://search.brave.com/search?q="), + iconUrl: new URL("https://brave.com/static-assets/images/brave-logo-sans-text.svg"), + id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + displayName: "Brave Search", + }, }; export const searchEngineNames: ReadonlySet = new Set(Object.keys(config) as SearchEngineName[]); diff --git a/source/utils/storage.ts b/source/utils/storage.ts index a081958..5a22147 100644 --- a/source/utils/storage.ts +++ b/source/utils/storage.ts @@ -23,8 +23,23 @@ export async function setStorage( export async function getAllStorage(): Promise { const result = await browser.storage.local.get(null); - return { - ...defaultStorage, - ...result, + // Deep-merge searchEngineSettings so new engines added in later versions + // are picked up by existing users (whose stored value would otherwise + // completely override the defaults via a shallow spread). + const searchEngineSettings: StorageSchema['searchEngineSettings'] = { + ...defaultStorage.searchEngineSettings, + ...(result.searchEngineSettings as StorageSchema['searchEngineSettings'] | undefined), }; + + // Append any engines present in the defaults but missing from the stored + // order (i.e. engines added in a newer version of the extension). + const storedOrder = result.searchEngineOrder as StorageSchema['searchEngineOrder'] | undefined; + const newEngines = defaultStorage.searchEngineOrder.filter( + (e) => !storedOrder?.includes(e) + ); + const searchEngineOrder: StorageSchema['searchEngineOrder'] = storedOrder + ? [...storedOrder, ...newEngines] + : defaultStorage.searchEngineOrder; + + return {searchEngineSettings, searchEngineOrder}; }