feat: implement dynamic theme loading and detection logic

added styles for ytm
This commit is contained in:
2026-02-14 09:09:33 +05:30
parent c992ca8595
commit e97559b7be
8 changed files with 342 additions and 65 deletions

View File

@@ -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');

View File

@@ -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": [
"<all_urls>"

View File

@@ -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';
}
});
});

View File

@@ -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)}`);

View File

@@ -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;
}

View File

@@ -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;
}

65
borlander/themes.json Normal file
View File

@@ -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"
}
]
}

View File

@@ -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/<site>/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