Théo LUDWIG 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

154 lines
3.9 KiB
JavaScript

import MarkdownIt from "markdown-it"
import { getHtmlAttributeRe } from "./markdownlint-rule-helpers/helpers.js"
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)
}