226 lines
8.0 KiB
JavaScript
226 lines
8.0 KiB
JavaScript
// =============================================================================
|
|
// Empty Link Fixer — content script.
|
|
//
|
|
// On the website configured in config.js (loaded first, same isolated world),
|
|
// every anchor that has an href but NO visible content gets the href inserted
|
|
// as text, e.g. <a href="/docs/api"></a> becomes <a href="/docs/api">/docs/api</a>
|
|
//
|
|
// This "always" works, including on dynamically rendered sites (SPAs, virtual
|
|
// DOM, infinite scroll, web components), because the extension:
|
|
// 1. fixes every empty anchor already present when the script starts,
|
|
// 2. observes ALL DOM changes (added/removed nodes, emptied text,
|
|
// href attributes appearing) with a MutationObserver and fixes on the fly,
|
|
// 3. watches inside open shadow roots (web components), and
|
|
// 4. keeps a small periodic re-scan as a safety net.
|
|
// =============================================================================
|
|
|
|
(() => {
|
|
'use strict';
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Configuration (from config.js)
|
|
// -------------------------------------------------------------------------
|
|
const cfg = (globalThis && globalThis.LINK_FIX_CONFIG) || {};
|
|
const hostList = Array.isArray(cfg.hosts) ? cfg.hosts : [];
|
|
const includeSubdomains = cfg.includeSubdomains !== false;
|
|
const wantAllSites = hostList.includes('*');
|
|
const debug = cfg.debug === true;
|
|
|
|
const currentHost = (location.hostname || '').toLowerCase();
|
|
|
|
const normalizeHost = (input) =>
|
|
String(input)
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '') // scheme
|
|
.replace(/^\/\//, '') // protocol-relative URLs
|
|
.split('/')[0] // path
|
|
.split(':')[0] // port
|
|
.replace(/^www\./, '');
|
|
|
|
const onTargetSite =
|
|
wantAllSites ||
|
|
hostList.some((entry) => {
|
|
const configured = normalizeHost(entry);
|
|
if (!configured) return false;
|
|
return (
|
|
currentHost === configured ||
|
|
(includeSubdomains && currentHost.endsWith('.' + configured))
|
|
);
|
|
});
|
|
|
|
if (!onTargetSite) return; // Not the configured website: do nothing.
|
|
|
|
// -------------------------------------------------------------------------
|
|
// What counts as an "empty" anchor
|
|
// -------------------------------------------------------------------------
|
|
|
|
// Elements that provide visible, non-text content inside an <a> (icons,
|
|
// images, video, form controls, ...). Such links are NOT "empty".
|
|
const MEDIA_SELECTOR = [
|
|
'img', 'svg', 'picture', 'canvas', 'video', 'audio',
|
|
'iframe', 'embed', 'object',
|
|
'input', 'select', 'textarea', 'button', 'progress', 'meter',
|
|
].join(', ');
|
|
|
|
// hrefs that are not real navigations: inserting them as text is pointless.
|
|
const VOID_HREF_RE = /^(?:javascript|data|vbscript):/i;
|
|
|
|
function isFixableAnchor(a) {
|
|
if (!a || a.tagName !== 'A') return false;
|
|
if (!a.hasAttribute('href')) return false;
|
|
|
|
const href = a.getAttribute('href').trim();
|
|
if (!href || href === '#' || VOID_HREF_RE.test(href)) return false;
|
|
|
|
// Already has visible text?
|
|
if (a.textContent.trim() !== '') return false;
|
|
|
|
// Carries visible media content (icon, image, ...)?
|
|
if (a.querySelector(MEDIA_SELECTOR) !== null) return false;
|
|
|
|
// Leave WYSIWYG/editable regions alone.
|
|
if (a.closest('[contenteditable]:not([contenteditable="false"])')) return false;
|
|
|
|
return true;
|
|
}
|
|
|
|
function fixAnchor(a) {
|
|
if (!isFixableAnchor(a)) return;
|
|
const href = a.getAttribute('href').trim();
|
|
|
|
// Remove whitespace-only text nodes so the result is tidy, then append the
|
|
// href as real text (children that are merely empty are kept intact).
|
|
a.normalize();
|
|
const children = Array.from(a.childNodes);
|
|
for (const node of children) {
|
|
if (node.nodeType === Node.TEXT_NODE && !node.textContent.trim()) {
|
|
node.remove();
|
|
}
|
|
}
|
|
|
|
a.appendChild(a.ownerDocument.createTextNode(href));
|
|
if (debug) console.debug('[linkfix] filled empty link ->', href, a);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Watching the live document
|
|
// -------------------------------------------------------------------------
|
|
|
|
const OBSERVER_OPTIONS = {
|
|
childList: true, // nodes added/removed anywhere
|
|
characterData: true, // text emptied in place
|
|
attributes: true, // href attribute appearing/changing
|
|
attributeFilter: ['href'],
|
|
subtree: true,
|
|
};
|
|
|
|
const observedRoots = new WeakSet();
|
|
let lastMutationAt = Date.now();
|
|
|
|
function fixAnchorsIn(root) {
|
|
if (!root) return;
|
|
if (
|
|
root.nodeType === Node.ELEMENT_NODE &&
|
|
root.matches &&
|
|
root.matches('a[href]')
|
|
) {
|
|
fixAnchor(root);
|
|
}
|
|
const anchors = root.querySelectorAll ? root.querySelectorAll('a[href]') : [];
|
|
for (const a of anchors) fixAnchor(a);
|
|
}
|
|
|
|
function onMutations(mutations) {
|
|
for (const mutation of mutations) {
|
|
try {
|
|
if (mutation.type === 'childList') {
|
|
// Content removed from inside a link may have left it empty.
|
|
if (
|
|
mutation.target &&
|
|
mutation.target.nodeType === Node.ELEMENT_NODE
|
|
) {
|
|
const ownerAnchor = mutation.target.closest('a');
|
|
if (ownerAnchor) fixAnchor(ownerAnchor);
|
|
}
|
|
// Newly added content: fix empty links inside it and look for
|
|
// web components (open shadow roots) that came with it.
|
|
for (const node of mutation.addedNodes) {
|
|
if (node.nodeType !== Node.ELEMENT_NODE) continue;
|
|
fixAnchorsIn(node);
|
|
discoverShadowRoots(node);
|
|
}
|
|
} else {
|
|
// characterData (text node emptied) or href attribute change:
|
|
// the closest anchor may now be empty (or newly hrefless-empty).
|
|
const el =
|
|
mutation.target.nodeType === Node.ELEMENT_NODE
|
|
? mutation.target
|
|
: mutation.target.parentElement;
|
|
if (el && el.closest) {
|
|
const ownerAnchor = el.closest('a');
|
|
if (ownerAnchor) fixAnchor(ownerAnchor);
|
|
}
|
|
}
|
|
lastMutationAt = Date.now();
|
|
} catch (err) {
|
|
console.warn('[linkfix] error while processing a mutation:', err);
|
|
}
|
|
}
|
|
}
|
|
|
|
function attachObserver(root) {
|
|
if (!root || observedRoots.has(root)) return;
|
|
observedRoots.add(root);
|
|
const observer = new MutationObserver(onMutations);
|
|
observer.observe(root, OBSERVER_OPTIONS);
|
|
fixAnchorsIn(root); // fix what is already there
|
|
discoverShadowRoots(root); // and what lives inside open shadow roots
|
|
}
|
|
|
|
// Find open shadow roots (web components) reachable from `scope` and watch
|
|
// each one, recursively. Closed shadow roots cannot be touched — by design.
|
|
function discoverShadowRoots(scope) {
|
|
if (!scope) return;
|
|
const stack = [
|
|
scope.nodeType === Node.DOCUMENT_NODE ? scope.documentElement : scope,
|
|
];
|
|
while (stack.length) {
|
|
const node = stack.pop();
|
|
if (!node || node.nodeType !== Node.ELEMENT_NODE) continue;
|
|
if (node.shadowRoot) attachObserver(node.shadowRoot);
|
|
for (const child of node.children) stack.push(child);
|
|
}
|
|
}
|
|
|
|
function start() {
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', start, { once: true });
|
|
return;
|
|
}
|
|
try {
|
|
attachObserver(document);
|
|
if (debug) console.debug('[linkfix] active on', currentHost);
|
|
|
|
// Safety net for content that changes without observable mutations
|
|
// (e.g. a host element attaching its shadow root after connection).
|
|
const rescanMs = Number(cfg.rescanIntervalMs) || 0;
|
|
if (rescanMs > 0) {
|
|
setInterval(() => {
|
|
if (document.visibilityState === 'hidden') return;
|
|
try {
|
|
fixAnchorsIn(document);
|
|
discoverShadowRoots(document);
|
|
} catch (err) {
|
|
console.warn('[linkfix] error during re-scan:', err);
|
|
}
|
|
}, rescanMs);
|
|
}
|
|
} catch (err) {
|
|
console.warn('[linkfix] failed to initialise:', err);
|
|
}
|
|
}
|
|
|
|
start();
|
|
})();
|