From 6f0a9d73b739ecaf3b5fb31e888af1d520f3095c Mon Sep 17 00:00:00 2001 From: Lukas Mager <7467162+lkmgr@users.noreply.github.com> Date: Thu, 21 Aug 2025 17:28:33 +0200 Subject: [PATCH 1/6] refactor: migrate CLI parser from `meow` to `yargs` * auto-generates CLI help text * built-in `implies` and `conflicts` for options --- package-lock.json | 326 ++++++++++++++++++++++++-------------- package.json | 5 +- src/cli.ts | 387 ++++++++++++++++++++++++---------------------- test/test.cli.ts | 15 +- 4 files changed, 424 insertions(+), 309 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5ee05b1c..e9fb693c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,10 +14,10 @@ "glob": "^11.0.2", "htmlparser2": "^10.0.0", "marked": "^15.0.12", - "meow": "^13.0.0", "mime": "^4.0.0", "server-destroy": "^1.0.1", - "srcset": "^5.0.1" + "srcset": "^5.0.1", + "yargs": "^18.0.0" }, "bin": { "linkinator": "build/src/cli.js" @@ -27,6 +27,7 @@ "@types/escape-html": "^1.0.1", "@types/node": "^22.0.0", "@types/server-destroy": "^1.0.1", + "@types/yargs": "^17.0.33", "@vitest/coverage-v8": "^3.2.4", "execa": "^9.0.0", "husky": "^9.0.11", @@ -38,7 +39,7 @@ }, "engines": { "bun": "1.2.20", - "node": ">=20", + "node": ">=22.7", "npm": ">=9" } }, @@ -1693,6 +1694,23 @@ "@types/node": "*" } }, + "node_modules/@types/yargs": { + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@vitest/coverage-v8": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", @@ -2327,94 +2345,54 @@ } }, "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", "license": "ISC", "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/cliui/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" }, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": ">=20" } }, "node_modules/cliui/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", "license": "MIT" }, "node_modules/cliui/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "license": "MIT", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" + "node": ">=18" }, - "engines": { - "node": ">=8" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/cliui/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" @@ -2969,7 +2947,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -3190,12 +3167,23 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-east-asian-width": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", + "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-stream": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", @@ -3975,6 +3963,7 @@ "version": "13.2.0", "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", "integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -7703,6 +7692,54 @@ "node": ">=20.8.1" } }, + "node_modules/semantic-release/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/semantic-release/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/semantic-release/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/semantic-release/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, "node_modules/semantic-release/node_modules/get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", @@ -7716,6 +7753,81 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/semantic-release/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/semantic-release/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/semantic-release/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/semantic-release/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/semantic-release/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/semver": { "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", @@ -9013,84 +9125,58 @@ "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, "license": "ISC", "engines": { "node": ">=10" } }, "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", "license": "MIT", "dependencies": { - "cliui": "^8.0.1", + "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", + "string-width": "^7.2.0", "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" + "yargs-parser": "^22.0.0" }, "engines": { - "node": ">=12" + "node": "^20.19.0 || ^22.12.0 || >=23" } }, "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", "license": "ISC", "engines": { - "node": ">=12" - } - }, - "node_modules/yargs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" + "node": "^20.19.0 || ^22.12.0 || >=23" } }, "node_modules/yargs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", "license": "MIT" }, "node_modules/yargs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "license": "MIT", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" + "node": ">=18" }, - "engines": { - "node": ">=8" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/yoctocolors": { diff --git a/package.json b/package.json index b283b22a..68212c2f 100644 --- a/package.json +++ b/package.json @@ -29,16 +29,17 @@ "glob": "^11.0.2", "htmlparser2": "^10.0.0", "marked": "^15.0.12", - "meow": "^13.0.0", "mime": "^4.0.0", "server-destroy": "^1.0.1", - "srcset": "^5.0.1" + "srcset": "^5.0.1", + "yargs": "^18.0.0" }, "devDependencies": { "@biomejs/biome": "1.9.4", "@types/escape-html": "^1.0.1", "@types/node": "^22.0.0", "@types/server-destroy": "^1.0.1", + "@types/yargs": "^17.0.33", "@vitest/coverage-v8": "^3.2.4", "execa": "^9.0.0", "husky": "^9.0.11", diff --git a/src/cli.ts b/src/cli.ts index d061965d..a75bdbc1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,200 +2,184 @@ import process from 'node:process'; import chalk from 'chalk'; -import meow from 'meow'; +import yargs from 'yargs'; +import { hideBin } from 'yargs/helpers'; import { type Flags, getConfig } from './config.ts'; import { LinkChecker } from './index.ts'; import { Format, LogLevel, Logger } from './logger.ts'; import type { CheckOptions } from './options.ts'; -import { type LinkResult, LinkState, type RetryInfo } from './types.ts'; - -const cli = meow( - ` - Usage - $ linkinator LOCATION [ --arguments ] - - Positional arguments - - LOCATION - Required. Either the URLs or the paths on disk to check for broken links. - - Flags - - --concurrency - The number of connections to make simultaneously. Defaults to 100. - - --config - Path to the config file to use. Looks for \`linkinator.config.json\` by default. - - --directory-listing - Include an automatic directory index file when linking to a directory. - Defaults to 'false'. - - --format, -f - Return the data in CSV or JSON format. - - --help - Show this command. - - --markdown - Automatically parse and scan markdown if scanning from a location on disk. - - --recurse, -r - Recursively follow links on the same root domain. - - --retry, - Automatically retry requests that return HTTP 429 responses and include - a 'retry-after' header. Defaults to false. - - --retry-no-header, - Automatically retry requests that return HTTP 429 responses and DON'T - include a 'retry-after' header. Defaults to false. - - --retry-no-header-count, - How many times should a HTTP 429 response with no 'retry-after' header - be retried? Defaults to -1 for infinite retries. - - --retry-no-header-delay, - Delay in ms between retries for HTTP 429 responses with - no 'retry-after' header. - - --retry-errors, - Automatically retry requests that return 5xx or unknown response. - - --retry-errors-count, - How many times should an error be retried? - - --retry-errors-jitter, - Random jitter in ms applied to error retry. - - --server-root - When scanning a locally directory, customize the location on disk - where the server is started. Defaults to the path passed in [LOCATION]. - - --skip, -s - List of urls in regexy form to not include in the check. - - --timeout - Request timeout in ms. Defaults to 0 (no timeout). - - --url-rewrite-search - Pattern to search for in urls. Must be used with --url-rewrite-replace. - - --url-rewrite-replace - Expression used to replace search content. Must be used with --url-rewrite-search. - - --user-agent - The user agent passed in all HTTP requests. Defaults to 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.117 Safari/537.36' - - --verbosity - Override the default verbosity for this command. Available options are - 'debug', 'info', 'warning', 'error', and 'none'. Defaults to 'warning'. - - Examples - $ linkinator docs/ - $ linkinator https://www.google.com - $ linkinator . --recurse - $ linkinator . --skip www.googleapis.com - $ linkinator . --format CSV -`, - { - importMeta: import.meta, - flags: { - config: { type: 'string' }, - concurrency: { type: 'number' }, - recurse: { type: 'boolean', shortFlag: 'r' }, - skip: { type: 'string', shortFlag: 's', isMultiple: true }, - format: { type: 'string', shortFlag: 'f' }, - silent: { type: 'boolean' }, - timeout: { type: 'number' }, - markdown: { type: 'boolean' }, - serverRoot: { type: 'string' }, - verbosity: { type: 'string' }, - directoryListing: { type: 'boolean' }, - retry: { type: 'boolean' }, - retryNoHeader: { type: 'boolean' }, - retryNoHeaderCount: { type: 'number', default: -1 }, - retryNoHeaderDelay: { type: 'number', default: 30 * 60 * 1000 }, - retryErrors: { type: 'boolean' }, - retryErrorsCount: { type: 'number', default: 5 }, - retryErrorsJitter: { type: 'number', default: 3000 }, - urlRewriteSearch: { type: 'string' }, - urlReWriteReplace: { type: 'string' }, +import { + type CrawlResult, + type LinkResult, + LinkState, + type RetryInfo, +} from './types.ts'; + +const parser = yargs(hideBin(process.argv)) + .usage( + 'Usage: $0 LOCATION [options]\n\nWith LOCATION being either the URLs or the paths on disk to check for broken links.', + ) + .demandCommand(1, 'LOCATION is required') + .options({ + concurrency: { + type: 'number', + describe: + 'The number of connections to make simultaneously. Defaults to 100.', + }, + config: { + type: 'string', + describe: + 'Path to the config file to use. Looks for `linkinator.config.json` by default.', + }, + directoryListing: { + type: 'boolean', + describe: + "Include an automatic directory index file when linking to a directory. Defaults to 'false'.", + }, + format: { + alias: 'f', + type: 'string', + describe: 'Return the data in CSV or JSON format.', + }, + markdown: { + type: 'boolean', + describe: + 'Automatically parse and scan markdown if scanning from a location on disk.', + }, + recurse: { + alias: 'r', + type: 'boolean', + describe: 'Recursively follow links on the same root domain.', + }, + retry: { + type: 'boolean', + describe: + "Automatically retry requests that return HTTP 429 responses and include a 'retry-after' header. Defaults to false.", + }, + retryNoHeader: { + type: 'boolean', + describe: + "Automatically retry requests that return HTTP 429 responses and DON'T include a 'retry-after' header. Defaults to false.", + }, + retryNoHeaderCount: { + type: 'number', + default: -1, + describe: + "How many times should a HTTP 429 response with no 'retry-after' header be retried? Defaults to -1 for infinite retries.", + }, + retryNoHeaderDelay: { + type: 'number', + default: 30 * 60 * 1000, + describe: + "Delay in ms between retries for HTTP 429 responses with no 'retry-after' header.", + }, + retryErrors: { + type: 'boolean', + describe: + 'Automatically retry requests that return 5xx or unknown response.', + }, + retryErrorsCount: { + type: 'number', + default: 5, + describe: 'How many times should an error be retried?', + }, + retryErrorsJitter: { + type: 'number', + default: 3000, + describe: 'Random jitter in ms applied to error retry.', + }, + serverRoot: { + type: 'string', + describe: + 'When scanning a local directory, customize the location on disk where the server is started. Defaults to the path passed in [LOCATION].', + }, + silent: { + type: 'boolean', + describe: 'Silence output (alias for --verbosity error).', }, - booleanDefault: undefined, - }, -); + skip: { + alias: 's', + coerce: (arg) => { + if (typeof arg === 'string') { + return arg.split(/[\s,]+/).filter(Boolean) as string[]; + } -let flags: Flags; + if (Array.isArray(arg)) { + const linksToSkip: string[] = []; + for (const skip of arg) { + linksToSkip.push(...skip.split(/[\s,]+/).filter(Boolean)); + } + return linksToSkip; + } + }, + describe: 'List of urls in regexy form to not include in the check.', + }, + timeout: { + type: 'number', + describe: 'Request timeout in ms. Defaults to 0 (no timeout).', + }, + urlRewriteSearch: { + type: 'string', + describe: + 'Pattern to search for in urls. Must be used with --url-rewrite-replace.', + }, + urlRewriteReplace: { + type: 'string', + describe: + 'Expression used to replace search content. Must be used with --url-rewrite-search.', + }, + userAgent: { + type: 'string', + describe: `The user agent passed in all HTTP requests. Defaults to 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.117 Safari/537.36'`, + }, + verbosity: { + type: 'string', + choices: Object.values(LogLevel) as string[], + describe: + "Override the default verbosity for this command. Available options are 'debug', 'info', 'warning', 'error', and 'none'. Defaults to 'warning'.", + }, + }) + .implies('urlRewriteSearch', 'urlRewriteReplace') + .implies('urlRewriteReplace', 'urlRewriteSearch') + .conflicts('silent', 'verbosity') + .conflicts('verbosity', 'silent') + .strict() + .help() + .example([ + ['$0 docs/'], + ['$0 https://www.google.com'], + ['$0 . --recurse'], + ['$0 . --skip www.googleapis.com'], + ['$0 . --format CSV'], + ]); async function main() { - if (cli.input.length === 0) { - cli.showHelp(); - return; - } + const argv = await parser.parseAsync(); - flags = await getConfig(cli.flags); - if ( - (flags.urlRewriteReplace && !flags.urlRewriteSearch) || - (flags.urlRewriteSearch && !flags.urlRewriteReplace) - ) { - throw new Error( - 'The url-rewrite-replace flag must be used with the url-rewrite-search flag.', - ); - } + const inputs = argv._.map((v) => v.toString()); + const flags = await getConfig(argv); const start = Date.now(); const verbosity = parseVerbosity(flags); const format = parseFormat(flags); const logger = new Logger(verbosity, format); - logger.error(`🏊‍♂️ crawling ${cli.input.join(' ')}`); + logger.error(`🏊‍♂️ crawling ${inputs.join(' ')}`); const checker = new LinkChecker(); if (format === Format.CSV) { - const header = 'url,status,state,parent,failureDetails'; - console.log(header); + console.log('url,status,state,parent,failureDetails'); } checker.on('retry', (info: RetryInfo) => { logger.warn(`Retrying: ${info.url} in ${info.secondsUntilRetry} seconds.`); }); checker.on('link', (link: LinkResult) => { - let state = ''; - switch (link.state) { - case LinkState.BROKEN: { - state = `[${chalk.red(link.status?.toString())}]`; - logger.error(`${state} ${chalk.gray(link.url)}`); - break; - } - - case LinkState.OK: { - state = `[${chalk.green(link.status?.toString())}]`; - logger.warn(`${state} ${chalk.gray(link.url)}`); - break; - } - - case LinkState.SKIPPED: { - state = `[${chalk.grey('SKP')}]`; - logger.info(`${state} ${chalk.gray(link.url)}`); - break; - } - } - - if (format === Format.CSV) { - const showIt = shouldShowResult(link, verbosity); - if (showIt) { - const failureDetails = link.failureDetails - ? JSON.stringify(link.failureDetails, null, 2) - : ''; - console.log( - `"${link.url}",${link.status},${link.state},"${link.parent || ''}","${failureDetails}"`, - ); - } - } + handleLink(link, logger, format, verbosity); }); + const options: CheckOptions = { - path: cli.input, + path: inputs, recurse: flags.recurse, timeout: Number(flags.timeout), markdown: flags.markdown, @@ -210,17 +194,15 @@ async function main() { retryErrorsCount: Number(flags.retryErrorsCount), retryErrorsJitter: Number(flags.retryErrorsJitter), }; + + // TODO: `skip` is already parsed to an array using yargs. Remove when `Flags` type is adjusted if (flags.skip) { if (typeof flags.skip === 'string') { options.linksToSkip = flags.skip.split(/[\s,]+/).filter(Boolean); } else if (Array.isArray(flags.skip)) { - // With `isMultiple` enabled in meow, a comma delimeted list will still - // be passed as an array, but with a single element that still needs to - // be split. options.linksToSkip = []; for (const skip of flags.skip) { - const rules = skip.split(/[\s,]+/).filter(Boolean); - options.linksToSkip.push(...rules); + options.linksToSkip.push(...skip.split(/[\s,]+/).filter(Boolean)); } } } @@ -235,9 +217,57 @@ async function main() { } const result = await checker.check(options); - const filteredResults = result.links.filter((link) => - shouldShowResult(link, verbosity), + outputResults(result, format, verbosity, logger, start); +} + +function handleLink( + link: LinkResult, + logger: Logger, + format: Format, + verbosity: LogLevel, +) { + let state = ''; + switch (link.state) { + case LinkState.BROKEN: { + state = `[${chalk.red(link.status?.toString())}]`; + logger.error(`${state} ${chalk.gray(link.url)}`); + break; + } + + case LinkState.OK: { + state = `[${chalk.green(link.status?.toString())}]`; + logger.warn(`${state} ${chalk.gray(link.url)}`); + break; + } + + case LinkState.SKIPPED: { + state = `[${chalk.grey('SKP')}]`; + logger.info(`${state} ${chalk.gray(link.url)}`); + break; + } + } + + if (format === Format.CSV && shouldShowResult(link, verbosity)) { + const failureDetails = link.failureDetails + ? JSON.stringify(link.failureDetails, null, 2) + : ''; + console.log( + `"${link.url}",${link.status},${link.state},"${link.parent || ''}","${failureDetails}"`, + ); + } +} + +function outputResults( + result: CrawlResult, + format: Format, + verbosity: LogLevel, + logger: Logger, + start: number, +) { + const filteredResults = result.links.filter((l) => + shouldShowResult(l, verbosity), ); + if (format === Format.JSON) { result.links = filteredResults; console.log(JSON.stringify(result, null, 2)); @@ -291,6 +321,7 @@ async function main() { return false; }); + if (links.length === 0) { continue; } @@ -349,12 +380,6 @@ async function main() { } function parseVerbosity(flags: Flags): LogLevel { - if (flags.silent && flags.verbosity) { - throw new Error( - 'The SILENT and VERBOSITY flags cannot both be defined. Please consider using VERBOSITY only.', - ); - } - if (flags.silent) { return LogLevel.ERROR; } diff --git a/test/test.cli.ts b/test/test.cli.ts index c0d43aa3..ce35f62b 100644 --- a/test/test.cli.ts +++ b/test/test.cli.ts @@ -51,7 +51,7 @@ describe('cli', () => { const response = await execa(node, [linkinator], { reject: false, }); - assert.match(response.stdout, /\$ linkinator LOCATION \[ --arguments ]/); + assert.match(response.stderr, /Usage: .+ LOCATION \[options\]/); }); it('should flag skipped links', async () => { @@ -194,12 +194,12 @@ describe('cli', () => { it('should throw on invalid verbosity', async () => { const response = await execa( node, - [linkinator, './README.md', '--VERBOSITY', 'LOL'], + [linkinator, './README.md', '--verbosity', 'LOL'], { reject: false, }, ); - assert.match(response.stderr, /VERBOSITY must be/); + assert.match(response.stderr, /Invalid values:\n\s*Argument: verbosity/); }); it('should throw when verbosity and silent are flagged', async () => { @@ -210,7 +210,10 @@ describe('cli', () => { reject: false, }, ); - assert.match(response.stderr, /The SILENT and VERBOSITY flags/); + assert.match( + response.stderr, + /Arguments verbosity and silent are mutually exclusive/, + ); }); it('should show no output for verbosity=NONE', async () => { @@ -257,7 +260,7 @@ describe('cli', () => { }, ); assert.strictEqual(response.exitCode, 1); - assert.match(response.stderr, /flag must be used/); + assert.match(response.stderr, /Missing dependent arguments/); }); it('should fail if a url replacement is provided without a search', async () => { @@ -269,7 +272,7 @@ describe('cli', () => { }, ); assert.strictEqual(response.exitCode, 1); - assert.match(response.stderr, /flag must be used/); + assert.match(response.stderr, /Missing dependent arguments/); }); it('should respect url rewrites', async () => { From 6e22810cfcd16548e0d8490aa0fc94db0851cfb3 Mon Sep 17 00:00:00 2001 From: Lukas Mager <7467162+lkmgr@users.noreply.github.com> Date: Fri, 22 Aug 2025 10:33:03 +0200 Subject: [PATCH 2/6] refactor: create `SharedOptions` type --- src/config.ts | 18 +++--------------- src/options.ts | 26 +++++++++++++++----------- 2 files changed, 18 insertions(+), 26 deletions(-) diff --git a/src/config.ts b/src/config.ts index fee9bb48..adf2dce8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,29 +1,17 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; import process from 'node:process'; +import type { SharedOptions } from './options.ts'; -export type Flags = { - concurrency?: number; +// All flags that can be set using CLI or config file +export type Flags = SharedOptions & { config?: string; - recurse?: boolean; skip?: string | string[]; format?: string; silent?: boolean; verbosity?: string; - timeout?: number; - markdown?: boolean; - serverRoot?: string; - directoryListing?: boolean; - retry?: boolean; - retryNoHeader?: boolean; - retryNoHeaderCount?: number; - retryNoHeaderDelay?: number; - retryErrors?: boolean; - retryErrorsCount?: number; - retryErrorsJitter?: number; urlRewriteSearch?: string; urlRewriteReplace?: string; - extraHeaders?: { [key: string]: string }; }; const validConfigExtensions = ['.js', '.mjs', '.cjs', '.json']; diff --git a/src/options.ts b/src/options.ts index e667385d..56f4532a 100644 --- a/src/options.ts +++ b/src/options.ts @@ -3,19 +3,12 @@ import path from 'node:path'; import process from 'node:process'; import { glob } from 'glob'; -export type UrlRewriteExpression = { - pattern: RegExp; - replacement: string; -}; - -export type CheckOptions = { +// Options used for CLI, config file and API +export type SharedOptions = { concurrency?: number; - port?: number; - path: string | string[]; recurse?: boolean; timeout?: number; markdown?: boolean; - linksToSkip?: string[] | ((link: string) => Promise); serverRoot?: string; directoryListing?: boolean; retry?: boolean; @@ -25,9 +18,20 @@ export type CheckOptions = { retryErrors?: boolean; retryErrorsCount?: number; retryErrorsJitter?: number; - urlRewriteExpressions?: UrlRewriteExpression[]; - userAgent?: string; extraHeaders?: { [key: string]: string }; + userAgent?: string; +}; + +export type UrlRewriteExpression = { + pattern: RegExp; + replacement: string; +}; + +export type CheckOptions = SharedOptions & { + path: string | string[]; + port?: number; + linksToSkip?: string[] | ((link: string) => Promise); + urlRewriteExpressions?: UrlRewriteExpression[]; }; export type InternalCheckOptions = { From 0454500be9e4be4880c19dc3f87585543a73ecba Mon Sep 17 00:00:00 2001 From: Lukas Mager <7467162+lkmgr@users.noreply.github.com> Date: Mon, 25 Aug 2025 17:20:46 +0200 Subject: [PATCH 3/6] refactor: define option defaults in a single location --- src/cli.ts | 66 ++++++++++++++++++---------------------------- src/config.ts | 13 +-------- src/crawler.ts | 20 +++++++------- src/options.ts | 36 +++++++++++++++---------- src/utils.ts | 4 +-- test/test.cli.ts | 12 ++++++--- test/test.index.ts | 6 ++--- 7 files changed, 71 insertions(+), 86 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index a75bdbc1..2ed45e27 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,7 +7,7 @@ import { hideBin } from 'yargs/helpers'; import { type Flags, getConfig } from './config.ts'; import { LinkChecker } from './index.ts'; import { Format, LogLevel, Logger } from './logger.ts'; -import type { CheckOptions } from './options.ts'; +import { type CheckOptions, DEFAULT_OPTIONS } from './options.ts'; import { type CrawlResult, type LinkResult, @@ -15,6 +15,9 @@ import { type RetryInfo, } from './types.ts'; +// `defaultDescription` is used instead of `default` to show the default values +// in the help but not actually set the values. This is done in `processOptions` +// so that options from the config file are not overwritten with CLI defaults. const parser = yargs(hideBin(process.argv)) .usage( 'Usage: $0 LOCATION [options]\n\nWith LOCATION being either the URLs or the paths on disk to check for broken links.', @@ -23,8 +26,8 @@ const parser = yargs(hideBin(process.argv)) .options({ concurrency: { type: 'number', - describe: - 'The number of connections to make simultaneously. Defaults to 100.', + defaultDescription: DEFAULT_OPTIONS.concurrency.toString(), + describe: 'The number of connections to make simultaneously.', }, config: { type: 'string', @@ -33,8 +36,9 @@ const parser = yargs(hideBin(process.argv)) }, directoryListing: { type: 'boolean', + defaultDescription: DEFAULT_OPTIONS.directoryListing.toString(), describe: - "Include an automatic directory index file when linking to a directory. Defaults to 'false'.", + 'Include an automatic directory index file when linking to a directory.', }, format: { alias: 'f', @@ -53,39 +57,42 @@ const parser = yargs(hideBin(process.argv)) }, retry: { type: 'boolean', + defaultDescription: DEFAULT_OPTIONS.retry.toString(), describe: - "Automatically retry requests that return HTTP 429 responses and include a 'retry-after' header. Defaults to false.", + "Automatically retry requests that return HTTP 429 responses and include a 'retry-after' header.", }, retryNoHeader: { type: 'boolean', + defaultDescription: DEFAULT_OPTIONS.retryNoHeader.toString(), describe: - "Automatically retry requests that return HTTP 429 responses and DON'T include a 'retry-after' header. Defaults to false.", + "Automatically retry requests that return HTTP 429 responses and DON'T include a 'retry-after' header.", }, retryNoHeaderCount: { type: 'number', - default: -1, + defaultDescription: DEFAULT_OPTIONS.retryNoHeaderCount.toString(), describe: - "How many times should a HTTP 429 response with no 'retry-after' header be retried? Defaults to -1 for infinite retries.", + "How many times should a HTTP 429 response with no 'retry-after' header be retried?", }, retryNoHeaderDelay: { type: 'number', - default: 30 * 60 * 1000, + defaultDescription: DEFAULT_OPTIONS.retryNoHeaderDelay.toString(), describe: "Delay in ms between retries for HTTP 429 responses with no 'retry-after' header.", }, retryErrors: { type: 'boolean', + defaultDescription: DEFAULT_OPTIONS.retryErrors.toString(), describe: 'Automatically retry requests that return 5xx or unknown response.', }, retryErrorsCount: { type: 'number', - default: 5, + defaultDescription: DEFAULT_OPTIONS.retryErrorsCount.toString(), describe: 'How many times should an error be retried?', }, retryErrorsJitter: { type: 'number', - default: 3000, + defaultDescription: DEFAULT_OPTIONS.retryErrorsJitter.toString(), describe: 'Random jitter in ms applied to error retry.', }, serverRoot: { @@ -99,24 +106,13 @@ const parser = yargs(hideBin(process.argv)) }, skip: { alias: 's', - coerce: (arg) => { - if (typeof arg === 'string') { - return arg.split(/[\s,]+/).filter(Boolean) as string[]; - } - - if (Array.isArray(arg)) { - const linksToSkip: string[] = []; - for (const skip of arg) { - linksToSkip.push(...skip.split(/[\s,]+/).filter(Boolean)); - } - return linksToSkip; - } - }, + type: 'string', describe: 'List of urls in regexy form to not include in the check.', }, timeout: { type: 'number', - describe: 'Request timeout in ms. Defaults to 0 (no timeout).', + defaultDescription: DEFAULT_OPTIONS.timeout.toString(), + describe: 'Request timeout in ms.', }, urlRewriteSearch: { type: 'string', @@ -130,11 +126,11 @@ const parser = yargs(hideBin(process.argv)) }, userAgent: { type: 'string', - describe: `The user agent passed in all HTTP requests. Defaults to 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.117 Safari/537.36'`, + defaultDescription: DEFAULT_OPTIONS.userAgent.toString(), + describe: 'The user agent passed in all HTTP requests.', }, verbosity: { type: 'string', - choices: Object.values(LogLevel) as string[], describe: "Override the default verbosity for this command. Available options are 'debug', 'info', 'warning', 'error', and 'none'. Defaults to 'warning'.", }, @@ -143,6 +139,7 @@ const parser = yargs(hideBin(process.argv)) .implies('urlRewriteReplace', 'urlRewriteSearch') .conflicts('silent', 'verbosity') .conflicts('verbosity', 'silent') + .version(false) .strict() .help() .example([ @@ -180,22 +177,9 @@ async function main() { const options: CheckOptions = { path: inputs, - recurse: flags.recurse, - timeout: Number(flags.timeout), - markdown: flags.markdown, - concurrency: Number(flags.concurrency), - serverRoot: flags.serverRoot, - directoryListing: flags.directoryListing, - retry: flags.retry, - retryNoHeader: flags.retryNoHeader, - retryNoHeaderCount: Number(flags.retryNoHeaderCount), - retryNoHeaderDelay: Number(flags.retryNoHeaderDelay), - retryErrors: flags.retryErrors, - retryErrorsCount: Number(flags.retryErrorsCount), - retryErrorsJitter: Number(flags.retryErrorsJitter), + ...flags, }; - // TODO: `skip` is already parsed to an array using yargs. Remove when `Flags` type is adjusted if (flags.skip) { if (typeof flags.skip === 'string') { options.linksToSkip = flags.skip.split(/[\s,]+/).filter(Boolean); diff --git a/src/config.ts b/src/config.ts index adf2dce8..b07cef44 100644 --- a/src/config.ts +++ b/src/config.ts @@ -26,20 +26,9 @@ export async function getConfig(flags: Flags) { config = (await tryGetDefaultConfig()) || {}; } - // `meow` is set up to pass boolean flags as `undefined` if not passed. - // copy the struct, and delete properties that are `undefined` so the merge - // doesn't blast away config level settings. - const strippedFlags = { ...flags }; - for (const [key, value] of Object.entries(strippedFlags)) { - if (value === undefined || (Array.isArray(value) && value.length === 0)) { - delete (strippedFlags as Record>)[key]; - } - } - // Combine the flags passed on the CLI with the flags in the config file, // with CLI flags getting precedence - config = { ...config, ...strippedFlags }; - return config; + return { ...config, ...flags }; } /** diff --git a/src/crawler.ts b/src/crawler.ts index de0b2afe..7df26db6 100644 --- a/src/crawler.ts +++ b/src/crawler.ts @@ -32,7 +32,7 @@ export type CrawlOptions = { delayCache: Map; retryErrorsCache: Map; retryNoHeaderCache: Map; - checkOptions: CheckOptions; + checkOptions: InternalCheckOptions; queue: Queue; rootPath: string; retry: boolean; @@ -77,7 +77,7 @@ export class LinkChecker extends EventEmitter { } const queue = new Queue({ - concurrency: options.concurrency || 100, + concurrency: options.concurrency, }); const results = new Array(); @@ -101,14 +101,14 @@ export class LinkChecker extends EventEmitter { retryNoHeaderCache, queue, rootPath: path, - retry: Boolean(options_.retry), - retryNoHeader: Boolean(options_.retryNoHeader), - retryNoHeaderCount: options_.retryNoHeaderCount ?? -1, - retryNoHeaderDelay: options_.retryNoHeaderDelay ?? 30 * 60 * 1000, - retryErrors: Boolean(options_.retryErrors), - retryErrorsCount: options_.retryErrorsCount ?? 5, - retryErrorsJitter: options_.retryErrorsJitter ?? 3000, - extraHeaders: options.extraHeaders ?? {}, + retry: options.retry, + retryNoHeader: options.retryNoHeader, + retryNoHeaderCount: options.retryNoHeaderCount, + retryNoHeaderDelay: options.retryNoHeaderDelay, + retryErrors: options.retryErrors, + retryErrorsCount: options.retryErrorsCount, + retryErrorsJitter: options.retryErrorsJitter, + extraHeaders: options.extraHeaders, }); }); } diff --git a/src/options.ts b/src/options.ts index 56f4532a..9176971d 100644 --- a/src/options.ts +++ b/src/options.ts @@ -34,13 +34,30 @@ export type CheckOptions = SharedOptions & { urlRewriteExpressions?: UrlRewriteExpression[]; }; +export const DEFAULT_OPTIONS = { + concurrency: 100, + directoryListing: false, + extraHeaders: {}, + retry: false, + retryErrors: false, + retryErrorsCount: 5, + retryErrorsJitter: 5000, + retryNoHeader: false, + retryNoHeaderCount: -1, + retryNoHeaderDelay: 30 * 60 * 1000, + timeout: 20000, + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.117 Safari/537.36', +} satisfies Partial; + +type DefaultKeys = keyof typeof DEFAULT_OPTIONS; + +// Extend CheckOptions but make all keys that have a default value required export type InternalCheckOptions = { syntheticServerRoot?: string; staticHttpServerHost?: string; -} & CheckOptions; - -export const DEFAULT_USER_AGENT = - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.117 Safari/537.36'; +} & Omit & + Required>; /** * Validate the provided flags all work with each other. @@ -49,7 +66,7 @@ export const DEFAULT_USER_AGENT = export async function processOptions( options_: CheckOptions, ): Promise { - const options: InternalCheckOptions = { ...options_ }; + const options: InternalCheckOptions = { ...DEFAULT_OPTIONS, ...options_ }; // Ensure at least one path is provided if (options.path.length === 0) { @@ -61,11 +78,6 @@ export async function processOptions( options.path = [options.path]; } - // Disable directory listings by default - if (options.directoryListing === undefined) { - options.directoryListing = false; - } - // Ensure we do not mix http:// and file system paths. The paths passed in // must all be filesystem paths, or HTTP paths. let isUrlType: boolean | undefined; @@ -87,12 +99,8 @@ export async function processOptions( ); } - options.userAgent = options.userAgent ?? DEFAULT_USER_AGENT; options.serverRoot &&= path.normalize(options.serverRoot); - // Add extra headers - options.extraHeaders = options.extraHeaders ?? {}; - // Expand globs into paths if (!isUrlType) { const paths: string[] = []; diff --git a/src/utils.ts b/src/utils.ts index 5d1bedf5..3fd20ea4 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -83,9 +83,7 @@ export function createFetchOptions(options: CrawlOptions): RequestInit { for (const [header, value] of Object.entries(options.extraHeaders)) { fetchOptions.headers.append(header, value); } - fetchOptions.signal = AbortSignal.timeout( - options.checkOptions.timeout || 20000, - ); + fetchOptions.signal = AbortSignal.timeout(options.checkOptions.timeout); return fetchOptions; } diff --git a/test/test.cli.ts b/test/test.cli.ts index ce35f62b..4979e905 100644 --- a/test/test.cli.ts +++ b/test/test.cli.ts @@ -199,7 +199,7 @@ describe('cli', () => { reject: false, }, ); - assert.match(response.stderr, /Invalid values:\n\s*Argument: verbosity/); + assert.match(response.stderr, /VERBOSITY must be/); }); it('should throw when verbosity and silent are flagged', async () => { @@ -260,7 +260,10 @@ describe('cli', () => { }, ); assert.strictEqual(response.exitCode, 1); - assert.match(response.stderr, /Missing dependent arguments/); + assert.match( + response.stderr, + /Missing dependent arguments|Implications failed/, + ); }); it('should fail if a url replacement is provided without a search', async () => { @@ -272,7 +275,10 @@ describe('cli', () => { }, ); assert.strictEqual(response.exitCode, 1); - assert.match(response.stderr, /Missing dependent arguments/); + assert.match( + response.stderr, + /Missing dependent arguments|Implications failed/, + ); }); it('should respect url rewrites', async () => { diff --git a/test/test.index.ts b/test/test.index.ts index 47581243..be7619dc 100644 --- a/test/test.index.ts +++ b/test/test.index.ts @@ -8,7 +8,7 @@ import { LinkState, check, } from '../src/index.js'; -import { DEFAULT_USER_AGENT } from '../src/options.ts'; +import { DEFAULT_OPTIONS } from '../src/options.ts'; import { invertedPromise } from './utils.ts'; nock.disableNetConnect(); @@ -455,14 +455,14 @@ describe('linkinator', () => { const scopes = [ nock('http://example.invalid') .get('/', undefined, { - reqheaders: { 'User-Agent': DEFAULT_USER_AGENT }, + reqheaders: { 'User-Agent': DEFAULT_OPTIONS.userAgent }, }) .replyWithFile(200, 'test/fixtures/local/index.html', { 'Content-Type': 'text/html; charset=UTF-8', }), nock('http://example.invalid') .get('/page2.html', undefined, { - reqheaders: { 'User-Agent': DEFAULT_USER_AGENT }, + reqheaders: { 'User-Agent': DEFAULT_OPTIONS.userAgent }, }) .replyWithFile(200, 'test/fixtures/local/page2.html', { 'Content-Type': 'text/html; charset=UTF-8', From 0364d73e7488945d57292e74772aaf598c3f682c Mon Sep 17 00:00:00 2001 From: Lukas Mager <7467162+lkmgr@users.noreply.github.com> Date: Wed, 27 Aug 2025 10:57:07 +0200 Subject: [PATCH 4/6] docs: update README binary instructions --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ab745fae..665759af 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,8 @@ chmod +x linkinator [macOS arm64 Download](https://github.com/avenga/linkinator/releases/latest/download/linkinator-darwin-arm64) -The binary needs to be marked as executable. Also, there might be a quarantine attribute applied automatically because the binary is unsigned, which needs to be removed. +The binary needs to be marked as executable. +**Important:** when downloading the binary using a browser instead of `wget`/`curl`, it might receive a quarantine attribute. See below on how to remove it. ```sh # x64 @@ -76,7 +77,7 @@ chmod +x linkinator ./linkinator --help ``` -If you see an error when executing (file damaged, invalid signature or similar), there might be a quarantine attribute applied automatically because the binary is unsigned, which needs to be removed. +**If you see an error when executing** (file damaged, invalid signature or similar), there might be a quarantine attribute applied automatically because the binary is unsigned, which needs to be removed: ```sh xattr -d com.apple.quarantine linkinator From c60594590c48acaa23074bda73b3f6201b5dc399 Mon Sep 17 00:00:00 2001 From: Lukas Mager <7467162+lkmgr@users.noreply.github.com> Date: Wed, 27 Aug 2025 13:45:37 +0200 Subject: [PATCH 5/6] ci: run tests only once --- .github/workflows/ci.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index eb9c0b4f..60a142fe 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -38,7 +38,6 @@ jobs: cache: npm - run: node -v - run: npm ci - - run: npm run test - run: npm run coverage - name: Upload results to Codecov uses: codecov/codecov-action@v5 From 9976e6e0d35a1b7dfd3171b16a0c79c8a3486fa1 Mon Sep 17 00:00:00 2001 From: Lukas Mager <7467162+lkmgr@users.noreply.github.com> Date: Wed, 27 Aug 2025 14:01:57 +0200 Subject: [PATCH 6/6] test: exclude cli from coverage because it is tested using `execa` --- vitest.config.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/vitest.config.ts b/vitest.config.ts index c934f644..67c67086 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,7 +6,10 @@ export default defineConfig({ testTimeout: 20_000, coverage: { provider: 'v8', - include: ['src/**'] + include: ['src/**'], + exclude: [ + 'src/cli.ts' // CLI is tested by calling it using `execa` and asserting the output + ] }, }, });