UNPKG

@alauda/doom

Version:

Doctor Doom making docs.

135 lines (134 loc) 5.19 kB
import fs from 'node:fs'; import path from 'node:path'; import { isExternalUrl, parseUrl } from '@rspress/shared'; import { extractTextAndId } from '@rspress/shared/node-utils'; import { lintRule } from 'unified-lint-rule'; import { visit } from 'unist-util-visit'; import { visitParents } from 'unist-util-visit-parents'; import { isDoc } from "../cli/helpers.js"; import { mdProcessor, mdxProcessor } from "../plugins/index.js"; import { getConfig } from "./utils.js"; const anchorsCache = new Map(); const astCache = new Map(); const getAnchors = (filepath) => { if (anchorsCache.has(filepath)) { return anchorsCache.get(filepath); } const anchors = new Set(); let ast = astCache.get(filepath); if (!ast) { const processor = filepath.endsWith('.mdx') ? mdxProcessor : mdProcessor; ast = processor.parse(fs.readFileSync(filepath, 'utf-8')); astCache.set(filepath, ast); } visit(ast, (node) => { if (node.type === 'heading') { visit(node, 'text', (text) => { const [, id] = extractTextAndId(text.value); if (id) { anchors.add(id); } }); } else if (node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') { for (const attr of node.attributes) { if (attr.type === 'mdxJsxAttribute' && attr.name === 'id') { if (typeof attr.value === 'string') { anchors.add(attr.value); } break; } } } else if (node.type === 'html') { const matched = node.value.match( // eslint-disable-next-line regexp/no-super-linear-backtracking /<[a-z]+(?:\s+[^>]*?)?\sid=(["'])([\s\S]*?)\1/iu); if (matched) { anchors.add(matched[2]); } } }); anchorsCache.set(filepath, anchors); return anchors; }; export const noUnmatchedAnchor = lintRule('doom-lint:no-unmatched-anchor', async (root, vfile) => { const { config } = await getConfig(); const filepath = vfile.path; const dirpath = path.dirname(filepath); const configRoot = config.root; const configLang = config.lang; const relativePath = path.relative(configRoot, filepath); // Ignore files outside the root directory if (relativePath.startsWith('..')) { return; } astCache.set(filepath, root); visitParents(root, (node, parents) => { let url; if (node.type === 'link' || node.type === 'definition') { url = node.url; } else if ((node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') && node.name === 'a') { for (const attr of node.attributes) { if (attr.type === 'mdxJsxAttribute' && attr.name === 'href') { if (typeof attr.value === 'string') { url = attr.value; } break; } } } else if (node.type === 'html') { const matched = node.value.match( // eslint-disable-next-line regexp/no-super-linear-backtracking /<a\s+(?:[^>]*?\s)?href=(["'])([\s\S]*?)\1/iu); if (matched) { url = matched[2]; } } if (!url || isExternalUrl(url) || !url.includes('#')) { return; } const { url: parsedUrl, hash } = parseUrl(url); let refFilepath = filepath; if (parsedUrl.startsWith('/')) { refFilepath = path.resolve(configRoot, configLang + parsedUrl); } else if (parsedUrl) { refFilepath = path.resolve(dirpath, parsedUrl); } let ext = path.extname(refFilepath); if (ext === '.html') { refFilepath = refFilepath.slice(0, -ext.length); ext = ''; } if (!ext) { for (const ext of ['.md', '.mdx']) { if (fs.existsSync(refFilepath + ext)) { refFilepath += ext; break; } } } if (!isDoc(refFilepath)) { return; } // If the referenced file does not exist, we ignore it here and let the `check-dead-links` rule handle it if (filepath !== refFilepath && !fs.existsSync(refFilepath)) { return; } const relativeRefPath = path.relative(configRoot, refFilepath); // We ignore API docs because their anchors are generated dynamically and may not be present in the source files if (relativeRefPath.startsWith(`${configLang}/apis/`)) { return; } const anchors = getAnchors(refFilepath); if (!anchors.has(hash)) { vfile.message(`Unmatched anchor \`${hash}\` in link \`${url}\`, make sure the target has the correct id with \`{#${hash}}\` in heading or \`<a id="${hash}"></a>\` element.`, { ancestors: [...parents, node], place: node.position }); } }); });