rehype-external-link-title
v0.1.0
Published
rehype plugin that fetches the page title of external links and sets it as the `title` attribute, with pluggable caching.
Maintainers
Readme
rehype-external-link-title
rehype plugin that fetches the page <title> of every external
link in your HTML and writes it to the link's title attribute (so users see
the destination's real name when they hover the link), with a pluggable
caching layer.
Contents
- What is this?
- When should I use this?
- Install
- Use
- API
- Examples
- Types
- Compatibility
- Security
- Contributing
- Releasing
- License
What is this?
This is a unified (rehype) plugin. It walks the hast tree, finds
external <a> elements (by default: anchors whose href starts with http://
or https://), fetches each unique URL, parses the <title> element from the
response, and stores it on the anchor as a title attribute.
To avoid hammering remote servers (and to keep your build times reasonable), results are persisted to a cache. The cache is pluggable: a default lowdb-backed JSON file is provided out of the box, and you can swap in your own backend (Redis, KV, in-memory, etc.) by implementing a tiny two-method interface.
When should I use this?
Use this plugin if you publish content with many external references — blog posts, link round-ups, documentation — and you want hover tooltips to display the actual page title rather than the raw URL.
You probably shouldn't use it if:
- Your build runs in a sandbox without outbound network access.
- You don't trust the remote pages and don't want to render their titles
(consider
rehype-sanitizedownstream regardless — see Security). - Build performance is more important than hover-over UX (the first build fetches every link; subsequent builds are cache hits).
Install
This package is ESM only. In Node.js (version 18+):
npm install rehype-external-link-titlepnpm add rehype-external-link-titleUse
Say we have the following input HTML:
<p>Read more on <a href="https://example.com">example.com</a>.</p>…and a script example.js:
import {unified} from 'unified'
import rehypeParse from 'rehype-parse'
import rehypeStringify from 'rehype-stringify'
import rehypeExternalLinkTitle from 'rehype-external-link-title'
const file = await unified()
.use(rehypeParse, {fragment: true})
.use(rehypeExternalLinkTitle)
.use(rehypeStringify)
.process('<p><a href="https://example.com">example.com</a></p>')
console.log(String(file))…running node example.js yields (assuming the page's title is Example Domain):
<p><a href="https://example.com" title="Example Domain" data-title-updated-at="2026-04-19T00:00:00.000Z">example.com</a></p>API
This package exports the named identifiers lowdbCache, memoryCache, and the
TypeScript types Cache, CacheEntry, FetchOptions, LinkPredicate, and
Options. The default export is rehypeExternalLinkTitle.
unified().use(rehypeExternalLinkTitle[, options])
Adds page titles to external <a> elements as title attributes, with caching.
Parameters
options(Options, optional) — configuration
Returns
Async transform.
Options
Configuration (TypeScript type).
| Field | Type | Default | Description |
| ------------------ | ------------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| cache | string \| Cache | built-in lowdbCache() | undefined: lowdb at ./db.titles.json. string: lowdb at the given path. Cache: your own implementation. |
| ttl | number | Infinity | TTL for successful entries, in ms. |
| failureTtl | number | 86_400_000 (24 h) | TTL for failed entries (title === null), in ms. Use 0 to never cache failures, Infinity to cache forever. |
| test | (href, node) => boolean | http(s)://... | Predicate deciding which <a> elements to process. |
| attribute | string | 'title' | Attribute name written on the link element. |
| includeUpdatedAt | boolean | true | Whether to also write data-title-updated-at (ISO timestamp). |
| concurrency | number | 8 | Maximum concurrent outbound fetches per transformer invocation. |
| fetch | FetchOptions | see below | Options forwarded to the internal HTTP client (timeout, userAgent, signal). |
Cache
The plugin treats the cache as a dumb async key/value store. TTL/staleness handling is performed by the plugin itself, so cache implementations stay trivial:
export interface CacheEntry {
title: string | null // `null` = "we tried and got nothing"
updatedAt: string // ISO-8601
}
export interface Cache {
get(url: string): Promise<CacheEntry | undefined> | CacheEntry | undefined
set(url: string, entry: CacheEntry): Promise<void> | void
delete?(url: string): Promise<void> | void // optional
}Both sync and async return values are supported, so a Map-backed cache or a
Redis-backed cache are equally easy to write.
Built-in caches
import {lowdbCache, memoryCache} from 'rehype-external-link-title/cache'
const persistent = lowdbCache({path: '.cache/titles.json'})
const ephemeral = memoryCache()lowdbCache(options?: {path?: string})— JSON file backed by lowdb. The file is opened lazily on first use (no top-level I/O).memoryCache()—Map-backed; useful for tests or short-lived processes.
Examples
Custom cache path
unified().use(rehypeExternalLinkTitle, {cache: '.cache/external-link-titles.json'})Refetch every entry older than a week
unified().use(rehypeExternalLinkTitle, {ttl: 7 * 24 * 60 * 60 * 1000})Bring your own cache (Redis-style)
import type {Cache, CacheEntry} from 'rehype-external-link-title'
const redisCache: Cache = {
async get(url) {
const raw = await redis.get(`title:${url}`)
return raw ? (JSON.parse(raw) as CacheEntry) : undefined
},
async set(url, entry) {
await redis.set(`title:${url}`, JSON.stringify(entry))
},
async delete(url) {
await redis.del(`title:${url}`)
},
}
unified().use(rehypeExternalLinkTitle, {cache: redisCache})Custom User-Agent
unified().use(rehypeExternalLinkTitle, {
fetch: {userAgent: 'MyCoolBlog/1.0 (+https://example.com/about)'},
})Process only a subset of links
unified().use(rehypeExternalLinkTitle, {
test: (href) => href.startsWith('https://en.wikipedia.org/'),
})Types
This package is fully typed with TypeScript. It exports the additional
types Options, Cache, CacheEntry, FetchOptions, and LinkPredicate.
Compatibility
Compatible with maintained versions of Node.js (>=18). Works with unified
version 11+.
Security
This plugin sets the title attribute on <a> elements based on data fetched
from third-party servers. While title is generally not an XSS vector
(browsers do not interpret it as HTML), you should still pair this plugin with
rehype-sanitize downstream, configured to allow the
title attribute on anchors:
import rehypeSanitize, {defaultSchema} from 'rehype-sanitize'
unified()
.use(rehypeExternalLinkTitle)
.use(rehypeSanitize, {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
a: [...(defaultSchema.attributes?.a ?? []), 'title'],
},
})The HTML returned by remote servers is sanitized internally with DOMPurify
(stripped down to <html>/<head>/<title> only) before the title is
extracted, so malicious script tags in the source page are discarded before
parsing.
Contributing
Local setup
pnpm install
pnpm test # run vitest
pnpm test:coverage # run vitest with v8 coverage (writes ./coverage)
pnpm typecheck # tsc --noEmit
pnpm build # tsdown → ./distSecret scanning (gitleaks pre-commit hook)
This repo ships a Docker-based gitleaks pre-commit hook in .githooks/pre-commit. It scans the staged diff only (so it's fast) and blocks the commit if any secret is detected.
Enable the hook (one-time, per clone)
git config core.hooksPath .githooksHooks live inside the repo (under
.githooks/) instead of.git/hooks/so they're versioned and shared. Each contributor must opt in once with the command above — git does not auto-trust in-repo hooks, by design.
Requirements
- Docker must be available on
PATH. The hook pulls/runs the pinned imagezricethezav/gitleaks:v8.30.1once per commit (no local Go install needed). - If Docker is missing, the hook prints a warning and lets the commit through, so it doesn't break contributors who haven't installed Docker yet — but CI will still reject committed secrets (see below).
Bypassing
For an intentional false-positive bypass on a single commit:
git commit --no-verifyManual full-repo scan
docker run --rm -v "$(pwd):/repo" -w /repo zricethezav/gitleaks:v8.30.1 git --redact --verboseProject-specific allowlists (e.g. test fixtures that look like keys but aren't) live in .gitleaks.toml.
Continuous integration
.github/workflows/ci.yml runs on every push and pull request:
- Build & test —
pnpm install --frozen-lockfile,pnpm typecheck,pnpm test:coverage,pnpm build. - SonarQube Cloud scan — uploads
coverage/lcov.infoplus a static analysis pass. Requires theSONAR_TOKENrepository secret (generated at SonarCloud → My Account → Security). - Gitleaks scan — full-history secret scan via gitleaks/gitleaks-action. No license needed for personal-account repos.
All actions are pinned to commit SHAs (with the human-readable tag in a comment) so the workflow is reproducible and auditable.
Releasing
Releases are published to npm by .github/workflows/publish.yml on every published GitHub Release, using npm trusted publishing (OIDC) — no NPM_TOKEN secret is involved.
One-time setup on npmjs.com
- Publish version
0.1.0manually once (so the package exists). Then on subsequent releases the trusted publisher takes over. - Visit
https://www.npmjs.com/package/rehype-external-link-title/access→ Trusted Publisher → add a new GitHub Actions publisher with:- Organization or user:
aripalo - Repository:
rehype-external-link-title - Workflow filename:
publish.yml - Environment: (leave blank, or set if you want manual approval gating)
- Organization or user:
- Delete any pre-existing
NPM_TOKENrepository secret — it's no longer needed and is now an unused attack surface.
Cutting a release
- Bump the
versionfield inpackage.jsonand commit. - Tag and push:
git tag v0.2.0 && git push --tags - Create a GitHub Release for that tag (via the GitHub UI or
gh release create v0.2.0). - The workflow runs typecheck + tests + build, then
npm publish --provenance --access public. The OIDC token is exchanged with npm for short-lived publish credentials, and a provenance attestation is attached to the published version.
Anatomy of package.json lifecycle scripts
| Script | When it runs | Purpose |
|---|---|---|
| prepack | npm pack, npm publish, git installs | Builds dist/ so the tarball is always complete |
| prepublishOnly | npm publish only | Runs pnpm typecheck && pnpm test as a publish gate |
