Overview
ln-external-links runs globally across document.body watching all <a> and <area> elements. When an element's href hostname differs from window.location.host, it sets target="_blank", merges rel="noopener noreferrer", appends an accessible <span class="sr-only">(opens in new tab)</span>, and marks it with data-ln-external-link="processed" to prevent redundant mutations.
There is no opt-in attribute and no imperative init call — the decorator runs autonomously, safely handling initial server-rendered markup and dynamic links added via AJAX.
The component exists because external-link hardening is a tiny, everywhere concern. The project that gets it right does it once, in one place, and forgets it. The project that gets it wrong scatters one-off hand-decorations across templates, misses the dynamic ones loaded by AJAX, and ships the day a new contributor adds an <a href="https://..."> without thinking about rel.
"External" is defined precisely as hostname inequality: the link's hostname is non-empty AND differs from window.location.hostname. That single test rejects mailto:, tel:, javascript:, relative paths, and same-page anchors in one line. It does not distinguish ports or protocols — different ports on the same host are "internal." Subdomains of the same root are "external."
For the full guide see the ln-external-links README and the architecture reference.
Anatomy
There is no markup contract. Every <a> and <area> on the page participates by default. The "before" / "after" view tells the whole story:
<!-- Before (your authored HTML) -->
<a href="https://example.com">Read more</a>
<!-- After (what ln-external-links produces) -->
<a href="https://example.com"
target="_blank"
rel="noopener noreferrer"
data-ln-external-link="processed">
Read more
<span class="sr-only">(opens in new tab)</span>
</a>
<!-- Internal links — no change -->
<a href="/dashboard">Dashboard</a>
<a href="#section">Jump</a>
<a href="mailto:hello@livenetworks.mk">Email</a>
The four mutations
For each external link, the component performs four DOM writes in this order: target="_blank", rel="noopener noreferrer", append a <span class="sr-only">(opens in new tab)</span> as the link's last child, and set the marker attribute data-ln-external-link="processed". The marker is the idempotency gate — every subsequent processing pass early-returns on links that already carry it.
What rel="noopener noreferrer" buys you
noopener strips window.opener in the new tab — without it, the destination site can call window.opener.location = '...' and silently navigate the source tab to a phishing page (the classic "tabnabbing" exploit). Modern browsers already apply this semantics to target="_blank" by default since 2021, but rel="noopener" remains recommended as defense-in-depth and for older browsers — it costs nothing to set. noreferrer additionally suppresses the Referer header on the outgoing request — a privacy posture, not a security primitive.
Accessibility — the sr-only hint
WCAG 2.4.4 ("Link Purpose") expects a verbal cue when target="_blank" opens a link in a new tab. The component appends an English-language hint span ((opens in new tab)) inside every processed link. Sighted users see no change because .sr-only clips the span to a 1×1 box; screen readers announce the suffix when the link receives focus.
What counts as "external"
The single test (line 9 of the source): link.hostname && link.hostname !== window.location.hostname. Behavior on the URLs your project will actually encounter:
href | External? | Why |
|---|---|---|
https://example.com/path | yes | Different host |
https://app.example.com (page on example.com) | yes | Subdomain — different hostname |
https://samehost.com/path (page on samehost.com) | no | Same hostname |
https://samehost.com:8443 (page on default port) | no | Hostname strips port |
http://samehost.com (page on https://) | no | Hostname ignores scheme |
/dashboard (relative path) | no | Hostname inherits from page |
#section | no | Same-page anchor |
mailto:hello@example.com | no | Empty hostname → fails the && guard |
tel:+38970000000 | no | Empty hostname |
javascript:void(0) | no | Empty hostname |
data:text/html;... | no | Empty hostname |
Live demo — decorated vs. untouched
Inspect the links below in DevTools. The external ones carry target="_blank", rel="noopener noreferrer", and a data-ln-external-link="processed" marker — none of which is in this page's HTML source. The internal links are unmodified.
Internal links (no change)
Example — Tracking :processed and :clicked events
Two events bubble out of every processed link: ln-external-links:processed when the decoration finishes, and ln-external-links:clicked when the user clicks. The log below subscribes to both. Click any external link in the section above — open them in a new tab, no harm done — and watch the entries appear.
Note about :clicked.detail.text: it includes the (opens in new tab) hint suffix because link.textContent concatenates all descendant text. See the README "Common mistakes" item 3 for stripping it.
Example — Dynamically inserted external links
The MutationObserver watches document.body with childList: true, subtree: true. New <a> nodes (and any nested in inserted subtrees) get decorated automatically. Click the button to insert a fragment containing one external and one internal link via innerHTML; watch the inserted external link gain its decoration in DevTools.
Example — Mutating href redecorates automatically
The MutationObserver watches attributeFilter: ['href'] in addition to childList. Changing an existing link's href from internal to external triggers _processLink on the next microtask — no manual call needed. The link below starts internal (/dashboard); the first button flips its href to https://example.com and the decoration appears. The reset button restores the original state.
Test link (initially internal)
Open DevTools and watch the target, rel, and data-ln-external-link attributes appear after the flip. The state read below is captured synchronously after setAttribute('href', ...) and re-read on ln-external-links:processed — you'll see the values flip on the second microtask.
Example — rel="me" merge mode
The component reads the link's existing rel token list, ensures noopener and noreferrer are present, and writes the result back. Microformat values authored on the link survive the decoration pass. Inspect the link below in DevTools — the authored rel="me" is preserved alongside the added flags.
Expected post-decoration: rel="me noopener noreferrer", target="_blank", marker set, sr-only hint appended.
Example — Opting out via the unofficial whitelist hatch
There is no public opt-out attribute. The unofficial path is to pre-set data-ln-external-link="processed" in your markup — the idempotency guard at line 13 treats the link as already-decorated and skips it. The link below is external but pre-marked, so it stays in the same tab without target="_blank".
<a href="https://oauth.example.com/return"
data-ln-external-link="processed">
Continue with Example (same tab)
</a>
Continue with Example (same tab)
Use this for OAuth callbacks, payment processors that require same-tab flows, or any link where target="_blank" is wrong. Document the hatch in your project — it works because of how the guard is implemented, not because the component declares "skip" as a feature.
Example — Confirm-before-leaving interstitial
The library does not ship an interstitial. The pattern below composes ln-modal with a capture-phase click listener that intercepts the navigation BEFORE ln-external-links' bubble-phase delegate dispatches :clicked. Modifier-key clicks (Ctrl, Cmd, Shift, middle-click) are passed through so users keep their browser-native shortcuts.
Modifier-key click (Ctrl/Cmd/Shift/middle) bypasses the modal — try it.
API
// The only public function. Re-scans a subtree for a, area elements
// and applies decoration synchronously. Already-processed links no-op.
window.lnExternalLinks.process(container);
// Default container is document.body — no argument re-scans the whole page.
window.lnExternalLinks.process();
// To force re-decoration on a link whose href just changed:
link.removeAttribute('data-ln-external-link');
window.lnExternalLinks.process(link.parentElement);
There is no destroy(), no init(), no per-element instance API. The component is a single document-level click delegate plus a single body-level MutationObserver — both live for the document's lifetime.
Events
| Event | Bubbles | Cancelable | Detail | When |
|---|---|---|---|---|
ln-external-links:processed |
yes | no | { link, href } |
A link finishes decoration (after all four mutations). Fired on the link itself. |
ln-external-links:clicked |
yes | no | { link, href, text } |
The user clicks anywhere inside a processed external link. text includes the sr-only hint suffix. |
:clicked is a notification, not a hook. The component does not call preventDefault() on the click and does not check the CustomEvent's defaultPrevented. To intercept navigation, attach a click listener to the link or a parent in the capture phase and preventDefault() the platform click event — see the interstitial example.
Common mistakes
1. Calling preventDefault() on :clicked and expecting navigation to stop
The component does not honor defaultPrevented on the CustomEvent. Use a capture-phase platform click listener to intercept navigation.
2. Different ports treated as cross-host
The host comparison uses hostname, which strips ports. localhost:3000 and localhost:8080 are "internal" to the component. Same with different protocols on the same host.
3. Reading :clicked.detail.text for analytics and getting the sr-only hint suffix
link.textContent includes the appended (opens in new tab) hint span. Strip it with label.replace(/\s*\(opens in new tab\)\s*$/, '') or read non-hint child nodes manually.