Compare commits

..

47 Commits

Author SHA1 Message Date
9bb5ffe0ae
docs: markdownItFactory is not necessary
Ref: https://github.com/theoludwig/markdownlint-rule-relative-links/issues/13
2025-05-20 21:35:43 +02:00
876384344c
feat: add support for markdownlint v0.38.0 2025-05-11 16:45:14 +02:00
70bdb7013e
style: fix prettier 2024-12-28 23:15:32 +01:00
db57d67b0b
fix: use .markdownlint-cli2.mjs for the configuration file 2024-12-28 23:13:48 +01:00
aa24db4fac
feat: usage of ESM modules imports (instead of CommonJS)
Fixes #10

BREAKING CHANGE: This package is now pure ESM

BREAKING CHANGE: minimum supported Node.js >= 22.0.0
2024-12-28 22:52:51 +01:00
b4a04d2e8e
chore: remove not needed npm engine requirement 2024-11-11 20:34:22 +01:00
54e45d3e5d
build(deps): update latest 2024-11-11 13:24:45 +01:00
dd70d6da3a
chore: remove usage of git hooks (husky, lint-staged, commitlint) + usage of node --run 2024-09-07 00:03:10 +02:00
85f465306f
feat: stricter validation of heading fragments by being Case sensitive
Fixes #8

BREAKING CHANGE: Heading fragments is now Case sensitive.
For example "#ExistIng-Heading" is invalid as it should be "#existing-heading".
2024-05-27 22:50:43 +02:00
450cdb84f0
feat: support columns numbers (and line range) in links fragments
Fixes #7
2024-05-27 22:26:21 +02:00
2df95e97d8
build(deps): update latest 2024-05-27 21:26:05 +02:00
bf9403ad84
style: fix prettier 2024-04-06 20:14:59 +02:00
9675c7a275
fix: update markdown-it to v14.1.0
This allows to use the same version as markdownlint v0.34.0.
2024-04-06 20:10:13 +02:00
5af131b840
fix: link fragment with accents should be valid if the heading exists
Fixes a regression introduced in v2.3.0, which needed to lower case/manage case insensitive heading.
2024-01-31 21:56:55 +01:00
f332c833ca
feat: support line number checking in link fragment (e.g: '#L50')
Fixes #6
2024-01-31 01:14:27 +01:00
e20ee54b05
fix: should only check valid fragments in markdown (.md) files 2024-01-31 00:10:41 +01:00
5c39afbe20
refactor: simplify logic understanding 2024-01-30 23:57:38 +01:00
cc9a1cf6a2
chore: cleaner configs 2024-01-29 21:24:22 +01:00
1095647d41
docs(license): add email address 2024-01-29 21:09:23 +01:00
146f904866 chore: small tweaks 2024-01-12 01:30:52 +01:00
64396954e4 chore: dependency vendoring of markdownlint-rule-helpers 2024-01-12 01:30:52 +01:00
92f35daeaf docs: explain limitations/features 2024-01-12 01:30:52 +01:00
7ef7cc3bb3 refactor: early conditions first 2024-01-12 01:30:52 +01:00
0479652ffe test: add cases for fragments checking 2024-01-12 01:30:52 +01:00
68f35ddc0b fix: empty id fragment should be invalid 2024-01-12 01:30:52 +01:00
747203c23b fix: fragments checking should work in other elements than only anchor (e.g: <div>) 2024-01-12 01:30:52 +01:00
a5deae599a fix: ignore checking fragment in own file
We don't need to check if fragments are valid in own file.
It's already checked by the rule MD051 of markdownlint (else we have the error duplicated).
Ref: https://github.com/DavidAnson/markdownlint/blob/main/doc/md051.md
2024-01-12 01:30:52 +01:00
Igor Tsiglyar
24a0788d32 feat: html anchor support 2024-01-12 01:30:52 +01:00
9d2cc818d5
docs: fix broken GitHub Action link 2024-01-10 21:09:11 +01:00
7465ffd8bc
test: separate cases, so it's easier to know what fails 2024-01-10 00:01:13 +01:00
1ddcdc7b18
fix: cleaner code + better error messages 2024-01-09 23:20:17 +01:00
fcd0340e57
docs: describe the Additional features and the Limitations 2024-01-09 21:58:00 +01:00
7bf3b93822
build(deps): update latest 2024-01-09 21:33:27 +01:00
9c87395d82
fix: update dependencies to latest 2023-12-26 21:11:07 +01:00
f99e2bbe4d
docs: fix linting 2023-11-23 00:40:34 +01:00
e642c7ceee
fix: update dependencies to latest 2023-11-23 00:37:15 +01:00
9cf3168e66
chore: better Prettier config for easier reviews 2023-10-23 23:11:41 +02:00
b0c27b3c3e
build(deps): update latest
All checks were successful
Lint / lint (push) Successful in 54s
Test / test (push) Successful in 33s
2023-07-18 23:18:39 +02:00
a33c682974
feat: improve errors message to distinguish between file system and fragment errors 2023-06-27 13:15:03 +02:00
6c4e8cec9c
feat: validate relative links fragments
Similar to https://github.com/DavidAnson/markdownlint/blob/main/doc/md051.md

Fixes #2

BREAKING CHANGE: Validate links fragments in relative links
2023-06-24 11:42:09 +02:00
9e28311791
fix: update author - Théo LUDWIG 2023-06-24 10:11:32 +02:00
f80d746c89
build(deps): update latest 2023-06-24 10:00:40 +02:00
2d63a30f48
refactor: usage of node:test instead of tap 2023-06-24 09:58:45 +02:00
Divlo
cbc6042bd5
feat: add npm package provenance
Ref: https://github.blog/2023-04-19-introducing-npm-package-provenance/
2023-05-13 16:05:01 +02:00
Divlo
c65607bcc3
build(deps): update latest 2023-05-13 16:03:06 +02:00
Divlo
c985af6156
fix: ignore absolute paths /absolute/path 2023-04-02 21:10:47 +02:00
Divlo
77b8988bea
build(deps): update latest 2023-04-02 21:03:51 +02:00
86 changed files with 7190 additions and 18436 deletions

View File

@ -1 +0,0 @@
{ "extends": ["@commitlint/config-conventional"] }

View File

@ -1,7 +0,0 @@
{
"extends": ["conventions", "prettier"],
"plugins": ["prettier"],
"rules": {
"prettier/prettier": "error"
}
}

View File

@ -1,8 +1,8 @@
---
name: '🐛 Bug Report'
about: 'Report an unexpected problem or unintended behavior.'
title: '[Bug]'
labels: 'bug'
name: "🐛 Bug Report"
about: "Report an unexpected problem or unintended behavior."
title: "[Bug]"
labels: "bug"
---
<!--

View File

