16 Commits

Author SHA1 Message Date
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
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
c65607bcc3 build(deps): update latest 2023-05-13 16:03:06 +02:00
40 changed files with 3649 additions and 6605 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -1,8 +1,8 @@
--- ---
name: '🙋 Question' name: "🙋 Question"
about: 'Further information is requested.' about: "Further information is requested."
title: '[Question]' title: "[Question]"
labels: 'question' labels: "question"
--- ---
### 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. --> <!-- 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 ## List any relevant issue numbers

View File

@ -1,4 +1,4 @@
name: 'Lint' name: "Lint"
on: on:
push: push:
@ -8,21 +8,22 @@ on:
jobs: jobs:
lint: lint:
runs-on: 'ubuntu-latest' runs-on: "ubuntu-latest"
steps: steps:
- uses: 'actions/checkout@v3.5.0' - uses: "actions/checkout@v4.1.1"
- name: 'Use Node.js' - name: "Setup Node.js"
uses: 'actions/setup-node@v3.6.0' uses: "actions/setup-node@v4.0.1"
with: with:
node-version: 'lts/*' node-version: "lts/*"
cache: 'npm' cache: "npm"
- name: 'Install' - name: "Install dependencies"
run: 'npm install' run: "npm clean-install"
- run: 'npm run lint:commit -- --to "${{ github.sha }}"' - run: 'npm run lint:commit -- --to "${{ github.sha }}"'
- run: 'npm run lint:editorconfig' - run: "npm run lint:editorconfig"
- run: 'npm run lint:markdown' - run: "npm run lint:markdown"
- run: 'npm run lint:eslint' - run: "npm run lint:eslint"
- run: 'npm run lint:prettier' - run: "npm run lint:prettier"
- run: "npm run lint:javascript"

View File

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

View File

@ -1,4 +1,4 @@
name: 'Test' name: "Test"
on: on:
push: push:
@ -8,18 +8,18 @@ on:
jobs: jobs:
test: test:
runs-on: 'ubuntu-latest' runs-on: "ubuntu-latest"
steps: steps:
- uses: 'actions/checkout@v3.5.0' - uses: "actions/checkout@v4.1.1"
- name: 'Use Node.js' - name: "Setup Node.js"
uses: 'actions/setup-node@v3.6.0' uses: "actions/setup-node@v4.0.1"
with: with:
node-version: 'lts/*' node-version: "lts/*"
cache: 'npm' cache: "npm"
- name: 'Install' - name: "Install dependencies"
run: 'npm install' run: "npm clean-install"
- name: 'Test' - name: "Test"
run: 'npm run test' run: "npm run test"

View File

@ -2,4 +2,5 @@
. "$(dirname "$0")/_/husky.sh" . "$(dirname "$0")/_/husky.sh"
npm run lint:staged npm run lint:staged
npm run lint:javascript
npm run test npm run test

View File

@ -1,5 +1,11 @@
{ {
"config": {
"extends": "markdownlint/style/prettier",
"relative-links": true,
"default": true,
"MD033": false
},
"globs": ["**/*.{md,mdx}"], "globs": ["**/*.{md,mdx}"],
"ignores": ["**/node_modules", "**/test/fixtures"], "ignores": ["**/node_modules", "**/test/fixtures/**"],
"customRules": ["./src/index.js"] "customRules": ["./src/index.js"]
} }

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 save-exact=true
provenance=true

View File

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

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

@ -3,6 +3,6 @@
"prettier.configPath": ".prettierrc.json", "prettier.configPath": ".prettierrc.json",
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll": true "source.fixAll": "explicit"
} }
} }

View File

@ -60,7 +60,7 @@ representative at an online or offline event.
Instances of abusive, harassing, or otherwise unacceptable behavior may be Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at 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 complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the 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**! 🎉 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 ## Types of contributions
- Reporting a bug. - Reporting a bug.
@ -11,7 +19,7 @@ Thanks a lot for your interest in contributing to **markdownlint-rule-relative-l
## Pull Requests ## 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. - Ensure your code respect linting.
@ -21,26 +29,4 @@ If you're adding new features to **markdownlint-rule-relative-links**, please in
## Commits ## 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. The commit message guidelines adheres to [Conventional Commits](https://www.conventionalcommits.org/) 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.

View File

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

View File

