Compare commits

...

29 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
67 changed files with 5557 additions and 5695 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

@ -4,16 +4,16 @@ on:
push:
branches: [develop]
pull_request:
branches: [master, develop]
branches: [main, develop]
jobs:
lint:
runs-on: "ubuntu-latest"
steps:
- uses: "actions/checkout@v4.1.1"
- uses: "actions/checkout@v4.2.2"
- name: "Setup Node.js"
uses: "actions/setup-node@v4.0.1"
uses: "actions/setup-node@v4.1.0"
with:
node-version: "lts/*"
cache: "npm"
@ -21,9 +21,15 @@ jobs:
- 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: "npm run lint:javascript"
- 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

@ -2,7 +2,7 @@ name: "Release"
on:
push:
branches: [master]
branches: [main]
jobs:
release:
@ -13,13 +13,13 @@ jobs:
pull-requests: "write"
id-token: "write"
steps:
- uses: "actions/checkout@v4.1.1"
- uses: "actions/checkout@v4.2.2"
with:
fetch-depth: 0
persist-credentials: false
- name: "Setup Node.js"
uses: "actions/setup-node@v4.0.1"
uses: "actions/setup-node@v4.1.0"
with:
node-version: "lts/*"
cache: "npm"
@ -31,7 +31,7 @@ jobs:
run: "npm audit signatures"
- name: "Release"
run: "npm run release"
run: "node --run release"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@ -4,16 +4,16 @@ on:
push:
branches: [develop]
pull_request:
branches: [master, develop]
branches: [main, develop]
jobs:
test:
runs-on: "ubuntu-latest"
steps:
- uses: "actions/checkout@v4.1.1"
- uses: "actions/checkout@v4.2.2"
- name: "Setup Node.js"
uses: "actions/setup-node@v4.0.1"
uses: "actions/setup-node@v4.1.0"
with:
node-version: "lts/*"
cache: "npm"
@ -22,4 +22,4 @@ jobs:
run: "npm clean-install"
- name: "Test"
run: "npm run 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,6 +0,0 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npm run lint:staged
npm run lint:javascript
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,11 +0,0 @@
{
"config": {
"extends": "markdownlint/style/prettier",
"relative-links": true,
"default": true,
"MD033": false
},
"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

4
.npmrc
View File

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

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/commit-analyzer",
"@semantic-release/release-notes-generator",
"@semantic-release/npm",
"@semantic-release/github"
]

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": "explicit"
}
}

View File

@ -1,6 +1,6 @@
# MIT License
Copyright (c) Théo LUDWIG
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

@ -10,7 +10,7 @@
<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/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-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>
<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>
@ -49,13 +49,12 @@ awesome.md:3 relative-links Relative links should be valid ["./invalid.txt" shou
### Additional features
- Support images (e.g: `![Image](./image.png)`).
- Support anchors (heading fragment links) (e.g: `[Link](./awesome.md#existing-heading)`).
- 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 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" />`).
- 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.
@ -67,7 +66,7 @@ Contributions are welcome to improve the rule, and to alleviate these limitation
## Prerequisites
- [Node.js](https://nodejs.org/) >= 16.0.0
[Node.js](https://nodejs.org/) >= 22.0.0
## Installation
@ -81,18 +80,22 @@ There are various ways [markdownlint](https://github.com/DavidAnson/markdownlint
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`
@ -108,7 +111,7 @@ We recommend configuring [markdownlint-cli2](https://github.com/DavidAnson/markd
## 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,
},
},
})

10187
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -18,6 +18,8 @@
"markdownlint-rule"
],
"main": "src/index.js",
"types": "src/index.d.ts",
"type": "module",
"files": [
"src"
],
@ -26,46 +28,35 @@
"provenance": true
},
"engines": {
"node": ">=16.0.0",
"npm": ">=9.0.0"
"node": ">=22.0.0"
},
"scripts": {
"lint:commit": "commitlint",
"lint:editorconfig": "editorconfig-checker",
"lint:markdown": "markdownlint-cli2",
"lint:eslint": "eslint . --max-warnings 0 --report-unused-disable-directives --ignore-path .gitignore",
"lint:prettier": "prettier . --check --ignore-path .gitignore",
"lint:javascript": "tsc --project jsconfig.json --noEmit",
"lint:staged": "lint-staged",
"test": "node --test --experimental-test-coverage ./test",
"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.0.0"
"markdown-it": "14.1.0"
},
"devDependencies": {
"@commitlint/cli": "18.4.4",
"@commitlint/config-conventional": "18.4.4",
"@types/markdown-it": "13.0.7",
"@types/node": "20.10.8",
"editorconfig-checker": "5.1.2",
"eslint": "8.56.0",
"eslint-config-conventions": "13.1.0",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-import": "2.29.1",
"eslint-plugin-prettier": "5.1.2",
"eslint-plugin-promise": "6.1.1",
"eslint-plugin-unicorn": "50.0.1",
"husky": "8.0.3",
"lint-staged": "15.2.0",
"markdownlint": "0.33.0",
"markdownlint-cli2": "0.11.0",
"pinst": "3.0.0",
"prettier": "3.1.1",
"semantic-release": "22.0.12",
"typescript": "5.3.3"
"@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,23 +1,29 @@
"use strict"
import { pathToFileURL } from "node:url"
import fs from "node:fs"
const { pathToFileURL } = require("node:url")
const fs = require("node:fs")
const {
filterTokens,
import { filterTokens } from "./markdownlint-rule-helpers/helpers.js"
import {
convertHeadingToHTMLFragment,
getMarkdownHeadings,
} = require("./utils.js")
getMarkdownIdOrAnchorNameFragments,
isValidIntegerString,
getNumberOfLines,
getLineNumberStringFromFragment,
lineFragmentRe,
} from "./utils.js"
export { markdownIt } from "./utils.js"
/** @typedef {import('markdownlint').Rule} MarkdownLintRule */
/**
* @type {MarkdownLintRule}
*/
const customRule = {
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 ?? []
@ -45,50 +51,125 @@ const customRule = {
}
}
if (hrefSrc != null) {
const url = new URL(hrefSrc, pathToFileURL(params.name))
const isRelative =
url.protocol === "file:" && !hrefSrc.startsWith("/")
if (isRelative) {
const detail = `"${hrefSrc}"`
if (hrefSrc == null) {
continue
}
if (!fs.existsSync(url)) {
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: `${detail} should exist in the file system`,
})
continue
}
if (url.hash.length <= 0) {
if (hrefSrc.includes("#")) {
if (type === "image") {
onError({
lineNumber,
detail: `${detail} should exist in the file system`,
detail: `${detail} should not have a fragment identifier as it is an image`,
})
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`,
})
}
}
onError({
lineNumber,
detail: `${detail} should have a valid fragment identifier`,
})
continue
}
continue
}
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")
}

