Add Manifest V3 extension that fills empty links with their href

On a compile-time configured website (config.js), empty <a> 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.
This commit is contained in:
pi 2026-09-02 11:34:50 +02:00
commit 6ef3933378
6 changed files with 478 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules/
package-lock.json
*.log

63
README.md Normal file
View File

@ -0,0 +1,63 @@
# Empty Link Fixer
A Chrome extension (Manifest V3) that automatically fixes empty `<a>` 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
<!-- before -->
<a href="/docs/api"></a>
<!-- after -->
<a href="/docs/api">/docs/api</a>
```
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
```

30
config.js Normal file
View File

@ -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,
};

225
content.js Normal file
View File

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

13
manifest.json Normal file
View File

@ -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": ["<all_urls>"],
"js": ["config.js", "content.js"],
"run_at": "document_idle"
}
]
}

144
tests/smoke.mjs Normal file
View File

@ -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(`
<a id="e1" href="https://example.com/a"></a>
<a id="e2" href="/docs">Already has text</a>
<a id="e3" href="/icon"><img src="x.png" alt="icon"></a>
<a id="e4" href="/svg"><svg viewBox="0 0 10 10"><circle cx="5" cy="5" r="5"/></svg></a>
<a id="e5" href="/ws"> </a>
<a id="e6" href="#"></a>
<a id="e7" href="javascript:void(0)"></a>
<a id="e8" href="/hidden" style="display:none"></a>
`);
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('<div id="root"></div>');
await tick();
dom.window.document.querySelector('#root').innerHTML =
'<a href="/dyn"></a><a href="/kept">keep me</a>';
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('<a id="t1" href="/t1">content here</a>');
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('<a id="t2" href="/t2">content here</a>');
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('<a id="t3"></a>');
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('<div id="host"></div>');
const { document } = dom.window;
await tick();
const host = document.querySelector('#host');
const root = host.attachShadow({ mode: 'open' });
root.innerHTML = '<a href="/shadow"></a><a href="/shadow2">shadow text</a>';
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('<a id="off" href="https://other.com/x"></a>', {
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);
});