├── .editorconfig ├── .github └── workflows │ ├── bb.yml │ └── main.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .remarkignore ├── index.d.ts ├── index.js ├── lib └── index.js ├── license ├── package.json ├── readme.md ├── test.js └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.github/workflows/bb.yml: -------------------------------------------------------------------------------- 1 | name: bb 2 | on: 3 | issues: 4 | types: [opened, reopened, edited, closed, labeled, unlabeled] 5 | pull_request_target: 6 | types: [opened, reopened, edited, closed, labeled, unlabeled] 7 | jobs: 8 | main: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: unifiedjs/beep-boop-beta@main 12 | with: 13 | repo-token: ${{secrets.GITHUB_TOKEN}} 14 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | jobs: 2 | main: 3 | name: ${{matrix.node}} 4 | runs-on: ubuntu-latest 5 | steps: 6 | - uses: actions/checkout@v4 7 | - uses: actions/setup-node@v4 8 | with: 9 | node-version: ${{matrix.node}} 10 | - run: npm install 11 | - run: npm test 12 | - uses: codecov/codecov-action@v4 13 | strategy: 14 | matrix: 15 | node: 16 | - lts/hydrogen 17 | - node 18 | name: main 19 | on: 20 | - pull_request 21 | - push 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | node_modules/ 3 | *.d.ts.map 4 | *.d.ts 5 | *.log 6 | *.tsbuildinfo 7 | .DS_Store 8 | yarn.lock 9 | !/index.d.ts 10 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | ignore-scripts=true 2 | package-lock=false 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | *.md 3 | -------------------------------------------------------------------------------- /.remarkignore: -------------------------------------------------------------------------------- 1 | test/ 2 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import type {Options as RawDeadOrAliveOptions} from 'dead-or-alive' 2 | 3 | export {default} from './lib/index.js' 4 | 5 | /** 6 | * Configuration. 7 | */ 8 | export interface Options { 9 | /** 10 | * Options passed to `dead-or-alive` 11 | * (optional); 12 | * `deadOrAliveOptions.findUrls` is always off as further URLs are not 13 | * applicable. 14 | */ 15 | deadOrAliveOptions?: Readonly | null | undefined 16 | /** 17 | * Check relative values relative to this URL 18 | * (optional, example: `'https://example.com/from'`); 19 | * you can also define this by setting `origin` and `pathname` in 20 | * `file.data.meta`. 21 | */ 22 | from?: string | null | undefined 23 | /** 24 | * Whether to ignore `localhost` links such as `http://localhost/*`, 25 | * `http://127.0.0.1/*` 26 | * (default: `false`); 27 | * shortcut for a skip pattern of 28 | * `/^(https?:\/\/)(localhost|127\.0\.0\.1)(:\d+)?/`. 29 | */ 30 | skipLocalhost?: boolean | null | undefined 31 | /** 32 | * Whether to let offline runs pass quietly 33 | * (default: `false`). 34 | */ 35 | skipOffline?: boolean | null | undefined 36 | /** 37 | * List of patterns for URLs that should be skipped 38 | * (optional); 39 | * each URL will be tested against each pattern and will be ignored if 40 | * `new RegExp(pattern).test(url) === true`. 41 | */ 42 | skipUrlPatterns?: ReadonlyArray | null | undefined 43 | } 44 | 45 | /** 46 | * Configuration for `dead-or-alive` as supported by 47 | * `remark-lint-no-dead-urls`. 48 | */ 49 | interface DeadOrAliveOptions extends RawDeadOrAliveOptions { 50 | /** 51 | * Find URLs in the final resource; 52 | * not supported in `remark-lint-no-dead-urls` as it’s not applicable. 53 | */ 54 | findUrls?: never 55 | } 56 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // Note: types exposed from `index.d.ts`. 2 | export {default} from './lib/index.js' 3 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @import {Nodes, Resource, Root} from 'mdast' 3 | * @import {Options} from 'remark-lint-no-dead-urls' 4 | * @import {VFile} from 'vfile' 5 | */ 6 | 7 | /** 8 | * @typedef {Extract} Resources 9 | * Resource nodes. 10 | */ 11 | 12 | import {deadOrAlive} from 'dead-or-alive' 13 | import {ok as assert} from 'devlop' 14 | import isOnline from 'is-online' 15 | import pAll from 'p-all' 16 | import pLimit from 'p-limit' 17 | import {lintRule} from 'unified-lint-rule' 18 | import {visit} from 'unist-util-visit' 19 | 20 | const limit = pLimit(1) 21 | 22 | /** @type {Readonly} */ 23 | const emptyOptions = {} 24 | const defaultSkipUrlPatterns = [/^(?!https?)/i] 25 | const remarkLintNoDeadUrls = lintRule( 26 | { 27 | origin: 'remark-lint:no-dead-urls', 28 | url: 'https://github.com/remarkjs/remark-lint-no-dead-urls' 29 | }, 30 | rule 31 | ) 32 | 33 | export default remarkLintNoDeadUrls 34 | 35 | /** 36 | * Warn when URLs are dead. 37 | * 38 | * ###### Notes 39 | * 40 | * To improve performance, 41 | * decrease `maxRetries` in `deadOrAliveOptions` and/or decrease the value used 42 | * for `sleep` in `deadOrAliveOptions`. 43 | * The normal behavior is to assume connections might be flakey and to sleep a 44 | * while and retry a couple times. 45 | * 46 | * If you do not care whether anchors exist and don’t need to support HTML 47 | * redirects, 48 | * you can pass `checkAnchor: false` and `followMetaHttpEquiv: false` in 49 | * `deadOrAliveOptions`, 50 | * which enables a fast path without parsing HTML. 51 | * 52 | * @param {Root} tree 53 | * Tree. 54 | * @param {VFile} file 55 | * File. 56 | * @param {Readonly | null | undefined} [options] 57 | * Configuration (optional). 58 | * @returns {Promise} 59 | * Nothing. 60 | */ 61 | async function rule(tree, file, options) { 62 | // Operate one file at a time. 63 | // Otherwise we’d send out tons of requests at a time for say 10 files. 64 | await limit(async function () { 65 | /** @type {Map>} */ 66 | const nodesByUrl = new Map() 67 | const online = await isOnline() 68 | const settings = options || emptyOptions 69 | const skipUrlPatterns = settings.skipUrlPatterns 70 | ? settings.skipUrlPatterns.map(function (d) { 71 | return typeof d === 'string' ? new RegExp(d) : d 72 | }) 73 | : [...defaultSkipUrlPatterns] 74 | 75 | if (settings.skipLocalhost) { 76 | skipUrlPatterns.push(/^(https?:\/\/)(localhost|127\.0\.0\.1)(:\d+)?/) 77 | } 78 | 79 | /* c8 ignore next 9 -- difficult to test */ 80 | if (!online) { 81 | if (!settings.skipOffline) { 82 | file.info( 83 | 'Unexpected offline connection, expected either an online connection or `skipOffline: true`' 84 | ) 85 | } 86 | 87 | return 88 | } 89 | 90 | const meta = /** @type {Record | undefined} */ ( 91 | file.data.meta 92 | ) 93 | 94 | const from = 95 | settings.from || 96 | (meta && 97 | typeof meta.origin === 'string' && 98 | typeof meta.pathname === 'string' 99 | ? new URL(meta.pathname, meta.origin).href 100 | : undefined) 101 | 102 | const deadOrAliveOptions = { 103 | ...settings.deadOrAliveOptions, 104 | findUrls: false 105 | } 106 | 107 | visit(tree, function (node) { 108 | if ('url' in node && typeof node.url === 'string') { 109 | const value = node.url 110 | const colon = value.indexOf(':') 111 | const questionMark = value.indexOf('?') 112 | const numberSign = value.indexOf('#') 113 | const slash = value.indexOf('/') 114 | let relativeToSomething = false 115 | 116 | if ( 117 | // If there is no protocol, it’s relative. 118 | colon < 0 || 119 | // If the first colon is after a `?`, `#`, or `/`, it’s not a protocol. 120 | (slash > -1 && colon > slash) || 121 | (questionMark > -1 && colon > questionMark) || 122 | (numberSign > -1 && colon > numberSign) 123 | ) { 124 | relativeToSomething = true 125 | } 126 | 127 | // We can only check URLs relative to something if `from` is passed. 128 | if (relativeToSomething && !from) { 129 | return 130 | } 131 | 132 | const url = new URL(value, from).href 133 | 134 | if ( 135 | skipUrlPatterns.some(function (skipPattern) { 136 | return skipPattern.test(url) 137 | }) 138 | ) { 139 | return 140 | } 141 | 142 | let list = nodesByUrl.get(url) 143 | 144 | if (!list) { 145 | list = [] 146 | nodesByUrl.set(url, list) 147 | } 148 | 149 | list.push(node) 150 | } 151 | }) 152 | 153 | const urls = [...nodesByUrl.keys()] 154 | 155 | await pAll( 156 | urls.map(function (url) { 157 | return async function () { 158 | const nodes = nodesByUrl.get(url) 159 | assert(nodes) 160 | 161 | const result = await deadOrAlive(url, deadOrAliveOptions) 162 | 163 | for (const node of nodes) { 164 | for (const message of result.messages) { 165 | const product = file.message( 166 | 'Unexpected dead URL `' + url + '`, expected live URL', 167 | {ancestors: [node], cause: message, place: node.position} 168 | ) 169 | product.fatal = message.fatal 170 | } 171 | 172 | if ( 173 | result.permanent && 174 | result.status === 'alive' && 175 | new URL(url).href !== result.url 176 | ) { 177 | const message = file.message( 178 | 'Unexpected redirecting URL `' + 179 | url + 180 | '`, expected final URL `' + 181 | result.url + 182 | '`', 183 | {ancestors: [node], place: node.position} 184 | ) 185 | message.actual = url 186 | message.expected = [result.url] 187 | } 188 | } 189 | } 190 | }), 191 | // Operate on 10 URLs at a time. 192 | {concurrency: 10} 193 | ) 194 | }) 195 | } 196 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) David Clark 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remark-lint-no-dead-urls", 3 | "version": "2.0.1", 4 | "description": "remark-lint rule to warn when URLs are dead", 5 | "license": "MIT", 6 | "keywords": [ 7 | "lint", 8 | "markdown", 9 | "remark-lint-rule", 10 | "remark-lint", 11 | "remark", 12 | "rule" 13 | ], 14 | "repository": "remarkjs/remark-lint-no-dead-urls", 15 | "bugs": "https://github.com/remarkjs/remark-lint-no-dead-urls/issues", 16 | "funding": { 17 | "type": "opencollective", 18 | "url": "https://opencollective.com/unified" 19 | }, 20 | "author": "David Clark ", 21 | "contributors": [ 22 | "David Clark ", 23 | "Titus Wormer (https://wooorm.com)" 24 | ], 25 | "sideEffects": false, 26 | "type": "module", 27 | "exports": "./index.js", 28 | "files": [ 29 | "lib/", 30 | "index.d.ts.map", 31 | "index.d.ts", 32 | "index.js" 33 | ], 34 | "dependencies": { 35 | "@types/mdast": "^4.0.0", 36 | "dead-or-alive": "^1.0.0", 37 | "devlop": "^1.0.0", 38 | "is-online": "^11.0.0", 39 | "p-all": "^5.0.0", 40 | "p-limit": "^6.0.0", 41 | "unified-lint-rule": "^3.0.0", 42 | "unist-util-visit": "^5.0.0", 43 | "vfile": "^6.0.0", 44 | "vfile-message": "^4.0.0" 45 | }, 46 | "devDependencies": { 47 | "@types/node": "^22.0.0", 48 | "c8": "^10.0.0", 49 | "prettier": "^3.0.0", 50 | "remark": "^15.0.0", 51 | "remark-cli": "^12.0.0", 52 | "remark-preset-wooorm": "^10.0.0", 53 | "type-coverage": "^2.0.0", 54 | "typescript": "^5.0.0", 55 | "undici": "^6.0.0", 56 | "vfile-sort": "^4.0.0", 57 | "xo": "^0.59.0" 58 | }, 59 | "scripts": { 60 | "build": "tsc --build --clean && tsc --build && type-coverage", 61 | "format": "remark . --frail --output --quiet && prettier . --log-level warn --write && xo --fix", 62 | "prepack": "npm run build && npm run format", 63 | "test-api": "node --conditions development test.js", 64 | "test-coverage": "c8 --100 --reporter lcov npm run test-api", 65 | "test": "npm run build && npm run format && npm run test-coverage" 66 | }, 67 | "prettier": { 68 | "bracketSpacing": false, 69 | "singleQuote": true, 70 | "semi": false, 71 | "tabWidth": 2, 72 | "trailingComma": "none", 73 | "useTabs": false 74 | }, 75 | "remarkConfig": { 76 | "plugins": [ 77 | "remark-preset-wooorm" 78 | ] 79 | }, 80 | "typeCoverage": { 81 | "atLeast": 100, 82 | "detail": true, 83 | "ignoreCatch": true, 84 | "strict": true 85 | }, 86 | "xo": { 87 | "overrides": [ 88 | { 89 | "files": [ 90 | "**/*.d.ts" 91 | ], 92 | "rules": { 93 | "@typescript-eslint/array-type": [ 94 | "error", 95 | { 96 | "default": "generic" 97 | } 98 | ], 99 | "@typescript-eslint/ban-types": [ 100 | "error", 101 | { 102 | "extendDefaults": true 103 | } 104 | ], 105 | "@typescript-eslint/consistent-type-definitions": [ 106 | "error", 107 | "interface" 108 | ] 109 | } 110 | } 111 | ], 112 | "prettier": true 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # remark-lint-no-dead-urls 2 | 3 | [![Build][badge-build-image]][badge-build-url] 4 | [![Coverage][badge-coverage-image]][badge-coverage-url] 5 | [![Downloads][badge-downloads-image]][badge-downloads-url] 6 | [![Size][badge-size-image]][badge-size-url] 7 | [![Sponsors][badge-sponsors-image]][badge-collective-url] 8 | [![Backers][badge-backers-image]][badge-collective-url] 9 | [![Chat][badge-chat-image]][badge-chat-url] 10 | 11 | **[`remark-lint`][github-remark-lint]** rule to warn when URLs are dead. 12 | 13 | ## Contents 14 | 15 | * [What is this?](#what-is-this) 16 | * [When should I use this?](#when-should-i-use-this) 17 | * [Install](#install) 18 | * [Use](#use) 19 | * [API](#api) 20 | * [`Options`](#options) 21 | * [`unified().use(remarkLintNoDeadUrls[, options])`](#unifieduseremarklintnodeadurls-options) 22 | * [Related](#related) 23 | * [Compatibility](#compatibility) 24 | * [Security](#security) 25 | * [Contribute](#contribute) 26 | * [License](#license) 27 | 28 | ## What is this? 29 | 30 | This lint rule checks whether URLs are alive or not. 31 | 32 | ## When should I use this? 33 | 34 | You can use this lint rule to check that URLs are alive. 35 | 36 | It’s similar to [`remark-validate-links`][github-remark-validate-links], 37 | but there’s an important difference. 38 | That package checks the file system locally: 39 | whether `path/to/example.md` exists. 40 | But this package, 41 | `remark-lint-no-dead-urls`, 42 | checks the internet: 43 | whether `https://a.com` is alive, 44 | `/docs/example` is reachable on `https://mydomain.com`, 45 | and even whether certain IDs exist on a web page. 46 | 47 | This package uses [`dead-or-alive`][github-dead-or-alive]. 48 | You can use it when you want to check URLs programmatically yourself. 49 | 50 | ## Install 51 | 52 | This package is [ESM only][github-gist-esm]. 53 | In Node.js (version 18+), 54 | install with [npm][npm-install]: 55 | 56 | ```sh 57 | npm install remark-lint-no-dead-urls 58 | ``` 59 | 60 | In Deno with [`esm.sh`][esm-sh]: 61 | 62 | ```js 63 | import remarkLintNoDeadUrls from 'https://esm.sh/remark-lint-no-dead-urls@2' 64 | ``` 65 | 66 | In browsers with [`esm.sh`][esm-sh]: 67 | 68 | ```html 69 | 72 | ``` 73 | 74 | ## Use 75 | 76 | On the API: 77 | 78 | ```js 79 | import remarkLintNoDeadUrls from 'remark-lint-no-dead-urls' 80 | import remarkLint from 'remark-lint' 81 | import remarkParse from 'remark-parse' 82 | import remarkStringify from 'remark-stringify' 83 | import {read} from 'to-vfile' 84 | import {unified} from 'unified' 85 | import {reporter} from 'vfile-reporter' 86 | 87 | const file = await read('example.md') 88 | 89 | await unified() 90 | .use(remarkParse) 91 | .use(remarkLint) 92 | .use(remarkLintNoDeadUrls) 93 | .use(remarkStringify) 94 | .process(file) 95 | 96 | console.error(reporter(file)) 97 | ``` 98 | 99 | On the CLI: 100 | 101 | ```sh 102 | remark --frail --use remark-lint --use remark-lint-no-dead-urls . 103 | ``` 104 | 105 | On the CLI in a config file (here a `package.json`): 106 | 107 | ```diff 108 | … 109 | "remarkConfig": { 110 | "plugins": [ 111 | … 112 | "remark-lint", 113 | + "remark-lint-no-dead-urls", 114 | … 115 | ] 116 | } 117 | … 118 | ``` 119 | 120 | ## API 121 | 122 | This package exports no identifiers. 123 | It exports the additional [TypeScript][] type 124 | [`Options`][api-options]. 125 | The default export is 126 | [`remarkLintNoDeadUrls`][api-remark-lint-no-dead-urls]. 127 | 128 | ### `Options` 129 | 130 | Configuration (TypeScript type). 131 | 132 | ###### Fields 133 | 134 | * `deadOrAliveOptions` (`Options` from `dead-or-alive`, optional) 135 | — options passed to `dead-or-alive`; 136 | [`deadOrAliveOptions.findUrls`][github-dead-or-alive-options] is always off 137 | as further URLs are not applicable 138 | * `from` (`string`, optional, example: `'https://example.com/from'`) 139 | — check relative values relative to this URL; 140 | you can also define this by setting `origin` and `pathname` in 141 | `file.data.meta` 142 | * `skipLocalhost` (`boolean`, default: `false`) 143 | — whether to ignore `localhost` links such as `http://localhost/*`, 144 | `http://127.0.0.1/*`; 145 | shortcut for a skip pattern of 146 | `/^(https?:\/\/)(localhost|127\.0\.0\.1)(:\d+)?/` 147 | * `skipOffline` (`boolean`, default: `false`) 148 | — whether to let offline runs pass quietly 149 | * `skipUrlPatterns` (`Array`, optional) 150 | — list of patterns for URLs that should be skipped; 151 | each URL will be tested against each pattern and will be ignored if 152 | `new RegExp(pattern).test(url) === true` 153 | 154 | ### `unified().use(remarkLintNoDeadUrls[, options])` 155 | 156 | Warn when URLs are dead. 157 | 158 | ###### Notes 159 | 160 | To improve performance, 161 | decrease `maxRetries` in [`deadOrAliveOptions`][github-dead-or-alive-options] 162 | and/or decrease the value used for 163 | `sleep` in `deadOrAliveOptions`. 164 | The normal behavior is to assume connections might be flakey and to sleep a 165 | while and retry a couple times. 166 | 167 | If you do not care whether anchors exist and don’t need to support HTML 168 | redirects, 169 | you can pass `checkAnchor: false` and `followMetaHttpEquiv: false` in 170 | [`deadOrAliveOptions`][github-dead-or-alive-options], 171 | which enables a fast path without parsing HTML. 172 | 173 | ###### Parameters 174 | 175 | * `options` ([`Options`][api-options], optional) 176 | — configuration 177 | 178 | ###### Returns 179 | 180 | Transform (`(tree: Root, file: VFile) => Promise`). 181 | 182 | ## Related 183 | 184 | * [`remark-lint`][github-remark-lint] 185 | — markdown code style linter 186 | * [`remark-validate-links`][github-remark-validate-links] 187 | — ensure local links work 188 | 189 | ## Compatibility 190 | 191 | This projects is compatible with maintained versions of Node.js. 192 | 193 | When we cut a new major release, 194 | we drop support for unmaintained versions of Node. 195 | This means we try to keep the current release line, 196 | `remark-lint-no-dead-urls@2`, 197 | compatible with Node.js 18. 198 | 199 | ## Security 200 | 201 | This package can typically be considered safe. 202 | Note that this package checks URLs over the internet. 203 | Don’t use this if you consider that’s dangerous. 204 | 205 | ## Contribute 206 | 207 | See [`contributing.md`][health-contributing] in [`remarkjs/.github`][health] 208 | for ways to get started. 209 | See [`support.md`][health-support] for ways to get help. 210 | 211 | This project has a [code of conduct][health-coc]. 212 | By interacting with this repository, organization, or community you agree to 213 | abide by its terms. 214 | 215 | ## License 216 | 217 | [MIT][file-license] © [David Clark][github-david-clark] 218 | 219 | [api-remark-lint-no-dead-urls]: #unifieduseremarklintnodeadurls-options 220 | 221 | [api-options]: #options 222 | 223 | [badge-backers-image]: https://opencollective.com/unified/backers/badge.svg 224 | 225 | [badge-build-image]: https://github.com/remarkjs/remark-lint-no-dead-urls/actions/workflows/main.yml/badge.svg 226 | 227 | [badge-build-url]: https://github.com/remarkjs/remark-lint-no-dead-urls/actions 228 | 229 | [badge-collective-url]: https://opencollective.com/unified 230 | 231 | [badge-coverage-image]: https://img.shields.io/codecov/c/github/remarkjs/remark-lint-no-dead-urls.svg 232 | 233 | [badge-coverage-url]: https://codecov.io/github/remarkjs/remark-lint-no-dead-urls 234 | 235 | [badge-downloads-image]: https://img.shields.io/npm/dm/remark-lint-no-dead-urls.svg 236 | 237 | [badge-downloads-url]: https://www.npmjs.com/package/remark-lint-no-dead-urls 238 | 239 | [badge-size-image]: https://img.shields.io/bundlejs/size/remark-lint-no-dead-urls 240 | 241 | [badge-size-url]: https://bundlejs.com/?q=remark-lint-no-dead-urls 242 | 243 | [badge-sponsors-image]: https://opencollective.com/unified/sponsors/badge.svg 244 | 245 | [badge-chat-image]: https://img.shields.io/badge/chat-discussions-success.svg 246 | 247 | [badge-chat-url]: https://github.com/remarkjs/remark/discussions 248 | 249 | [esm-sh]: https://esm.sh 250 | 251 | [file-license]: license 252 | 253 | [github-david-clark]: https://github.com/davidtheclark 254 | 255 | [github-dead-or-alive-options]: https://github.com/wooorm/dead-or-alive#options 256 | 257 | [github-dead-or-alive]: https://github.com/wooorm/dead-or-alive 258 | 259 | [github-gist-esm]: https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c 260 | 261 | [github-remark-validate-links]: https://github.com/remarkjs/remark-validate-links 262 | 263 | [github-remark-lint]: https://github.com/remarkjs/remark-lint 264 | 265 | [health-coc]: https://github.com/remarkjs/.github/blob/main/code-of-conduct.md 266 | 267 | [health-contributing]: https://github.com/remarkjs/.github/blob/main/contributing.md 268 | 269 | [health-support]: https://github.com/remarkjs/.github/blob/main/support.md 270 | 271 | [health]: https://github.com/remarkjs/.github 272 | 273 | [npm-install]: https://docs.npmjs.com/cli/install 274 | 275 | [typescript]: https://www.typescriptlang.org 276 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert/strict' 2 | import test from 'node:test' 3 | import remarkLintNoDeadUrls from 'remark-lint-no-dead-urls' 4 | import {remark} from 'remark' 5 | import {MockAgent, getGlobalDispatcher, setGlobalDispatcher} from 'undici' 6 | import {compareMessage} from 'vfile-sort' 7 | 8 | test('remark-lint-no-dead-urls', async function (t) { 9 | await t.test('should expose the public api', async function () { 10 | assert.deepEqual( 11 | Object.keys(await import('remark-lint-no-dead-urls')).sort(), 12 | ['default'] 13 | ) 14 | }) 15 | 16 | await t.test('should work', async function () { 17 | const globalDispatcher = getGlobalDispatcher() 18 | const mockAgent = new MockAgent() 19 | mockAgent.enableNetConnect(/(?=a)b/) 20 | setGlobalDispatcher(mockAgent) 21 | const interceptable = mockAgent.get('https://exists.com') 22 | interceptable.intercept({path: '/'}).reply(200, 'ok') 23 | interceptable.intercept({path: '/does/not/'}).reply(404, 'nok') 24 | 25 | mockAgent 26 | .get('https://does-not-exists.com') 27 | .intercept({path: '/'}) 28 | .reply(404, 'nok') 29 | 30 | const document = ` 31 | # Title 32 | 33 | Here is a [good link](https://exists.com). 34 | 35 | Here is a [bad link](https://exists.com/does/not/). 36 | 37 | Here is another [bad link](https://does-not-exists.com). 38 | ` 39 | 40 | const file = await remark().use(remarkLintNoDeadUrls).process(document) 41 | 42 | await mockAgent.close() 43 | await setGlobalDispatcher(globalDispatcher) 44 | 45 | file.messages.sort(compareMessage) 46 | 47 | assert.deepEqual(file.messages.map(String), [ 48 | '6:11-6:51: Unexpected dead URL `https://exists.com/does/not/`, expected live URL', 49 | '8:17-8:56: Unexpected dead URL `https://does-not-exists.com/`, expected live URL' 50 | ]) 51 | }) 52 | 53 | await t.test('should work w/o URLs', async function () { 54 | const document = `# Title 55 | 56 | No URLs in here. 57 | ` 58 | const file = await remark().use(remarkLintNoDeadUrls).process(document) 59 | 60 | assert.equal(file.messages.length, 0) 61 | }) 62 | 63 | await t.test('should normally ignore relative URLs', async function () { 64 | const document = `[](a.md) 65 | [](/b.md) 66 | [](./c.md) 67 | [](../d.md) 68 | [](#e) 69 | [](?f) 70 | [](//g.com) 71 | [](/h:i) 72 | [](?j:k) 73 | [](#l:m) 74 | ` 75 | const file = await remark().use(remarkLintNoDeadUrls).process(document) 76 | 77 | assert.equal(file.messages.length, 0) 78 | }) 79 | 80 | await t.test('should checks full URLs', async function () { 81 | const globalDispatcher = getGlobalDispatcher() 82 | const mockAgent = new MockAgent() 83 | mockAgent.enableNetConnect(/(?=a)b/) 84 | setGlobalDispatcher(mockAgent) 85 | 86 | const document = `[](http://a.com) 87 | [](https://b.com) 88 | [](C:\\Documents\\c.md) 89 | [](file:///Users/tilde/d.js) 90 | ` 91 | const file = await remark() 92 | // Note: `[]` to overwrite the default only-http check in `skipUrlPatterns`. 93 | .use(remarkLintNoDeadUrls, {skipUrlPatterns: []}) 94 | .process(document) 95 | 96 | await mockAgent.close() 97 | await setGlobalDispatcher(globalDispatcher) 98 | 99 | file.messages.sort(compareMessage) 100 | 101 | assert.deepEqual(file.messages.map(String), [ 102 | '1:1-1:17: Unexpected dead URL `http://a.com/`, expected live URL', 103 | '2:1-2:18: Unexpected dead URL `https://b.com/`, expected live URL', 104 | '3:1-3:22: Unexpected dead URL `c:\\Documents\\c.md`, expected live URL', 105 | '4:1-4:29: Unexpected dead URL `file:///Users/tilde/d.js`, expected live URL' 106 | ]) 107 | }) 108 | 109 | await t.test('should check relative URLs w/ `from`', async function () { 110 | const globalDispatcher = getGlobalDispatcher() 111 | const mockAgent = new MockAgent() 112 | mockAgent.enableNetConnect(/(?=a)b/) 113 | setGlobalDispatcher(mockAgent) 114 | 115 | const document = ` 116 | [](a.md) 117 | [](/b.md) 118 | [](./c.md) 119 | [](../d.md) 120 | [](#e) 121 | [](?f) 122 | [](//g.com) 123 | ` 124 | 125 | const file = await remark() 126 | .use(remarkLintNoDeadUrls, {from: 'https://example.com/from/folder'}) 127 | .process(document) 128 | 129 | await mockAgent.close() 130 | await setGlobalDispatcher(globalDispatcher) 131 | 132 | file.messages.sort(compareMessage) 133 | 134 | assert.deepEqual(file.messages.map(String), [ 135 | '2:1-2:9: Unexpected dead URL `https://example.com/from/a.md`, expected live URL', 136 | '3:1-3:10: Unexpected dead URL `https://example.com/b.md`, expected live URL', 137 | '4:1-4:11: Unexpected dead URL `https://example.com/from/c.md`, expected live URL', 138 | '5:1-5:12: Unexpected dead URL `https://example.com/d.md`, expected live URL', 139 | '6:1-6:7: Unexpected dead URL `https://example.com/from/folder#e`, expected live URL', 140 | '7:1-7:7: Unexpected dead URL `https://example.com/from/folder?f`, expected live URL', 141 | '8:1-8:12: Unexpected dead URL `https://g.com/`, expected live URL' 142 | ]) 143 | }) 144 | 145 | await t.test( 146 | 'should check relative URLs w/ `meta.origin`, `meta.pathname`', 147 | async function () { 148 | const globalDispatcher = getGlobalDispatcher() 149 | const mockAgent = new MockAgent() 150 | mockAgent.enableNetConnect(/(?=a)b/) 151 | setGlobalDispatcher(mockAgent) 152 | 153 | const document = '[](a.md)' 154 | const file = await remark() 155 | .use(remarkLintNoDeadUrls) 156 | .process({ 157 | data: { 158 | meta: {origin: 'https://example.com', pathname: '/from/folder'} 159 | }, 160 | value: document 161 | }) 162 | 163 | await mockAgent.close() 164 | await setGlobalDispatcher(globalDispatcher) 165 | 166 | file.messages.sort(compareMessage) 167 | 168 | assert.deepEqual(file.messages.map(String), [ 169 | '1:1-1:9: Unexpected dead URL `https://example.com/from/a.md`, expected live URL' 170 | ]) 171 | } 172 | ) 173 | 174 | await t.test('should check definitions, images', async function () { 175 | const globalDispatcher = getGlobalDispatcher() 176 | const mockAgent = new MockAgent() 177 | mockAgent.enableNetConnect(/(?=a)b/) 178 | setGlobalDispatcher(mockAgent) 179 | 180 | const document = ` 181 | ![image](https://example.com/a) 182 | 183 | [link](https://example.com/b) 184 | 185 | [definition]: https://example.com/c 186 | ` 187 | const file = await remark().use(remarkLintNoDeadUrls).process(document) 188 | 189 | await mockAgent.close() 190 | await setGlobalDispatcher(globalDispatcher) 191 | 192 | file.messages.sort(compareMessage) 193 | 194 | assert.deepEqual(file.messages.map(String), [ 195 | '2:1-2:32: Unexpected dead URL `https://example.com/a`, expected live URL', 196 | '4:1-4:30: Unexpected dead URL `https://example.com/b`, expected live URL', 197 | '6:1-6:36: Unexpected dead URL `https://example.com/c`, expected live URL' 198 | ]) 199 | }) 200 | 201 | await t.test('should skip URLs w/ unknown protocols', async function () { 202 | const globalDispatcher = getGlobalDispatcher() 203 | const mockAgent = new MockAgent() 204 | mockAgent.enableNetConnect(/(?=a)b/) 205 | setGlobalDispatcher(mockAgent) 206 | 207 | const document = ` 208 | [a](mailto:me@me.com) 209 | 210 | [b](ftp://path/to/file.txt) 211 | 212 | [c](flopper://a/b/c) 213 | ` 214 | const file = await remark().use(remarkLintNoDeadUrls).process(document) 215 | 216 | await mockAgent.close() 217 | await setGlobalDispatcher(globalDispatcher) 218 | 219 | file.messages.sort(compareMessage) 220 | 221 | assert.deepEqual(file.messages.map(String), []) 222 | }) 223 | 224 | await t.test('should ignore localhost w/ `skipLocalhost`', async function () { 225 | const globalDispatcher = getGlobalDispatcher() 226 | const mockAgent = new MockAgent() 227 | mockAgent.enableNetConnect(/(?=a)b/) 228 | setGlobalDispatcher(mockAgent) 229 | 230 | const document = ` 231 | * [a](http://localhost) 232 | * [b](http://localhost/alex/test) 233 | * [c](http://localhost:3000) 234 | * [d](http://localhost:3000/alex/test) 235 | * [e](http://127.0.0.1) 236 | * [f](http://127.0.0.1:3000) 237 | * [g](http://example.com) 238 | ` 239 | const file = await remark() 240 | .use(remarkLintNoDeadUrls, {skipLocalhost: true}) 241 | .process(document) 242 | 243 | await mockAgent.close() 244 | await setGlobalDispatcher(globalDispatcher) 245 | 246 | file.messages.sort(compareMessage) 247 | 248 | assert.deepEqual(file.messages.map(String), [ 249 | '8:3-8:26: Unexpected dead URL `http://example.com/`, expected live URL' 250 | ]) 251 | }) 252 | 253 | await t.test('should support anchors', async function () { 254 | const globalDispatcher = getGlobalDispatcher() 255 | const mockAgent = new MockAgent() 256 | mockAgent.enableNetConnect(/(?=a)b/) 257 | setGlobalDispatcher(mockAgent) 258 | const site = mockAgent.get('https://example.com') 259 | 260 | site.intercept({path: '/'}).reply(200, '