@ -5,12 +5,12 @@
</p> </p>
<p align="center"> <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="./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> <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 /> <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/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/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-linksactions/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 /> <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://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> <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. **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 ### Example
@ -37,15 +37,28 @@ With `awesome.md` content:
```md ```md
[abc](./abc.txt) [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 ```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 anchors (heading fragment links) (e.g: `[Link](./awesome.md#existing-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 supported, html syntax is not supported (e.g: `<a href="./link.txt" />` or `<img src="./image.png" />`).
- Anchors checking is limited to headings, other elements are not supported (e.g: with a "id", `<div id="anchor" />`).
Contributions are welcome to improve the rule, and to alleviate these limitations. See [CONTRIBUTING.md](./CONTRIBUTING.md) for more information.
### Related links ### Related links
- [DavidAnson/markdownlint#253](https://github.com/DavidAnson/markdownlint/issues/253) - [DavidAnson/markdownlint#253](https://github.com/DavidAnson/markdownlint/issues/253)
@ -64,9 +77,9 @@ npm install --save-dev markdownlint-rule-relative-links
## Configuration ## 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.jsonc`

24
jsconfig.json Normal file
View File

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

9480
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -3,16 +3,16 @@
"version": "0.0.0-development", "version": "0.0.0-development",
"public": true, "public": true,
"description": "Custom rule for markdownlint to validate relative links.", "description": "Custom rule for markdownlint to validate relative links.",
"author": "Divlo <contact@divlo.fr>", "author": "Théo LUDWIG <contact@theoludwig.fr>",
"license": "MIT", "license": "MIT",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/Divlo/markdownlint-rule-relative-links.git" "url": "https://github.com/theoludwig/markdownlint-rule-relative-links.git"
}, },
"bugs": { "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": [ "keywords": [
"markdownlint", "markdownlint",
"markdownlint-rule" "markdownlint-rule"
@ -21,42 +21,51 @@
"files": [ "files": [
"src" "src"
], ],
"publishConfig": {
"access": "public",
"provenance": true
},
"engines": { "engines": {
"node": ">=16.0.0", "node": ">=16.0.0",
"npm": ">=8.0.0" "npm": ">=9.0.0"
}, },
"scripts": { "scripts": {
"lint:commit": "commitlint", "lint:commit": "commitlint",
"lint:editorconfig": "editorconfig-checker", "lint:editorconfig": "editorconfig-checker",
"lint:markdown": "markdownlint-cli2", "lint:markdown": "markdownlint-cli2",
"lint:eslint": "eslint \".\" --ignore-path \".gitignore\"", "lint:eslint": "eslint . --max-warnings 0 --report-unused-disable-directives --ignore-path .gitignore",
"lint:prettier": "prettier \".\" --check --ignore-path \".gitignore\"", "lint:prettier": "prettier . --check --ignore-path .gitignore",
"lint:javascript": "tsc --project jsconfig.json --noEmit",
"lint:staged": "lint-staged", "lint:staged": "lint-staged",
"test": "tap", "test": "node --test --experimental-test-coverage ./test",
"release": "semantic-release", "release": "semantic-release",
"postinstall": "husky install", "postinstall": "husky install",
"prepublishOnly": "pinst --disable", "prepublishOnly": "pinst --disable",
"postpublish": "pinst --enable" "postpublish": "pinst --enable"
}, },
"dependencies": {
"markdown-it": "14.0.0"
},
"devDependencies": { "devDependencies": {
"@commitlint/cli": "17.5.1", "@commitlint/cli": "18.4.4",
"@commitlint/config-conventional": "17.4.4", "@commitlint/config-conventional": "18.4.4",
"@types/tap": "15.0.8", "@types/markdown-it": "13.0.7",
"editorconfig-checker": "5.0.1", "@types/node": "20.10.8",
"eslint": "8.37.0", "editorconfig-checker": "5.1.2",
"eslint-config-conventions": "8.0.0", "eslint": "8.56.0",
"eslint-config-prettier": "8.8.0", "eslint-config-conventions": "13.1.0",
"eslint-plugin-import": "2.27.5", "eslint-config-prettier": "9.1.0",
"eslint-plugin-prettier": "4.2.1", "eslint-plugin-import": "2.29.1",
"eslint-plugin-prettier": "5.1.2",
"eslint-plugin-promise": "6.1.1", "eslint-plugin-promise": "6.1.1",
"eslint-plugin-unicorn": "46.0.0", "eslint-plugin-unicorn": "50.0.1",
"husky": "8.0.3", "husky": "8.0.3",
"lint-staged": "13.2.0", "lint-staged": "15.2.0",
"markdownlint": "0.28.0", "markdownlint": "0.33.0",
"markdownlint-cli2": "0.6.0", "markdownlint-cli2": "0.11.0",
"pinst": "3.0.0", "pinst": "3.0.0",
"prettier": "2.8.7", "prettier": "3.1.1",
"semantic-release": "21.0.1", "semantic-release": "22.0.12",
"tap": "16.3.4" "typescript": "5.3.3"
} }
} }

View File

@ -1,86 +1,94 @@
'use strict' "use strict"
const { pathToFileURL } = require('node:url') const { pathToFileURL } = require("node:url")
const fs = require('node:fs') const fs = require("node:fs")
const {
filterTokens,
convertHeadingToHTMLFragment,
getMarkdownHeadings,
} = require("./utils.js")
/** @typedef {import('markdownlint').Rule} MarkdownLintRule */
/** /**
* Calls the provided function for each matching token. * @type {MarkdownLintRule}
*
* @param {Object} params RuleParams instance.
* @param {string} type Token type identifier.
* @param {Function} handler Callback function.
* @returns {void}
*/ */
const filterTokens = (params, type, handler) => {
for (const token of params.tokens) {
if (token.type === type) {
handler(token)
}
}
}
/**
* 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) => {
onError({
lineNumber,
detail,
context,
range,
fixInfo
})
}
const customRule = { const customRule = {
names: ['relative-links'], names: ["relative-links"],
description: 'Relative links should be valid', description: "Relative links should be valid",
tags: ['links'], tags: ["links"],
function: (params, onError) => { function: (params, onError) => {
filterTokens(params, 'inline', (token) => { filterTokens(params, "inline", (token) => {
token.children.forEach((child) => { const children = token.children ?? []
const { lineNumber, type, attrs } = child for (const child of children) {
const { type, attrs, lineNumber } = child
/** @type {string | null} */ /** @type {string | undefined} */
let hrefSrc = null let hrefSrc
if (type === 'link_open') { if (type === "link_open") {
attrs.forEach((attr) => { for (const attr of attrs) {
if (attr[0] === 'href') { if (attr[0] === "href") {
hrefSrc = attr[1] hrefSrc = attr[1]
break
} }
}) }
} }
if (type === 'image') { if (type === "image") {
attrs.forEach((attr) => { for (const attr of attrs) {
if (attr[0] === 'src') { if (attr[0] === "src") {
hrefSrc = attr[1] hrefSrc = attr[1]
break
} }
}) }
} }
if (hrefSrc != null) { if (hrefSrc != null) {
const url = new URL(hrefSrc, pathToFileURL(params.name)) const url = new URL(hrefSrc, pathToFileURL(params.name))
url.hash = ''
const isRelative = const isRelative =
url.protocol === 'file:' && !hrefSrc.startsWith('/') url.protocol === "file:" && !hrefSrc.startsWith("/")
if (isRelative && !fs.existsSync(url)) { if (isRelative) {
const detail = `Link "${hrefSrc}" is dead` const detail = `"${hrefSrc}"`
addError(onError, lineNumber, detail)
if (!fs.existsSync(url)) {
onError({
lineNumber,
detail: `${detail} should exist in the file system`,
})
continue
}
if (type === "link_open" && url.hash !== "") {
const fileContent = fs.readFileSync(url, { encoding: "utf8" })
const headings = getMarkdownHeadings(fileContent)
/** @type {Map<string, number>} */
const fragments = new Map()
const headingsHTMLFragments = 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
})
if (!headingsHTMLFragments.includes(url.hash)) {
onError({
lineNumber,
detail: `${detail} should have a valid fragment identifier`,
})
}
}
} }
} }
}) }
}) })
} },
} }
module.exports = customRule module.exports = customRule

