Ferrolink / Reference
CLI and checks
Fast, parallel link checker for static HTML builds.
ferrolink reads the output directory of a static site build and checks that
every link, asset reference, and #fragment points at something the build
contains. It parses every page in parallel, resolves references the way a
browser does, and maps URLs onto files the way static hosts do. It needs no
server and no network.
It was built to gate releases and deployments of Ardo documentation sites, and works on the output of any static site generator. It ships as a command-line tool on crates.io and as an npm package with prebuilt native binaries, a JavaScript API, and a Vite plugin.
$ ferrolink check docs/build/pages --exclude 'v[345]/**'
v6/guide/component-playground/index.html
line 17 broken link /v6/storybook
1 issue in 1 file · 318 pages · 10845 internal links checked · 482 external not checked · 24 ms
Website and getting started · Node.js and Vite reference
What it checks
- Links and assets:
a,area, andlinkhref;script,img,source,video,audio,track,iframe, andembedsrc;srcsetcandidates;videoposter;objectdata; SVGuseandimage;metarefresh targets;og:image,og:url, andtwitter:image. - Anchors: a fragment must name an element
id, or an<a name>, on the target page.#topand text fragments (#:~:text=) always resolve. - Base path: on a site served below
/<repo>/, an absolute link that forgets the prefix is reported asoutside base. - Deployment files at the site root: version paths in Ardo's
versions.json, and redirect and rewrite destinations in_redirects(Netlify, Cloudflare Pages) andvercel.json.
Links inside comments, scripts, styles, and other raw-text elements are ignored, as a browser ignores them. External URLs are counted, not fetched.
Install
The command-line tool:
cargo install --locked ferrolink
The development version, straight from the repository:
cargo install --locked --git https://github.com/sebastian-software/ferrolink
For Node.js and Vite, without a Rust toolchain:
npm install --save-dev ferrolink
The npm package brings a prebuilt binary for macOS (arm64, x64), Linux (arm64, x64; glibc and musl), and Windows (arm64, x64). It needs Node.js 20.19+ or 22.12+, as Vite 7 does.
Usage
# A site served from the domain root
ferrolink check docs/build/client
# A GitHub Pages project site, served below /ardo/
ferrolink check docs/build/client --base /ardo/
# Absolute links to your own host (canonical, og:url) are checked too
ferrolink check dist --site-url https://example.org/docs/
# Keep checking links into an archived version, but not the links inside it
ferrolink check docs/build/pages --exclude 'v3/**'
# Accept links to versions that are deployed separately
ferrolink check docs/build/client --assume-present 'v[345]/**'
# Check links and assets, but not #fragments
ferrolink check dist --no-anchors
# Machine-readable output for CI annotations
ferrolink check dist --format json
The exit code is 0 when every reference resolves, 1 when issues were
found, and 2 when the check could not run.
--exclude skips the links inside matching pages; --assume-present
accepts links to matching paths, or to the index.html a host serves for
them, without looking for them in the build or checking their anchors. Both
take globs relative to the build directory. An --assume-present glob with a
leading / matches the absolute URL path instead, base path included, and so
also covers paths outside the base, such as /blog/** for another app on the
same host.
How URLs map to files
ferrolink follows the rules GitHub Pages, Netlify, Cloudflare Pages, and most
static servers share. A path ending in / serves its index.html. Otherwise
the exact file wins, then <path>.html, then <path>/index.html. Lookups are
case-sensitive, as on those hosts, even on a case-insensitive local disk.
Node.js
import { check } from "ferrolink"
const report = await check({
root: "dist",
siteUrl: "https://example.org/docs/",
exclude: ["v3/**"],
})
for (const issue of report.issues) {
console.log(`${issue.source}:${issue.line} ${issue.kind} ${issue.href}`)
}
check() takes the options of the command line in camel case (basePath,
siteUrl, exclude, assumePresent, anchors, deployFiles, threads)
and resolves with the report --format json prints: summary, issues, and
timings. It runs on a worker thread, so the event loop stays free, and
rejects only when the check cannot run. Broken links are issues in the report.
formatIssues(report.issues) renders them the way the command line prints
them, grouped by file.
Vite
// vite.config.ts
import { defineConfig } from "vite"
import ferrolink from "ferrolink/vite"
export default defineConfig({
plugins: [ferrolink({ siteUrl: "https://example.org/" })],
})
The plugin checks the client build output once the build is complete and
fails the build when it finds issues; level: "warn" logs them instead. It
takes the options of check() except root, plus dir to check another
directory than the client build.outDir. basePath defaults to Vite's
base. In an app build, where frameworks such as Ardo render their HTML in
buildApp, the check runs after every other buildApp hook, so it sees the
rendered pages. The plugin works with Vite 7 and 8.
API stability
The JavaScript API is the supported way to use ferrolink from code. check(),
formatIssues(), the fields of the options and of the report, the issue kinds,
and the ferrolink/vite plugin with its options follow semantic versioning: before
1.0, a breaking change only comes with a new minor version (0.x.0) and is
called out in the changelog. New options, report fields, and issue kinds can
arrive in any minor version; treat an issue kind you do not know as an issue.
The values of timings and origin, error messages, and the text of log
output and of formatIssues() are not part of the contract. The Rust library API serves the CLI and the npm binding
and can change in any minor release before 1.0.
Performance
On the 318-page Ardo documentation build (9.2 MB of HTML, 1,087 files), a full check takes about 26 ms on an Apple M1 Ultra. The directory walk uses ferralk; pages are scanned on every core.
ferrolink scans with Ferroni regular expressions: one pattern finds comments and start tags, a second splits the attributes of the tags that matter. docs/parsers.md records how it was chosen over the html5gum, html5ever, and lol_html tokenizers on the same workload.
Development
cargo fmt --all --check
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
cargo test --workspace --all-features --locked
cargo run --release -- check path/to/build
The npm package lives in node/, with the napi-rs binding in node/native/:
cd node
pnpm install
pnpm build
pnpm test
Website development
The Ardo site in homepage/ uses the shared Ferramenta theme without local CSS.
The CLI reference, Node reference, parser decision and changelog are generated
from their canonical repository documents. Edit those sources, not the generated
routes.
cd homepage
pnpm install
pnpm dev
pnpm verify
pnpm verify runs the native before/after example, source checks, production
build and browser checks at eight widths. The built site is also checked by
Ferrolink itself, including its /ferrolink/ deployment path. GitHub Actions
deploys main to GitHub Pages.
Maintaining this README
Edit README.md.src, then run mise run readme:write.
License
Apache-2.0, © Sebastian Software GmbH.