hi

', { 261 | headers: {'Content-Type': 'text/html'} 262 | }) 263 | 264 | const document = ` 265 | [a](https://example.com#exists) 266 | [b](https://example.com#does-not-exist) 267 | ` 268 | const file = await remark().use(remarkLintNoDeadUrls).process(document) 269 | 270 | await mockAgent.close() 271 | await setGlobalDispatcher(globalDispatcher) 272 | 273 | file.messages.sort(compareMessage) 274 | 275 | assert.deepEqual(file.messages.map(String), [ 276 | '3:1-3:40: Unexpected dead URL `https://example.com/#does-not-exist`, expected live URL' 277 | ]) 278 | }) 279 | 280 | await t.test('should support `skipUrlPatterns`', async function () { 281 | const globalDispatcher = getGlobalDispatcher() 282 | const mockAgent = new MockAgent() 283 | mockAgent.enableNetConnect(/(?=a)b/) 284 | setGlobalDispatcher(mockAgent) 285 | 286 | const document = ` 287 | [a](http://aaa.com) 288 | [b](http://aaa.com/somePath) 289 | [c](http://aaa.com/somePath?withQuery=wow) 290 | [d](http://bbb.com/somePath/maybe) 291 | ` 292 | const file = await remark() 293 | .use(remarkLintNoDeadUrls, { 294 | skipUrlPatterns: [/^http:\/\/aaa\.com/, '^http://bbb\\.com'] 295 | }) 296 | .process(document) 297 | 298 | await mockAgent.close() 299 | await setGlobalDispatcher(globalDispatcher) 300 | 301 | file.messages.sort(compareMessage) 302 | 303 | assert.deepEqual(file.messages.map(String), []) 304 | }) 305 | 306 | await t.test('should support `deadOrAlive` options', async function () { 307 | const globalDispatcher = getGlobalDispatcher() 308 | const mockAgent = new MockAgent() 309 | mockAgent.enableNetConnect(/(?=a)b/) 310 | setGlobalDispatcher(mockAgent) 311 | const site = mockAgent.get('https://example.com') 312 | 313 | site.intercept({path: '/'}).reply(200, '

