Skip to content

Commit 9b086c6

Browse files
JustinBeckwithclaudeBeckwithRobot
authored
feat: upgrade to linkinator 7.4 and add statusCodes and redirects options (#216)
* feat: upgrade to linkinator 7.4 and add statusCodes and redirects options This update brings linkinator to version 7.4.0 and exposes new configuration options for more flexible link validation: - statusCodes: Map HTTP status codes to actions (ok, warn, skip, error) with support for patterns like 4xx and 5xx - redirects: Configure how to handle HTTP redirects (allow, warn, or error) The new options enable users to customize which status codes should be treated as errors, warnings, or skipped, providing greater control over link validation behavior. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * npm run build --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: BeckwithRobot <justin.beckwith+beckwithrobot@gmail.com>
1 parent aa44ee0 commit 9b086c6

8 files changed

Lines changed: 153 additions & 15 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ jobs:
6666
- `cleanUrls` - Enable support for clean URLs (extensionless paths). Allows validation of URLs without file extensions, useful for modern static hosting. Defaults to `false`.
6767
- `checkCss` - Enable parsing and extraction of URLs from CSS files, style blocks, and inline styles. Defaults to `false`.
6868
- `checkFragments` - Enable validation of fragment identifiers (anchor links) on HTML pages. Defaults to `false`.
69+
- `statusCodes` - JSON object mapping HTTP status codes to actions (`ok`, `warn`, `skip`, `error`). Supports patterns like `4xx` or `5xx`. Example: `{"404": "error", "5xx": "warn", "301": "ok"}`.
70+
- `redirects` - How to handle HTTP redirects. Options: `allow` (default), `warn`, or `error`.
6971
- `urlRewriteSearch` - Pattern to search for in urls. Must be used with `urlRewriteReplace`.
7072
- `urlRewriteReplace` - Expression used to replace search content. Must be used with `urlRewriteSearch`.
7173
- `verbosity` - Override the default verbosity for this command. Available options are "DEBUG", "INFO", "WARNING", "ERROR", and "NONE". Defaults to "WARNING".

action.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,13 @@ inputs:
9090
description: Enable validation of fragment identifiers (anchor links) on HTML pages.
9191
default: 'false'
9292
required: false
93+
statusCodes:
94+
description: JSON object mapping HTTP status codes to actions (ok, warn, skip, error). Supports patterns like 4xx or 5xx. Example - {"404":"error","5xx":"warn"}
95+
required: false
96+
redirects:
97+
description: How to handle HTTP redirects. Options - "allow" (default), "warn", or "error".
98+
default: 'allow'
99+
required: false
93100
outputs:
94101
results:
95102
description: 'The full results of the scan.'

dist/index.cjs

Lines changed: 87 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35394,6 +35394,30 @@ async function bufferStream(stream2) {
3539435394
return Buffer.concat(chunks);
3539535395
}
3539635396

35397+
// node_modules/linkinator/build/src/url-utils.js
35398+
function normalizeBaseUrl(baseUrl, cleanUrls = false) {
35399+
if (cleanUrls) {
35400+
return baseUrl;
35401+
}
35402+
try {
35403+
const url = new URL(baseUrl);
35404+
const pathname = url.pathname;
35405+
if (pathname.endsWith("/")) {
35406+
return baseUrl;
35407+
}
35408+
const lastSegment = pathname.split("/").pop() || "";
35409+
const hasExtension = lastSegment.includes(".") && lastSegment.indexOf(".") > 0;
35410+
const isCommonPageName = ["index", "default", "home", "main"].includes(lastSegment.toLowerCase());
35411+
if (!hasExtension && !isCommonPageName) {
35412+
url.pathname = `${pathname}/`;
35413+
return url.href;
35414+
}
35415+
return baseUrl;
35416+
} catch {
35417+
return baseUrl;
35418+
}
35419+
}
35420+
3539735421
// node_modules/linkinator/build/src/config.js
3539835422
var import_node_fs3 = require("node:fs");
3539935423
var import_node_path4 = __toESM(require("node:path"), 1);
@@ -35710,7 +35734,23 @@ var LinkChecker = class extends import_node_events3.EventEmitter {
3571035734
return;
3571135735
}
3571235736
const redirect = detectRedirect(status, originalUrl, response);
35713-
if (status === 999) {
35737+
const customAction = getStatusCodeAction(status, options.checkOptions.statusCodes);
35738+
if (customAction === "ok") {
35739+
state = LinkState.OK;
35740+
} else if (customAction === "warn") {
35741+
state = LinkState.OK;
35742+
this.emit("statusCodeWarning", {
35743+
url: originalUrl,
35744+
status
35745+
});
35746+
} else if (customAction === "skip") {
35747+
state = LinkState.SKIPPED;
35748+
} else if (customAction === "error") {
35749+
state = LinkState.BROKEN;
35750+
if (response !== void 0) {
35751+
failures.push(response);
35752+
}
35753+
} else if (status === 999) {
3571435754
state = LinkState.SKIPPED;
3571535755
} else if (status === 403 && response !== void 0 && response.headers["cf-mitigated"]) {
3571635756
state = LinkState.SKIPPED;
@@ -35799,7 +35839,10 @@ var LinkChecker = class extends import_node_events3.EventEmitter {
3579935839
let urlResults = [];
3580035840
if (response?.body) {
3580135841
const nodeStream = toNodeReadable(response.body);
35802-
const baseUrl = response.url || options.url.href;
35842+
let baseUrl = response.url || options.url.href;
35843+
if (isHtml(response)) {
35844+
baseUrl = normalizeBaseUrl(baseUrl, options.checkOptions.cleanUrls);
35845+
}
3580335846
if (isHtml(response)) {
3580435847
urlResults = await getLinks(nodeStream, baseUrl, options.checkOptions.checkCss);
3580535848
} else if (isCss(response) && options.checkOptions.checkCss) {
@@ -36055,6 +36098,32 @@ async function makeRequest(method, url, options = {}) {
3605536098
url: response.url
3605636099
};
3605736100
}
36101+
function matchesStatusCodePattern(status, pattern) {
36102+
if (pattern === status.toString()) {
36103+
return true;
36104+
}
36105+
if (pattern.endsWith("xx") && pattern.length === 3) {
36106+
const firstDigit = pattern[0];
36107+
const statusFirstDigit = Math.floor(status / 100).toString();
36108+
return firstDigit === statusFirstDigit;
36109+
}
36110+
return false;
36111+
}
36112+
function getStatusCodeAction(status, statusCodes) {
36113+
if (!statusCodes) {
36114+
return void 0;
36115+
}
36116+
const exactMatch = statusCodes[status.toString()];
36117+
if (exactMatch) {
36118+
return exactMatch;
36119+
}
36120+
for (const [pattern, action] of Object.entries(statusCodes)) {
36121+
if (matchesStatusCodePattern(status, pattern)) {
36122+
return action;
36123+
}
36124+
}
36125+
return void 0;
36126+
}
3605836127
function detectRedirect(status, originalUrl, response) {
3605936128
const isRedirectStatus = status >= 300 && status < 400;
3606036129
const urlChanged = response?.url && response.url !== originalUrl;
@@ -36087,7 +36156,8 @@ async function getFullConfig() {
3608736156
requireHttps: false,
3608836157
cleanUrls: false,
3608936158
checkCss: false,
36090-
checkFragments: false
36159+
checkFragments: false,
36160+
redirects: "allow"
3609136161
};
3609236162
const actionsConfig = {
3609336163
path: parseList("paths"),
@@ -36109,7 +36179,9 @@ async function getFullConfig() {
3610936179
requireHttps: parseBoolean("requireHttps"),
3611036180
cleanUrls: parseBoolean("cleanUrls"),
3611136181
checkCss: parseBoolean("checkCss"),
36112-
checkFragments: parseBoolean("checkFragments")
36182+
checkFragments: parseBoolean("checkFragments"),
36183+
statusCodes: parseJSON("statusCodes"),
36184+
redirects: parseString("redirects")
3611336185
};
3611436186
const urlRewriteSearch = parseString("urlRewriteSearch");
3611536187
const urlRewriteReplace = parseString("urlRewriteReplace");
@@ -36290,6 +36362,17 @@ function parseBoolean(input) {
3629036362
}
3629136363
return void 0;
3629236364
}
36365+
function parseJSON(input) {
36366+
const value = import_core.default.getInput(input) || void 0;
36367+
if (value) {
36368+
try {
36369+
return JSON.parse(value);
36370+
} catch (err) {
36371+
throw new Error(`Invalid JSON for ${input}: ${err.message}`);
36372+
}
36373+
}
36374+
return void 0;
36375+
}
3629336376
function getVerbosity(verbosity) {
3629436377
verbosity = verbosity.toUpperCase();
3629536378
const options = Object.keys(LogLevel);

dist/index.cjs.map

Lines changed: 3 additions & 3 deletions
Large diffs are not rendered by default.

package-lock.json

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
"homepage": "https://github.com/JustinBeckwith/linkinator-action#readme",
3131
"dependencies": {
3232
"@actions/core": "^1.11.1",
33-
"linkinator": "^7.3.0"
33+
"linkinator": "^7.4.0"
3434
},
3535
"devDependencies": {
3636
"@biomejs/biome": "^2.2.7",

src/action.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export async function getFullConfig() {
2020
cleanUrls: false,
2121
checkCss: false,
2222
checkFragments: false,
23+
redirects: 'allow',
2324
};
2425
// The options returned from `getInput` appear to always be strings.
2526
const actionsConfig = {
@@ -43,6 +44,8 @@ export async function getFullConfig() {
4344
cleanUrls: parseBoolean('cleanUrls'),
4445
checkCss: parseBoolean('checkCss'),
4546
checkFragments: parseBoolean('checkFragments'),
47+
statusCodes: parseJSON('statusCodes'),
48+
redirects: parseString('redirects'),
4649
};
4750
const urlRewriteSearch = parseString('urlRewriteSearch');
4851
const urlRewriteReplace = parseString('urlRewriteReplace');
@@ -254,6 +257,18 @@ function parseBoolean(input) {
254257
return undefined;
255258
}
256259

260+
function parseJSON(input) {
261+
const value = core.getInput(input) || undefined;
262+
if (value) {
263+
try {
264+
return JSON.parse(value);
265+
} catch (err) {
266+
throw new Error(`Invalid JSON for ${input}: ${err.message}`);
267+
}
268+
}
269+
return undefined;
270+
}
271+
257272
function getVerbosity(verbosity) {
258273
verbosity = verbosity.toUpperCase();
259274
const options = Object.keys(LogLevel);

test/test.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,37 @@ describe('linkinator action', () => {
372372
assert.ok(inputStub.called);
373373
});
374374

375+
it('should handle statusCodes option', async () => {
376+
const inputStub = sinon.stub(core, 'getInput');
377+
inputStub.withArgs('paths').returns('test/fixtures/test.md');
378+
inputStub.withArgs('statusCodes').returns('{"404":"error","5xx":"warn"}');
379+
inputStub.returns('');
380+
const config = await getFullConfig();
381+
assert.deepStrictEqual(config.statusCodes, { '404': 'error', '5xx': 'warn' });
382+
assert.ok(inputStub.called);
383+
});
384+
385+
it('should handle redirects option', async () => {
386+
const inputStub = sinon.stub(core, 'getInput');
387+
inputStub.withArgs('paths').returns('test/fixtures/test.md');
388+
inputStub.withArgs('redirects').returns('warn');
389+
inputStub.returns('');
390+
const config = await getFullConfig();
391+
assert.strictEqual(config.redirects, 'warn');
392+
assert.ok(inputStub.called);
393+
});
394+
395+
it('should throw for invalid statusCodes JSON', async () => {
396+
const inputStub = sinon.stub(core, 'getInput');
397+
inputStub.withArgs('paths').returns('test/fixtures/test.md');
398+
inputStub.withArgs('statusCodes').returns('{invalid json}');
399+
inputStub.returns('');
400+
await assert.rejects(
401+
async () => await getFullConfig(),
402+
/Invalid JSON for statusCodes/
403+
);
404+
});
405+
375406
it('should handle branch names with slashes in URL rewriting', async () => {
376407
stubSummary();
377408
sinon.stub(process, 'env').value({

0 commit comments

Comments
 (0)