View File

@ -1,23 +1,10 @@
const MarkdownIt = require("markdown-it")
import MarkdownIt from "markdown-it"
/** @typedef {import('markdownlint').RuleParams} MarkdownLintRuleParams */
/** @typedef {import('markdownlint').MarkdownItToken} MarkdownItToken */
import { getHtmlAttributeRe } from "./markdownlint-rule-helpers/helpers.js"
/**
* 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)
}
}
}
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
@ -27,7 +14,7 @@ const filterTokens = (params, type, handler) => {
* @param {string} inlineText Inline token for heading.
* @returns {string} Fragment string for heading.
*/
const convertHeadingToHTMLFragment = (inlineText) => {
export const convertHeadingToHTMLFragment = (inlineText) => {
return (
"#" +
encodeURIComponent(
@ -53,8 +40,7 @@ const ignoredTokens = new Set(["heading_open", "heading_close"])
* @param {string} content
* @returns {string[]}
*/
const getMarkdownHeadings = (content) => {
const markdownIt = new MarkdownIt({ html: true })
export const getMarkdownHeadings = (content) => {
const tokens = markdownIt.parse(content, {})
/** @type {string[]} */
@ -94,8 +80,74 @@ const getMarkdownHeadings = (content) => {
return headings
}
module.exports = {
filterTokens,
convertHeadingToHTMLFragment,
getMarkdownHeadings,
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

Before

Width:  |  Height:  |  Size: 95 B

After

Width:  |  Height:  |  Size: 95 B

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

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

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)

View File

@ -1,4 +1,4 @@
# Valid
# Awesome
## Existing Heading

View File

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

View File

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

View File

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

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)

View File

@ -1,9 +1,9 @@
const { test } = require("node:test")
const assert = require("node:assert/strict")
import { test } from "node:test"
import assert from "node:assert/strict"
const { markdownlint } = require("markdownlint").promises
import * as markdownlint from "markdownlint/promise"
const relativeLinksRule = require("../src/index.js")
import relativeLinksRule, { markdownIt } from "../src/index.js"
/**
*
@ -11,102 +11,240 @@ const relativeLinksRule = require("../src/index.js")
* @returns
*/
const validateMarkdownLint = async (fixtureFile) => {
const lintResults = await markdownlint({
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 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("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'],
},
]
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)
})
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 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("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",
},
]
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',
)
})
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(", ")}`,
)
})
}
})
})

View File

@ -1,10 +1,14 @@
const { test } = require("node:test")
const assert = require("node:assert/strict")
import { test } from "node:test"
import assert from "node:assert/strict"
const {
import {
convertHeadingToHTMLFragment,
getMarkdownHeadings,
} = require("../src/utils.js")
getMarkdownIdOrAnchorNameFragments,
isValidIntegerString,
getNumberOfLines,
getLineNumberStringFromFragment,
} from "../src/utils.js"
test("utils", async (t) => {
await t.test("convertHeadingToHTMLFragment", async () => {
@ -34,4 +38,42 @@ test("utils", async (t) => {
["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")
})
})

View File

@ -2,13 +2,14 @@
"compilerOptions": {
"lib": ["ESNext"],
"target": "ESNext",
"module": "CommonJS",
"moduleResolution": "Node",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"checkJs": true,
"allowJs": true,
"noEmit": true,
"rootDir": ".",
"baseUrl": ".",
"skipLibCheck": true,
"strict": true,
"allowUnusedLabels": false,
"allowUnreachableCode": false,