├── .editorconfig ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── BUG.md │ ├── DOCUMENTATION.md │ ├── FEATURE_REQUEST.md │ ├── IMPROVEMENT.md │ └── QUESTION.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── lint.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .markdownlint-cli2.mjs ├── .npmrc ├── .prettierrc.json ├── .releaserc.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── eslint.config.js ├── package-lock.json ├── package.json ├── src ├── index.d.ts ├── index.js ├── markdownlint-rule-helpers │ └── helpers.js └── utils.js ├── test ├── fixtures │ ├── config-dependent │ │ └── absolute-paths.md │ ├── image.png │ ├── invalid │ │ ├── empty-id-fragment │ │ │ ├── awesome.md │ │ │ └── empty-id-fragment.md │ │ ├── ignore-empty-fragment-checking-for-image.md │ │ ├── ignore-fragment-checking-for-image.md │ │ ├── ignore-name-fragment-if-not-an-anchor │ │ │ ├── awesome.md │ │ │ └── ignore-name-fragment-if-not-an-anchor.md │ │ ├── ignore-not-an-id-fragment │ │ │ ├── awesome.md │ │ │ └── ignore-not-an-id-fragment.md │ │ ├── invalid-heading-case-sensitive │ │ │ ├── awesome.md │ │ │ └── invalid-heading-case-sensitive.md │ │ ├── invalid-heading-with-L-fragment │ │ │ ├── awesome.md │ │ │ └── invalid-heading-with-L-fragment.md │ │ ├── invalid-line-column-range-number-fragment │ │ │ ├── awesome.md │ │ │ └── invalid-line-column-range-number-fragment.md │ │ ├── invalid-line-number-fragment │ │ │ ├── awesome.md │ │ │ └── invalid-line-number-fragment.md │ │ ├── non-existing-anchor-name-fragment │ │ │ ├── awesome.md │ │ │ └── non-existing-anchor-name-fragment.md │ │ ├── non-existing-element-id-fragment │ │ │ ├── awesome.md │ │ │ └── non-existing-element-id-fragment.md │ │ ├── non-existing-file.md │ │ ├── non-existing-heading-fragment │ │ │ ├── awesome.md │ │ │ └── non-existing-heading-fragment.md │ │ └── non-existing-image.md │ └── valid │ │ ├── existing-anchor-name-fragment │ │ ├── awesome.md │ │ └── existing-anchor-name-fragment.md │ │ ├── existing-element-id-fragment │ │ ├── awesome.md │ │ └── existing-element-id-fragment.md │ │ ├── existing-file.md │ │ ├── existing-heading-fragment │ │ ├── awesome.md │ │ └── existing-heading-fragment.md │ │ ├── existing-heading-with-accents │ │ ├── awesome.md │ │ └── existing-heading-with-accents.md │ │ ├── existing-image.md │ │ ├── ignore-absolute-paths.md │ │ ├── ignore-external-links.md │ │ ├── ignore-fragment-checking-in-own-file.md │ │ ├── only-parse-markdown-files-for-fragments │ │ ├── abc.txt │ │ ├── awesome.html │ │ └── only-parse-markdown-files-for-fragments.md │ │ ├── valid-heading-like-number-fragment │ │ ├── awesome.md │ │ └── valid-heading-like-number-fragment.md │ │ ├── valid-line-column-range-number-fragment │ │ ├── awesome.md │ │ └── valid-line-column-range-number-fragment.md │ │ └── valid-line-number-fragment │ │ ├── awesome.md │ │ └── valid-line-number-fragment.md ├── index.test.js └── utils.test.js └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # For more information see: https://editorconfig.org/ 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 2 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/BUG.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "🐛 Bug Report" 3 | about: "Report an unexpected problem or unintended behavior." 4 | title: "[Bug]" 5 | labels: "bug" 6 | --- 7 | 8 | 12 | 13 | ## Steps To Reproduce 14 | 15 | 1. Step 1 16 | 2. Step 2 17 | 18 | ## The current behavior 19 | 20 | ## The expected behavior 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/DOCUMENTATION.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "📜 Documentation" 3 | about: "Correct spelling errors, improvements or additions to documentation files (README, CONTRIBUTING...)." 4 | title: "[Documentation]" 5 | labels: "documentation" 6 | --- 7 | 8 | 9 | 10 | ## Documentation 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | ## Proposal 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "✨ Feature Request" 3 | about: "Suggest a new feature idea." 4 | title: "[Feature]" 5 | labels: "feature request" 6 | --- 7 | 8 | 9 | 10 | ## Description 11 | 12 | 13 | 14 | ## Describe the solution you'd like 15 | 16 | 17 | 18 | ## Describe alternatives you've considered 19 | 20 | 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/IMPROVEMENT.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "🔧 Improvement" 3 | about: "Improve structure/format/performance/refactor/tests of the code." 4 | title: "[Improvement]" 5 | labels: "improvement" 6 | --- 7 | 8 | 9 | 10 | ## Type of Improvement 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | ## Proposal 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/QUESTION.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "🙋 Question" 3 | about: "Further information is requested." 4 | title: "[Question]" 5 | labels: "question" 6 | --- 7 | 8 | ### Question 9 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # What changes this PR introduce? 4 | 5 | ## List any relevant issue numbers 6 | 7 | ## Is there anything you'd like reviewers to focus on? 8 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: "Lint" 2 | 3 | on: 4 | push: 5 | branches: [develop] 6 | pull_request: 7 | branches: [main, develop] 8 | 9 | jobs: 10 | lint: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v4.2.2" 14 | 15 | - name: "Setup Node.js" 16 | uses: "actions/setup-node@v4.1.0" 17 | with: 18 | node-version: "lts/*" 19 | cache: "npm" 20 | 21 | - name: "Install dependencies" 22 | run: "npm clean-install" 23 | 24 | - run: "node --run lint:editorconfig" 25 | - run: "node --run lint:markdown" 26 | - run: "node --run lint:eslint" 27 | - run: "node --run lint:prettier" 28 | - run: "node --run lint:typescript" 29 | 30 | commitlint: 31 | runs-on: "ubuntu-latest" 32 | steps: 33 | - uses: "actions/checkout@v4.2.2" 34 | 35 | - uses: "wagoid/commitlint-github-action@v6.2.0" 36 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: "Release" 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | jobs: 8 | release: 9 | runs-on: "ubuntu-latest" 10 | permissions: 11 | contents: "write" 12 | issues: "write" 13 | pull-requests: "write" 14 | id-token: "write" 15 | steps: 16 | - uses: "actions/checkout@v4.2.2" 17 | with: 18 | fetch-depth: 0 19 | persist-credentials: false 20 | 21 | - name: "Setup Node.js" 22 | uses: "actions/setup-node@v4.1.0" 23 | with: 24 | node-version: "lts/*" 25 | cache: "npm" 26 | 27 | - name: "Install dependencies" 28 | run: "npm clean-install" 29 | 30 | - name: "Verify the integrity of provenance attestations and registry signatures for installed dependencies" 31 | run: "npm audit signatures" 32 | 33 | - name: "Release" 34 | run: "node --run release" 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 38 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: "Test" 2 | 3 | on: 4 | push: 5 | branches: [develop] 6 | pull_request: 7 | branches: [main, develop] 8 | 9 | jobs: 10 | test: 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | runs-on: 15 | - "ubuntu-latest" 16 | - "windows-latest" 17 | - "macos-latest" 18 | runs-on: "${{ matrix.runs-on }}" 19 | steps: 20 | - uses: "actions/checkout@v4.2.2" 21 | 22 | - name: "Setup Node.js" 23 | uses: "actions/setup-node@v4.1.0" 24 | with: 25 | node-version: "lts/*" 26 | cache: "npm" 27 | 28 | - name: "Install dependencies" 29 | run: "npm clean-install" 30 | 31 | - name: "Test" 32 | run: "node --run test" 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | node_modules 3 | .npm 4 | 5 | # testing 6 | coverage 7 | .nyc_output 8 | 9 | # debug 10 | npm-debug.log* 11 | 12 | # IDEs and editors 13 | /.idea 14 | .project 15 | .classpath 16 | .c9/ 17 | *.launch 18 | .settings/ 19 | *.sublime-workspace 20 | 21 | # IDE - VSCode 22 | .vscode/* 23 | 24 | # misc 25 | .DS_Store 26 | -------------------------------------------------------------------------------- /.markdownlint-cli2.mjs: -------------------------------------------------------------------------------- 1 | import relativeLinksRule from "./src/index.js" 2 | 3 | const config = { 4 | config: { 5 | extends: "markdownlint/style/prettier", 6 | default: true, 7 | "relative-links": { 8 | root_path: ".", 9 | }, 10 | "no-inline-html": false, 11 | }, 12 | globs: ["**/*.md"], 13 | ignores: ["**/node_modules", "**/test/fixtures/**"], 14 | customRules: [relativeLinksRule], 15 | } 16 | 17 | export default config 18 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact = true 2 | provenance = true 3 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false 3 | } 4 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "branches": ["main", { "name": "beta", "prerelease": true }], 3 | "plugins": [ 4 | "@semantic-release/commit-analyzer", 5 | "@semantic-release/release-notes-generator", 6 | "@semantic-release/npm", 7 | "@semantic-release/github" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | . 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][mozilla coc]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][faq]. Translations are available 126 | at [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html 130 | [mozilla coc]: https://github.com/mozilla/diversity 131 | [faq]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # 💡 Contributing 2 | 3 | Thanks a lot for your interest in contributing to **markdownlint-rule-relative-links**! 🎉 4 | 5 | ## Code of Conduct 6 | 7 | **markdownlint-rule-relative-links** adopted the [Contributor Covenant](https://www.contributor-covenant.org/) as its Code of Conduct, and we expect project participants to adhere to it. Please read [the full text](/CODE_OF_CONDUCT.md) so that you can understand what actions will and will not be tolerated. 8 | 9 | ## Open Development 10 | 11 | All work on **markdownlint-rule-relative-links** happens directly on this repository. Both core team members and external contributors send pull requests which go through the same review process. 12 | 13 | ## Types of contributions 14 | 15 | - Reporting a bug. 16 | - Suggest a new feature idea. 17 | - Correct spelling errors, improvements or additions to documentation files (README, CONTRIBUTING...). 18 | - Improve structure/format/performance/refactor/tests of the code. 19 | 20 | ## Pull Requests 21 | 22 | - **Please first discuss** the change you wish to make via [issue](https://github.com/theoludwig/markdownlint-rule-relative-links/issues) before making a change. It might avoid a waste of your time. 23 | 24 | - Ensure your code respect linting. 25 | 26 | - Make sure your **code passes the tests**. 27 | 28 | If you're adding new features to **markdownlint-rule-relative-links**, please include tests. 29 | 30 | ## Commits 31 | 32 | The commit message guidelines adheres to [Conventional Commits](https://www.conventionalcommits.org/) and [Semantic Versioning](https://semver.org/) for releases. 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) Théo LUDWIG 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

