Skip to content
Ferrolink

Ferrolink / Reference

CLI and checks

Fast, parallel link checker for static HTML builds.

CI License: Apache-2.0 MSRV

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, and link href; script, img, source, video, audio, track, iframe, and embed src; srcset candidates; video poster; object data; SVG use and image; meta refresh targets; og:image, og:url, and twitter:image.
  • Anchors: a fragment must name an element id, or an <a name>, on the target page. #top and text fragments (#:~:text=) always resolve.
  • Base path: on a site served below /<repo>/, an absolute link that forgets the prefix is reported as outside base.
  • Deployment files at the site root: version paths in Ardo's versions.json, and redirect and rewrite destinations in _redirects (Netlify, Cloudflare Pages) and vercel.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.