// ============================================================================= // 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. becomes /docs/api // // 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 (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(); })();