markdownlint-rule-relative-links

2 | 3 |

4 | Custom rule for markdownlint to validate relative links. 5 |

6 | 7 |

8 | CONTRIBUTING 9 | Licence MIT 10 | Contributor Covenant 11 |
12 | Lint 13 | Test 14 |
15 | Conventional Commits 16 | semantic-release 17 | npm version 18 |

19 | 20 | ## 📜 About 21 | 22 | **markdownlint-rule-relative-links** is a [markdownlint](https://github.com/DavidAnson/markdownlint) custom rule to validate relative links. 23 | 24 | It ensures that relative links (using `file:` protocol) are working and exists in the file system of the project that uses [markdownlint](https://github.com/DavidAnson/markdownlint). 25 | 26 | ### Example 27 | 28 | File structure: 29 | 30 | ```txt 31 | ├── abc.txt 32 | └── awesome.md 33 | ``` 34 | 35 | With `awesome.md` content: 36 | 37 | ```md 38 | [abc](./abc.txt) 39 | 40 | [Invalid link](./invalid.txt) 41 | ``` 42 | 43 | Running [markdownlint-cli2](https://github.com/DavidAnson/markdownlint-cli2) with `markdownlint-rule-relative-links` will output: 44 | 45 | ```sh 46 | awesome.md:3 relative-links Relative links should be valid ["./invalid.txt" should exist in the file system] 47 | ``` 48 | 49 | ### Additional features 50 | 51 | - Support images (e.g: `![Image](./image.png)`). 52 | - Support links fragments similar to the [built-in `markdownlint` rule - MD051](https://github.com/DavidAnson/markdownlint/blob/main/doc/md051.md) (e.g: `[Link](./awesome.md#heading)`). 53 | - Ignore external links and absolute paths as it only checks relative links (e.g: `https://example.com/` or `/absolute/path.png`). 54 | - If necessary, absolute paths can be validated too, with [`root_path` configuration option](#absolute-paths). 55 | 56 | ### Limitations 57 | 58 | - Only images and links defined using markdown syntax are validated, html syntax is ignored (e.g: `` or ``). 59 | 60 | Contributions are welcome to improve the rule, and to alleviate these limitations. See [CONTRIBUTING.md](/CONTRIBUTING.md) for more information. 61 | 62 | ### Related links 63 | 64 | - [DavidAnson/markdownlint#253](https://github.com/DavidAnson/markdownlint/issues/253) 65 | - [DavidAnson/markdownlint#121](https://github.com/DavidAnson/markdownlint/issues/121) 66 | - [nschonni/markdownlint-valid-links](https://github.com/nschonni/markdownlint-valid-links) 67 | 68 | ## Prerequisites 69 | 70 | [Node.js](https://nodejs.org/) >= 22.0.0 71 | 72 | ## Installation 73 | 74 | ```sh 75 | npm install --save-dev markdownlint-rule-relative-links 76 | ``` 77 | 78 | ## Configuration 79 | 80 | There are various ways [markdownlint](https://github.com/DavidAnson/markdownlint) can be configured using objects, config files etc. For more information on configuration refer to [options.config](https://github.com/DavidAnson/markdownlint#optionsconfig). 81 | 82 | We recommend configuring [markdownlint-cli2](https://github.com/DavidAnson/markdownlint-cli2) over [markdownlint-cli](https://github.com/igorshubovych/markdownlint-cli) for compatibility with the [vscode-markdownlint](https://github.com/DavidAnson/vscode-markdownlint) extension. 83 | 84 | `.markdownlint-cli2.mjs` 85 | 86 | ```js 87 | import relativeLinksRule from "markdownlint-rule-relative-links" 88 | 89 | const config = { 90 | config: { 91 | default: true, 92 | "relative-links": true, 93 | }, 94 | globs: ["**/*.md"], 95 | ignores: ["**/node_modules"], 96 | customRules: [relativeLinksRule], 97 | } 98 | 99 | export default config 100 | ``` 101 | 102 | `package.json` 103 | 104 | ```json 105 | { 106 | "scripts": { 107 | "lint:markdown": "markdownlint-cli2" 108 | } 109 | } 110 | ``` 111 | 112 | ### Absolute paths 113 | 114 | GitHub (and, likely, other similar platforms) resolves absolute paths in Markdown links relative to the repository root. 115 | 116 | To validate such links, add `root_path` option to the configuration: 117 | 118 | ```js 119 | config: { 120 | default: true, 121 | "relative-links": { 122 | root_path: ".", 123 | }, 124 | }, 125 | ``` 126 | 127 | After this change, all absolute paths will be converted to relative paths, and will be resolved relative to the specified directory. 128 | 129 | For example, if you run markdownlint from a subdirectory (if `package.json` is located in a subdirectory), you should set `root_path` to `".."`. 130 | 131 | ## Usage 132 | 133 | ```sh 134 | node --run lint:markdown 135 | ``` 136 | 137 | ## 💡 Contributing 138 | 139 | Anyone can help to improve the project, submit a Feature Request, a bug report or even correct a simple spelling mistake. 140 | 141 | The steps to contribute can be found in the [CONTRIBUTING.md](/CONTRIBUTING.md) file. 142 | 143 | ## 📄 License 144 | 145 | [MIT](/LICENSE) 146 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import typescriptESLint from "typescript-eslint" 2 | import configConventions from "eslint-config-conventions" 3 | 4 | export default typescriptESLint.config(...configConventions, { 5 | files: ["**/*.ts", "**/*.tsx"], 6 | languageOptions: { 7 | parser: typescriptESLint.parser, 8 | parserOptions: { 9 | projectService: true, 10 | tsconfigRootDir: import.meta.dirname, 11 | }, 12 | }, 13 | }) 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "markdownlint-rule-relative-links", 3 | "version": "0.0.0-development", 4 | "public": true, 5 | "description": "Custom rule for markdownlint to validate relative links.", 6 | "author": "Théo LUDWIG ", 7 | "license": "MIT", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/theoludwig/markdownlint-rule-relative-links.git" 11 | }, 12 | "bugs": { 13 | "url": "https://github.com/theoludwig/markdownlint-rule-relative-links/issues" 14 | }, 15 | "homepage": "https://github.com/theoludwig/markdownlint-rule-relative-links#readme", 16 | "keywords": [ 17 | "markdownlint", 18 | "markdownlint-rule" 19 | ], 20 | "main": "src/index.js", 21 | "types": "src/index.d.ts", 22 | "type": "module", 23 | "files": [ 24 | "src" 25 | ], 26 | "publishConfig": { 27 | "access": "public", 28 | "provenance": true 29 | }, 30 | "engines": { 31 | "node": ">=22.0.0" 32 | }, 33 | "scripts": { 34 | "lint:editorconfig": "editorconfig-checker", 35 | "lint:markdown": "markdownlint-cli2", 36 | "lint:eslint": "eslint . --max-warnings 0", 37 | "lint:prettier": "prettier . --check", 38 | "lint:typescript": "tsc --noEmit", 39 | "test": "node --test", 40 | "release": "semantic-release" 41 | }, 42 | "dependencies": { 43 | "markdown-it": "14.1.0" 44 | }, 45 | "devDependencies": { 46 | "@types/markdown-it": "14.1.2", 47 | "@types/node": "22.15.17", 48 | "editorconfig-checker": "6.0.1", 49 | "eslint": "9.26.0", 50 | "eslint-config-conventions": "19.2.0", 51 | "eslint-plugin-promise": "7.2.1", 52 | "eslint-plugin-unicorn": "59.0.1", 53 | "eslint-plugin-import-x": "4.11.1", 54 | "globals": "16.1.0", 55 | "markdownlint": "0.38.0", 56 | "markdownlint-cli2": "0.18.0", 57 | "prettier": "3.5.3", 58 | "semantic-release": "24.2.3", 59 | "typescript-eslint": "8.32.0", 60 | "typescript": "5.8.3" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | import type MarkdownIt from "markdown-it" 2 | import type { Rule } from "markdownlint" 3 | 4 | declare const relativeLinksRule: Rule 5 | export default relativeLinksRule 6 | 7 | declare const markdownIt: MarkdownIt 8 | export { markdownIt } 9 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { pathToFileURL } from "node:url" 2 | import fs from "node:fs" 3 | 4 | import { filterTokens } from "./markdownlint-rule-helpers/helpers.js" 5 | import { 6 | convertHeadingToHTMLFragment, 7 | getMarkdownHeadings, 8 | getMarkdownIdOrAnchorNameFragments, 9 | isValidIntegerString, 10 | getNumberOfLines, 11 | getLineNumberStringFromFragment, 12 | lineFragmentRe, 13 | } from "./utils.js" 14 | 15 | export { markdownIt } from "./utils.js" 16 | 17 | /** @typedef {import('markdownlint').Rule} MarkdownLintRule */ 18 | 19 | /** 20 | * @type {MarkdownLintRule} 21 | */ 22 | const relativeLinksRule = { 23 | names: ["relative-links"], 24 | description: "Relative links should be valid", 25 | tags: ["links"], 26 | parser: "markdownit", 27 | function: (params, onError) => { 28 | filterTokens(params, "inline", (token) => { 29 | const children = token.children ?? [] 30 | for (const child of children) { 31 | const { type, attrs, lineNumber } = child 32 | 33 | /** @type {string | undefined} */ 34 | let hrefSrc 35 | 36 | if (type === "link_open") { 37 | for (const attr of attrs) { 38 | if (attr[0] === "href") { 39 | hrefSrc = attr[1] 40 | break 41 | } 42 | } 43 | } 44 | 45 | if (type === "image") { 46 | for (const attr of attrs) { 47 | if (attr[0] === "src") { 48 | hrefSrc = attr[1] 49 | break 50 | } 51 | } 52 | } 53 | 54 | if (hrefSrc == null || hrefSrc.startsWith("#")) { 55 | continue 56 | } 57 | 58 | let url 59 | 60 | if (hrefSrc.startsWith("/")) { 61 | const rootPath = params.config["root_path"] 62 | 63 | if (!rootPath) { 64 | continue 65 | } 66 | 67 | url = new URL(`.${hrefSrc}`, pathToFileURL(`${rootPath}/`)) 68 | } else { 69 | url = new URL(hrefSrc, pathToFileURL(params.name)) 70 | } 71 | 72 | if (url.protocol !== "file:") { 73 | continue 74 | } 75 | 76 | const detail = `"${hrefSrc}"` 77 | 78 | if (!fs.existsSync(url)) { 79 | onError({ 80 | lineNumber, 81 | detail: `${detail} should exist in the file system`, 82 | }) 83 | continue 84 | } 85 | 86 | if (url.hash.length <= 0) { 87 | if (hrefSrc.includes("#")) { 88 | if (type === "image") { 89 | onError({ 90 | lineNumber, 91 | detail: `${detail} should not have a fragment identifier as it is an image`, 92 | }) 93 | continue 94 | } 95 | 96 | onError({ 97 | lineNumber, 98 | detail: `${detail} should have a valid fragment identifier`, 99 | }) 100 | continue 101 | } 102 | continue 103 | } 104 | 105 | if (type === "image") { 106 | onError({ 107 | lineNumber, 108 | detail: `${detail} should not have a fragment identifier as it is an image`, 109 | }) 110 | continue 111 | } 112 | 113 | if (!url.pathname.endsWith(".md")) { 114 | continue 115 | } 116 | 117 | const fileContent = fs.readFileSync(url, { encoding: "utf8" }) 118 | const headings = getMarkdownHeadings(fileContent) 119 | const idOrAnchorNameHTMLFragments = 120 | getMarkdownIdOrAnchorNameFragments(fileContent) 121 | 122 | /** @type {Map} */ 123 | const fragments = new Map() 124 | 125 | const fragmentsHTML = headings.map((heading) => { 126 | const fragment = convertHeadingToHTMLFragment(heading) 127 | const count = fragments.get(fragment) ?? 0 128 | fragments.set(fragment, count + 1) 129 | if (count !== 0) { 130 | return `${fragment}-${count}` 131 | } 132 | return fragment 133 | }) 134 | 135 | fragmentsHTML.push(...idOrAnchorNameHTMLFragments) 136 | 137 | if (!fragmentsHTML.includes(url.hash)) { 138 | if (url.hash.startsWith("#L")) { 139 | const lineNumberFragmentString = getLineNumberStringFromFragment( 140 | url.hash, 141 | ) 142 | 143 | const hasOnlyDigits = isValidIntegerString(lineNumberFragmentString) 144 | if (!hasOnlyDigits) { 145 | if (lineFragmentRe.test(url.hash)) { 146 | continue 147 | } 148 | 149 | onError({ 150 | lineNumber, 151 | detail: `${detail} should have a valid fragment identifier`, 152 | }) 153 | continue 154 | } 155 | 156 | const lineNumberFragment = Number.parseInt( 157 | lineNumberFragmentString, 158 | 10, 159 | ) 160 | const numberOfLines = getNumberOfLines(fileContent) 161 | if (lineNumberFragment > numberOfLines) { 162 | onError({ 163 | lineNumber, 164 | detail: `${detail} should have a valid fragment identifier, ${detail} should have at least ${lineNumberFragment} lines to be valid`, 165 | }) 166 | continue 167 | } 168 | 169 | continue 170 | } 171 | 172 | onError({ 173 | lineNumber, 174 | detail: `${detail} should have a valid fragment identifier`, 175 | }) 176 | continue 177 | } 178 | } 179 | }) 180 | }, 181 | } 182 | 183 | export default relativeLinksRule 184 | -------------------------------------------------------------------------------- /src/markdownlint-rule-helpers/helpers.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Dependency Vendoring of `markdownlint-rule-helpers` 3 | * @see https://www.npmjs.com/package/markdownlint-rule-helpers 4 | */ 5 | 6 | /** @typedef {import('markdownlint').RuleParams} MarkdownLintRuleParams */ 7 | /** @typedef {import('markdownlint').MarkdownItToken} MarkdownItToken */ 8 | 9 | /** 10 | * Calls the provided function for each matching token. 11 | * 12 | * @param {MarkdownLintRuleParams} params RuleParams instance. 13 | * @param {string} type Token type identifier. 14 | * @param {(token: MarkdownItToken) => void} handler Callback function. 15 | * @returns {void} 16 | */ 17 | export const filterTokens = (params, type, handler) => { 18 | for (const token of params.parsers.markdownit.tokens) { 19 | if (token.type === type) { 20 | handler(token) 21 | } 22 | } 23 | } 24 | 25 | /** 26 | * Gets a Regular Expression for matching the specified HTML attribute. 27 | * 28 | * @param {string} name HTML attribute name. 29 | * @returns {RegExp} Regular Expression for matching. 30 | */ 31 | export const getHtmlAttributeRe = (name) => { 32 | return new RegExp(`\\s${name}\\s*=\\s*['"]?([^'"\\s>]*)`, "iu") 33 | } 34 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import MarkdownIt from "markdown-it" 2 | 3 | import { getHtmlAttributeRe } from "./markdownlint-rule-helpers/helpers.js" 4 | 5 | export const markdownIt = new MarkdownIt({ html: true }) 6 | 7 | export const lineFragmentRe = /^#(?:L\d+(?:C\d+)?-L\d+(?:C\d+)?|L\d+)$/ 8 | 9 | /** 10 | * Converts a Markdown heading into an HTML fragment according to the rules 11 | * used by GitHub. 12 | * 13 | * @see https://github.com/DavidAnson/markdownlint/blob/d01180ec5a014083ee9d574b693a8d7fbc1e566d/lib/md051.js#L1 14 | * @param {string} inlineText Inline token for heading. 15 | * @returns {string} Fragment string for heading. 16 | */ 17 | export const convertHeadingToHTMLFragment = (inlineText) => { 18 | return ( 19 | "#" + 20 | encodeURIComponent( 21 | inlineText 22 | .toLowerCase() 23 | // RegExp source with Ruby's \p{Word} expanded into its General Categories 24 | // https://github.com/gjtorikian/html-pipeline/blob/main/lib/html/pipeline/toc_filter.rb 25 | // https://ruby-doc.org/core-3.0.2/Regexp.html 26 | .replace( 27 | /[^\p{Letter}\p{Mark}\p{Number}\p{Connector_Punctuation}\- ]/gu, 28 | "", 29 | ) 30 | .replace(/ /gu, "-"), 31 | ) 32 | ) 33 | } 34 | 35 | const headingTags = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]) 36 | const ignoredTokens = new Set(["heading_open", "heading_close"]) 37 | 38 | /** 39 | * Gets the headings from a Markdown string. 40 | * @param {string} content 41 | * @returns {string[]} 42 | */ 43 | export const getMarkdownHeadings = (content) => { 44 | const tokens = markdownIt.parse(content, {}) 45 | 46 | /** @type {string[]} */ 47 | const headings = [] 48 | 49 | /** @type {string | null} */ 50 | let headingToken = null 51 | 52 | for (const token of tokens) { 53 | if (headingTags.has(token.tag)) { 54 | if (token.type === "heading_open") { 55 | headingToken = token.markup 56 | } else if (token.type === "heading_close") { 57 | headingToken = null 58 | } 59 | } 60 | 61 | if (ignoredTokens.has(token.type)) { 62 | continue 63 | } 64 | 65 | if (headingToken === null) { 66 | continue 67 | } 68 | 69 | const children = token.children ?? [] 70 | 71 | headings.push( 72 | `${children 73 | .map((token) => { 74 | return token.content 75 | }) 76 | .join("")}`, 77 | ) 78 | } 79 | 80 | return headings 81 | } 82 | 83 | const nameHTMLAttributeRegex = getHtmlAttributeRe("name") 84 | const idHTMLAttributeRegex = getHtmlAttributeRe("id") 85 | 86 | /** 87 | * Gets the id or anchor name fragments from a Markdown string. 88 | * @param {string} content 89 | * @returns {string[]} 90 | */ 91 | export const getMarkdownIdOrAnchorNameFragments = (content) => { 92 | const tokens = markdownIt.parse(content, {}) 93 | 94 | /** @type {string[]} */ 95 | const result = [] 96 | 97 | for (const token of tokens) { 98 | const regexMatch = 99 | idHTMLAttributeRegex.exec(token.content) || 100 | nameHTMLAttributeRegex.exec(token.content) 101 | if (regexMatch == null) { 102 | continue 103 | } 104 | 105 | const idOrName = regexMatch[1] 106 | if (idOrName == null || idOrName.length <= 0) { 107 | continue 108 | } 109 | 110 | const htmlFragment = "#" + idOrName 111 | if (!result.includes(htmlFragment)) { 112 | result.push(htmlFragment) 113 | } 114 | } 115 | 116 | return result 117 | } 118 | 119 | /** 120 | * Checks if a string is a valid integer. 121 | * 122 | * Using `Number.parseInt` combined with `Number.isNaN` will not be sufficient enough because `Number.parseInt("1abc", 10)` will return `1` (a valid number) instead of `NaN`. 123 | * 124 | * @param {string} value 125 | * @returns {boolean} 126 | * @example isValidIntegerString("1") // true 127 | * @example isValidIntegerString("45") // true 128 | * @example isValidIntegerString("1abc") // false 129 | * @example isValidIntegerString("1.0") // false 130 | */ 131 | export const isValidIntegerString = (value) => { 132 | const regex = /^\d+$/ 133 | return regex.test(value) 134 | } 135 | 136 | /** 137 | * Gets the number of lines in a string, based on the number of `\n` characters. 138 | * @param {string} content 139 | * @returns {number} 140 | */ 141 | export const getNumberOfLines = (content) => { 142 | return content.split("\n").length 143 | } 144 | 145 | /** 146 | * Gets the line number string from a fragment. 147 | * @param {string} fragment 148 | * @returns {string} 149 | * @example getLineNumberStringFromFragment("#L50") // 50 150 | */ 151 | export const getLineNumberStringFromFragment = (fragment) => { 152 | return fragment.slice(2) 153 | } 154 | -------------------------------------------------------------------------------- /test/fixtures/config-dependent/absolute-paths.md: -------------------------------------------------------------------------------- 1 | # Valid 2 | 3 | ![Absolute Path](/test/fixtures/image.png) 4 | -------------------------------------------------------------------------------- /test/fixtures/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theoludwig/markdownlint-rule-relative-links/3ebc40c2ada35d690b320dd429e56fa818f5dbc8/test/fixtures/image.png -------------------------------------------------------------------------------- /test/fixtures/invalid/empty-id-fragment/awesome.md: -------------------------------------------------------------------------------- 1 | # Awesome 2 | 3 |
Content
4 | -------------------------------------------------------------------------------- /test/fixtures/invalid/empty-id-fragment/empty-id-fragment.md: -------------------------------------------------------------------------------- 1 | # Invalid 2 | 3 | [Link fragment](./awesome.md#) 4 | -------------------------------------------------------------------------------- /test/fixtures/invalid/ignore-empty-fragment-checking-for-image.md: -------------------------------------------------------------------------------- 1 | # Invalid 2 | 3 | ![Image](../image.png#) 4 | -------------------------------------------------------------------------------- /test/fixtures/invalid/ignore-fragment-checking-for-image.md: -------------------------------------------------------------------------------- 1 | # Invalid 2 | 3 | ![Image](../image.png#non-existing-fragment) 4 | -------------------------------------------------------------------------------- /test/fixtures/invalid/ignore-name-fragment-if-not-an-anchor/awesome.md: -------------------------------------------------------------------------------- 1 | # Awesome 2 | 3 | 4 | -------------------------------------------------------------------------------- /test/fixtures/invalid/ignore-name-fragment-if-not-an-anchor/ignore-name-fragment-if-not-an-anchor.md: -------------------------------------------------------------------------------- 1 | # Invalid 2 | 3 | [Invalid](./awesome.md#name-should-be-ignored) 4 | -------------------------------------------------------------------------------- /test/fixtures/invalid/ignore-not-an-id-fragment/awesome.md: -------------------------------------------------------------------------------- 1 | # Awesome 2 | 3 |
Content
4 | -------------------------------------------------------------------------------- /test/fixtures/invalid/ignore-not-an-id-fragment/ignore-not-an-id-fragment.md: -------------------------------------------------------------------------------- 1 | # Invalid 2 | 3 | [Invalid](./awesome.md#not-an-id-should-be-ignored) 4 | -------------------------------------------------------------------------------- /test/fixtures/invalid/invalid-heading-case-sensitive/awesome.md: -------------------------------------------------------------------------------- 1 | # Awesome 2 | 3 | ## Existing Heading 4 | -------------------------------------------------------------------------------- /test/fixtures/invalid/invalid-heading-case-sensitive/invalid-heading-case-sensitive.md: -------------------------------------------------------------------------------- 1 | # Valid 2 | 3 | [Link fragment](./awesome.md#ExistIng-Heading) 4 | -------------------------------------------------------------------------------- /test/fixtures/invalid/invalid-heading-with-L-fragment/awesome.md: -------------------------------------------------------------------------------- 1 | # Awesome 2 | -------------------------------------------------------------------------------- /test/fixtures/invalid/invalid-heading-with-L-fragment/invalid-heading-with-L-fragment.md: -------------------------------------------------------------------------------- 1 | # Invalid 2 | 3 | [Link fragment line number 7](./awesome.md#L7abc) 4 | -------------------------------------------------------------------------------- /test/fixtures/invalid/invalid-line-column-range-number-fragment/awesome.md: -------------------------------------------------------------------------------- 1 | # Awesome 2 | -------------------------------------------------------------------------------- /test/fixtures/invalid/invalid-line-column-range-number-fragment/invalid-line-column-range-number-fragment.md: -------------------------------------------------------------------------------- 1 | # Invalid 2 | 3 | [Invalid](./awesome.md#L12-not-a-line-link) 4 | 5 | [Invalid](./awesome.md#l7) 6 | 7 | [Invalid](./awesome.md#L) 8 | 9 | [Invalid](./awesome.md#L7extra) 10 | 11 | [Invalid](./awesome.md#L30C) 12 | 13 | [Invalid](./awesome.md#L30Cextra) 14 | 15 | [Invalid](./awesome.md#L30L12) 16 | 17 | [Invalid](./awesome.md#L30C12) 18 | 19 | [Invalid](./awesome.md#L30C11-) 20 | 21 | [Invalid](./awesome.md#L30C11-L) 22 | 23 | [Invalid](./awesome.md#L30C11-L31C) 24 | 25 | [Invalid](./awesome.md#L30C11-C31) 26 | 27 | [Invalid](./awesome.md#C30) 28 | 29 | [Invalid](./awesome.md#C11-C31) 30 | 31 | [Invalid](./awesome.md#C11-L4C31) 32 | -------------------------------------------------------------------------------- /test/fixtures/invalid/invalid-line-number-fragment/awesome.md: -------------------------------------------------------------------------------- 1 | # Awesome 2 | -------------------------------------------------------------------------------- /test/fixtures/invalid/invalid-line-number-fragment/invalid-line-number-fragment.md: -------------------------------------------------------------------------------- 1 | # Invalid 2 | 3 | [Link fragment line number 7](./awesome.md#L7) 4 | -------------------------------------------------------------------------------- /test/fixtures/invalid/non-existing-anchor-name-fragment/awesome.md: -------------------------------------------------------------------------------- 1 | # Awesome 2 | 3 |
Link 4 | -------------------------------------------------------------------------------- /test/fixtures/invalid/non-existing-anchor-name-fragment/non-existing-anchor-name-fragment.md: -------------------------------------------------------------------------------- 1 | # Invalid 2 | 3 | [Link fragment](./awesome.md#non-existing-anchor-name-fragment) 4 | -------------------------------------------------------------------------------- /test/fixtures/invalid/non-existing-element-id-fragment/awesome.md: -------------------------------------------------------------------------------- 1 | # Awesome 2 | 3 |
Content
4 | -------------------------------------------------------------------------------- /test/fixtures/invalid/non-existing-element-id-fragment/non-existing-element-id-fragment.md: -------------------------------------------------------------------------------- 1 | # Invalid 2 | 3 | [Link fragment](./awesome.md#non-existing-element-id-fragment) 4 | -------------------------------------------------------------------------------- /test/fixtures/invalid/non-existing-file.md: -------------------------------------------------------------------------------- 1 | # Invalid 2 | 3 | [File](./index.test.js) 4 | -------------------------------------------------------------------------------- /test/fixtures/invalid/non-existing-heading-fragment/awesome.md: -------------------------------------------------------------------------------- 1 | # Awesome 2 | 3 | ## Existing Heading 4 | -------------------------------------------------------------------------------- /test/fixtures/invalid/non-existing-heading-fragment/non-existing-heading-fragment.md: -------------------------------------------------------------------------------- 1 | # Invalid 2 | 3 | [Link fragment](./awesome.md#non-existing-heading) 4 | -------------------------------------------------------------------------------- /test/fixtures/invalid/non-existing-image.md: -------------------------------------------------------------------------------- 1 | # Invalid 2 | 3 | ![Image](./image.png) 4 | -------------------------------------------------------------------------------- /test/fixtures/valid/existing-anchor-name-fragment/awesome.md: -------------------------------------------------------------------------------- 1 | # Awesome 2 | 3 | Link 4 | -------------------------------------------------------------------------------- /test/fixtures/valid/existing-anchor-name-fragment/existing-anchor-name-fragment.md: -------------------------------------------------------------------------------- 1 | # Invalid 2 | 3 | [Link fragment](./awesome.md#existing-heading-anchor) 4 | -------------------------------------------------------------------------------- /test/fixtures/valid/existing-element-id-fragment/awesome.md: -------------------------------------------------------------------------------- 1 | # Awesome 2 | 3 |
Content
4 | -------------------------------------------------------------------------------- /test/fixtures/valid/existing-element-id-fragment/existing-element-id-fragment.md: -------------------------------------------------------------------------------- 1 | # Invalid 2 | 3 | [Link fragment](./awesome.md#existing-element-id-fragment) 4 | -------------------------------------------------------------------------------- /test/fixtures/valid/existing-file.md: -------------------------------------------------------------------------------- 1 | # Valid 2 | 3 | [File](../../index.test.js) 4 | -------------------------------------------------------------------------------- /test/fixtures/valid/existing-heading-fragment/awesome.md: -------------------------------------------------------------------------------- 1 | # Awesome 2 | 3 | ## Existing Heading 4 | 5 | ### Repeated Heading 6 | 7 | Text 8 | 9 | ### Repeated Heading 10 | 11 | Text 12 | 13 | ### Repeated Heading 14 | -------------------------------------------------------------------------------- /test/fixtures/valid/existing-heading-fragment/existing-heading-fragment.md: -------------------------------------------------------------------------------- 1 | # Valid 2 | 3 | [Link fragment](./awesome.md#existing-heading) 4 | 5 | [Link fragment Repeated 0](./awesome.md#repeated-heading) 6 | 7 | [Link fragment Repeated 1](./awesome.md#repeated-heading-1) 8 | 9 | [Link fragment Repeated 2](./awesome.md#repeated-heading-2) 10 | -------------------------------------------------------------------------------- /test/fixtures/valid/existing-heading-with-accents/awesome.md: -------------------------------------------------------------------------------- 1 | # Awesome 2 | 3 | ## Développement 4 | -------------------------------------------------------------------------------- /test/fixtures/valid/existing-heading-with-accents/existing-heading-with-accents.md: -------------------------------------------------------------------------------- 1 | # Valid 2 | 3 | [Link fragment](./awesome.md#développement) 4 | -------------------------------------------------------------------------------- /test/fixtures/valid/existing-image.md: -------------------------------------------------------------------------------- 1 | # Valid 2 | 3 | ![Image](../image.png) 4 | -------------------------------------------------------------------------------- /test/fixtures/valid/ignore-absolute-paths.md: -------------------------------------------------------------------------------- 1 | # Valid 2 | 3 | ![Absolute Path](/absolute/path.png) 4 | -------------------------------------------------------------------------------- /test/fixtures/valid/ignore-external-links.md: -------------------------------------------------------------------------------- 1 | # Valid 2 | 3 | [External https link](https://example.com/) 4 | 5 | [External https link 2](https:./external.https) 6 | 7 | [External ftp link](ftp:./external.ftp) 8 | -------------------------------------------------------------------------------- /test/fixtures/valid/ignore-fragment-checking-in-own-file.md: -------------------------------------------------------------------------------- 1 | # Valid 2 | 3 |
Content
4 | 5 | [Link fragment](#non-existing-element-id-fragment) 6 | -------------------------------------------------------------------------------- /test/fixtures/valid/only-parse-markdown-files-for-fragments/abc.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theoludwig/markdownlint-rule-relative-links/3ebc40c2ada35d690b320dd429e56fa818f5dbc8/test/fixtures/valid/only-parse-markdown-files-for-fragments/abc.txt -------------------------------------------------------------------------------- /test/fixtures/valid/only-parse-markdown-files-for-fragments/awesome.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Awesome 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /test/fixtures/valid/only-parse-markdown-files-for-fragments/only-parse-markdown-files-for-fragments.md: -------------------------------------------------------------------------------- 1 | # Valid 2 | 3 | [Link fragment HTML](./awesome.html#existing-heading) 4 | 5 | [Link fragment TXT](./abc.txt#existing-heading) 6 | 7 | [Link fragment Image](../../image.png#existing-heading) 8 | -------------------------------------------------------------------------------- /test/fixtures/valid/valid-heading-like-number-fragment/awesome.md: -------------------------------------------------------------------------------- 1 | # Awesome 2 | 3 | ## L7 4 | -------------------------------------------------------------------------------- /test/fixtures/valid/valid-heading-like-number-fragment/valid-heading-like-number-fragment.md: -------------------------------------------------------------------------------- 1 | # Valid 2 | 3 | [Link fragment](./awesome.md#l7) 4 | -------------------------------------------------------------------------------- /test/fixtures/valid/valid-line-column-range-number-fragment/awesome.md: -------------------------------------------------------------------------------- 1 | # Awesome 2 | 3 | ## L12 Not A Line Link 4 | -------------------------------------------------------------------------------- /test/fixtures/valid/valid-line-column-range-number-fragment/valid-line-column-range-number-fragment.md: -------------------------------------------------------------------------------- 1 | # Valid 2 | 3 | [Valid](./awesome.md#l12-not-a-line-link) 4 | 5 | [Valid](./awesome.md#L30-L31) 6 | 7 | [Valid](./awesome.md#L3C24-L88) 8 | 9 | [Valid](./awesome.md#L304-L314C98) 10 | 11 | [Valid](./awesome.md#L200C4-L3244C2) 12 | -------------------------------------------------------------------------------- /test/fixtures/valid/valid-line-number-fragment/awesome.md: -------------------------------------------------------------------------------- 1 | # Awesome 2 | 3 | ABC 4 | 5 | Line 5 6 | 7 | Line 7 Text 8 | 9 | ## L7 10 | -------------------------------------------------------------------------------- /test/fixtures/valid/valid-line-number-fragment/valid-line-number-fragment.md: -------------------------------------------------------------------------------- 1 | # Valid 2 | 3 | [Link fragment line number 7](./awesome.md#L7) 4 | -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | import { test } from "node:test" 2 | import assert from "node:assert/strict" 3 | 4 | import * as markdownlint from "markdownlint/promise" 5 | 6 | import relativeLinksRule, { markdownIt } from "../src/index.js" 7 | 8 | const defaultConfig = { 9 | "relative-links": true, 10 | } 11 | 12 | /** 13 | * 14 | * @param {string} fixtureFile 15 | * @param {Object} config 16 | * @returns 17 | */ 18 | const validateMarkdownLint = async (fixtureFile, config = defaultConfig) => { 19 | const lintResults = await markdownlint.lint({ 20 | files: [fixtureFile], 21 | config: { 22 | default: false, 23 | ...config, 24 | }, 25 | customRules: [relativeLinksRule], 26 | markdownItFactory: () => { 27 | return markdownIt 28 | }, 29 | }) 30 | return lintResults[fixtureFile] 31 | } 32 | 33 | test("ensure the rule validates correctly", async (t) => { 34 | await t.test("should be invalid", async (t) => { 35 | const testCases = [ 36 | { 37 | name: "should be invalid with an empty id fragment", 38 | fixturePath: 39 | "test/fixtures/invalid/empty-id-fragment/empty-id-fragment.md", 40 | errors: ['"./awesome.md#" should have a valid fragment identifier'], 41 | }, 42 | { 43 | name: "should be invalid with a name fragment other than for an anchor", 44 | fixturePath: 45 | "test/fixtures/invalid/ignore-name-fragment-if-not-an-anchor/ignore-name-fragment-if-not-an-anchor.md", 46 | errors: [ 47 | '"./awesome.md#name-should-be-ignored" should have a valid fragment identifier', 48 | ], 49 | }, 50 | { 51 | name: "should be invalid with a non-existing id fragment (data-id !== id)", 52 | fixturePath: 53 | "test/fixtures/invalid/ignore-not-an-id-fragment/ignore-not-an-id-fragment.md", 54 | errors: [ 55 | '"./awesome.md#not-an-id-should-be-ignored" should have a valid fragment identifier', 56 | ], 57 | }, 58 | { 59 | name: "should be invalid with uppercase letters in fragment (case sensitive)", 60 | fixturePath: 61 | "test/fixtures/invalid/invalid-heading-case-sensitive/invalid-heading-case-sensitive.md", 62 | errors: [ 63 | '"./awesome.md#ExistIng-Heading" should have a valid fragment identifier', 64 | ], 65 | }, 66 | { 67 | name: "should be invalid with invalid heading with #L fragment", 68 | fixturePath: 69 | "test/fixtures/invalid/invalid-heading-with-L-fragment/invalid-heading-with-L-fragment.md", 70 | errors: [ 71 | '"./awesome.md#L7abc" should have a valid fragment identifier', 72 | ], 73 | }, 74 | { 75 | name: "should be invalid with a invalid line column range number fragment", 76 | fixturePath: 77 | "test/fixtures/invalid/invalid-line-column-range-number-fragment/invalid-line-column-range-number-fragment.md", 78 | errors: [ 79 | '"./awesome.md#L12-not-a-line-link" should have a valid fragment identifier', 80 | '"./awesome.md#l7" should have a valid fragment identifier', 81 | '"./awesome.md#L" should have a valid fragment identifier', 82 | '"./awesome.md#L7extra" should have a valid fragment identifier', 83 | '"./awesome.md#L30C" should have a valid fragment identifier', 84 | '"./awesome.md#L30Cextra" should have a valid fragment identifier', 85 | '"./awesome.md#L30L12" should have a valid fragment identifier', 86 | '"./awesome.md#L30C12" should have a valid fragment identifier', 87 | '"./awesome.md#L30C11-" should have a valid fragment identifier', 88 | '"./awesome.md#L30C11-L" should have a valid fragment identifier', 89 | '"./awesome.md#L30C11-L31C" should have a valid fragment identifier', 90 | '"./awesome.md#L30C11-C31" should have a valid fragment identifier', 91 | '"./awesome.md#C30" should have a valid fragment identifier', 92 | '"./awesome.md#C11-C31" should have a valid fragment identifier', 93 | '"./awesome.md#C11-L4C31" should have a valid fragment identifier', 94 | ], 95 | }, 96 | { 97 | name: "should be invalid with a invalid line number fragment", 98 | fixturePath: 99 | "test/fixtures/invalid/invalid-line-number-fragment/invalid-line-number-fragment.md", 100 | errors: [ 101 | '"./awesome.md#L7" should have a valid fragment identifier, "./awesome.md#L7" should have at least 7 lines to be valid', 102 | ], 103 | }, 104 | { 105 | name: "should be invalid with a non-existing anchor name fragment", 106 | fixturePath: 107 | "test/fixtures/invalid/non-existing-anchor-name-fragment/non-existing-anchor-name-fragment.md", 108 | errors: [ 109 | '"./awesome.md#non-existing-anchor-name-fragment" should have a valid fragment identifier', 110 | ], 111 | }, 112 | { 113 | name: "should be invalid with a non-existing element id fragment", 114 | fixturePath: 115 | "test/fixtures/invalid/non-existing-element-id-fragment/non-existing-element-id-fragment.md", 116 | errors: [ 117 | '"./awesome.md#non-existing-element-id-fragment" should have a valid fragment identifier', 118 | ], 119 | }, 120 | { 121 | name: "should be invalid with a non-existing heading fragment", 122 | fixturePath: 123 | "test/fixtures/invalid/non-existing-heading-fragment/non-existing-heading-fragment.md", 124 | errors: [ 125 | '"./awesome.md#non-existing-heading" should have a valid fragment identifier', 126 | ], 127 | }, 128 | { 129 | name: "should be invalid with a link to an image with a empty fragment", 130 | fixturePath: 131 | "test/fixtures/invalid/ignore-empty-fragment-checking-for-image.md", 132 | errors: [ 133 | '"../image.png#" should not have a fragment identifier as it is an image', 134 | ], 135 | }, 136 | { 137 | name: "should be invalid with a link to an image with a fragment", 138 | fixturePath: 139 | "test/fixtures/invalid/ignore-fragment-checking-for-image.md", 140 | errors: [ 141 | '"../image.png#non-existing-fragment" should not have a fragment identifier as it is an image', 142 | ], 143 | }, 144 | { 145 | name: "should be invalid with a non-existing file", 146 | fixturePath: "test/fixtures/invalid/non-existing-file.md", 147 | errors: ['"./index.test.js" should exist in the file system'], 148 | }, 149 | { 150 | name: "should be invalid with a non-existing image", 151 | fixturePath: "test/fixtures/invalid/non-existing-image.md", 152 | errors: ['"./image.png" should exist in the file system'], 153 | }, 154 | { 155 | name: "should be invalid with incorrect absolute paths", 156 | fixturePath: "test/fixtures/config-dependent/absolute-paths.md", 157 | errors: ['"/test/fixtures/image.png" should exist in the file system'], 158 | config: { 159 | "relative-links": { 160 | root_path: "test", 161 | }, 162 | }, 163 | }, 164 | ] 165 | 166 | for (const { 167 | name, 168 | fixturePath, 169 | errors, 170 | config = defaultConfig, 171 | } of testCases) { 172 | await t.test(name, async () => { 173 | const lintResults = 174 | (await validateMarkdownLint(fixturePath, config)) ?? [] 175 | const errorsDetails = lintResults.map((result) => { 176 | assert.deepEqual(result.ruleNames, relativeLinksRule.names) 177 | assert.deepEqual( 178 | result.ruleDescription, 179 | relativeLinksRule.description, 180 | ) 181 | return result.errorDetail 182 | }) 183 | assert.deepStrictEqual( 184 | errorsDetails, 185 | errors, 186 | `${fixturePath}: Expected errors`, 187 | ) 188 | }) 189 | } 190 | }) 191 | 192 | await t.test("should be valid", async (t) => { 193 | const testCases = [ 194 | { 195 | name: "should be valid with an existing anchor name fragment", 196 | fixturePath: 197 | "test/fixtures/valid/existing-anchor-name-fragment/existing-anchor-name-fragment.md", 198 | }, 199 | { 200 | name: "should be valid with an existing element id fragment", 201 | fixturePath: 202 | "test/fixtures/valid/existing-element-id-fragment/existing-element-id-fragment.md", 203 | }, 204 | { 205 | name: "should be valid with an existing heading fragment", 206 | fixturePath: 207 | "test/fixtures/valid/existing-heading-fragment/existing-heading-fragment.md", 208 | }, 209 | { 210 | name: 'should be valid with an existing heading fragment with accents (e.g: "é")', 211 | fixturePath: 212 | "test/fixtures/valid/existing-heading-with-accents/existing-heading-with-accents.md", 213 | }, 214 | { 215 | name: "should only parse markdown files for fragments checking", 216 | fixturePath: 217 | "test/fixtures/valid/only-parse-markdown-files-for-fragments/only-parse-markdown-files-for-fragments.md", 218 | }, 219 | { 220 | name: "should support lines and columns range numbers in link fragments", 221 | fixturePath: 222 | "test/fixtures/valid/valid-line-column-range-number-fragment/valid-line-column-range-number-fragment.md", 223 | }, 224 | { 225 | name: 'should be valid with valid heading "like" line number fragment', 226 | fixturePath: 227 | "test/fixtures/valid/valid-heading-like-number-fragment/valid-heading-like-number-fragment.md", 228 | }, 229 | { 230 | name: "should be valid with valid line number fragment", 231 | fixturePath: 232 | "test/fixtures/valid/valid-line-number-fragment/valid-line-number-fragment.md", 233 | }, 234 | { 235 | name: "should be valid with an existing file", 236 | fixturePath: "test/fixtures/valid/existing-file.md", 237 | }, 238 | { 239 | name: "should be valid with an existing image", 240 | fixturePath: "test/fixtures/valid/existing-image.md", 241 | }, 242 | { 243 | name: "should ignore absolute paths if root_path is not set", 244 | fixturePath: "test/fixtures/valid/ignore-absolute-paths.md", 245 | }, 246 | { 247 | name: "should ignore external links", 248 | fixturePath: "test/fixtures/valid/ignore-external-links.md", 249 | }, 250 | { 251 | name: "should ignore checking fragment in own file", 252 | fixturePath: 253 | "test/fixtures/valid/ignore-fragment-checking-in-own-file.md", 254 | }, 255 | { 256 | name: "should be valid with correct absolute paths if root_path is set", 257 | fixturePath: "test/fixtures/config-dependent/absolute-paths.md", 258 | config: { 259 | "relative-links": { 260 | root_path: ".", 261 | }, 262 | }, 263 | }, 264 | ] 265 | 266 | for (const { name, fixturePath, config = defaultConfig } of testCases) { 267 | await t.test(name, async () => { 268 | const lintResults = 269 | (await validateMarkdownLint(fixturePath, config)) ?? [] 270 | const errorsDetails = lintResults.map((result) => { 271 | return result.errorDetail 272 | }) 273 | assert.equal( 274 | errorsDetails.length, 275 | 0, 276 | `${fixturePath}: Expected no errors, got ${errorsDetails.join(", ")}`, 277 | ) 278 | }) 279 | } 280 | }) 281 | }) 282 | -------------------------------------------------------------------------------- /test/utils.test.js: -------------------------------------------------------------------------------- 1 | import { test } from "node:test" 2 | import assert from "node:assert/strict" 3 | 4 | import { 5 | convertHeadingToHTMLFragment, 6 | getMarkdownHeadings, 7 | getMarkdownIdOrAnchorNameFragments, 8 | isValidIntegerString, 9 | getNumberOfLines, 10 | getLineNumberStringFromFragment, 11 | } from "../src/utils.js" 12 | 13 | test("utils", async (t) => { 14 | await t.test("convertHeadingToHTMLFragment", async () => { 15 | assert.strictEqual( 16 | convertHeadingToHTMLFragment("Valid Fragments"), 17 | "#valid-fragments", 18 | ) 19 | assert.strictEqual( 20 | convertHeadingToHTMLFragment("Valid Heading With Underscores _"), 21 | "#valid-heading-with-underscores-_", 22 | ) 23 | assert.strictEqual( 24 | convertHeadingToHTMLFragment( 25 | `Valid Heading With Quotes ' And Double Quotes "`, 26 | ), 27 | "#valid-heading-with-quotes--and-double-quotes-", 28 | ) 29 | assert.strictEqual( 30 | convertHeadingToHTMLFragment("🚀 Valid Heading With Emoji"), 31 | "#-valid-heading-with-emoji", 32 | ) 33 | }) 34 | 35 | await t.test("getMarkdownHeadings", async () => { 36 | assert.deepStrictEqual( 37 | getMarkdownHeadings("# Hello\n\n## World\n\n## Hello, world!\n"), 38 | ["Hello", "World", "Hello, world!"], 39 | ) 40 | }) 41 | 42 | await t.test("getMarkdownIdOrAnchorNameFragments", async () => { 43 | assert.deepStrictEqual( 44 | getMarkdownIdOrAnchorNameFragments( 45 | 'Link', 46 | ), 47 | ["#anchorId"], 48 | ) 49 | assert.deepStrictEqual( 50 | getMarkdownIdOrAnchorNameFragments('Link'), 51 | ["#anchorName"], 52 | ) 53 | assert.deepStrictEqual( 54 | getMarkdownIdOrAnchorNameFragments("Link"), 55 | [], 56 | ) 57 | assert.deepStrictEqual(getMarkdownIdOrAnchorNameFragments(""), []) 58 | assert.deepStrictEqual(getMarkdownIdOrAnchorNameFragments(""), []) 59 | }) 60 | 61 | await t.test("isValidIntegerString", async () => { 62 | assert.strictEqual(isValidIntegerString("1"), true) 63 | assert.strictEqual(isValidIntegerString("45"), true) 64 | assert.strictEqual(isValidIntegerString("1abc"), false) 65 | assert.strictEqual(isValidIntegerString("1.0"), false) 66 | }) 67 | 68 | await t.test("getNumberOfLines", async () => { 69 | assert.strictEqual(getNumberOfLines(""), 1) 70 | assert.strictEqual(getNumberOfLines("Hello"), 1) 71 | assert.strictEqual(getNumberOfLines("Hello\nWorld"), 2) 72 | assert.strictEqual(getNumberOfLines("Hello\nWorld\n"), 3) 73 | assert.strictEqual(getNumberOfLines("Hello\nWorld\n\n"), 4) 74 | }) 75 | 76 | await t.test("getLineNumberStringFromFragment", async () => { 77 | assert.strictEqual(getLineNumberStringFromFragment("#L50"), "50") 78 | }) 79 | }) 80 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ESNext"], 4 | "target": "ESNext", 5 | "module": "NodeNext", 6 | "moduleResolution": "NodeNext", 7 | "checkJs": true, 8 | "allowJs": true, 9 | "noEmit": true, 10 | "rootDir": ".", 11 | "baseUrl": ".", 12 | "skipLibCheck": true, 13 | "strict": true, 14 | "allowUnusedLabels": false, 15 | "allowUnreachableCode": false, 16 | "noFallthroughCasesInSwitch": true, 17 | "noImplicitAny": true, 18 | "noImplicitOverride": true, 19 | "noImplicitReturns": true, 20 | "noUncheckedIndexedAccess": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "forceConsistentCasingInFileNames": true 24 | } 25 | } 26 | --------------------------------------------------------------------------------