mirror of
https://github.com/caiwx86/small-packages.git
synced 2026-11-03 00:53:56 +08:00
456 lines
17 KiB
JavaScript
456 lines
17 KiB
JavaScript
'use strict';
|
|
'require baseclass';
|
|
|
|
/* A view's injected CSS is never deleted; a poisoned document is left to a real load. A view's
|
|
* <style> outlives its page under SPA nav (filemanager's unlayered `display: none !important` hides
|
|
* Save/Reset on every later page). Deleting is one-way: a library importing CSS at module eval never
|
|
* re-runs (ACE's 14 KB sheet came back as a black rectangle). A sheet reaching into the widget
|
|
* universe the theme styles spends the document: invasiveSheet() is the test (universe read from
|
|
* cascade.css, 0.3 ms per nav) and the router falls back to a real load. Exempt: `[data-fs-shell]`
|
|
* and anything in `#view`. */
|
|
let _themeNames = null;
|
|
|
|
const NAME_RE = /[.#][A-Za-z_][\w-]*/g;
|
|
|
|
function maskStrings(text) {
|
|
let out = '', q = null;
|
|
for (let i = 0; i < text.length; i++) {
|
|
const ch = text[i];
|
|
if (q === null) {
|
|
out += ch;
|
|
if (ch === '"' || ch === '\'') q = ch;
|
|
continue;
|
|
}
|
|
if (ch === '\\') {
|
|
out += ' ';
|
|
if (i + 1 < text.length) { out += ' '; i++; }
|
|
continue;
|
|
}
|
|
out += (ch === q) ? ch : ' ';
|
|
if (ch === q) q = null;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const origText = new WeakMap();
|
|
|
|
function themeNames() {
|
|
if (_themeNames) return _themeNames;
|
|
const names = new Set(); /* every class and id the theme styles */
|
|
const props = new Set(); /* every custom property it declares or reads */
|
|
const walk = (rules) => {
|
|
for (const r of rules) {
|
|
if (r.selectorText)
|
|
(maskStrings(r.selectorText).match(NAME_RE) || []).forEach((n) => names.add(n));
|
|
if (r.cssText)
|
|
(r.cssText.match(/--[A-Za-z_][\w-]*/g) || []).forEach((p) => props.add(p));
|
|
if (r.cssRules) walk(r.cssRules);
|
|
}
|
|
};
|
|
for (const ss of document.styleSheets) {
|
|
if (!ss.href || !(/\/cascade\.css/).test(ss.href)) continue;
|
|
try { walk(ss.cssRules); } catch (e) { return null; }
|
|
}
|
|
_themeNames = names.size ? { names, props } : null;
|
|
return _themeNames;
|
|
}
|
|
|
|
function selectorParts(text) {
|
|
const scan = maskStrings(text);
|
|
const out = [];
|
|
let depth = 0, start = 0;
|
|
for (let i = 0; i < scan.length; i++) {
|
|
const ch = scan[i];
|
|
if (ch === '(') depth++;
|
|
else if (ch === ')') depth--;
|
|
else if (ch === ',' && depth === 0) { out.push(text.slice(start, i).trim()); start = i + 1; }
|
|
}
|
|
out.push(text.slice(start).trim());
|
|
return out.filter(Boolean);
|
|
}
|
|
|
|
function stripPseudoArgs(part) {
|
|
const scan = maskStrings(part);
|
|
let out = '', depth = 0;
|
|
for (let i = 0; i < scan.length; i++) {
|
|
const ch = scan[i];
|
|
if (ch === '(' ) { depth++; if (depth === 1) { out += ' '; continue; } }
|
|
if (ch === ')') { depth--; continue; }
|
|
if (!depth) out += ch;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/* Is this selector part held inside the app's own markup by a name the theme does not know
|
|
* (`#cbi-podkop-section > .cbi-section-remove`)? Such a part can reach neither another page nor
|
|
* our chrome. */
|
|
function pinnedToApp(part, names) {
|
|
return (stripPseudoArgs(part).match(NAME_RE) || []).some((n) => !names.has(n));
|
|
}
|
|
|
|
/* A rule with a bare selector (`:root`, `pre`, `*`) cannot touch us if none of its declarations
|
|
* can: a custom property this theme never reads is inert wherever it lands. */
|
|
function inertDeclarations(rule, props) {
|
|
const st = rule.style;
|
|
if (!st || !st.length) return false; /* no declarations to judge -> judge by selector */
|
|
for (let i = 0; i < st.length; i++) {
|
|
const p = st.item(i);
|
|
if (p.slice(0, 2) !== '--') return false; /* a real property: it paints something */
|
|
if (props.has(p)) return false; /* a custom property the theme itself reads */
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/* true when this sheet can repaint a page that is not its own; an unreadable one (loading, 404,
|
|
* cross-origin) is invasive by default, so unknown CSS takes the slow path, never the broken one */
|
|
function invasiveSheet(el, universe) {
|
|
const sheet = el.sheet;
|
|
if (!sheet) return true;
|
|
|
|
const { names, props } = universe;
|
|
let invasive = false;
|
|
const walk = (rules) => {
|
|
for (const r of rules) {
|
|
if (invasive) return;
|
|
if (r.selectorText) {
|
|
for (const p of selectorParts(r.selectorText)) {
|
|
if (pinnedToApp(p, names)) continue;
|
|
if (inertDeclarations(r, props)) continue;
|
|
invasive = true;
|
|
return;
|
|
}
|
|
}
|
|
if (r.cssRules) walk(r.cssRules);
|
|
if (r.styleSheet) {
|
|
let imported;
|
|
try { imported = r.styleSheet.cssRules; } catch (e) { invasive = true; return; }
|
|
if (imported) walk(imported);
|
|
}
|
|
}
|
|
};
|
|
try { walk(sheet.cssRules); } catch (e) { return true; }
|
|
return invasive;
|
|
}
|
|
|
|
const VIEW_SHEETS = 'style:not([data-fs-shell]), link[rel~="stylesheet"]:not([data-fs-shell])';
|
|
|
|
/* Does this sheet outlive the page it arrived with? One inside the view tree dies with the swap. */
|
|
function outlivesPage(el) {
|
|
return !el.closest('#view');
|
|
}
|
|
|
|
/* Is `path` (a menu.d node's `css`) already carried by this document in a form that survives a
|
|
* swap? Whole path, never a suffix: a suffix match would let a third party's `"css": "custom.css"`
|
|
* pass on luci-app-adblock's own. No in-tree node sets `css`. */
|
|
function documentCarries(path) {
|
|
const want = L.resource(String(path));
|
|
for (const link of document.querySelectorAll('link[rel~="stylesheet"][href]')) {
|
|
if (!outlivesPage(link))
|
|
continue;
|
|
if ((link.getAttribute('href') || '').split('?')[0] === want)
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/* An invasive sheet we own is contained: scopeToCurrentPage() darkens it on the next stamp. One we
|
|
* could not attribute (a pending @import, insertRule()) still spends the document. */
|
|
function documentPoisoned() {
|
|
const names = themeNames();
|
|
return Array.from(document.querySelectorAll(VIEW_SHEETS)).some(
|
|
(el) => outlivesPage(el)
|
|
&& (!names || (!_owner.has(el) && !_silenced.has(el) && invasiveSheet(el, names))));
|
|
}
|
|
|
|
/* ---- the fence: the chrome is ours, so make a foreign rule unable to match it ----
|
|
* Re-hosting settles specificity but not `!important` (the pure-CSS answer is ~550 flags).
|
|
* Appending `:where(:not([data-fs-chrome], [data-fs-chrome] *))` to a foreign selector's subject
|
|
* matches everything except us at zero specificity, so the app's rules keep their weight against
|
|
* each other. */
|
|
const CHROME_FENCE = ':where(:not([data-fs-chrome],[data-fs-chrome] *))';
|
|
|
|
function fenceSelector(part) {
|
|
const i = maskStrings(part).indexOf('::');
|
|
return i < 0 ? part + CHROME_FENCE : part.slice(0, i) + CHROME_FENCE + part.slice(i);
|
|
}
|
|
|
|
function fenceRules(rules, names) {
|
|
for (const r of rules) {
|
|
if (r.selectorText) {
|
|
const parts = selectorParts(r.selectorText);
|
|
if (parts.some((p) => !pinnedToApp(p, names))) {
|
|
try {
|
|
r.selectorText = parts
|
|
.map((p) => (pinnedToApp(p, names) ? p : fenceSelector(p))).join(', ');
|
|
} catch (e) { /* left unfenced on purpose: the app keeps its rule */ }
|
|
}
|
|
}
|
|
if (r.cssRules) fenceRules(r.cssRules, names);
|
|
if (r.styleSheet) {
|
|
try { fenceRules(r.styleSheet.cssRules, names); } catch (e) { /* unfenced */ }
|
|
}
|
|
}
|
|
}
|
|
|
|
function fenceImported(styleEl, names) {
|
|
const fence = () => {
|
|
let ready;
|
|
try { ready = !!styleEl.sheet.cssRules[0].styleSheet.cssRules; } catch (e) { ready = false; }
|
|
if (ready) fenceRules(styleEl.sheet.cssRules, names);
|
|
return ready;
|
|
};
|
|
if (fence()) return;
|
|
styleEl.addEventListener('load', () => {
|
|
if (!fence()) {
|
|
console.error('footstrap: the re-hosted @import loaded but could not be read — the sheet '
|
|
+ 'stays unfenced and may repaint the chrome on this page.', styleEl);
|
|
}
|
|
}, { once: true });
|
|
}
|
|
|
|
const serializeRules = (rules) => Array.from(rules, (r) => r.cssText).join('\n');
|
|
|
|
/* ---- a <style>'s textContent is not its sheet ----
|
|
* Wrapping re-sets textContent, which re-parses, and whatever the parse does not reproduce is
|
|
* deleted (a <style> filled by insertRule() has empty text over live rules): textIsSheet()
|
|
* decides. */
|
|
let _probe = null;
|
|
function textIsSheet(el, live) {
|
|
try {
|
|
if (!_probe) _probe = new CSSStyleSheet();
|
|
_probe.replaceSync(el.textContent);
|
|
return serializeRules(_probe.cssRules) === serializeRules(live);
|
|
} catch (e) { return false; }
|
|
}
|
|
|
|
const _silenced = new WeakSet();
|
|
|
|
/* Take a re-hosted <link> out of the cascade: `el.disabled = true` alone does not;
|
|
* `el.sheet.disabled` decides whether the CSS paints, and a still-loading <link> has no .sheet
|
|
* yet. */
|
|
function silence(el) {
|
|
_silenced.add(el);
|
|
setEnabled(el, false);
|
|
}
|
|
|
|
/* ---- page ownership: contain an invasive sheet instead of spending the document ----
|
|
* A foreign sheet is injected by one page, and spending the document for each is paid by ordinary
|
|
* pages (filemanager lands two <style>s, ssclash four more). Removal on the way out is wrong
|
|
* (L.require caches, so a revisit renders unstyled); disabling is reversible. Owner =
|
|
* `body[data-page]` when re-hosted. */
|
|
const _owner = new WeakMap();
|
|
const APP_DEPTH = 3;
|
|
|
|
let _curKey = null;
|
|
|
|
function appKey(segs) {
|
|
return (segs || []).slice(0, APP_DEPTH).join('/');
|
|
}
|
|
|
|
/* Who a sheet injected right now belongs to, when that is not the page the chrome shows: click a
|
|
* page whose module injects CSS, click away before it lands, and the <style> appears under the
|
|
* superseding page's data-page. The router names the owner during such a require; of two
|
|
* overlapping cold requires the newer wins. */
|
|
let _ownerHint = null;
|
|
let _ownerGen = -1;
|
|
function attributeTo(segs, gen) {
|
|
/* a stale require letting go of a slot somebody else now holds: leave it alone */
|
|
if (segs == null && gen !== _ownerGen) return;
|
|
_ownerHint = (segs == null) ? null : appKey(segs);
|
|
_ownerGen = (segs == null) ? -1 : gen;
|
|
}
|
|
|
|
function ownerKey() {
|
|
return (_ownerHint !== null) ? _ownerHint : currentKey();
|
|
}
|
|
|
|
function currentKey() {
|
|
if (_curKey !== null) return _curKey;
|
|
return appKey(L.env.dispatchpath);
|
|
}
|
|
|
|
function setEnabled(el, on) {
|
|
el.disabled = !on;
|
|
if (el.sheet) el.sheet.disabled = !on;
|
|
else if (!on) el.addEventListener('load', () => { if (el.sheet) el.sheet.disabled = true; }, { once: true });
|
|
}
|
|
|
|
/* Called by the router right after it stamps data-page, with the resolved segments. Only sheets we
|
|
* own are touched; `keep` is the page still on screen, whose sheets stay lit during staging. */
|
|
function scopeToCurrentPage(segs, keep) {
|
|
if (segs) _curKey = appKey(segs);
|
|
const key = currentKey();
|
|
const spared = (keep && keep.length) ? appKey(keep) : null;
|
|
document.querySelectorAll(VIEW_SHEETS).forEach((el) => {
|
|
if (!outlivesPage(el) || !_owner.has(el)) return;
|
|
const owner = _owner.get(el);
|
|
if (spared !== null && owner === spared && owner !== key) return;
|
|
setEnabled(el, owner === key);
|
|
});
|
|
}
|
|
|
|
function claimOwner(el) {
|
|
const key = ownerKey();
|
|
_owner.set(el, key);
|
|
if (outlivesPage(el)) setEnabled(el, key === currentKey());
|
|
}
|
|
|
|
/* Re-host an invasive sheet into the existing `theme` layer so it still renders its page: an
|
|
* unlayered declaration beats every layered one, so an app's `* { margin: 0 }` would own the
|
|
* chrome. Measured: unlayered = chrome broken, app OK; `@layer theme` = both OK; a layer before
|
|
* theme = app broken. Never delete the sheet: an app's "already imported?" check must find it. */
|
|
function rehostIntoThemeLayer(el, universe) {
|
|
if (el.dataset.fsLayered) return;
|
|
|
|
if (el.tagName === 'LINK') {
|
|
/* A link the app marked alternate or disabled paints nothing and is left unmarked and watched:
|
|
* enabling it adds no node, so only an attribute observer sees it. */
|
|
if ((/\balternate\b/i).test(el.rel) || el.hasAttribute('disabled')) {
|
|
enableWatch().observe(el, { attributes: true, attributeFilter: [ 'disabled', 'rel' ] });
|
|
return;
|
|
}
|
|
const s = document.createElement('style');
|
|
s.dataset.fsLayered = '1';
|
|
s.textContent = '@import url("' + el.href.replace(/["\\]/g, '\\$&') + '") layer(theme)'
|
|
+ (el.media ? ' ' + el.media : '') + ';';
|
|
el.dataset.fsLayered = '1';
|
|
el.after(s); /* keep source order: ties inside the layer still resolve as they did */
|
|
silence(el);
|
|
claimOwner(s);
|
|
fenceImported(s, universe.names);
|
|
return;
|
|
}
|
|
|
|
const rules = el.sheet && el.sheet.cssRules;
|
|
if (!rules || !rules.length) return;
|
|
|
|
/* Handled, and never twice: fenceRules() is not idempotent, since an already-fenced selector reads
|
|
* as unpinned again. */
|
|
el.dataset.fsLayered = '1';
|
|
|
|
/* Wrap only if the text still is the sheet; otherwise the sheet stays unlayered rather than lose
|
|
* rules, and is still fenced below (pure CSSOM, the half that answers `!important`). */
|
|
if (textIsSheet(el, rules)) {
|
|
origText.set(el, el.textContent); /* dedupeViewSheets keys on this — see there */
|
|
el.textContent = '@layer theme {\n' + el.textContent + '\n}';
|
|
}
|
|
try { if (el.sheet) fenceRules(el.sheet.cssRules, universe.names); } catch (e) { /* unfenced, not broken */ }
|
|
claimOwner(el); /* a <style> is re-hosted in place, so it paints itself */
|
|
}
|
|
|
|
function rehostInvasiveSheets() {
|
|
const universe = themeNames(); /* {names, props} — not a bare Set; the fence wants .names */
|
|
if (!universe) return;
|
|
document.querySelectorAll(VIEW_SHEETS).forEach((el) => {
|
|
if (el.dataset.fsLayered) return;
|
|
/* `insertRule()` produces no mutation record, so an empty <style> filled later is never looked at
|
|
* again: a sheet with no rules yet is not an innocent one. */
|
|
let rules = null;
|
|
try { rules = el.sheet && el.sheet.cssRules; } catch (e) { /* cross-origin: judged below */ }
|
|
if (rules && !rules.length && !el.__fsRecheck) {
|
|
el.__fsRecheck = true;
|
|
window.setTimeout(() => {
|
|
let now;
|
|
try { now = el.isConnected && el.sheet && el.sheet.cssRules; } catch (e) { return; }
|
|
if (now && now.length) rehostInvasiveSheets();
|
|
}, 2000);
|
|
}
|
|
if (invasiveSheet(el, universe)) rehostIntoThemeLayer(el, universe);
|
|
});
|
|
}
|
|
|
|
/* ---- the one thing that is safe to remove: a byte-identical second copy ----
|
|
* `luci-app-podkop` injects 4 KB from render() with no guard and `luci-app-mosdns` re-appends three
|
|
* CodeMirror <link>s, so every SPA re-visit adds a copy parsed forever. The first copy keeps the
|
|
* rules and any app handle. Re-hosting runs before this, or "keep the first" would keep the raw
|
|
* copy and strand the shim. A duplicate needs the same owner. */
|
|
function sheetKey(el) {
|
|
if (el.tagName === 'LINK') return 'LINK|' + el.href;
|
|
const t = origText.get(el);
|
|
if (t !== undefined) return 'STYLE|' + t;
|
|
/* Not wrapped, so the textContent may not be the sheet: insertRule-built <style>s all have an
|
|
* empty one, and keying on the text would remove the second. */
|
|
const rules = el.sheet && el.sheet.cssRules;
|
|
if (!rules || !rules.length) return null;
|
|
return 'STYLE|' + serializeRules(rules);
|
|
}
|
|
|
|
function dedupeViewSheets() {
|
|
const seen = new Set();
|
|
document.querySelectorAll(VIEW_SHEETS).forEach((el) => {
|
|
if (!outlivesPage(el)) return;
|
|
const key = sheetKey(el);
|
|
if (key === null) return;
|
|
/* owner first: a page key never holds '\n', so the first one always ends the owner part */
|
|
const k = (_owner.get(el) || '') + '\n' + key;
|
|
if (seen.has(k)) el.remove();
|
|
else seen.add(k);
|
|
});
|
|
}
|
|
|
|
/* ---- the layer order is a document-wide fact, and a sheet inserted first can rewrite it ----
|
|
* `@layer tokens, base, theme, page;` holds only while cascade.css is the first sheet to name a
|
|
* layer: Ace inserts its <style> first in <head>, and an earlier sheet naming `theme` makes it the
|
|
* weakest layer. Inserting a new sheet re-runs the ordering while moving one does not (measured),
|
|
* so the order is re-declared from a fresh <style> placed first. */
|
|
const LAYER_ORDER = '@layer tokens, base, theme, page;';
|
|
let _layerStmt = null;
|
|
|
|
function reassertLayerOrder() {
|
|
const head = document.head;
|
|
if (!head) return;
|
|
const own = [...document.querySelectorAll('link[rel~="stylesheet"]')]
|
|
.find((l) => (/\/cascade\.css/).test(l.href || ''));
|
|
if (!own) return;
|
|
const anchor = _layerStmt && _layerStmt.isConnected ? _layerStmt : own;
|
|
const ahead = [...document.querySelectorAll('style, link[rel~="stylesheet"]')]
|
|
.some((el) => el !== anchor && el !== own &&
|
|
(anchor.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING));
|
|
if (!ahead) return;
|
|
|
|
if (_layerStmt) _layerStmt.remove();
|
|
_layerStmt = document.createElement('style');
|
|
_layerStmt.textContent = LAYER_ORDER;
|
|
/* before the first sheet, not into <head>: a document_start script (an extension) appends its
|
|
* <style> to <html> ahead of <head>, and Firefox then laid Overview out 2125px short at 390px */
|
|
const first = document.querySelector('style, link[rel~="stylesheet"]');
|
|
first.parentNode.insertBefore(_layerStmt, first);
|
|
}
|
|
|
|
function sweepViewSheets() {
|
|
rehostInvasiveSheets();
|
|
dedupeViewSheets();
|
|
reassertLayerOrder();
|
|
}
|
|
|
|
let _enableWatch = null;
|
|
function enableWatch() {
|
|
return _enableWatch || (_enableWatch = new MutationObserver(sweepViewSheets));
|
|
}
|
|
|
|
function watchViewSheets() {
|
|
sweepViewSheets();
|
|
const mo = new MutationObserver((muts) => {
|
|
for (const m of muts)
|
|
for (const n of m.addedNodes)
|
|
if (n.nodeName === 'STYLE' || n.nodeName === 'LINK') {
|
|
/* `continue`, not `return`: our own statement can share a batch with the sheet
|
|
* that made it necessary */
|
|
if (n === _layerStmt) continue;
|
|
sweepViewSheets();
|
|
return;
|
|
}
|
|
});
|
|
mo.observe(document.head, { childList: true });
|
|
/* …and <body>: a <style> appended there after chrome init is seen by nothing otherwise. */
|
|
mo.observe(document.body, { childList: true });
|
|
}
|
|
|
|
return baseclass.extend({
|
|
attributeTo,
|
|
documentCarries,
|
|
documentPoisoned,
|
|
scopeToCurrentPage,
|
|
watchViewSheets,
|
|
});
|