feat: implement dynamic theme loading and detection logic
added styles for ytm
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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>"
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
});
|
||||
});
|
||||
79
borlander/scripts/generate-themes.mjs
Normal file
79
borlander/scripts/generate-themes.mjs
Normal 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)}`);
|
||||
16
borlander/sites/music.youtube.com/styles.css
Normal file
16
borlander/sites/music.youtube.com/styles.css
Normal 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;
|
||||
}
|
||||
26
borlander/sites/web.whatsapp.com/styles.css
Normal file
26
borlander/sites/web.whatsapp.com/styles.css
Normal 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
65
borlander/themes.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
19
readme.md
19
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/<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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user