From 6ef39333787754ec27649799d80c2c553dee1dc7 Mon Sep 17 00:00:00 2001 From: pi Date: Wed, 2 Sep 2026 11:34:50 +0200 Subject: [PATCH] Add Manifest V3 extension that fills empty links with their href MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a compile-time configured website (config.js), empty tags — an href but no visible content — get the href inserted as text. Works with dynamically rendered content via a MutationObserver (childList, characterData, href attributes), recursive open-shadow-root watching, and a periodic safety-net re-scan. Includes manifest.json, config.js, content.js, README, and a jsdom smoke test. --- .gitignore | 3 + README.md | 63 ++++++++++++++ config.js | 30 +++++++ content.js | 225 ++++++++++++++++++++++++++++++++++++++++++++++++ manifest.json | 13 +++ tests/smoke.mjs | 144 +++++++++++++++++++++++++++++++ 6 files changed, 478 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config.js create mode 100644 content.js create mode 100644 manifest.json create mode 100644 tests/smoke.mjs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2e6fae9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +package-lock.json +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..4f24988 --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# Empty Link Fixer + +A Chrome extension (Manifest V3) that automatically fixes empty `` tags on a +website you configure at build time — links with an `href` but no visible +content get the href inserted as their text: + +```html + + + + +/docs/api +``` + +It also works on **dynamically rendered sites** (SPAs, virtual DOM, infinite +scroll, web components): a content script watches the live DOM and fills empty +links as soon as they appear. + +## Install + +1. Open `chrome://extensions`, enable **Developer mode**. +2. Click **Load unpacked** and select this directory. +3. Visit the configured site — empty links are filled automatically. + +## Configuration + +Edit **`config.js`** before loading the extension (no runtime UI), then reload +the extension in `chrome://extensions`: + +```js +globalThis.LINK_FIX_CONFIG = { + hosts: ["example.com"], // website(s), matched against location.hostname + includeSubdomains: true, // also www., docs., app. example.com + rescanIntervalMs: 3000, // safety-net re-scan; 0 disables it + debug: false, // verbose console logging +}; +``` + +`hosts: ["*"]` fixes every site (debugging only). For local testing, serve a +page over HTTP and set `hosts: ["localhost"]` (hostname only — the port is +ignored). + +## How it works + +1. **Initial pass** — empty anchors already on the page are fixed at startup. +2. **`MutationObserver`** over the whole document reacts to nodes added/removed, + text emptied in place, and `href` attributes appearing later. +3. **Open shadow roots** (web components) are watched recursively. +4. **Periodic re-scan** catches changes with no observable DOM mutation. + +An anchor is touched only if it has a real `href` (not `#` or +`javascript:`/`data:`/`vbscript:`), no visible text, and no media content +(`img`, `svg`, `canvas`, …). Links with text or icons are left alone, as are +`contenteditable` regions. + +## Layout + +``` +manifest.json MV3 declaration (injects the content script on all sites) +config.js site configuration — the only file you normally edit +content.js the fixer (initial scan + MutationObserver + shadow DOM) +tests/smoke.mjs smoke test: npm install --no-save jsdom && node tests/smoke.mjs +``` diff --git a/config.js b/config.js new file mode 100644 index 0000000..d430234 --- /dev/null +++ b/config.js @@ -0,0 +1,30 @@ +// ============================================================================= +// Empty Link Fixer — website configuration ("compile-time"). +// +// This is the ONLY file you normally need to edit, BEFORE loading the extension +// (hence "compile-time": changing it requires reloading the extension). +// +// After editing: go to chrome://extensions -> click "Reload" on this extension, +// then reload the target page. +// ============================================================================= + +globalThis.LINK_FIX_CONFIG = { + // The website(s) the extension should fix, matched against location.hostname. + // Examples: + // hosts: ["example.com"] -> only example.com + // hosts: ["example.com"] -> with includeSubdomains below, also + // www.example.com, docs.example.com, ... + // hosts: ["*"] -> every site (debugging only) + hosts: ["web.webex.com"], + + // Also act on subdomains of the configured hosts. + includeSubdomains: true, + + // Safety net: every N ms the extension re-scans the page. This covers content + // that appears without observable DOM mutations (e.g. a web component that + // attaches an open shadow root). 0 disables the periodic re-scan. + rescanIntervalMs: 3000, + + // Log what the extension does to the DevTools console. + debug: true, +}; diff --git a/content.js b/content.js new file mode 100644 index 0000000..75d6661 --- /dev/null +++ b/content.js @@ -0,0 +1,225 @@ +// ============================================================================= +// 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(); +})(); diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..a780f7f --- /dev/null +++ b/manifest.json @@ -0,0 +1,13 @@ +{ + "manifest_version": 3, + "name": "Empty Link Fixer", + "version": "1.0.0", + "description": "On the configured website, fills empty anchor links (an href but no visible content) with the href as text. Also works with dynamically rendered content.", + "content_scripts": [ + { + "matches": [""], + "js": ["config.js", "content.js"], + "run_at": "document_idle" + } + ] +} diff --git a/tests/smoke.mjs b/tests/smoke.mjs new file mode 100644 index 0000000..493b926 --- /dev/null +++ b/tests/smoke.mjs @@ -0,0 +1,144 @@ +// Smoke test for the Empty Link Fixer content script. +// +// Runs the actual config.js + content.js against jsdom pages and verifies that +// empty anchors are filled — statically, on dynamically added content, emptied +// text, late href attributes and inside (open) shadow DOM — while links that +// have text or media content are left alone. +// +// Usage: node tests/smoke.mjs (needs `npm install --no-save jsdom`) + +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import assert from 'node:assert/strict'; +import { JSDOM } from 'jsdom'; + +const dir = fileURLToPath(new URL('..', import.meta.url)); +const configJs = readFileSync(`${dir}config.js`, 'utf8'); +const contentJs = readFileSync(`${dir}content.js`, 'utf8'); + +const tick = (ms = 40) => new Promise((r) => setTimeout(r, ms)); + +function boot(html, { url = 'https://example.com/', hosts = ['example.com'] } = {}) { + const dom = new JSDOM(html, { + url, + runScripts: 'outside-only', + pretendToBeVisual: true, + }); + const { window } = dom; + window.eval(configJs); + window.eval(`globalThis.LINK_FIX_CONFIG = ${JSON.stringify({ + hosts, + includeSubdomains: true, + rescanIntervalMs: 0, // disable the safety-net timer during tests + debug: false, + })};`); + window.eval(contentJs); + return dom; +} + +const $ = (dom, sel) => dom.window.document.querySelector(sel); + +async function run() { + // ---------------------------------------------------------------- static + { + const dom = boot(` + + Already has text + icon + + + + + + `); + await tick(); + assert.equal($(dom, '#e1').textContent, 'https://example.com/a', 'empty anchor filled with href'); + assert.equal($(dom, '#e2').textContent.trim(), 'Already has text', 'text anchor untouched'); + assert.equal($(dom, '#e3').textContent, '', 'image anchor untouched'); + assert.ok($(dom, '#e3').querySelector('img'), 'image preserved inside anchor'); + assert.equal($(dom, '#e4').textContent, '', 'svg anchor untouched'); + assert.ok($(dom, '#e4').querySelector('svg'), 'svg preserved inside anchor'); + assert.equal($(dom, '#e5').textContent.trim(), '/ws', 'whitespace-only anchor filled'); + assert.equal($(dom, '#e6').textContent, '', 'placeholder href="#" not filled'); + assert.equal($(dom, '#e7').textContent, '', 'javascript: href not filled'); + assert.equal($(dom, '#e8').textContent.trim(), '/hidden', 'empty anchor filled regardless of visibility'); + dom.window.close(); + } + + // ------------------------------------------------- dynamically inserted + { + const dom = boot('
'); + await tick(); + dom.window.document.querySelector('#root').innerHTML = + 'keep me'; + await tick(); + assert.equal($(dom, 'a[href="/dyn"]').textContent.trim(), '/dyn', 'dynamically added empty anchor filled'); + assert.equal($(dom, 'a[href="/kept"]').textContent.trim(), 'keep me', 'dynamically added text anchor untouched'); + dom.window.close(); + } + + // ------------------------------------------------- text emptied in place + { + const dom = boot('content here'); + await tick(); + // Simulate a framework emptying the text node in place (characterData). + dom.window.document.querySelector('#t1').firstChild.nodeValue = ''; + await tick(); + assert.equal($(dom, '#t1').textContent.trim(), '/t1', 'anchor re-filled after its text node was emptied'); + dom.window.close(); + } + + // ------------------------------------------------- children replaced + { + const dom = boot('content here'); + await tick(); + // Simulate a re-render that wipes the inner content entirely. + dom.window.document.querySelector('#t2').textContent = ''; + await tick(); + assert.equal($(dom, '#t2').textContent.trim(), '/t2', 'anchor re-filled after children were removed'); + dom.window.close(); + } + + // ------------------------------------------------- href added later + { + const dom = boot(''); + await tick(); + dom.window.document.querySelector('#t3').setAttribute('href', '/later'); + await tick(); + assert.equal($(dom, '#t3').textContent.trim(), '/later', 'anchor filled when href appears dynamically'); + dom.window.close(); + } + + // ------------------------------------------------- open shadow DOM + { + const dom = boot('
'); + const { document } = dom.window; + await tick(); + const host = document.querySelector('#host'); + const root = host.attachShadow({ mode: 'open' }); + root.innerHTML = 'shadow text'; + document.body.appendChild(host); // host enters the DOM after init + await tick(); + assert.equal(root.querySelector('a[href="/shadow"]').textContent.trim(), '/shadow', 'anchor inside open shadow root filled'); + assert.equal(root.querySelector('a[href="/shadow2"]').textContent.trim(), 'shadow text', 'text anchor in shadow root untouched'); + dom.window.close(); + } + + // ------------------------------------------------- different site + { + const dom = boot('', { + url: 'https://somewhere-else.com/', + hosts: ['example.com'], + }); + await tick(); + assert.equal($(dom, '#off').textContent, '', 'nothing happens on a non-configured site'); + dom.window.close(); + } + + console.log('All smoke tests passed ✔'); +} + +run().catch((err) => { + console.error(err); + process.exit(1); +});