@ -1,8 +1,8 @@
---
name: '📜 Documentation'
about: 'Correct spelling errors, improvements or additions to documentation files (README, CONTRIBUTING...).'
title: '[Documentation]'
labels: 'documentation'
name: "📜 Documentation"
about: "Correct spelling errors, improvements or additions to documentation files (README, CONTRIBUTING...)."
title: "[Documentation]"
labels: "documentation"
---
<!-- Please make sure your issue has not already been fixed. -->

View File

@ -1,8 +1,8 @@
---
name: '✨ Feature Request'
about: 'Suggest a new feature idea.'
title: '[Feature]'
labels: 'feature request'
name: "✨ Feature Request"
about: "Suggest a new feature idea."
title: "[Feature]"
labels: "feature request"
---
<!-- Please make sure your issue has not already been fixed. -->

View File

@ -1,8 +1,8 @@
---
name: '🔧 Improvement'
about: 'Improve structure/format/performance/refactor/tests of the code.'
title: '[Improvement]'
labels: 'improvement'
name: "🔧 Improvement"
about: "Improve structure/format/performance/refactor/tests of the code."
title: "[Improvement]"
labels: "improvement"
---
<!-- Please make sure your issue has not already been fixed. -->

View File

@ -1,8 +1,8 @@
---
name: '🙋 Question'
about: 'Further information is requested.'
title: '[Question]'
labels: 'question'
name: "🙋 Question"
about: "Further information is requested."
title: "[Question]"
labels: "question"
---
### Question

View File

@ -1,6 +1,6 @@
<!-- Please first discuss the change you wish to make via issue before making a change. It might avoid a waste of your time. -->
## What changes this PR introduce?
# What changes this PR introduce?
## List any relevant issue numbers

View File

@ -1,28 +1,35 @@
name: 'Lint'
name: "Lint"
on:
push:
branches: [develop]
pull_request:
branches: [master, develop]
branches: [main, develop]
jobs:
lint:
runs-on: 'ubuntu-latest'
runs-on: "ubuntu-latest"
steps:
- uses: 'actions/checkout@v3.0.0'
- uses: "actions/checkout@v4.2.2"
- name: 'Use Node.js'
uses: 'actions/setup-node@v3.1.0'
- name: "Setup Node.js"
uses: "actions/setup-node@v4.1.0"
with:
node-version: 'lts/*'
cache: 'npm'
node-version: "lts/*"
cache: "npm"
- name: 'Install'
run: 'npm install'
- name: "Install dependencies"
run: "npm clean-install"
- run: 'npm run lint:commit -- --to "${{ github.sha }}"'
- run: 'npm run lint:editorconfig'
- run: 'npm run lint:markdown'
- run: 'npm run lint:eslint'
- run: 'npm run lint:prettier'
- run: "node --run lint:editorconfig"
- run: "node --run lint:markdown"
- run: "node --run lint:eslint"
- run: "node --run lint:prettier"
- run: "node --run lint:typescript"
commitlint:
runs-on: "ubuntu-latest"
steps:
- uses: "actions/checkout@v4.2.2"
- uses: "wagoid/commitlint-github-action@v6.2.0"

View File

@ -1,29 +1,37 @@
name: 'Release'
name: "Release"
on:
push:
branches: [master]
branches: [main]
jobs:
release:
runs-on: 'ubuntu-latest'
runs-on: "ubuntu-latest"
permissions:
contents: "write"
issues: "write"
pull-requests: "write"
id-token: "write"
steps:
- uses: 'actions/checkout@v3.0.0'
- uses: "actions/checkout@v4.2.2"
with:
fetch-depth: 0
persist-credentials: false
- name: 'Use Node.js'
uses: 'actions/setup-node@v3.1.0'
- name: "Setup Node.js"
uses: "actions/setup-node@v4.1.0"
with:
node-version: 'lts/*'
cache: 'npm'
node-version: "lts/*"
cache: "npm"
- name: 'Install'
run: 'npm install'
- name: "Install dependencies"
run: "npm clean-install"
- name: 'Release'
run: 'npm run release'
- name: "Verify the integrity of provenance attestations and registry signatures for installed dependencies"
run: "npm audit signatures"
- name: "Release"
run: "node --run release"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@ -1,25 +1,25 @@
name: 'Test'
name: "Test"
on:
push:
branches: [develop]
pull_request:
branches: [master, develop]
branches: [main, develop]
jobs:
test:
runs-on: 'ubuntu-latest'
runs-on: "ubuntu-latest"
steps:
- uses: 'actions/checkout@v3.0.0'
- uses: "actions/checkout@v4.2.2"
- name: 'Use Node.js'
uses: 'actions/setup-node@v3.1.0'
- name: "Setup Node.js"
uses: "actions/setup-node@v4.1.0"
with:
node-version: 'lts/*'
cache: 'npm'
node-version: "lts/*"
cache: "npm"
- name: 'Install'
run: 'npm install'
- name: "Install dependencies"
run: "npm clean-install"
- name: 'Test'
run: 'npm run test'
- name: "Test"
run: "node --run test"

4
.gitignore vendored
View File

@ -20,10 +20,6 @@ npm-debug.log*
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# misc
.DS_Store

View File

@ -1,4 +0,0 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npm run lint:commit -- --edit

View File

@ -1,5 +0,0 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npm run lint:staged
npm run test

View File

@ -1,6 +0,0 @@
{
"*": ["editorconfig-checker"],
"*.{js,jsx,ts,tsx}": ["prettier --write", "eslint --fix"],
"*.{json,jsonc,yml,yaml}": ["prettier --write"],
"*.{md,mdx}": ["prettier --write", "markdownlint-cli2 --fix"]
}

View File

@ -1,5 +0,0 @@
{
"globs": ["**/*.{md,mdx}"],
"ignores": ["**/node_modules", "**/test/fixtures"],
"customRules": ["./src/index.js"]
}

15
.markdownlint-cli2.mjs Normal file
View File

@ -0,0 +1,15 @@
import relativeLinksRule from "./src/index.js"
const config = {
config: {
extends: "markdownlint/style/prettier",
default: true,
"relative-links": true,
"no-inline-html": false,
},
globs: ["**/*.md"],
ignores: ["**/node_modules", "**/test/fixtures/**"],
customRules: [relativeLinksRule],
}
export default config

View File

@ -1,8 +0,0 @@
{
"default": true,
"relative-links": true,
"extends": "markdownlint/style/prettier",
"MD024": false,
"MD033": false,
"MD041": false
}

1
.npmrc
View File

@ -1 +1,2 @@
save-exact = true
provenance = true

View File

@ -1,6 +1,3 @@
{
"singleQuote": true,
"jsxSingleQuote": true,
"semi": false,
"trailingComma": "none"
"semi": false
}

View File

