diff --git a/bin/cli.mjs b/bin/cli.mjs index 27c3d069..0b3bec64 100755 --- a/bin/cli.mjs +++ b/bin/cli.mjs @@ -113,7 +113,7 @@ const { const linter = createLinter(lintDryRun, disableRule); const { loadFiles } = createMarkdownLoader(); -const { parseApiDocs } = createMarkdownParser(); +const { parseApiDocs } = createMarkdownParser(linter); const apiDocFiles = await loadFiles(input, ignore); @@ -124,9 +124,6 @@ const { runGenerators } = createGenerator(parsedApiDocs); // Retrieves Node.js release metadata from a given Node.js version and CHANGELOG.md file const { getAllMajors } = createNodeReleases(changelog); -// Runs the Linter on the parsed API docs -linter.lintAll(parsedApiDocs); - if (target) { await runGenerators({ // A list of target modes for the API docs parser diff --git a/src/linter/engine.mjs b/src/linter/engine.mjs index 9ae164c7..7daff2ca 100644 --- a/src/linter/engine.mjs +++ b/src/linter/engine.mjs @@ -1,29 +1,30 @@ 'use strict'; /** - * Creates a linter engine instance to validate ApiDocMetadataEntry entries + * Creates a linter engine instance to validate mdast trees. * * @param {import('./types').LintRule[]} rules Lint rules to validate the entries against */ const createLinterEngine = rules => { /** - * Validates an array of ApiDocMetadataEntry entries against all defined rules + * Validates an array of mdast trees against all defined rules * - * @param {ApiDocMetadataEntry[]} entries + * @param {import('vfile').VFile} file + * @param {import('mdast').Root[]} tree * @returns {import('./types').LintIssue[]} */ - const lintAll = entries => { + const lint = (file, tree) => { const issues = []; for (const rule of rules) { - issues.push(...rule(entries)); + issues.push(...rule(file, tree)); } return issues; }; return { - lintAll, + lint, }; }; diff --git a/src/linter/index.mjs b/src/linter/index.mjs index 451d6ee3..318ebc74 100644 --- a/src/linter/index.mjs +++ b/src/linter/index.mjs @@ -5,10 +5,11 @@ import reporters from './reporters/index.mjs'; import rules from './rules/index.mjs'; /** - * Creates a linter instance to validate ApiDocMetadataEntry entries + * Creates a linter instance to validate mdast trees * * @param {boolean} dryRun Whether to run the engine in dry-run mode * @param {string[]} disabledRules List of disabled rules names + * @returns {import('./types').Linter} */ const createLinter = (dryRun, disabledRules) => { /** @@ -34,10 +35,12 @@ const createLinter = (dryRun, disabledRules) => { /** * Lints all entries using the linter engine * - * @param entries + * @param {import('vfile').VFile} file + * @param {import('mdast').Root} tree + * @returns {void} */ - const lintAll = entries => { - issues.push(...engine.lintAll(entries)); + const lint = (file, tree) => { + issues.push(...engine.lint(file, tree)); }; /** @@ -68,7 +71,7 @@ const createLinter = (dryRun, disabledRules) => { }; return { - lintAll, + lint, report, hasError, }; diff --git a/src/linter/rules/index.mjs b/src/linter/rules/index.mjs index eb0a6b0e..2198318f 100644 --- a/src/linter/rules/index.mjs +++ b/src/linter/rules/index.mjs @@ -1,16 +1,16 @@ 'use strict'; -import { duplicateStabilityNodes } from './duplicate-stability-nodes.mjs'; +// import { duplicateStabilityNodes } from './duplicate-stability-nodes.mjs'; import { invalidChangeVersion } from './invalid-change-version.mjs'; -import { missingChangeVersion } from './missing-change-version.mjs'; +// import { missingChangeVersion } from './missing-change-version.mjs'; import { missingIntroducedIn } from './missing-introduced-in.mjs'; /** * @type {Record} */ export default { - 'duplicate-stability-nodes': duplicateStabilityNodes, + // 'duplicate-stability-nodes': duplicateStabilityNodes, 'invalid-change-version': invalidChangeVersion, - 'missing-change-version': missingChangeVersion, + // 'missing-change-version': missingChangeVersion, 'missing-introduced-in': missingIntroducedIn, }; diff --git a/src/linter/rules/invalid-change-version.mjs b/src/linter/rules/invalid-change-version.mjs index 465e28a3..8e1e5f77 100644 --- a/src/linter/rules/invalid-change-version.mjs +++ b/src/linter/rules/invalid-change-version.mjs @@ -1,42 +1,152 @@ +import { visit } from 'unist-util-visit'; +import createQueries from '../../utils/queries/index.mjs'; +import { + isSeq, + parseDocument, + isMap, + isPair, + isScalar, + LineCounter, +} from 'yaml'; +import { + extractYamlContent, + normalizeYamlSyntax, +} from '../../utils/parser/index.mjs'; import { LINT_MESSAGES } from '../constants.mjs'; import { valid } from 'semver'; /** * Checks if any change version is invalid * - * @param {ApiDocMetadataEntry[]} entries + * @param {{value: string, location: import('../types').LintIssueLocation}[]} versions * @returns {Array} */ -export const invalidChangeVersion = entries => { +const getInvalidVersions = versions => { const issues = []; - for (const entry of entries) { - if (entry.changes.length === 0) continue; - - const allVersions = entry.changes - .filter(change => change.version) - .flatMap(change => - Array.isArray(change.version) ? change.version : [change.version] - ); - - const invalidVersions = allVersions.filter( - version => valid(version) === null - ); - - issues.push( - ...invalidVersions.map(version => ({ - level: 'warn', - message: LINT_MESSAGES.invalidChangeVersion.replace( - '{{version}}', - version - ), + const invalidVersions = versions.filter( + version => valid(version.value) === null + ); + + issues.push( + ...invalidVersions.map(version => ({ + level: 'warn', + message: LINT_MESSAGES.invalidChangeVersion.replace( + '{{version}}', + version.value + ), + location: version.location ?? undefined, + })) + ); + + return issues; +}; + +/** + * Normalizes a version + * + * @param {import('vfile').VFile} file + * @param {Pair} node + * @param {LineCounter} lineCounter + * @param {number} startLine + */ +const extractNodeVersions = (file, node, lineCounter, startLine) => { + if (isSeq(node.value)) { + return node.value.items.map(item => ({ + value: item.value, + location: { + path: `doc/api/${file.basename}`, + position: { + start: { + line: lineCounter.linePos(item.range[0]).line + startLine, + }, + end: { + line: lineCounter.linePos(item.range[1]).line + startLine, + }, + }, + }, + })); + } + + if (isScalar(node.value)) { + const offset = node.value.range[0]; + + return [ + { + value: node.value.value, location: { - path: entry.api_doc_source, - position: entry.yaml_position, + path: `doc/api/${file.basename}`, + position: { + start: { + line: lineCounter.linePos(offset).line + startLine, + }, + end: { + line: lineCounter.linePos(node.value.range[1]).line + startLine, + }, + }, }, - })) - ); + }, + ]; } + throw new Error('Change item must be a seq or scalar'); +}; + +/** + * Checks if any change version is invalid + * + * @param {import('vfile').VFile} file + * @param {import('mdast').Root} tree + * @returns {Array} + */ +export const invalidChangeVersion = (file, tree) => { + const issues = []; + + visit(tree, createQueries.UNIST.isYamlNode, node => { + const lineCounter = new LineCounter(); + + const normalizedYaml = normalizeYamlSyntax(extractYamlContent(node)); + const doc = parseDocument(normalizedYaml, { + lineCounter, + }); + + const changes = doc.get('changes'); + + if (!changes) { + return; + } + + if (!isSeq(changes)) { + throw new Error('Changes must be an seq'); + } + + changes.items.forEach(changeNode => { + if (isMap(changeNode) === false) { + throw new Error('Change item must be a map'); + } + + changeNode.items.forEach(changeItem => { + if (!isPair(changeItem)) { + throw new Error('Change item must be a pair'); + } + + if (changeItem.key.value !== 'version') { + return; + } + + return issues.push( + ...getInvalidVersions( + extractNodeVersions( + file, + changeItem, + lineCounter, + node.position.start.line + ) + ) + ); + }); + }); + }); + return issues; }; diff --git a/src/linter/rules/missing-introduced-in.mjs b/src/linter/rules/missing-introduced-in.mjs index 785bc6b1..4ef95f62 100644 --- a/src/linter/rules/missing-introduced-in.mjs +++ b/src/linter/rules/missing-introduced-in.mjs @@ -1,26 +1,30 @@ import { LINT_MESSAGES } from '../constants.mjs'; /** - * Checks if `introduced_in` field is missing + * Checks if `introduced_in` node is missing * - * @param {ApiDocMetadataEntry[]} entries + * @param {import('vfile').VFile} file + * @param {import('mdast').Root} tree * @returns {Array} */ -export const missingIntroducedIn = entries => { - const issues = []; +export const missingIntroducedIn = (file, tree) => { + const regex = //; - for (const entry of entries) { - // Early continue if not a top-level heading or if introduced_in exists - if (entry.heading.depth !== 1 || entry.introduced_in) continue; + const introduced_in = tree.children.find( + node => node.type === 'html' && regex.test(node.value) + ); - issues.push({ - level: 'info', - message: LINT_MESSAGES.missingIntroducedIn, - location: { - path: entry.api_doc_source, + if (!introduced_in) { + return [ + { + level: 'info', + message: LINT_MESSAGES.missingIntroducedIn, + location: { + path: file.path, + }, }, - }); + ]; } - return issues; + return []; }; diff --git a/src/linter/types.d.ts b/src/linter/types.d.ts index cd236513..a14cd669 100644 --- a/src/linter/types.d.ts +++ b/src/linter/types.d.ts @@ -1,4 +1,12 @@ +import { Root } from 'mdast'; import { Position } from 'unist'; +import { VFile } from 'vfile'; + +export interface Linter { + lint: (tree: Root) => void; + report: (reporterName: keyof typeof reporters) => void; + hasError: () => boolean; +} export type IssueLevel = 'info' | 'warn' | 'error'; @@ -13,6 +21,6 @@ export interface LintIssue { location: LintIssueLocation; } -type LintRule = (input: ApiDocMetadataEntry[]) => LintIssue[]; +type LintRule = (file: VFile, tree: Root[]) => LintIssue[]; export type Reporter = (msg: LintIssue) => void; diff --git a/src/parsers/markdown.mjs b/src/parsers/markdown.mjs index 3617baca..57abb57f 100644 --- a/src/parsers/markdown.mjs +++ b/src/parsers/markdown.mjs @@ -15,9 +15,9 @@ import { createNodeSlugger } from '../utils/slugger/index.mjs'; /** * Creates an API doc parser for a given Markdown API doc file * - * @param {import('./linter/index.mjs').Linter | undefined} linter + * @param {import('../linter/types').Linter} linter */ -const createParser = () => { +const createParser = linter => { // Creates an instance of the Remark processor with GFM support // which is used for stringifying the AST tree back to Markdown const remarkProcessor = getRemark(); @@ -63,6 +63,8 @@ const createParser = () => { // Parses the API doc into an AST tree using `unified` and `remark` const apiDocTree = remarkProcessor.parse(resolvedApiDoc); + linter.lint(resolvedApiDoc, apiDocTree); + // Get all Markdown Footnote definitions from the tree const markdownDefinitions = selectAll('definition', apiDocTree); @@ -137,8 +139,8 @@ const createParser = () => { // our YAML metadata structure, it transforms into YAML metadata // and then apply the YAML Metadata to the current Metadata entry visit(subTree, createQueries.UNIST.isYamlNode, node => { - // TODO: Is there always only one YAML node? apiEntryMetadata.setYamlPosition(node.position); + addYAMLMetadata(node, apiEntryMetadata); }); diff --git a/src/utils/parser/index.mjs b/src/utils/parser/index.mjs index a58ae14f..03e8e94e 100644 --- a/src/utils/parser/index.mjs +++ b/src/utils/parser/index.mjs @@ -11,6 +11,7 @@ import { DOC_TYPES_MAPPING_OTHER, DOC_TYPES_MAPPING_PRIMITIVES, } from './constants.mjs'; +import createQueries from '../queries/index.mjs'; /** * This method replaces plain text Types within the Markdown content into Markdown links @@ -82,6 +83,36 @@ export const transformTypeToReferenceLink = type => { return markdownLinks || type; }; +/** + * Extracts raw YAML content from a node + * + * @param {import('mdast').Html} node A HTML node containing the YAML content + * @returns {string} The extracted raw YAML content + */ +export const extractYamlContent = node => { + return node.value.replace( + createQueries.QUERIES.yamlInnerContent, + // Either capture a YAML multinline block, or a simple single-line YAML block + (_, simple, yaml) => simple || yaml + ); +}; + +/** + * Normalizes YAML syntax by fixing some non-cool formatted properties of the + * docs schema + * + * @param {string} yamlContent The raw YAML content to normalize + * @returns {string} The normalized YAML content + */ +export const normalizeYamlSyntax = yamlContent => { + return yamlContent + .replace('introduced_in=', 'introduced_in: ') + .replace('source_link=', 'source_link: ') + .replace('type=', 'type: ') + .replace('name=', 'name: ') + .replace(/^\n+|\n+$/g, ''); // Remove initial and final line break +}; + /** * Parses Markdown YAML source into a JavaScript object containing all the metadata * (this is forwarded to the parser so it knows what to do with said metadata) @@ -90,17 +121,12 @@ export const transformTypeToReferenceLink = type => { * @returns {ApiDocRawMetadataEntry} The parsed YAML metadata */ export const parseYAMLIntoMetadata = yamlString => { - const replacedContent = yamlString - // special validations for some non-cool formatted properties of the docs schema - .replace('introduced_in=', 'introduced_in: ') - .replace('source_link=', 'source_link: ') - .replace('type=', 'type: ') - .replace('name=', 'name: '); + const normalizedYaml = normalizeYamlSyntax(yamlString); // Ensures that the parsed YAML is an object, because even if it is not // i.e. a plain string or an array, it will simply not result into anything /** @type {ApiDocRawMetadataEntry | string} */ - let parsedYaml = yaml.parse(replacedContent); + let parsedYaml = yaml.parse(normalizedYaml); // Ensure that only Objects get parsed on Object.keys(), since some `