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:
commit
6ef3933378
|
|
@ -0,0 +1,3 @@
|
|||
node_modules/
|
||||
package-lock.json
|
||||
*.log
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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();
|
||||
})();
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue