WebexLinkFixer/content.js

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();
})();