@ -1,18 +1,8 @@
{
"branches": ["master"],
"branches": ["main", { "name": "beta", "prerelease": true }],
"plugins": [
[
"@semantic-release/commit-analyzer",
{
"preset": "conventionalcommits"
}
],
[
"@semantic-release/release-notes-generator",
{
"preset": "conventionalcommits"
}
],
"@semantic-release/npm",
"@semantic-release/github"
]

8
.taprc
View File

@ -1,8 +0,0 @@
ts: false
jsx: false
flow: false
check-coverage: false
coverage: false
files:
- 'test/**/*.test.js'

View File

@ -1,8 +0,0 @@
{
"recommendations": [
"editorconfig.editorconfig",
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"davidanson.vscode-markdownlint"
]
}

View File

@ -1,8 +0,0 @@
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"prettier.configPath": ".prettierrc.json",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll": true
}
}

View File

@ -60,7 +60,7 @@ representative at an online or offline event.
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
contact@divlo.fr.
<contact@theoludwig.fr>.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the

View File

@ -2,6 +2,14 @@
Thanks a lot for your interest in contributing to **markdownlint-rule-relative-links**! 🎉
## Code of Conduct
**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.
## Open Development
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.
## Types of contributions
- Reporting a bug.
@ -11,7 +19,7 @@ Thanks a lot for your interest in contributing to **markdownlint-rule-relative-l
## Pull Requests
- **Please first discuss** the change you wish to make via [issue](https://github.com/Divlo/markdownlint-rule-relative-links/issues) before making a change. It might avoid a waste of your time.
- **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.
- Ensure your code respect linting.
@ -21,26 +29,4 @@ If you're adding new features to **markdownlint-rule-relative-links**, please in
## Commits
The commit message guidelines respect [@commitlint/config-conventional](https://github.com/conventional-changelog/commitlint/tree/master/%40commitlint/config-conventional) and [Semantic Versioning](https://semver.org/) for releases.
### Types
Types define which kind of changes you made to the project.
| Types | Description |
| -------- | ------------------------------------------------------------------------------------------------------------ |
| feat | A new feature. |
| fix | A bug fix. |
| docs | Documentation only changes. |
| style | Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc). |
| refactor | A code change that neither fixes a bug nor adds a feature. |
| perf | A code change that improves performance. |
| test | Adding missing tests or correcting existing tests. |
| build | Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm). |
| ci | Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs). |
| chore | Other changes that don't modify src or test files. |
| revert | Reverts a previous commit. |
### Scopes
Scopes define what part of the code changed.
The commit message guidelines adheres to [Conventional Commits](https://www.conventionalcommits.org/) and [Semantic Versioning](https://semver.org/) for releases.

View File

@ -1,6 +1,6 @@
MIT License
# MIT License
Copyright (c) Divlo
Copyright (c) Théo LUDWIG <contact@theoludwig.fr>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@ -5,12 +5,12 @@
</p>
<p align="center">
<a href="./CONTRIBUTING.md"><img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" /></a>
<a href="./CONTRIBUTING.md"><img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" alt="CONTRIBUTING" /></a>
<a href="./LICENSE"><img src="https://img.shields.io/badge/licence-MIT-blue.svg" alt="Licence MIT"/></a>
<a href="./CODE_OF_CONDUCT.md"><img src="https://img.shields.io/badge/Contributor%20Covenant-v2.0%20adopted-ff69b4.svg" alt="Contributor Covenant" /></a>
<br />
<a href="https://github.com/Divlo/markdownlint-rule-relative-links/actions/workflows/lint.yml"><img src="https://github.com/Divlo/markdownlint-rule-relative-links/actions/workflows/lint.yml/badge.svg?branch=develop" /></a>
<a href="https://github.com/Divlo/markdownlint-rule-relative-linksactions/workflows/test.yml"><img src="https://github.com/Divlo/markdownlint-rule-relative-links/actions/workflows/test.yml/badge.svg?branch=develop" /></a>
<a href="https://github.com/theoludwig/markdownlint-rule-relative-links/actions/workflows/lint.yml"><img src="https://github.com/theoludwig/markdownlint-rule-relative-links/actions/workflows/lint.yml/badge.svg?branch=develop" alt="Lint" /></a>
<a href="https://github.com/theoludwig/markdownlint-rule-relative-links/actions/workflows/test.yml"><img src="https://github.com/theoludwig/markdownlint-rule-relative-links/actions/workflows/test.yml/badge.svg?branch=develop" alt="Test" /></a>
<br />
<a href="https://conventionalcommits.org"><img src="https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg" alt="Conventional Commits" /></a>
<a href="https://github.com/semantic-release/semantic-release"><img src="https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg" alt="semantic-release" /></a>
@ -21,7 +21,7 @@
**markdownlint-rule-relative-links** is a [markdownlint](https://github.com/DavidAnson/markdownlint) custom rule to validate relative links.
It ensures that relative links (using `file:` protocol) are working and not "dead" which means that it exists in the file system of the project that uses `markdownlint`.
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).
### Example
@ -37,15 +37,27 @@ With `awesome.md` content:
```md
[abc](./abc.txt)
[Dead link](./dead.txt)
[Invalid link](./invalid.txt)
```
Running `markdownlint-cli2` with `markdownlint-rule-relative-links` will output:
Running [markdownlint-cli2](https://github.com/DavidAnson/markdownlint-cli2) with `markdownlint-rule-relative-links` will output:
```sh
awesome.md:3 relative-links Relative links should be valid [Link "./dead.txt" is dead]
awesome.md:3 relative-links Relative links should be valid ["./invalid.txt" should exist in the file system]
```
### Additional features
- Support images (e.g: `![Image](./image.png)`).
- 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)`).
- Ignore external links and absolute paths as it only checks relative links (e.g: `https://example.com/` or `/absolute/path.png`).
### Limitations
- Only images and links defined using markdown syntax are validated, html syntax is ignored (e.g: `<a href="./link.txt" />` or `<img src="./image.png" />`).
Contributions are welcome to improve the rule, and to alleviate these limitations. See [CONTRIBUTING.md](./CONTRIBUTING.md) for more information.
### Related links
- [DavidAnson/markdownlint#253](https://github.com/DavidAnson/markdownlint/issues/253)
@ -54,7 +66,7 @@ awesome.md:3 relative-links Relative links should be valid [Link "./dead.txt" is
## Prerequisites
- [Node.js](https://nodejs.org/) >= 16.0.0
[Node.js](https://nodejs.org/) >= 22.0.0
## Installation
@ -64,22 +76,26 @@ npm install --save-dev markdownlint-rule-relative-links
## Configuration
There are various ways `markdownlint` can be configured using objects, config files etc. For more information on `markdownlint` configuration refer [options.config](https://github.com/DavidAnson/markdownlint#optionsconfig).
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).
We recommend configuring `markdownlint-cli2` over `markdownlint-cli` for compatibility with the `vscode-markdownlint` plugin.
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.
`.markdownlint-cli2.jsonc`
`.markdownlint-cli2.mjs`
```json
{
"config": {
"default": true,
"relative-links": true
```js
import relativeLinksRule from "markdownlint-rule-relative-links"
const config = {
config: {
default: true,
"relative-links": true,
},
"globs": ["**/*.{md,mdx}"],
"ignores": ["**/node_modules"],
"customRules": ["markdownlint-rule-relative-links"]
globs: ["**/*.md"],
ignores: ["**/node_modules"],
customRules: [relativeLinksRule],
}
export default config
```
`package.json`
@ -95,7 +111,7 @@ We recommend configuring `markdownlint-cli2` over `markdownlint-cli` for compati
## Usage
```sh
npm run lint:markdown
node --run lint:markdown
```
## 💡 Contributing

13
eslint.config.js Normal file
View File

@ -0,0 +1,13 @@
import typescriptESLint from "typescript-eslint"
import configConventions from "eslint-config-conventions"
export default typescriptESLint.config(...configConventions, {
files: ["**/*.ts", "**/*.tsx"],
languageOptions: {
parser: typescriptESLint.parser,
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
})

24206
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -3,60 +3,60 @@
"version": "0.0.0-development",
"public": true,
"description": "Custom rule for markdownlint to validate relative links.",
"author": "Divlo <contact@divlo.fr>",
"author": "Théo LUDWIG <contact@theoludwig.fr>",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/Divlo/markdownlint-rule-relative-links.git"
"url": "https://github.com/theoludwig/markdownlint-rule-relative-links.git"
},
"bugs": {
"url": "https://github.com/Divlo/markdownlint-rule-relative-links/issues"
"url": "https://github.com/theoludwig/markdownlint-rule-relative-links/issues"
},
"homepage": "https://github.com/Divlo/markdownlint-rule-relative-links#readme",
"homepage": "https://github.com/theoludwig/markdownlint-rule-relative-links#readme",
"keywords": [
"markdownlint",
"markdownlint-rule"
],
"main": "src/index.js",
"types": "src/index.d.ts",
"type": "module",
"files": [
"src"
],
"publishConfig": {
"access": "public",
"provenance": true
},
"engines": {
"node": ">=16.0.0",
"npm": ">=8.0.0"
"node": ">=22.0.0"
},
"scripts": {
"lint:commit": "commitlint",
"lint:editorconfig": "editorconfig-checker",
"lint:markdown": "markdownlint-cli2",
"lint:eslint": "eslint \"**/*.{js,jsx,ts,tsx}\" --ignore-path \".gitignore\"",
"lint:prettier": "prettier \".\" --check --ignore-path \".gitignore\"",
"lint:staged": "lint-staged",
"test": "tap",
"release": "semantic-release",
"postinstall": "husky install",
"prepublishOnly": "pinst --disable",
"postpublish": "pinst --enable"
"lint:eslint": "eslint . --max-warnings 0",
"lint:prettier": "prettier . --check",
"lint:typescript": "tsc --noEmit",
"test": "node --test",
"release": "semantic-release"
},
"dependencies": {
"markdown-it": "14.1.0"
},
"devDependencies": {
"@commitlint/cli": "17.3.0",
"@commitlint/config-conventional": "17.3.0",
"@types/tap": "15.0.7",
"editorconfig-checker": "4.0.2",
"eslint": "8.31.0",
"eslint-config-conventions": "6.0.0",
"eslint-config-prettier": "8.5.0",
"eslint-plugin-import": "2.26.0",
"eslint-plugin-prettier": "4.2.1",
"eslint-plugin-promise": "6.1.1",
"eslint-plugin-unicorn": "45.0.2",
"husky": "8.0.2",
"lint-staged": "13.1.0",
"markdownlint": "0.27.0",
"markdownlint-cli2": "0.6.0",
"pinst": "3.0.0",
"prettier": "2.8.1",
"semantic-release": "19.0.5",
"tap": "16.3.2"
"@types/markdown-it": "14.1.2",
"@types/node": "22.15.17",
"editorconfig-checker": "6.0.1",
"eslint": "9.26.0",
"eslint-config-conventions": "19.2.0",
"eslint-plugin-promise": "7.2.1",
"eslint-plugin-unicorn": "59.0.1",
"eslint-plugin-import-x": "4.11.1",
"globals": "16.1.0",
"markdownlint": "0.38.0",
"markdownlint-cli2": "0.18.0",
"prettier": "3.5.3",
"semantic-release": "24.2.3",
"typescript-eslint": "8.32.0",
"typescript": "5.8.3"
}
}

8
src/index.d.ts vendored Normal file
View File

@ -0,0 +1,8 @@
import type MarkdownIt from "markdown-it"
import type { Rule } from "markdownlint"
declare const relativeLinksRule: Rule
export default relativeLinksRule
declare const markdownIt: MarkdownIt
export { markdownIt }

View File

@ -1,85 +1,175 @@
'use strict'
import { pathToFileURL } from "node:url"
import fs from "node:fs"
const { pathToFileURL } = require('node:url')
const fs = require('node:fs')
import { filterTokens } from "./markdownlint-rule-helpers/helpers.js"
import {
convertHeadingToHTMLFragment,
getMarkdownHeadings,
getMarkdownIdOrAnchorNameFragments,
isValidIntegerString,
getNumberOfLines,
getLineNumberStringFromFragment,
lineFragmentRe,
} from "./utils.js"
export { markdownIt } from "./utils.js"
/** @typedef {import('markdownlint').Rule} MarkdownLintRule */
/**
* Calls the provided function for each matching token.
*
* @param {Object} params RuleParams instance.
* @param {string} type Token type identifier.
* @param {Function} handler Callback function.
* @returns {void}
* @type {MarkdownLintRule}
*/
const filterTokens = (params, type, handler) => {
for (const token of params.tokens) {
if (token.type === type) {
handler(token)
const relativeLinksRule = {
names: ["relative-links"],
description: "Relative links should be valid",
tags: ["links"],
parser: "markdownit",
function: (params, onError) => {
filterTokens(params, "inline", (token) => {
const children = token.children ?? []
for (const child of children) {
const { type, attrs, lineNumber } = child
/** @type {string | undefined} */
let hrefSrc
if (type === "link_open") {
for (const attr of attrs) {
if (attr[0] === "href") {
hrefSrc = attr[1]
break
}
}
}
/**
* Adds a generic error object via the onError callback.
*
* @param {Object} onError RuleOnError instance.
* @param {number} lineNumber Line number.
* @param {string} [detail] Error details.
* @param {string} [context] Error context.
* @param {number[]} [range] Column and length of error.
* @param {Object} [fixInfo] RuleOnErrorFixInfo instance.
* @returns {void}
*/
const addError = (onError, lineNumber, detail, context, range, fixInfo) => {
if (type === "image") {
for (const attr of attrs) {
if (attr[0] === "src") {
hrefSrc = attr[1]
break
}
}
}
if (hrefSrc == null) {
continue
}
const url = new URL(hrefSrc, pathToFileURL(params.name))
const isRelative =
url.protocol === "file:" &&
!hrefSrc.startsWith("/") &&
!hrefSrc.startsWith("#")
if (!isRelative) {
continue
}
const detail = `"${hrefSrc}"`
if (!fs.existsSync(url)) {
onError({
lineNumber,
detail,
context,
range,
fixInfo
detail: `${detail} should exist in the file system`,
})
continue
}
const customRule = {
names: ['relative-links'],
description: 'Relative links should be valid',
tags: ['links'],
function: (params, onError) => {
filterTokens(params, 'inline', (token) => {
token.children.forEach((child) => {
const { lineNumber, type, attrs } = child
/** @type {string | null} */
let hrefSrc = null
if (type === 'link_open') {
attrs.forEach((attr) => {
if (attr[0] === 'href') {
hrefSrc = attr[1]
}
if (url.hash.length <= 0) {
if (hrefSrc.includes("#")) {
if (type === "image") {
onError({
lineNumber,
detail: `${detail} should not have a fragment identifier as it is an image`,
})
continue
}
if (type === 'image') {
attrs.forEach((attr) => {
if (attr[0] === 'src') {
hrefSrc = attr[1]
}
onError({
lineNumber,
detail: `${detail} should have a valid fragment identifier`,
})
continue
}
continue
}
if (hrefSrc != null) {
const url = new URL(hrefSrc, pathToFileURL(params.name))
url.hash = ''
const isRelative = url.protocol === 'file:'
if (isRelative && !fs.existsSync(url)) {
const detail = `Link "${hrefSrc}" is dead`
addError(onError, lineNumber, detail)
if (type === "image") {
onError({
lineNumber,
detail: `${detail} should not have a fragment identifier as it is an image`,
})
continue
}
if (!url.pathname.endsWith(".md")) {
continue
}
const fileContent = fs.readFileSync(url, { encoding: "utf8" })
const headings = getMarkdownHeadings(fileContent)
const idOrAnchorNameHTMLFragments =
getMarkdownIdOrAnchorNameFragments(fileContent)
/** @type {Map<string, number>} */
const fragments = new Map()
const fragmentsHTML = headings.map((heading) => {
const fragment = convertHeadingToHTMLFragment(heading)
const count = fragments.get(fragment) ?? 0
fragments.set(fragment, count + 1)
if (count !== 0) {
return `${fragment}-${count}`
}
return fragment
})
fragmentsHTML.push(...idOrAnchorNameHTMLFragments)
if (!fragmentsHTML.includes(url.hash)) {
if (url.hash.startsWith("#L")) {
const lineNumberFragmentString = getLineNumberStringFromFragment(
url.hash,
)
const hasOnlyDigits = isValidIntegerString(lineNumberFragmentString)
if (!hasOnlyDigits) {
if (lineFragmentRe.test(url.hash)) {
continue
}
onError({
lineNumber,
detail: `${detail} should have a valid fragment identifier`,
})
continue
}
const lineNumberFragment = Number.parseInt(
lineNumberFragmentString,
10,
)
const numberOfLines = getNumberOfLines(fileContent)
if (lineNumberFragment > numberOfLines) {
onError({
lineNumber,
detail: `${detail} should have a valid fragment identifier, ${detail} should have at least ${lineNumberFragment} lines to be valid`,
})
continue
}
continue
}
onError({
lineNumber,
detail: `${detail} should have a valid fragment identifier`,
})
continue
}
}
})
})
}
},
}
module.exports = customRule
export default relativeLinksRule

View File

@ -0,0 +1,33 @@
/**
* Dependency Vendoring of `markdownlint-rule-helpers`
* @see https://www.npmjs.com/package/markdownlint-rule-helpers
*/
/** @typedef {import('markdownlint').RuleParams} MarkdownLintRuleParams */
/** @typedef {import('markdownlint').MarkdownItToken} MarkdownItToken */
/**
* Calls the provided function for each matching token.
*
* @param {MarkdownLintRuleParams} params RuleParams instance.
* @param {string} type Token type identifier.
* @param {(token: MarkdownItToken) => void} handler Callback function.
* @returns {void}
*/
export const filterTokens = (params, type, handler) => {
for (const token of params.parsers.markdownit.tokens) {
if (token.type === type) {
handler(token)
}
}
}
/**
* Gets a Regular Expression for matching the specified HTML attribute.
*
* @param {string} name HTML attribute name.
* @returns {RegExp} Regular Expression for matching.
*/
export const getHtmlAttributeRe = (name) => {
return new RegExp(`\\s${name}\\s*=\\s*['"]?([^'"\\s>]*)`, "iu")
}

153
src/utils.js Normal file
View File

@ -0,0 +1,153 @@
import MarkdownIt from "markdown-it"
import { getHtmlAttributeRe } from "./markdownlint-rule-helpers/helpers.js"
export const markdownIt = new MarkdownIt({ html: true })
export const lineFragmentRe = /^#(?:L\d+(?:C\d+)?-L\d+(?:C\d+)?|L\d+)$/
/**
* Converts a Markdown heading into an HTML fragment according to the rules
* used by GitHub.
*
* @see https://github.com/DavidAnson/markdownlint/blob/d01180ec5a014083ee9d574b693a8d7fbc1e566d/lib/md051.js#L1
* @param {string} inlineText Inline token for heading.
* @returns {string} Fragment string for heading.
*/
export const convertHeadingToHTMLFragment = (inlineText) => {
return (
"#" +
encodeURIComponent(
inlineText
.toLowerCase()
// RegExp source with Ruby's \p{Word} expanded into its General Categories
// https://github.com/gjtorikian/html-pipeline/blob/main/lib/html/pipeline/toc_filter.rb
// https://ruby-doc.org/core-3.0.2/Regexp.html
.replace(
/[^\p{Letter}\p{Mark}\p{Number}\p{Connector_Punctuation}\- ]/gu,
"",
)
.replace(/ /gu, "-"),
)
)
}
const headingTags = new Set(["h1", "h2", "h3", "h4", "h5", "h6"])
const ignoredTokens = new Set(["heading_open", "heading_close"])
/**
* Gets the headings from a Markdown string.
* @param {string} content
* @returns {string[]}
*/
export const getMarkdownHeadings = (content) => {
const tokens = markdownIt.parse(content, {})
/** @type {string[]} */
const headings = []
/** @type {string | null} */
let headingToken = null
for (const token of tokens) {
if (headingTags.has(token.tag)) {
if (token.type === "heading_open") {
headingToken = token.markup
} else if (token.type === "heading_close") {
headingToken = null
}
}
if (ignoredTokens.has(token.type)) {
continue
}
if (headingToken === null) {
continue
}
const children = token.children ?? []
headings.push(
`${children
.map((token) => {
return token.content
})
.join("")}`,
)
}
return headings
}
const nameHTMLAttributeRegex = getHtmlAttributeRe("name")
const idHTMLAttributeRegex = getHtmlAttributeRe("id")
/**
* Gets the id or anchor name fragments from a Markdown string.
* @param {string} content
* @returns {string[]}
*/
export const getMarkdownIdOrAnchorNameFragments = (content) => {
const tokens = markdownIt.parse(content, {})
/** @type {string[]} */
const result = []
for (const token of tokens) {
const regexMatch =
idHTMLAttributeRegex.exec(token.content) ||
nameHTMLAttributeRegex.exec(token.content)
if (regexMatch == null) {
continue
}
const idOrName = regexMatch[1]
if (idOrName == null || idOrName.length <= 0) {
continue
}
const htmlFragment = "#" + idOrName
if (!result.includes(htmlFragment)) {
result.push(htmlFragment)
}
}
return result
}
/**
* Checks if a string is a valid integer.
*
* 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`.
*
* @param {string} value
* @returns {boolean}
* @example isValidIntegerString("1") // true
* @example isValidIntegerString("45") // true
* @example isValidIntegerString("1abc") // false
* @example isValidIntegerString("1.0") // false
*/
export const isValidIntegerString = (value) => {
const regex = /^\d+$/
return regex.test(value)
}
/**
* Gets the number of lines in a string, based on the number of `\n` characters.
* @param {string} content
* @returns {number}
*/
export const getNumberOfLines = (content) => {
return content.split("\n").length
}
/**
* Gets the line number string from a fragment.
* @param {string} fragment
* @returns {string}
* @example getLineNumberStringFromFragment("#L50") // 50
*/
export const getLineNumberStringFromFragment = (fragment) => {
return fragment.slice(2)
}

View File

@ -1,34 +0,0 @@
const tap = require('tap')
const { markdownlint } = require('markdownlint').promises
const relativeLinks = require('../src/index.js')
tap.test('ensure we validate correctly', async (t) => {
const lintResults = await markdownlint({
files: ['test/fixtures/Valid.md', 'test/fixtures/Invalid.md'],
config: {
'relative-links': true
},
customRules: [relativeLinks]
})
t.equal(lintResults['test/fixtures/Valid.md'].length, 0)
t.equal(lintResults['test/fixtures/Invalid.md'].length, 2)
t.equal(
lintResults['test/fixtures/Invalid.md'][0]?.ruleDescription,
'Relative links should be valid'
)
t.equal(
lintResults['test/fixtures/Invalid.md'][0]?.errorDetail,
'Link "./basic.test.js" is dead'
)
t.equal(
lintResults['test/fixtures/Invalid.md'][1]?.ruleDescription,
'Relative links should be valid'
)
t.equal(
lintResults['test/fixtures/Invalid.md'][1]?.errorDetail,
'Link "../image.png" is dead'
)
})

View File

@ -1,5 +0,0 @@
# Invalid
[basic.js](./basic.test.js)
![Image](../image.png)

View File

@ -0,0 +1,3 @@
# Awesome
<div id>Content</div>

View File

@ -0,0 +1,3 @@
# Invalid
[Link fragment](./awesome.md#)

View File

@ -0,0 +1,3 @@
# Invalid
![Image](../image.png#)

View File

@ -0,0 +1,3 @@
# Invalid
![Image](../image.png#non-existing-fragment)

View File

@ -0,0 +1,3 @@
# Awesome
<input name="name-should-be-ignored" id="id-after-name" />

View File

@ -0,0 +1,3 @@
# Invalid
[Invalid](./awesome.md#name-should-be-ignored)

View File

@ -0,0 +1,3 @@
# Awesome
<div data-id="not-an-id-should-be-ignored">Content</div>

View File

@ -0,0 +1,3 @@
# Invalid
[Invalid](./awesome.md#not-an-id-should-be-ignored)

View File

@ -0,0 +1,3 @@
# Awesome
## Existing Heading

View File

@ -0,0 +1,3 @@
# Valid
[Link fragment](./awesome.md#ExistIng-Heading)

View File

@ -0,0 +1 @@
# Awesome

View File

@ -0,0 +1,3 @@
# Invalid
[Link fragment line number 7](./awesome.md#L7abc)

View File

@ -0,0 +1 @@
# Awesome

View File

@ -0,0 +1,31 @@
# Invalid
[Invalid](./awesome.md#L12-not-a-line-link)
[Invalid](./awesome.md#l7)
[Invalid](./awesome.md#L)
[Invalid](./awesome.md#L7extra)
[Invalid](./awesome.md#L30C)
[Invalid](./awesome.md#L30Cextra)
[Invalid](./awesome.md#L30L12)
[Invalid](./awesome.md#L30C12)
[Invalid](./awesome.md#L30C11-)
[Invalid](./awesome.md#L30C11-L)
[Invalid](./awesome.md#L30C11-L31C)
[Invalid](./awesome.md#L30C11-C31)
[Invalid](./awesome.md#C30)
[Invalid](./awesome.md#C11-C31)
[Invalid](./awesome.md#C11-L4C31)

View File

@ -0,0 +1 @@
# Awesome

View File

@ -0,0 +1,3 @@
# Invalid
[Link fragment line number 7](./awesome.md#L7)

View File

@ -0,0 +1,3 @@
# Awesome
<a name="existing-heading-anchor">Link</a>

View File

@ -0,0 +1,3 @@
# Invalid
[Link fragment](./awesome.md#non-existing-anchor-name-fragment)

View File

@ -0,0 +1,3 @@
# Awesome
<div id="existing-element-id-fragment">Content</div>

View File

@ -0,0 +1,3 @@
# Invalid
[Link fragment](./awesome.md#non-existing-element-id-fragment)

View File

@ -0,0 +1,3 @@
# Invalid
[File](./index.test.js)

View File

@ -0,0 +1,3 @@
# Awesome
## Existing Heading

View File

@ -0,0 +1,3 @@
# Invalid
[Link fragment](./awesome.md#non-existing-heading)

View File

@ -0,0 +1,3 @@
# Invalid
![Image](./image.png)

View File

@ -0,0 +1,3 @@
# Awesome
<a name="existing-heading-anchor">Link</a>

View File

@ -0,0 +1,3 @@
# Invalid
[Link fragment](./awesome.md#existing-heading-anchor)

View File

@ -0,0 +1,3 @@
# Awesome
<div id="existing-element-id-fragment">Content</div>

View File

@ -0,0 +1,3 @@
# Invalid
[Link fragment](./awesome.md#existing-element-id-fragment)

3
test/fixtures/valid/existing-file.md vendored Normal file
View File

@ -0,0 +1,3 @@
# Valid
[File](../../index.test.js)

View File

@ -0,0 +1,13 @@
# Awesome
## Existing Heading
### Repeated Heading
Text
### Repeated Heading
Text
### Repeated Heading

View File

@ -0,0 +1,9 @@
# Valid
[Link fragment](./awesome.md#existing-heading)
[Link fragment Repeated 0](./awesome.md#repeated-heading)
[Link fragment Repeated 1](./awesome.md#repeated-heading-1)
[Link fragment Repeated 2](./awesome.md#repeated-heading-2)

View File

@ -0,0 +1,3 @@
# Awesome
## Développement

View File

@ -0,0 +1,3 @@
# Valid
[Link fragment](./awesome.md#développement)

3
test/fixtures/valid/existing-image.md vendored Normal file
View File

@ -0,0 +1,3 @@
# Valid
![Image](../image.png)

View File

@ -0,0 +1,3 @@
# Valid
![Absolute Path](/absolute/path.png)

View File

@ -1,9 +1,5 @@
# Valid
[basic.js](../basic.test.js)
![Image](./image.png)
[External https link](https://example.com/)
[External https link 2](https:./external.https)

View File

@ -0,0 +1,5 @@
# Valid
<div id="existing-element-id-fragment">Content</div>
[Link fragment](#non-existing-element-id-fragment)

View File

@ -0,0 +1,9 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Awesome</title>
</head>
<body></body>
</html>

View File

@ -0,0 +1,7 @@
# Valid
[Link fragment HTML](./awesome.html#existing-heading)
[Link fragment TXT](./abc.txt#existing-heading)
[Link fragment Image](../../image.png#existing-heading)

View File

@ -0,0 +1,3 @@
# Awesome
## L7

View File

@ -0,0 +1,3 @@
# Valid
[Link fragment](./awesome.md#l7)

View File

@ -0,0 +1,3 @@
# Awesome
## L12 Not A Line Link

View File

@ -0,0 +1,11 @@
# Valid
[Valid](./awesome.md#l12-not-a-line-link)
[Valid](./awesome.md#L30-L31)
[Valid](./awesome.md#L3C24-L88)
[Valid](./awesome.md#L304-L314C98)
[Valid](./awesome.md#L200C4-L3244C2)

View File

@ -0,0 +1,9 @@
# Awesome
ABC
Line 5
Line 7 Text
## L7

View File

@ -0,0 +1,3 @@
# Valid
[Link fragment line number 7](./awesome.md#L7)

250
test/index.test.js Normal file
View File

@ -0,0 +1,250 @@
import { test } from "node:test"
import assert from "node:assert/strict"
import * as markdownlint from "markdownlint/promise"
import relativeLinksRule, { markdownIt } from "../src/index.js"
/**
*
* @param {string} fixtureFile
* @returns
*/
const validateMarkdownLint = async (fixtureFile) => {
const lintResults = await markdownlint.lint({
files: [fixtureFile],
config: {
default: false,
"relative-links": true,
},
customRules: [relativeLinksRule],
markdownItFactory: () => {
return markdownIt
},
})
return lintResults[fixtureFile]
}
test("ensure the rule validates correctly", async (t) => {
await t.test("should be invalid", async (t) => {
const testCases = [
{
name: "should be invalid with an empty id fragment",
fixturePath:
"test/fixtures/invalid/empty-id-fragment/empty-id-fragment.md",
errors: ['"./awesome.md#" should have a valid fragment identifier'],
},
{
name: "should be invalid with a name fragment other than for an anchor",
fixturePath:
"test/fixtures/invalid/ignore-name-fragment-if-not-an-anchor/ignore-name-fragment-if-not-an-anchor.md",
errors: [
'"./awesome.md#name-should-be-ignored" should have a valid fragment identifier',
],
},
{
name: "should be invalid with a non-existing id fragment (data-id !== id)",
fixturePath:
"test/fixtures/invalid/ignore-not-an-id-fragment/ignore-not-an-id-fragment.md",
errors: [
'"./awesome.md#not-an-id-should-be-ignored" should have a valid fragment identifier',
],
},
{
name: "should be invalid with uppercase letters in fragment (case sensitive)",
fixturePath:
"test/fixtures/invalid/invalid-heading-case-sensitive/invalid-heading-case-sensitive.md",
errors: [
'"./awesome.md#ExistIng-Heading" should have a valid fragment identifier',
],
},
{
name: "should be invalid with invalid heading with #L fragment",
fixturePath:
"test/fixtures/invalid/invalid-heading-with-L-fragment/invalid-heading-with-L-fragment.md",
errors: [
'"./awesome.md#L7abc" should have a valid fragment identifier',
],
},
{
name: "should be invalid with a invalid line column range number fragment",
fixturePath:
"test/fixtures/invalid/invalid-line-column-range-number-fragment/invalid-line-column-range-number-fragment.md",
errors: [
'"./awesome.md#L12-not-a-line-link" should have a valid fragment identifier',
'"./awesome.md#l7" should have a valid fragment identifier',
'"./awesome.md#L" should have a valid fragment identifier',
'"./awesome.md#L7extra" should have a valid fragment identifier',
'"./awesome.md#L30C" should have a valid fragment identifier',
'"./awesome.md#L30Cextra" should have a valid fragment identifier',
'"./awesome.md#L30L12" should have a valid fragment identifier',
'"./awesome.md#L30C12" should have a valid fragment identifier',
'"./awesome.md#L30C11-" should have a valid fragment identifier',
'"./awesome.md#L30C11-L" should have a valid fragment identifier',
'"./awesome.md#L30C11-L31C" should have a valid fragment identifier',
'"./awesome.md#L30C11-C31" should have a valid fragment identifier',
'"./awesome.md#C30" should have a valid fragment identifier',
'"./awesome.md#C11-C31" should have a valid fragment identifier',
'"./awesome.md#C11-L4C31" should have a valid fragment identifier',
],
},
{
name: "should be invalid with a invalid line number fragment",
fixturePath:
"test/fixtures/invalid/invalid-line-number-fragment/invalid-line-number-fragment.md",
errors: [
'"./awesome.md#L7" should have a valid fragment identifier, "./awesome.md#L7" should have at least 7 lines to be valid',
],
},
{
name: "should be invalid with a non-existing anchor name fragment",
fixturePath:
"test/fixtures/invalid/non-existing-anchor-name-fragment/non-existing-anchor-name-fragment.md",
errors: [
'"./awesome.md#non-existing-anchor-name-fragment" should have a valid fragment identifier',
],
},
{
name: "should be invalid with a non-existing element id fragment",
fixturePath:
"test/fixtures/invalid/non-existing-element-id-fragment/non-existing-element-id-fragment.md",
errors: [
'"./awesome.md#non-existing-element-id-fragment" should have a valid fragment identifier',
],
},
{
name: "should be invalid with a non-existing heading fragment",
fixturePath:
"test/fixtures/invalid/non-existing-heading-fragment/non-existing-heading-fragment.md",
errors: [
'"./awesome.md#non-existing-heading" should have a valid fragment identifier',
],
},
{
name: "should be invalid with a link to an image with a empty fragment",
fixturePath:
"test/fixtures/invalid/ignore-empty-fragment-checking-for-image.md",
errors: [
'"../image.png#" should not have a fragment identifier as it is an image',
],
},
{
name: "should be invalid with a link to an image with a fragment",
fixturePath:
"test/fixtures/invalid/ignore-fragment-checking-for-image.md",
errors: [
'"../image.png#non-existing-fragment" should not have a fragment identifier as it is an image',
],
},
{
name: "should be invalid with a non-existing file",
fixturePath: "test/fixtures/invalid/non-existing-file.md",
errors: ['"./index.test.js" should exist in the file system'],
},
{
name: "should be invalid with a non-existing image",
fixturePath: "test/fixtures/invalid/non-existing-image.md",
errors: ['"./image.png" should exist in the file system'],
},
]
for (const { name, fixturePath, errors } of testCases) {
await t.test(name, async () => {
const lintResults = (await validateMarkdownLint(fixturePath)) ?? []
const errorsDetails = lintResults.map((result) => {
assert.deepEqual(result.ruleNames, relativeLinksRule.names)
assert.deepEqual(
result.ruleDescription,
relativeLinksRule.description,
)
return result.errorDetail
})
assert.deepStrictEqual(
errorsDetails,
errors,
`${fixturePath}: Expected errors`,
)
})
}
})
await t.test("should be valid", async (t) => {
const testCases = [
{
name: "should be valid with an existing anchor name fragment",
fixturePath:
"test/fixtures/valid/existing-anchor-name-fragment/existing-anchor-name-fragment.md",
},
{
name: "should be valid with an existing element id fragment",
fixturePath:
"test/fixtures/valid/existing-element-id-fragment/existing-element-id-fragment.md",
},
{
name: "should be valid with an existing heading fragment",
fixturePath:
"test/fixtures/valid/existing-heading-fragment/existing-heading-fragment.md",
},
{
name: 'should be valid with an existing heading fragment with accents (e.g: "é")',
fixturePath:
"test/fixtures/valid/existing-heading-with-accents/existing-heading-with-accents.md",
},
{
name: "should only parse markdown files for fragments checking",
fixturePath:
"test/fixtures/valid/only-parse-markdown-files-for-fragments/only-parse-markdown-files-for-fragments.md",
},
{
name: "should support lines and columns range numbers in link fragments",
fixturePath:
"test/fixtures/valid/valid-line-column-range-number-fragment/valid-line-column-range-number-fragment.md",
},
{
name: 'should be valid with valid heading "like" line number fragment',
fixturePath:
"test/fixtures/valid/valid-heading-like-number-fragment/valid-heading-like-number-fragment.md",
},
{
name: "should be valid with valid line number fragment",
fixturePath:
"test/fixtures/valid/valid-line-number-fragment/valid-line-number-fragment.md",
},
{
name: "should be valid with an existing file",
fixturePath: "test/fixtures/valid/existing-file.md",
},
{
name: "should be valid with an existing image",
fixturePath: "test/fixtures/valid/existing-image.md",
},
{
name: "should ignore absolute paths",
fixturePath: "test/fixtures/valid/ignore-absolute-paths.md",
},
{
name: "should ignore external links",
fixturePath: "test/fixtures/valid/ignore-external-links.md",
},
{
name: "should ignore checking fragment in own file",
fixturePath:
"test/fixtures/valid/ignore-fragment-checking-in-own-file.md",
},
]
for (const { name, fixturePath } of testCases) {
await t.test(name, async () => {
const lintResults = (await validateMarkdownLint(fixturePath)) ?? []
const errorsDetails = lintResults.map((result) => {
return result.errorDetail
})
assert.equal(
errorsDetails.length,
0,
`${fixturePath}: Expected no errors, got ${errorsDetails.join(", ")}`,
)
})
}
})
})

79
test/utils.test.js Normal file
View File

@ -0,0 +1,79 @@
import { test } from "node:test"
import assert from "node:assert/strict"
import {
convertHeadingToHTMLFragment,
getMarkdownHeadings,
getMarkdownIdOrAnchorNameFragments,
isValidIntegerString,
getNumberOfLines,
getLineNumberStringFromFragment,
} from "../src/utils.js"
test("utils", async (t) => {
await t.test("convertHeadingToHTMLFragment", async () => {
assert.strictEqual(
convertHeadingToHTMLFragment("Valid Fragments"),
"#valid-fragments",
)
assert.strictEqual(
convertHeadingToHTMLFragment("Valid Heading With Underscores _"),
"#valid-heading-with-underscores-_",
)
assert.strictEqual(
convertHeadingToHTMLFragment(
`Valid Heading With Quotes ' And Double Quotes "`,
),
"#valid-heading-with-quotes--and-double-quotes-",
)
assert.strictEqual(
convertHeadingToHTMLFragment("🚀 Valid Heading With Emoji"),
"#-valid-heading-with-emoji",
)
})
await t.test("getMarkdownHeadings", async () => {
assert.deepStrictEqual(
getMarkdownHeadings("# Hello\n\n## World\n\n## Hello, world!\n"),
["Hello", "World", "Hello, world!"],
)
})
await t.test("getMarkdownIdOrAnchorNameFragments", async () => {
assert.deepStrictEqual(
getMarkdownIdOrAnchorNameFragments(
'<a name="anchorName" id="anchorId">Link</a>',
),
["#anchorId"],
)
assert.deepStrictEqual(
getMarkdownIdOrAnchorNameFragments('<a name="anchorName">Link</a>'),
["#anchorName"],
)
assert.deepStrictEqual(
getMarkdownIdOrAnchorNameFragments("<a>Link</a>"),
[],
)
assert.deepStrictEqual(getMarkdownIdOrAnchorNameFragments("<a>"), [])
assert.deepStrictEqual(getMarkdownIdOrAnchorNameFragments("<a id=>"), [])
})
await t.test("isValidIntegerString", async () => {
assert.strictEqual(isValidIntegerString("1"), true)
assert.strictEqual(isValidIntegerString("45"), true)
assert.strictEqual(isValidIntegerString("1abc"), false)
assert.strictEqual(isValidIntegerString("1.0"), false)
})
await t.test("getNumberOfLines", async () => {
assert.strictEqual(getNumberOfLines(""), 1)
assert.strictEqual(getNumberOfLines("Hello"), 1)
assert.strictEqual(getNumberOfLines("Hello\nWorld"), 2)
assert.strictEqual(getNumberOfLines("Hello\nWorld\n"), 3)
assert.strictEqual(getNumberOfLines("Hello\nWorld\n\n"), 4)
})
await t.test("getLineNumberStringFromFragment", async () => {
assert.strictEqual(getLineNumberStringFromFragment("#L50"), "50")
})
})

25
tsconfig.json Normal file
View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"lib": ["ESNext"],
"target": "ESNext",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"checkJs": true,
"allowJs": true,
"noEmit": true,
"rootDir": ".",
"baseUrl": ".",
"skipLibCheck": true,
"strict": true,
"allowUnusedLabels": false,
"allowUnreachableCode": false,
"noFallthroughCasesInSwitch": true,
"noImplicitAny": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"forceConsistentCasingInFileNames": true
}
}