diff --git a/borlander/content.js b/borlander/content.js index 1eef395..f09292f 100644 --- a/borlander/content.js +++ b/borlander/content.js @@ -1,51 +1,79 @@ const domain = window.location.hostname; -chrome.storage.local.get([domain, `${domain}_mode`], (result) => { - if (result[domain] === 'disabled') return; +async function loadThemesRegistry() { + try { + const url = chrome.runtime.getURL('themes.json'); + const res = await fetch(url); + if (!res.ok) throw new Error(`Failed to load themes.json: ${res.status}`); + return await res.json(); + } catch (e) { + console.warn('Borlander: could not load themes registry, falling back to static theme', e); + return { themes: [] }; + } +} - const forceGlobal = result[`${domain}_mode`] === 'global'; - let siteMatched = false; +function themeAppliesToPage(theme) { + if (!theme) return false; - if (!forceGlobal) { - - // 1. SONARR - const isSonarr = getComputedStyle(document.documentElement).getPropertyValue('--sonarrBlue').trim() !== "" || - document.title.toLowerCase().includes('sonarr'); - if (isSonarr) { injectSiteStyle('sites/sonarr.local/styles.css'); siteMatched = true; } - - // 2. CHESS.COM - if (!siteMatched && (domain.includes('chess.com') || !!document.querySelector('.board-layout-main'))) { - injectSiteStyle('sites/chess.com/styles.css'); siteMatched = true; - } - - // 3. ANILIST - if (!siteMatched && domain.includes('anilist.co')) { - injectSiteStyle('sites/anilist.co/styles.css'); siteMatched = true; - } - - // 4. GITEA - if (!siteMatched && (!!document.querySelector('meta[content*="gitea"]') || domain.includes('gitea'))) { - injectSiteStyle('sites/gitea.local/styles.css'); siteMatched = true; - } - - // 5. GITHUB - if (!siteMatched && domain.includes('github.com')) { - injectSiteStyle('sites/github.com/styles.css'); siteMatched = true; - } - - if (!siteMatched && domain.includes('solana.com')) { - injectSiteStyle('sites/solana.com/styles.css'); siteMatched = true; - } - - if (!siteMatched && domain.includes('chatgpt.com')) { - injectSiteStyle('sites/chatgpt.com/styles.css'); siteMatched = true; - } + if (theme.type === 'hostname') { + return typeof theme.hostnameIncludes === 'string' && domain.includes(theme.hostnameIncludes); } - if (!siteMatched) { + if (theme.type === 'heuristic' && theme.detect) { + const d = theme.detect; + let ok = false; + + if (typeof d.hostnameIncludes === 'string') { + ok = ok || domain.includes(d.hostnameIncludes); + } + if (typeof d.titleIncludes === 'string') { + ok = ok || document.title.toLowerCase().includes(d.titleIncludes.toLowerCase()); + } + if (typeof d.metaContentIncludes === 'string') { + ok = ok || !!document.querySelector(`meta[content*="${CSS.escape(d.metaContentIncludes)}"]`); + } + if (typeof d.cssVarNonEmpty === 'string') { + ok = ok || getComputedStyle(document.documentElement) + .getPropertyValue(d.cssVarNonEmpty) + .trim() !== ''; + } + + return ok; + } + + return false; +} + +function pickBestSiteTheme(themes) { + // Prefer hostname matches first, then heuristics. + for (const t of themes) { + if (t.type === 'hostname' && themeAppliesToPage(t)) return t; + } + for (const t of themes) { + if (t.type === 'heuristic' && themeAppliesToPage(t)) return t; + } + return null; +} + +(async () => { + const registry = await loadThemesRegistry(); + const themes = Array.isArray(registry.themes) ? registry.themes : []; + const applicable = pickBestSiteTheme(themes); + + chrome.storage.local.get([domain, `${domain}_mode`], (result) => { + if (result[domain] === 'disabled') return; + + // historical value is "global"; treat as "static". + const forceStatic = result[`${domain}_mode`] === 'global' || result[`${domain}_mode`] === 'static'; + + if (!forceStatic && applicable?.cssPath) { + injectSiteStyle(applicable.cssPath); + return; + } + injectSiteStyle('styles.css'); - } -}); + }); +})(); function injectSiteStyle(path) { const link = document.createElement('link'); diff --git a/borlander/manifest.json b/borlander/manifest.json index 431428a..ab821b9 100644 --- a/borlander/manifest.json +++ b/borlander/manifest.json @@ -32,13 +32,8 @@ { "resources": [ "styles.css", - "sites/sonarr.local/styles.css", - "sites/chess.com/styles.css", - "sites/gitea.local/styles.css", - "sites/anilist.co/styles.css", - "sites/github.com/styles.css", - "sites/solana.com/styles.css", - "sites/chatgpt.com/styles.css" + "themes.json", + "sites/*/styles.css" ], "matches": [ "" diff --git a/borlander/popup.js b/borlander/popup.js index d5ab798..30e5c8d 100644 --- a/borlander/popup.js +++ b/borlander/popup.js @@ -1,3 +1,15 @@ +async function loadThemesRegistry() { + try { + const url = chrome.runtime.getURL('themes.json'); + const res = await fetch(url); + if (!res.ok) throw new Error(`Failed to load themes.json: ${res.status}`); + return await res.json(); + } catch (e) { + console.warn('Borlander: could not load themes registry', e); + return { themes: [] }; + } +} + chrome.tabs.query({ active: true, currentWindow: true }, async (tabs) => { if (!tabs[0] || !tabs[0].url) return; @@ -9,34 +21,69 @@ chrome.tabs.query({ active: true, currentWindow: true }, async (tabs) => { const btn = document.getElementById('toggle-btn'); const themeBtn = document.getElementById('theme-mode-btn'); - let hasSpecificTheme = false; + const registry = await loadThemesRegistry(); + const themes = Array.isArray(registry.themes) ? registry.themes : []; + let hasSpecificTheme = false; try { - // Run detection logic inside the tab to match content.js logic + // Detect using the same rules as content.js, but executed within the actual page. const detectionResult = await chrome.scripting.executeScript({ target: { tabId: activeTab.id }, - func: () => { + args: [themes], + func: (themesFromExtension) => { const domain = window.location.hostname; - const isSonarr = getComputedStyle(document.documentElement).getPropertyValue('--sonarrBlue').trim() !== "" || - document.title.toLowerCase().includes('sonarr'); - const isChess = domain.includes('chess.com') || !!document.querySelector('.board-layout-main'); - const isAnilist = domain.includes('anilist.co'); - const isGitea = !!document.querySelector('meta[content*="gitea"]') || domain.includes('gitea'); - const isGithub = domain.includes('github.com'); - const isSolana = domain.includes('solana.com'); - const isChatgpt = domain.includes('chatgpt.com'); - return isSonarr || isChess || isSolana || isAnilist || isChatgpt || isGitea || isGithub; + function themeApplies(theme) { + if (!theme) return false; + + if (theme.type === 'hostname') { + return typeof theme.hostnameIncludes === 'string' && domain.includes(theme.hostnameIncludes); + } + + if (theme.type === 'heuristic' && theme.detect) { + const d = theme.detect; + let ok = false; + + if (typeof d.hostnameIncludes === 'string') { + ok = ok || domain.includes(d.hostnameIncludes); + } + if (typeof d.titleIncludes === 'string') { + ok = ok || document.title.toLowerCase().includes(d.titleIncludes.toLowerCase()); + } + if (typeof d.metaContentIncludes === 'string') { + ok = ok || !!document.querySelector(`meta[content*="${CSS.escape(d.metaContentIncludes)}"]`); + } + if (typeof d.cssVarNonEmpty === 'string') { + ok = ok || getComputedStyle(document.documentElement) + .getPropertyValue(d.cssVarNonEmpty) + .trim() !== ''; + } + + return ok; + } + + return false; + } + + // Prefer hostname rules, then heuristics. + for (const t of themesFromExtension) { + if (t.type === 'hostname' && themeApplies(t)) return true; + } + for (const t of themesFromExtension) { + if (t.type === 'heuristic' && themeApplies(t)) return true; + } + + return false; } }); hasSpecificTheme = detectionResult[0]?.result || false; } catch (e) { - console.warn("Could not inspect page for site-specific theme:", e); + console.warn('Could not inspect page for site-specific theme:', e); } chrome.storage.local.get([domain, `${domain}_mode`], (result) => { const isDisabled = result[domain] === 'disabled'; - btn.textContent = isDisabled ? "Enable" : "Disable"; + btn.textContent = isDisabled ? 'Enable' : 'Disable'; btn.onclick = () => { if (isDisabled) chrome.storage.local.remove(domain); @@ -47,15 +94,17 @@ chrome.tabs.query({ active: true, currentWindow: true }, async (tabs) => { if (hasSpecificTheme && !isDisabled) { themeBtn.style.display = 'block'; - const isGlobalMode = result[`${domain}_mode`] === 'global'; - themeBtn.textContent = isGlobalMode ? "Site Theme" : "Static Theme"; + const isStaticMode = result[`${domain}_mode`] === 'global' || result[`${domain}_mode`] === 'static'; + themeBtn.textContent = isStaticMode ? 'Site Theme' : 'Static Theme'; themeBtn.onclick = () => { - const newMode = isGlobalMode ? 'site' : 'global'; + const newMode = isStaticMode ? 'site' : 'static'; chrome.storage.local.set({ [`${domain}_mode`]: newMode }); chrome.tabs.reload(activeTab.id); window.close(); }; + } else { + themeBtn.style.display = 'none'; } }); }); \ No newline at end of file diff --git a/borlander/scripts/generate-themes.mjs b/borlander/scripts/generate-themes.mjs new file mode 100644 index 0000000..fb9c5e8 --- /dev/null +++ b/borlander/scripts/generate-themes.mjs @@ -0,0 +1,79 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +const ROOT = path.resolve(process.argv[2] ?? path.join(process.cwd(), 'borlander')); +const SITES_DIR = path.join(ROOT, 'sites'); +const OUT_FILE = path.join(ROOT, 'themes.json'); + +/** + * Heuristics for themes that don't map 1:1 to a hostname. + * Keyed by folder name under sites/. + */ +const HEURISTICS = { + 'sonarr.local': { + type: 'heuristic', + detect: { + titleIncludes: 'sonarr', + cssVarNonEmpty: '--sonarrBlue' + } + }, + 'gitea.local': { + type: 'heuristic', + detect: { + metaContentIncludes: 'gitea', + hostnameIncludes: 'gitea' + } + } +}; + +const HOSTNAME_ALIASES = { + // folderName: hostnameIncludes + 'web.whatsapp.com': 'web.whatsapp.com' +}; + +async function fileExists(p) { + try { + await fs.access(p); + return true; + } catch { + return false; + } +} + +const entries = []; +const siteFolders = (await fs.readdir(SITES_DIR, { withFileTypes: true })) + .filter((d) => d.isDirectory()) + .map((d) => d.name) + .sort(); + +for (const folder of siteFolders) { + const cssPath = path.join(SITES_DIR, folder, 'styles.css'); + if (!(await fileExists(cssPath))) continue; + + const relCssPath = `sites/${folder}/styles.css`; + + if (HEURISTICS[folder]) { + entries.push({ + id: folder, + cssPath: relCssPath, + ...HEURISTICS[folder] + }); + continue; + } + + const hostnameIncludes = HOSTNAME_ALIASES[folder] ?? folder; + entries.push({ + id: folder, + type: 'hostname', + hostnameIncludes, + cssPath: relCssPath + }); +} + +const payload = { + version: 1, + themes: entries +}; + +await fs.writeFile(OUT_FILE, JSON.stringify(payload, null, 2) + '\n', 'utf8'); +console.log(`Wrote ${entries.length} themes to ${path.relative(process.cwd(), OUT_FILE)}`); diff --git a/borlander/sites/music.youtube.com/styles.css b/borlander/sites/music.youtube.com/styles.css new file mode 100644 index 0000000..e892d4a --- /dev/null +++ b/borlander/sites/music.youtube.com/styles.css @@ -0,0 +1,16 @@ +#sections { + background-color: #000064 !important; +} + +#left-content { + background-color: #000064 !important; +} + +.background-gradient { + background-image: none !important; + background-color: #0000A4 !important; +} + +:root { + --ytmusic-nav-bar: #000064 !important; +} \ No newline at end of file diff --git a/borlander/sites/web.whatsapp.com/styles.css b/borlander/sites/web.whatsapp.com/styles.css new file mode 100644 index 0000000..301cd16 --- /dev/null +++ b/borlander/sites/web.whatsapp.com/styles.css @@ -0,0 +1,26 @@ +#main, +#side { + background-color: #0000A4; +} + +#side>div { + background-color: #000064 !important; +} + +header, +#pane-side { + background-color: #000064 !important; +} + +.xa3rncp.xa3rncp, +.xa3rncp.xa3rncp:root { + --WDS-surface-default: #000064 !important; +} + +:root { + --background-default: #000080 !important; +} + +._ak72 { + background-color: #000080 !important; +} \ No newline at end of file diff --git a/borlander/themes.json b/borlander/themes.json new file mode 100644 index 0000000..c6c905d --- /dev/null +++ b/borlander/themes.json @@ -0,0 +1,65 @@ +{ + "version": 1, + "themes": [ + { + "id": "anilist.co", + "type": "hostname", + "hostnameIncludes": "anilist.co", + "cssPath": "sites/anilist.co/styles.css" + }, + { + "id": "chatgpt.com", + "type": "hostname", + "hostnameIncludes": "chatgpt.com", + "cssPath": "sites/chatgpt.com/styles.css" + }, + { + "id": "chess.com", + "type": "hostname", + "hostnameIncludes": "chess.com", + "cssPath": "sites/chess.com/styles.css" + }, + { + "id": "gitea.local", + "cssPath": "sites/gitea.local/styles.css", + "type": "heuristic", + "detect": { + "metaContentIncludes": "gitea", + "hostnameIncludes": "gitea" + } + }, + { + "id": "github.com", + "type": "hostname", + "hostnameIncludes": "github.com", + "cssPath": "sites/github.com/styles.css" + }, + { + "id": "music.youtube.com", + "type": "hostname", + "hostnameIncludes": "music.youtube.com", + "cssPath": "sites/music.youtube.com/styles.css" + }, + { + "id": "solana.com", + "type": "hostname", + "hostnameIncludes": "solana.com", + "cssPath": "sites/solana.com/styles.css" + }, + { + "id": "sonarr.local", + "cssPath": "sites/sonarr.local/styles.css", + "type": "heuristic", + "detect": { + "titleIncludes": "sonarr", + "cssVarNonEmpty": "--sonarrBlue" + } + }, + { + "id": "web.whatsapp.com", + "type": "hostname", + "hostnameIncludes": "web.whatsapp.com", + "cssPath": "sites/web.whatsapp.com/styles.css" + } + ] +} diff --git a/readme.md b/readme.md index ee429a7..3a2fe73 100644 --- a/readme.md +++ b/readme.md @@ -1,5 +1,24 @@ # Borlander +Borlander is a MV3 browser extension that applies a classic Borland blue/yellow theme globally, with optional site-specific overrides. + +## Site-specific themes (automatic) + +Any folder under `borlander/sites//styles.css` is treated as a site theme automatically. + +* If the folder name looks like a hostname (e.g. `github.com`), it matches when the current page hostname includes that string. +* A couple themes are **heuristic-based** (currently `sonarr.local`, `gitea.local`) because they may run on custom domains. Those are defined in `borlander/scripts/generate-themes.mjs`. + +The registry lives in `borlander/themes.json` and is loaded by both `content.js` and `popup.js`. + +### Regenerate `themes.json` + +If you add/remove site themes in `borlander/sites/`, re-generate the registry: + +```fish +node borlander/scripts/generate-themes.mjs +``` + #### Monkeytype theme