UNPKG

eslint-plugin-mdx

Version:
721 lines (700 loc) 20.5 kB
'use strict'; var eslintMdx = require('eslint-mdx'); var node_module = require('node:module'); var fs = require('node:fs'); var path = require('node:path'); var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null; function _interopNamespaceDefault(e) { var n = Object.create(null); if (e) { Object.keys(e).forEach(function (k) { if (k !== 'default') { var d = Object.getOwnPropertyDescriptor(e, k); Object.defineProperty(n, k, d.get ? d : { enumerable: true, get: function () { return e[k]; } }); } }); } n.default = e; return Object.freeze(n); } var eslintMdx__namespace = /*#__PURE__*/_interopNamespaceDefault(eslintMdx); var mdx = /*#__PURE__*/Object.freeze({ __proto__: null, get DEFAULT_LANGUAGE_MAPPER () { return DEFAULT_LANGUAGE_MAPPER; }, get base () { return base; }, get cjsRequire () { return cjsRequire; }, get codeBlocks () { return codeBlocks; }, get configs () { return configs; }, get createRemarkProcessor () { return createRemarkProcessor; }, get flat () { return flat; }, get flatCodeBlocks () { return flatCodeBlocks; }, get getGlobals () { return getGlobals; }, get getShortLang () { return getShortLang; }, get meta () { return meta; }, get overrides () { return overrides$1; }, get processorOptions () { return processorOptions; }, get processors () { return processors; }, get recommended () { return recommended; }, get remark () { return remark; }, get rules () { return rules; } }); const base = { parser: "eslint-mdx", parserOptions: { sourceType: "module", ecmaVersion: "latest", ecmaFeatures: { jsx: true } }, plugins: ["mdx"], processor: "mdx/remark", rules: { "mdx/remark": "warn", "no-unused-expressions": "error" } }; const codeBlocks = { parserOptions: { ecmaFeatures: { // Adding a "use strict" directive at the top of // every code block is tedious and distracting, so // opt into strict mode parsing without the // directive. impliedStrict: true } }, rules: { // The Markdown parser automatically trims trailing // newlines from code blocks. "eol-last": "off", // In code snippets and examples, these rules are often // counterproductive to clarity and brevity. "no-undef": "off", "no-unused-expressions": "off", "no-unused-vars": "off", "@typescript-eslint/no-unused-vars": "off", "padded-blocks": "off", // Adding a "use strict" directive at the top of every // code block is tedious and distracting. The config // opts into strict mode parsing without the directive. strict: "off", // The processor will not receive a Unicode Byte Order // Mark from the Markdown parser. "unicode-bom": "off" } }; const getGlobals = (sources, initialGlobals = {}) => (Array.isArray(sources) ? ( // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion sources ) : Object.keys(sources)).reduce( (globals, source) => Object.assign(globals, { [source]: false }), initialGlobals ); const importMetaUrl = (typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href)); const cjsRequire = importMetaUrl ? node_module.createRequire(importMetaUrl) : require; const pkg = cjsRequire("../package.json"); const meta = { name: pkg.name, version: pkg.version }; const DEFAULT_LANGUAGE_MAPPER = { ecmascript: "js", javascript: "js", javascriptreact: "jsx", typescript: "ts", typescriptreact: "tsx", markdown: "md", markdownjsx: "mdx", markdownreact: "mdx", mdown: "md", mkdn: "md" }; function getShortLang(filename, languageMapper) { const language = filename.split(".").at(-1); if (languageMapper === false) { return language; } languageMapper = languageMapper ? { ...DEFAULT_LANGUAGE_MAPPER, ...languageMapper } : DEFAULT_LANGUAGE_MAPPER; const mapped = languageMapper[language]; if (mapped) { return mapped; } const lang = language.toLowerCase(); return languageMapper[lang] || lang; } const UNSATISFIABLE_RULES = /* @__PURE__ */ new Set([ "eol-last", // The Markdown parser strips trailing newlines in code fences "unicode-bom" // Code blocks will begin in the middle of Markdown files ]); const SUPPORTS_AUTOFIX = true; const BOM = "\uFEFF"; const blocksCache = /* @__PURE__ */ new Map(); function traverse(node, callbacks) { if (callbacks[node.type]) { callbacks[node.type](node); } else { callbacks["*"](); } const parent = node; if ("children" in parent) { for (const child of parent.children) { traverse(child, callbacks); } } } const COMMENTS = [ [ /^<!-{2,}/, // eslint-disable-next-line sonarjs/slow-regex /-{2,}>$/ ], [ /^\/\*+/, // eslint-disable-next-line sonarjs/slow-regex /\*+\/$/ ] ]; const eslintCommentRegex = /^(?:eslint\b|global\s)/u; function getComment(value, isMdx = false) { const [commentStart, commentEnd] = COMMENTS[+isMdx]; const commentStartMatched = commentStart.exec(value); const commentEndMatched = commentEnd.exec(value); if (commentStartMatched == null || commentEndMatched == null) { return ""; } const comment = value.slice(commentStartMatched[0].length, -commentEndMatched[0].length).trim(); if (!eslintCommentRegex.test(comment)) { return ""; } return comment; } const leadingWhitespaceRegex = /^[>\s]*/u; function getBeginningOfLineOffset(node) { return node.position.start.offset - node.position.start.column + 1; } function getIndentText(text, node) { return leadingWhitespaceRegex.exec( text.slice(getBeginningOfLineOffset(node)) )[0]; } function getBlockRangeMap(text, node, comments) { const startOffset = getBeginningOfLineOffset(node); const code = text.slice(startOffset, node.position.end.offset); const lines = code.split("\n"); const baseIndent = getIndentText(text, node).length; const commentLength = comments.reduce( (len, comment) => len + comment.length + 1, 0 ); const rangeMap = [ { indent: baseIndent, js: 0, md: 0 } ]; let jsOffset = commentLength; let mdOffset = startOffset + lines[0].length + 1; for (let i = 0; i + 1 < lines.length; i++) { const line = lines[i + 1]; const leadingWhitespaceLength = leadingWhitespaceRegex.exec(line)[0].length; const trimLength = Math.min(baseIndent, leadingWhitespaceLength); rangeMap.push({ indent: trimLength, js: jsOffset, // Advance `trimLength` character from the beginning of the Markdown // line to the beginning of the equivalent JS line, then compute the // delta. md: mdOffset + trimLength - jsOffset }); mdOffset += line.length + 1; jsOffset += line.length - trimLength + 1; } return rangeMap; } const codeBlockFileNameRegex = /filename=(?<quote>["'])(?<filename>.*?)\k<quote>/u; function fileNameFromMeta(block) { return codeBlockFileNameRegex.exec(block.meta)?.groups.filename.replaceAll(/\s+/gu, "_"); } function getOnDiskFilepath(filepath) { let fallback; try { if (!fs.statSync(filepath, { throwIfNoEntry: false })) { fallback = true; } } catch (err) { if (err.code === "ENOTDIR") { fallback = true; } } return fallback ? getOnDiskFilepath(path.dirname(filepath)) : filepath; } function preprocess(sourceText, filename, syncOptions) { const text = sourceText.startsWith(BOM) ? sourceText.slice(1) : sourceText; const { root } = eslintMdx.performSyncWork({ filePath: getOnDiskFilepath(filename), code: text, // FIXME: how to read `extensions` and `markdownExtensions` parser options? isMdx: filename.endsWith(".mdx"), ...syncOptions }); const blocks = []; blocksCache.set(filename, blocks); let allComments = []; function mdxExpression(node) { const comment = getComment(node.value, true); if (comment) { allComments.push(comment); } else { allComments = []; } } traverse(root, { "*"() { allComments = []; }, /** * Visit a code node. * * @param node The visited node. */ code(node) { if (!node.lang) { return; } const comments = []; for (const comment of allComments) { if (comment === "eslint-skip") { allComments = []; return; } comments.push(`/* ${comment} */`); } allComments = []; blocks.push({ ...node, baseIndentText: getIndentText(text, node), comments, rangeMap: getBlockRangeMap(text, node, comments) }); }, /** * Visit an HTML node. * * @param node The visited node. */ html(node) { const comment = getComment(node.value); if (comment) { allComments.push(comment); } else { allComments = []; } }, mdxFlowExpression: mdxExpression, mdxTextExpression: mdxExpression }); return blocks.map((block, index) => { const [language] = block.lang.trim().split(" "); return { filename: fileNameFromMeta(block) ?? `${index}.${language}`, text: [...block.comments, block.value, ""].join("\n") }; }); } function adjustFix(block, fix) { return { range: fix.range.map((range) => { let i = 1; while (i < block.rangeMap.length && block.rangeMap[i].js <= range) { i++; } return range + block.rangeMap[i - 1].md; }), text: fix.text.replaceAll("\n", ` ${block.baseIndentText}`) }; } function adjustBlock(block) { const leadingCommentLines = block.comments.reduce( (count, comment) => count + comment.split("\n").length, 0 ); const blockStart = block.position.start.line; return function adjustMessage(message) { if (!Number.isInteger(message.line)) { return { ...message, line: blockStart, column: block.position.start.column }; } const lineInCode = message.line - leadingCommentLines; if (lineInCode < 1 || lineInCode >= block.rangeMap.length) { return null; } const out = { line: lineInCode + blockStart, column: message.column + block.rangeMap[lineInCode].indent }; if (Number.isInteger(message.endLine)) { out.endLine = message.endLine - leadingCommentLines + blockStart; } if (Array.isArray(message.suggestions)) { out.suggestions = message.suggestions.map((suggestion) => ({ ...suggestion, fix: adjustFix(block, suggestion.fix) })); } const adjustedFix = {}; if (message.fix) { adjustedFix.fix = adjustFix(block, message.fix); } return { ...message, ...out, ...adjustedFix }; }; } function excludeUnsatisfiableRules(message) { return message && !UNSATISFIABLE_RULES.has(message.ruleId); } function postprocess(messages, filename) { const blocks = blocksCache.get(filename); blocksCache.delete(filename); return messages.flatMap((group, i) => { const adjust = adjustBlock(blocks[i]); return group.map(adjust).filter(excludeUnsatisfiableRules); }); } const markdownProcessor = { meta: { name: "mdx/markdown", version: meta.version }, preprocess, postprocess, supportsAutofix: SUPPORTS_AUTOFIX }; const processorOptions = {}; const linterPath = Object.keys(cjsRequire.cache).find( (path) => /([/\\])eslint\1lib(?:\1linter){2}\.js$/.test(path) ); const ESLinter = cjsRequire(linterPath || "eslint").Linter; const { verify } = ESLinter.prototype; ESLinter.prototype.verify = function(code, config, options) { const settings = (config.extractConfig?.( // eslint-disable-next-line unicorn-x/no-typeof-undefined typeof options === "undefined" || typeof options === "string" ? options : options.filename ) ?? config).settings ?? {}; processorOptions.lintCodeBlocks = settings["mdx/code-blocks"]; processorOptions.languageMapper = settings["mdx/language-mapper"]; processorOptions.ignoreRemarkConfig = settings["mdx/ignore-remark-config"]; processorOptions.remarkConfigPath = settings["mdx/remark-config-path"]; return verify.call(this, code, config, options); }; const createRemarkProcessor = ({ languageMapper, lintCodeBlocks, ...syncOptions } = processorOptions) => ({ meta: { name: "mdx/remark", version: meta.version }, supportsAutofix: true, preprocess(text, filename) { if (!lintCodeBlocks) { return [text]; } return [ text, ...markdownProcessor.preprocess(text, filename, syncOptions).map(({ text: text2, filename: filename2 }) => ({ text: text2, filename: filename2.slice(0, filename2.lastIndexOf(".")) + "." + getShortLang(filename2, languageMapper) })) ]; }, postprocess([mdxMessages, ...markdownMessages], filename) { return [ ...mdxMessages, ...markdownProcessor.postprocess(markdownMessages, filename) ].sort((a, b) => a.line - b.line || a.column - b.column).map((lintMessage) => { const { message, ruleId: eslintRuleId, severity: eslintSeverity } = lintMessage; if (eslintRuleId !== "mdx/remark") { return lintMessage; } const { source, ruleId, reason, severity } = JSON.parse( message ); return { ...lintMessage, ruleId: `${source}-${ruleId}`, message: reason, severity: Math.max( eslintSeverity, severity ) }; }); } }); const remark$1 = createRemarkProcessor(); const flat = { name: "mdx/flat", files: ["**/*.{md,mdx}"], languageOptions: { parser: eslintMdx__namespace, parserOptions: { ecmaFeatures: { jsx: true } }, globals: { React: false } }, plugins: { mdx }, processor: remark$1, rules: { "mdx/remark": "warn", "no-unused-expressions": "error", "react/react-in-jsx-scope": "off" } }; const { parserOptions, ...restConfig } = codeBlocks; const flatCodeBlocks = { name: "mdx/flat-code-blocks", files: ["**/*.{md,mdx}/**"], languageOptions: { parserOptions }, ...restConfig }; let isReactPluginAvailable = false; try { cjsRequire.resolve("eslint-plugin-react"); isReactPluginAvailable = true; } catch { } const overrides$1 = { ...base, globals: { React: false }, plugins: eslintMdx.arrayify( base.plugins, /* istanbul ignore next */ isReactPluginAvailable ? "react" : null ), rules: { "react/jsx-no-undef": ( /* istanbul ignore next */ isReactPluginAvailable ? [ 2, { allowGlobals: true } ] : 0 ), "react/react-in-jsx-scope": 0 } }; const overrides = [ { files: ["*.md", "*.mdx"], extends: "plugin:mdx/overrides", ...base }, { files: "**/*.{md,mdx}/**", extends: "plugin:mdx/code-blocks" } ]; const recommended = { overrides }; const addPrettierRules = () => { try { cjsRequire.resolve("prettier"); const { meta } = cjsRequire("eslint-plugin-prettier"); const version = meta?.version || ""; const [major, minor, patch] = version.split("."); if ( /* istanbul ignore next */ +major > 5 || +major === 5 && (+minor > 1 || +minor === 1 && Number.parseInt(patch) >= 2) ) { return; } overrides.push( { files: "*.md", rules: { "prettier/prettier": [ "error", { parser: "markdown" } ] } }, { files: "*.mdx", rules: { "prettier/prettier": [ "error", { parser: "mdx" } ] } } ); } catch { } }; addPrettierRules(); const configs = { base, "code-blocks": codeBlocks, codeBlocks, flat, flatCodeBlocks, overrides: overrides$1, recommended }; const processors = { remark: remark$1 }; const remark = { meta: { type: "layout", docs: { description: "Linter integration with remark plugins", // eslint-disable-next-line @typescript-eslint/ban-ts-comment, @typescript-eslint/prefer-ts-expect-error // @ts-ignore -- ESLint v10 removed category: "Stylistic Issues", recommended: true }, fixable: "code" }, create(context) { const filename = context.filename ?? // eslint-disable-next-line @typescript-eslint/ban-ts-comment, @typescript-eslint/prefer-ts-expect-error // @ts-ignore -- ESLint v10 removed // eslint-disable-next-line @typescript-eslint/no-unsafe-call /* istanbul ignore next */ context.getFilename(); const extname = path.extname(filename); const sourceCode = context.sourceCode ?? // eslint-disable-next-line @typescript-eslint/ban-ts-comment, @typescript-eslint/prefer-ts-expect-error // @ts-ignore -- ESLint v10 removed // eslint-disable-next-line @typescript-eslint/no-unsafe-call /* istanbul ignore next */ context.getSourceCode(); const { extensions, markdownExtensions, ignoreRemarkConfig, remarkConfigPath } = { // eslint-disable-next-line @typescript-eslint/ban-ts-comment, @typescript-eslint/prefer-ts-expect-error // @ts-ignore -- ESLint v10 removed ...context.parserOptions, ...context.languageOptions?.parserOptions }; const isMdx = [...eslintMdx.DEFAULT_EXTENSIONS, ...extensions || []].includes( extname ); const isMarkdown = [ ...eslintMdx.MARKDOWN_EXTENSIONS, ...markdownExtensions || [] ].includes(extname); return { Program(node) { if (!isMdx && !isMarkdown) { return; } const sourceText = sourceCode.getText(node); const { messages, content: fixedText } = eslintMdx.performSyncWork({ filePath: eslintMdx.getPhysicalFilename(filename), code: sourceText, cwd: context.cwd ?? // eslint-disable-next-line @typescript-eslint/ban-ts-comment, @typescript-eslint/prefer-ts-expect-error // @ts-ignore -- ESLint v10 removed // eslint-disable-next-line @typescript-eslint/no-unsafe-call -- ESLint v10 removed /* istanbul ignore next */ context.getCwd(), isMdx, process: true, ignoreRemarkConfig, remarkConfigPath }); let fixed = 0; for (const { source, reason, ruleId, fatal, line, column, place } of messages) { const severity = fatal ? 2 : fatal == null ? 0 : 1; if (!severity) { continue; } const message = { reason, source, ruleId, severity }; const point = { line, // ! eslint ast column is 0-indexed, but unified is 1-indexed column: column - 1 }; context.report({ // related to https://github.com/eslint/eslint/issues/14198 message: JSON.stringify(message), loc: ( /* istanbul ignore next */ place && "start" in place ? { ...point, start: { ...place.start, column: place.start.column - 1 }, end: { ...place.end, column: place.end.column - 1 } } : point ), node, fix: fixedText == null || fixedText === sourceText ? null : () => fixed++ ? null : { range: [0, sourceText.length], text: fixedText } }); } } }; } }; const rules = { remark }; exports.DEFAULT_LANGUAGE_MAPPER = DEFAULT_LANGUAGE_MAPPER; exports.base = base; exports.cjsRequire = cjsRequire; exports.codeBlocks = codeBlocks; exports.configs = configs; exports.createRemarkProcessor = createRemarkProcessor; exports.flat = flat; exports.flatCodeBlocks = flatCodeBlocks; exports.getGlobals = getGlobals; exports.getShortLang = getShortLang; exports.meta = meta; exports.overrides = overrides$1; exports.processorOptions = processorOptions; exports.processors = processors; exports.recommended = recommended; exports.remark = remark; exports.rules = rules;