mirror of
https://github.com/theoludwig/markdownlint-rule-relative-links.git
synced 2026-06-09 23:25:33 +02:00
203 lines
5.4 KiB
JavaScript
203 lines
5.4 KiB
JavaScript
import { fileURLToPath, pathToFileURL } from "node:url"
|
|
import fs from "node:fs"
|
|
import mime from "mime"
|
|
|
|
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 */
|
|
|
|
/**
|
|
* @type {MarkdownLintRule}
|
|
*/
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
if (type === "image") {
|
|
for (const attr of attrs) {
|
|
if (attr[0] === "src") {
|
|
hrefSrc = attr[1]
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if (hrefSrc == null || hrefSrc.startsWith("#")) {
|
|
continue
|
|
}
|
|
|
|
let url
|
|
|
|
if (hrefSrc.startsWith("/")) {
|
|
const rootPath = params.config["root_path"]
|
|
|
|
if (!rootPath) {
|
|
continue
|
|
}
|
|
|
|
url = new URL(`.${hrefSrc}`, pathToFileURL(`${rootPath}/`))
|
|
} else {
|
|
url = new URL(hrefSrc, pathToFileURL(params.name))
|
|
}
|
|
|
|
const detail = `"${hrefSrc}"`
|
|
|
|
if (
|
|
type === "image" &&
|
|
url.protocol !== "file:" &&
|
|
url.protocol !== "http:" &&
|
|
url.protocol !== "https:"
|
|
) {
|
|
onError({
|
|
lineNumber,
|
|
detail: `${detail} should be an image`,
|
|
})
|
|
continue
|
|
}
|
|
|
|
if (url.protocol !== "file:") {
|
|
continue
|
|
}
|
|
|
|
if (!fs.existsSync(url)) {
|
|
onError({
|
|
lineNumber,
|
|
detail: `${detail} should exist in the file system`,
|
|
})
|
|
continue
|
|
}
|
|
|
|
const mimeType = mime.getType(fileURLToPath(url))
|
|
if (type === "image" && (mimeType == null || !mimeType.startsWith("image/"))) {
|
|
onError({
|
|
lineNumber,
|
|
detail: `${detail} should be an image`,
|
|
})
|
|
continue
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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 fragmentCountDivider = params.config["fragment-index-divider"] ?? "-"
|
|
|
|
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}${fragmentCountDivider}${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
|
|
}
|
|
}
|
|
})
|
|
},
|
|
}
|
|
|
|
export default relativeLinksRule
|