101
src/utils.js Normal file
View File

@ -0,0 +1,101 @@
const MarkdownIt = require("markdown-it")
/** @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}
*/
const filterTokens = (params, type, handler) => {
for (const token of params.tokens) {
if (token.type === type) {
handler(token)
}
}
}
/**
* 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.
*/
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[]}
*/
const getMarkdownHeadings = (content) => {
const markdownIt = new MarkdownIt({ html: true })
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
}
module.exports = {
filterTokens,
convertHeadingToHTMLFragment,
getMarkdownHeadings,
}

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 @@
# Invalid
[File](./index.test.js)

View File

@ -0,0 +1,3 @@
# Valid
## 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)

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 @@
# Valid
## 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)

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,11 +1,5 @@
# Valid # Valid
[basic.js](../basic.test.js)
![Image](./image.png)
![Absolute Path](/absolute/path.png)
[External https link](https://example.com/) [External https link](https://example.com/)
[External https link 2](https:./external.https) [External https link 2](https:./external.https)

View File

Before

Width:  |  Height:  |  Size: 95 B

After

Width:  |  Height:  |  Size: 95 B

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

@ -0,0 +1,112 @@
const { test } = require("node:test")
const assert = require("node:assert/strict")
const { markdownlint } = require("markdownlint").promises
const relativeLinksRule = require("../src/index.js")
/**
*
* @param {string} fixtureFile
* @returns
*/
const validateMarkdownLint = async (fixtureFile) => {
const lintResults = await markdownlint({
files: [fixtureFile],
config: {
default: false,
"relative-links": true,
},
customRules: [relativeLinksRule],
})
return lintResults[fixtureFile]
}
test("ensure the rule validates correctly", async (t) => {
await t.test("should be valid", async (t) => {
await t.test("with an existing heading fragment", async () => {
const lintResults = await validateMarkdownLint(
"test/fixtures/valid/existing-heading-fragment/existing-heading-fragment.md",
)
assert.equal(lintResults?.length, 0)
})
await t.test("with an existing file", async () => {
const lintResults = await validateMarkdownLint(
"test/fixtures/valid/existing-file.md",
)
assert.equal(lintResults?.length, 0)
})
await t.test("with an existing image", async () => {
const lintResults = await validateMarkdownLint(
"test/fixtures/valid/existing-image.md",
)
assert.equal(lintResults?.length, 0)
})
await t.test("should ignore absolute paths", async () => {
const lintResults = await validateMarkdownLint(
"test/fixtures/valid/ignore-absolute-paths.md",
)
assert.equal(lintResults?.length, 0)
})
await t.test("should ignore external links", async () => {
const lintResults = await validateMarkdownLint(
"test/fixtures/valid/ignore-external-links.md",
)
assert.equal(lintResults?.length, 0)
})
})
await t.test("should be invalid", async (t) => {
await t.test("with a non-existing heading fragment", async () => {
const lintResults = await validateMarkdownLint(
"test/fixtures/invalid/non-existing-heading-fragment/non-existing-heading-fragment.md",
)
assert.equal(lintResults?.length, 1)
assert.deepEqual(lintResults?.[0]?.ruleNames, relativeLinksRule.names)
assert.equal(
lintResults?.[0]?.ruleDescription,
relativeLinksRule.description,
)
assert.equal(
lintResults?.[0]?.errorDetail,
'"./awesome.md#non-existing-heading" should have a valid fragment identifier',
)
})
await t.test("with a non-existing file", async () => {
const lintResults = await validateMarkdownLint(
"test/fixtures/invalid/non-existing-file.md",
)
assert.equal(lintResults?.length, 1)
assert.deepEqual(lintResults?.[0]?.ruleNames, relativeLinksRule.names)
assert.equal(
lintResults?.[0]?.ruleDescription,
relativeLinksRule.description,
)
assert.equal(
lintResults?.[0]?.errorDetail,
'"./index.test.js" should exist in the file system',
)
})
await t.test("with a non-existing image", async () => {
const lintResults = await validateMarkdownLint(
"test/fixtures/invalid/non-existing-image.md",
)
assert.equal(lintResults?.length, 1)
assert.deepEqual(lintResults?.[0]?.ruleNames, relativeLinksRule.names)
assert.equal(
lintResults?.[0]?.ruleDescription,
relativeLinksRule.description,
)
assert.equal(
lintResults?.[0]?.errorDetail,
'"./image.png" should exist in the file system',
)
})
})
})

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

@ -0,0 +1,37 @@
const { test } = require("node:test")
const assert = require("node:assert/strict")
const {
convertHeadingToHTMLFragment,
getMarkdownHeadings,
} = require("../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!"],
)
})
})