Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 0 additions & 9 deletions linkinator.config.json

This file was deleted.

50 changes: 50 additions & 0 deletions linkinator.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* Config for `pnpm check:links`.
*
* Kept as `.mjs` rather than the `.json` linkinator picks up by default so
* `EXTERNAL_LINK` can carry this comment and be unit-tested
* (linkinator.config.test.mjs). Both matter: the rule below has a silent
* failure mode that disabled the whole check for months.
*/

/**
* Skip every off-site link, so CI never fails on someone else's outage or
* bot wall. Only links within the built site are verified.
*
* The negative lookahead is load-bearing. `linkinator dist/` serves the build
* from a local HTTP server and crawls it, so the pages under test are
* themselves `http://` URLs: a bare `https?://` rule matches the crawl root
* and linkinator exits happily having scanned zero links. That is precisely
* what the previous `--skip 'https?://'` did, which is why a sidebar link to a
* deleted `/api/*` page shipped green.
*
* Both hosts have to be excluded: linkinator binds the server to `127.0.0.1`
* (the crawl root) but builds its trailing-slash redirects against
* `localhost`. Excluding only one leaves every directory-style URL — nearly
* every page on the site — skipped at the redirect hop.
*/
export const EXTERNAL_LINK = '^https?://(?!(?:127\\.0\\.0\\.1|localhost)[:/])';

export default {
recurse: true,
verbosity: 'error',
skip: [EXTERNAL_LINK],

/**
* linkinator crawls the built site through a static server it runs itself,
* and that server drops the occasional connection when several hundred
* pages are pulled at once — surfacing as a status-0 "broken" link on a
* different file each run. Back the concurrency off and retry those.
*
* `retryErrors` only covers status 0, 5xx and 429; a 404 is never retried,
* so this buys reliability without softening the check that matters.
*
* `retryErrorsCount` and `retryErrorsJitter` are deliberately absent: meow
* declares defaults for both, and a flag with a default is never `undefined`
* for linkinator's config merge to strip, so a value set here would be
* silently overridden by the built-in 5 and 3000ms. Set them on the command
* line if they ever need changing.
*/
concurrency: 10,
retryErrors: true,
};
62 changes: 62 additions & 0 deletions linkinator.config.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest';
import config, { EXTERNAL_LINK } from './linkinator.config.mjs';

/**
* linkinator applies `skip` with `new RegExp(rule).test(url)` against the
* absolute URL of every link it is about to fetch — including the crawl root
* and every redirect target. A rule that over-matches does not fail the build,
* it silently shrinks the crawl, so these cases are the only thing standing
* between us and a link check that scans nothing.
*/
describe('EXTERNAL_LINK', () => {
const skips = (url) => new RegExp(EXTERNAL_LINK).test(url);

it('skips off-site links', () => {
expect(skips('https://github.com/Mergifyio')).toBe(true);
expect(skips('http://example.com/whatever')).toBe(true);
expect(skips('https://docs.mergify.com/api/activity-log')).toBe(true);
});

it('keeps the local crawl root, which linkinator binds to 127.0.0.1', () => {
expect(skips('http://127.0.0.1:3000/')).toBe(false);
expect(skips('http://127.0.0.1:3000/api/activity-log')).toBe(false);
});

it('keeps redirect targets, which linkinator builds against localhost', () => {
expect(skips('http://localhost:3000/api/activity-log/')).toBe(false);
expect(skips('http://localhost:3000/')).toBe(false);
});

// Without the trailing `[:/]` these would be treated as our own server.
it('does not mistake a look-alike host for the local server', () => {
expect(skips('https://localhost.example.com/')).toBe(true);
expect(skips('https://127.0.0.1.example.com/')).toBe(true);
});

// linkinator splits every skip rule on /[\s,]+/ and compiles the shards
// separately, so a rule carrying either would silently become a different,
// broader rule (and possibly an invalid one).
it('survives the split linkinator applies to skip rules', () => {
expect(EXTERNAL_LINK.split(/[\s,]+/)).toEqual([EXTERNAL_LINK]);
});
});

describe('the exported config', () => {
// `recurse` lives only here now that the npm script passes no flags but
// --server-root and --config: drop it and the crawl silently becomes one page.
it('recurses, or the crawl never leaves the entry points', () => {
expect(config.recurse).toBe(true);
});

it('skips using the rule asserted above', () => {
expect(config.skip).toEqual([EXTERNAL_LINK]);
});

// meow declares defaults for these two, and a flag holding a default is
// never stripped from the merge, so a value set here would never apply.
it('omits the retry knobs that config cannot actually set', () => {
expect(config.retryErrors).toBe(true);
expect(config).not.toHaveProperty('retryErrorsCount');
expect(config).not.toHaveProperty('retryErrorsJitter');
});
});
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"check": "astro check && eslint . && biome check .",
"check:config-examples": "node scripts/validate-config-examples.mjs",
"check:internal-leaks": "node scripts/check-internal-leaks.mjs",
"check:links": "linkinator dist/ --recurse --concurrency 25 --verbosity error --skip 'https?://'"
"check:links": "linkinator / enterprise/ --server-root dist --config linkinator.config.mjs"
},
"devDependencies": {
"@actions/core": "^3.0.1",
Expand Down