remark-lint-no-undefined-references
Version:
remark-lint rule to warn when references to undefined definitions are found
479 lines (436 loc) • 15.7 kB
JavaScript
/**
* remark-lint rule to warn when undefined definitions are referenced.
*
* ## What is this?
*
* This package checks that referenced definitions are defined.
*
* ## When should I use this?
*
* You can use this package to check for broken references.
*
* ## API
*
* ### `unified().use(remarkLintNoUndefinedReferences[, options])`
*
* Warn when undefined definitions are referenced.
*
* ###### Parameters
*
* * `options` ([`Options`][api-options], optional)
* — configuration
*
* ###### Returns
*
* Transform ([`Transformer` from `unified`][github-unified-transformer]).
*
* ### `Options`
*
* Configuration (TypeScript type).
*
* ###### Fields
*
* * `allow` (`Array<RegExp | string>`, optional)
* — list of values to allow between `[` and `]`
* * `allowShortcutLink` (`boolean`, default: `false`)
* — allow shortcut references, which are just brackets such as `[text]`
*
* ## Recommendation
*
* Shortcut references use an implicit syntax that could also occur as plain
* text.
* To illustrate,
* it is reasonable to expect an author adding `[…]` to abbreviate some text
* somewhere in a document:
*
* ```markdown
* > Some […] quote.
* ```
*
* This isn’t a problem,
* but it might become one when an author later adds a definition:
*
* ```markdown
* Some new text […][].
*
* […]: #read-more
* ```
*
* The second author might expect only their newly added text to form a link,
* but their changes also result in a link for the text by the first author.
*
* [api-options]: #options
* [api-remark-lint-no-undefined-references]: #unifieduseremarklintnoundefinedreferences-options
* [github-unified-transformer]: https://github.com/unifiedjs/unified#transformer
*
* @module no-undefined-references
* @author Titus Wormer
* @copyright Titus Wormer
* @license MIT
*
* @example
* {"name": "ok.md"}
*
* [Mercury][] is the first planet from the Sun and the smallest in the Solar
* System.
*
* Venus is the second planet from the [Sun.
*
* Earth is the third planet from the \[Sun] and the only astronomical object
* known to harbor life\.
*
* Mars is the fourth planet from the Sun: [].
*
* [mercury]: https://example.com/mercury/
*
* @example
* {"label": "input", "name": "not-ok.md"}
*
* [Mercury] is the first planet from the Sun and the smallest in the Solar
* System.
*
* [Venus][] is the second planet from the Sun.
*
* [Earth][earth] is the third planet from the Sun and the only astronomical
* object known to harbor life.
*
* ![Mars] is the fourth planet from the Sun in the [Solar
* System].
*
* > Jupiter is the fifth planet from the Sun and the largest in the [Solar
* > System][].
*
* [Saturn][ is the sixth planet from the Sun and the second-largest
* in the Solar System, after Jupiter.
*
* [*Uranus*][] is the seventh planet from the Sun.
*
* [Neptune][neptune][more] is the eighth and farthest planet from the Sun.
* @example
* {"label": "output", "name": "not-ok.md"}
*
* 1:1-1:10: Unexpected reference to undefined definition, expected corresponding definition (`mercury`) for a link or escaped opening bracket (`\[`) for regular text
* 4:1-4:10: Unexpected reference to undefined definition, expected corresponding definition (`venus`) for a link or escaped opening bracket (`\[`) for regular text
* 6:1-6:15: Unexpected reference to undefined definition, expected corresponding definition (`earth`) for a link or escaped opening bracket (`\[`) for regular text
* 9:2-9:8: Unexpected reference to undefined definition, expected corresponding definition (`mars`) for an image or escaped opening bracket (`\[`) for regular text
* 9:50-10:8: Unexpected reference to undefined definition, expected corresponding definition (`solar system`) for a link or escaped opening bracket (`\[`) for regular text
* 12:67-13:12: Unexpected reference to undefined definition, expected corresponding definition (`solar > system`) for a link or escaped opening bracket (`\[`) for regular text
* 15:1-15:9: Unexpected reference to undefined definition, expected corresponding definition (`saturn`) for a link or escaped opening bracket (`\[`) for regular text
* 18:1-18:13: Unexpected reference to undefined definition, expected corresponding definition (`*uranus*`) for a link or escaped opening bracket (`\[`) for regular text
* 20:1-20:19: Unexpected reference to undefined definition, expected corresponding definition (`neptune`) for a link or escaped opening bracket (`\[`) for regular text
* 20:19-20:25: Unexpected reference to undefined definition, expected corresponding definition (`more`) for a link or escaped opening bracket (`\[`) for regular text
*
* @example
* {"config": {"allow": ["…"]}, "name": "ok-allow.md"}
*
* Mercury is the first planet from the Sun and the smallest in the Solar
* System. […]
*
* @example
* {"config": {"allow": [{"source": "^mer"}, "venus"]}, "name": "source.md"}
*
* [Mercury][] is the first planet from the Sun and the smallest in the Solar
* System.
*
* [Venus][] is the second planet from the Sun.
*
* @example
* {"gfm": true, "label": "input", "name": "gfm.md"}
*
* Mercury[^mercury] is the first planet from the Sun and the smallest in the
* Solar System.
*
* [^venus]:
* **Venus** is the second planet from the Sun.
* @example
* {"gfm": true, "label": "output", "name": "gfm.md"}
*
* 1:8-1:18: Unexpected reference to undefined definition, expected corresponding definition (`mercury`) for a footnote or escaped opening bracket (`\[`) for regular text
*
* @example
* {"gfm": true, "label": "input", "name": "gfm-table.md"}
*
* | [Planet] | [Radius] |
* | ----------------- | --------- |
* | [Mercury] | 2439.7 km |
*
* [planet]: https://example.com/planet/
* @example
* {"gfm": true, "label": "output", "name": "gfm-table.md"}
*
* 1:23-1:31: Unexpected reference to undefined definition, expected corresponding definition (`radius`) for a link or escaped opening bracket (`\[`) for regular text
* 3:3-3:12: Unexpected reference to undefined definition, expected corresponding definition (`mercury`) for a link or escaped opening bracket (`\[`) for regular text
*
* @example
* {"config": {"allowShortcutLink": true}, "label": "input", "name": "allow-shortcut-link.md"}
*
* [Mercury] is the first planet from the Sun and the smallest in the Solar
* System.
*
* [Venus][] is the second planet from the Sun.
*
* [Earth][earth] is the third planet from the Sun and the only astronomical object
* known to harbor life.
* @example
* {"config": {"allowShortcutLink": true}, "label": "output", "name": "allow-shortcut-link.md"}
*
* 4:1-4:10: Unexpected reference to undefined definition, expected corresponding definition (`venus`) for a link or escaped opening bracket (`\[`) for regular text
* 6:1-6:15: Unexpected reference to undefined definition, expected corresponding definition (`earth`) for a link or escaped opening bracket (`\[`) for regular text
*/
/**
* @import {Nodes, Root} from 'mdast'
*/
/**
* @typedef Options
* Configuration.
* @property {ReadonlyArray<RegExp | string> | null | undefined} [allow]
* List of values to allow between `[` and `]` (optional)
* @property {boolean | null | undefined} [allowShortcutLink]
* Allow shortcut references, which are just brackets such as `[text]`
* (`boolean`, default: `false`)
*/
import {collapseWhiteSpace} from 'collapse-white-space'
import {ok as assert} from 'devlop'
import {normalizeIdentifier} from 'micromark-util-normalize-identifier'
import {lintRule} from 'unified-lint-rule'
import {pointEnd, pointStart} from 'unist-util-position'
import {visitParents} from 'unist-util-visit-parents'
import {location} from 'vfile-location'
/** @type {Readonly<Options>} */
const emptyOptions = {}
/** @type {ReadonlyArray<RegExp | string>} */
const emptyAllow = []
const lineEndingExpression = /(\r?\n|\r)[\t ]*(>[\t ]*)*/g
const remarkLintNoUndefinedReferences = lintRule(
{
origin: 'remark-lint:no-undefined-references',
url: 'https://github.com/remarkjs/remark-lint/tree/main/packages/remark-lint-no-undefined-references#readme'
},
/**
* @param {Root} tree
* Tree.
* @param {Readonly<Options> | null | undefined} [options]
* Configuration (optional).
* @returns {undefined}
* Nothing.
*/
function (tree, file, options) {
const settings = options || emptyOptions
const allow = settings.allow || emptyAllow
const allowShortcutLink = settings.allowShortcutLink || false
const value = String(file)
const toPoint = location(file).toPoint
/** @type {Set<string>} */
const definitionIdentifiers = new Set()
/** @type {Set<string>} */
const footnoteDefinitionIdentifiers = new Set()
/** @type {Array<RegExp>} */
const regexes = []
/** @type {Set<string>} */
const strings = new Set()
/** @type {Array<Array<Nodes>>} */
const phrasingStacks = []
let index = -1
while (++index < allow.length) {
const value = allow[index]
if (typeof value === 'string') {
strings.add(normalizeIdentifier(value))
} else if (typeof value === 'object' && 'source' in value) {
regexes.push(new RegExp(value.source, value.flags ?? 'i'))
}
}
visitParents(tree, function (node, parents) {
if (node.type === 'definition') {
definitionIdentifiers.add(normalizeIdentifier(node.identifier))
}
if (node.type === 'footnoteDefinition') {
footnoteDefinitionIdentifiers.add(normalizeIdentifier(node.identifier))
}
if (
node.type === 'heading' ||
node.type === 'paragraph' ||
node.type === 'tableCell'
) {
phrasingStacks.push([...parents, node])
}
})
for (const ancestors of phrasingStacks) {
findInPhrasingContainer(ancestors)
}
/**
* @param {Array<Nodes>} ancestors
* Ancestors, the last of which a parent of phrasing nodes.
* @returns {undefined}
* Nothing.
*/
function findInPhrasingContainer(ancestors) {
/** @type {Array<[ancestors: Array<Nodes>, brackets: Array<number>]>} */
const bracketRanges = []
const node = ancestors.at(-1)
assert(node) // Always defined.
assert('children' in node) // Always defined.
for (const child of node.children) {
if (child.type === 'text') {
findRangesInText(bracketRanges, [...ancestors, child])
} else if ('children' in child) {
findInPhrasingContainer([...ancestors, child])
}
}
// Remaining ranges.
for (const range of bracketRanges) {
handleRange(range)
}
}
/**
* @param {Array<[ancestors: Array<Nodes>, brackets: Array<number>]>} ranges
* @param {Array<Nodes>} ancestors
*/
function findRangesInText(ranges, ancestors) {
const node = ancestors.at(-1)
assert(node) // Always defined.
const end = pointEnd(node)
const start = pointStart(node)
// Bail if there’s no positional info.
if (
!end ||
!start ||
typeof start.offset !== 'number' ||
typeof end.offset !== 'number'
) {
return
}
const source = value.slice(start.offset, end.offset)
/** @type {Array<[number, string]>} */
const lines = [[start.offset, '']]
let last = 0
lineEndingExpression.lastIndex = 0
let match = lineEndingExpression.exec(source)
while (match) {
const index = match.index
const lineTuple = lines.at(-1)
assert(lineTuple) // Always defined.
lineTuple[1] = source.slice(last, index)
last = index + match[0].length
lines.push([start.offset + last, ''])
match = lineEndingExpression.exec(source)
}
const lineTuple = lines.at(-1)
assert(lineTuple) // Always defined.
lineTuple[1] = source.slice(last)
for (const lineTuple of lines) {
const [lineStart, line] = lineTuple
let index = 0
while (index < line.length) {
const code = line.charCodeAt(index)
// Opening bracket.
if (code === 91 /* `[` */) {
ranges.push([ancestors, [lineStart + index]])
index++
}
// Skip escaped brackets.
else if (code === 92 /* `\` */) {
const next = line.charCodeAt(index + 1)
index++
if (next === 91 /* `[` */ || next === 93 /* `]` */) {
index++
}
}
// Close bracket.
else if (code === 93 /* `]` */) {
const bracketInfo = ranges.at(-1)
// No opening, ignore.
if (!bracketInfo) {
index++
}
// `][`.
else if (
line.charCodeAt(index + 1) === 91 /* `[` */ &&
// That would be the end of a reference already.
bracketInfo[1].length !== 3
) {
index++
bracketInfo[1].push(lineStart + index, lineStart + index)
index++
}
// `]` with earlier `[`.
else {
index++
bracketInfo[1].push(lineStart + index)
handleRange(bracketInfo)
ranges.pop()
}
}
// Anything else.
else {
index++
}
}
}
}
/**
* @param {[ancestors: Array<Nodes>, brackets: Array<number>]} bracketRange
* Info.
* @returns {undefined}
* Nothing.
*/
function handleRange(bracketRange) {
const [ancestors, range] = bracketRange
// `[`.
if (range.length === 1) return
// `[x][`.
if (range.length === 3) range.length = 2
// No need to warn for just `[]`.
if (range.length === 2 && range[0] + 2 === range[1]) return
const label =
value.charCodeAt(range[0] - 1) === 33 /* `!` */
? 'image'
: value.charCodeAt(range[0] + 1) === 94 /* `^` */
? 'footnote'
: 'link'
const offset = range.length === 4 && range[2] + 2 !== range[3] ? 2 : 0
let id = normalizeIdentifier(
collapseWhiteSpace(
value.slice(range[0 + offset] + 1, range[1 + offset] - 1),
{style: 'html', trim: true}
)
)
let defined = definitionIdentifiers
if (label === 'footnote') {
// Footnotes can’t have spaces.
/* c8 ignore next -- bit superfluous to test. */
if (id.includes(' ')) return
defined = footnoteDefinitionIdentifiers
// Drop the `^`.
id = id.slice(1)
}
if (
(allowShortcutLink && range.length === 2) ||
defined.has(id) ||
strings.has(id) ||
regexes.some(function (regex) {
return regex.test(id)
})
) {
return
}
const start = toPoint(range[0])
const end = toPoint(range[range.length - 1])
if (end && start) {
file.message(
'Unexpected reference to undefined definition, expected corresponding definition (`' +
id.toLowerCase() +
'`) for ' +
(label === 'image' ? 'an' : 'a') +
' ' +
label +
' or escaped opening bracket (`\\[`) for regular text',
{
ancestors,
place: {start, end}
}
)
}
}
}
)
export default remarkLintNoUndefinedReferences