hi

', { 314 | headers: {'Content-Type': 'text/html'} 315 | }) 316 | 317 | const document = `[b](https://example.com#does-not-exist)` 318 | const file = await remark() 319 | .use(remarkLintNoDeadUrls, {deadOrAliveOptions: {checkAnchor: false}}) 320 | .process(document) 321 | 322 | await mockAgent.close() 323 | await setGlobalDispatcher(globalDispatcher) 324 | 325 | file.messages.sort(compareMessage) 326 | 327 | assert.deepEqual(file.messages.map(String), []) 328 | }) 329 | 330 | await t.test('should support permanent redirects', async function () { 331 | const globalDispatcher = getGlobalDispatcher() 332 | const mockAgent = new MockAgent() 333 | mockAgent.enableNetConnect(/(?=a)b/) 334 | setGlobalDispatcher(mockAgent) 335 | const site = mockAgent.get('https://example.com') 336 | 337 | site.intercept({path: '/from'}).reply(301, '', { 338 | headers: {Location: '/to'} 339 | }) 340 | 341 | site.intercept({path: '/to'}).reply(200, 'ok', { 342 | headers: {'Content-Type': 'text/html'} 343 | }) 344 | 345 | const document = `[a](https://example.com/from)` 346 | const file = await remark().use(remarkLintNoDeadUrls).process(document) 347 | 348 | await mockAgent.close() 349 | await setGlobalDispatcher(globalDispatcher) 350 | 351 | file.messages.sort(compareMessage) 352 | 353 | assert.deepEqual(file.messages.map(String), [ 354 | '1:1-1:30: Unexpected redirecting URL `https://example.com/from`, expected final URL `https://example.com/to`' 355 | ]) 356 | }) 357 | 358 | await t.test('should support temporary redirects', async function () { 359 | const globalDispatcher = getGlobalDispatcher() 360 | const mockAgent = new MockAgent() 361 | mockAgent.enableNetConnect(/(?=a)b/) 362 | setGlobalDispatcher(mockAgent) 363 | const site = mockAgent.get('https://example.com') 364 | 365 | site.intercept({path: '/from'}).reply(302, '', { 366 | headers: {Location: '/to'} 367 | }) 368 | 369 | site.intercept({path: '/to'}).reply(200, 'ok', { 370 | headers: {'Content-Type': 'text/html'} 371 | }) 372 | 373 | const document = `[a](https://example.com/from)` 374 | const file = await remark().use(remarkLintNoDeadUrls).process(document) 375 | 376 | await mockAgent.close() 377 | await setGlobalDispatcher(globalDispatcher) 378 | 379 | file.messages.sort(compareMessage) 380 | 381 | assert.deepEqual(file.messages.map(String), []) 382 | }) 383 | }) 384 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "checkJs": true, 4 | "customConditions": ["development"], 5 | "declaration": true, 6 | "declarationMap": true, 7 | "emitDeclarationOnly": true, 8 | "exactOptionalPropertyTypes": true, 9 | "lib": ["es2022"], 10 | "module": "node16", 11 | "strict": true, 12 | "target": "es2022" 13 | }, 14 | "exclude": ["coverage/", "node_modules/"], 15 | "include": ["**/*.js", "index.d.ts"] 16 | } 17 | --------------------------------------------------------------------------------