diff --git a/packages/lint/README.md b/packages/lint/README.md index 9be36ce5..acd3c40d 100644 --- a/packages/lint/README.md +++ b/packages/lint/README.md @@ -27,7 +27,7 @@ import annotate, { components } from "comark-annotate/html"; const diagnostics = await lint(markdown, { plugins: [annotate()], components }); ``` -`lint()` never throws and returns `{ rule, line, column?, message, fix, cause? }[]`. When parsing or a rule throws, `cause` holds the original exception. The CLI omits `cause` from JSON. Without `components`, unknown components aren't reported, and without `file`, links to other files aren't checked. `lintFiles(paths, options)` checks many files in one run, as the CLI does. +`lint()` never throws and returns `{ rule, severity, line, column?, message, fix, cause? }[]`, where `severity` is the rule's, `error` or `warn`. When parsing or a rule throws, `cause` holds the original exception. The CLI omits `cause` from JSON. Without `components`, unknown components aren't reported, and without `file`, links to other files aren't checked. `lintFiles(paths, options)` checks many files in one run, as the CLI does. For ESLint 9.15 or later, install `eslint` and `@eslint/markdown` and add the recommended config to `eslint.config.js`. It lints `.md` and `.mdc` files with every rule but `comark/dead-url`, each one configurable like any ESLint rule, and `settings.comark.config` points at a `lint-comark.config.mjs`: diff --git a/packages/lint/docs/getting-started.md b/packages/lint/docs/getting-started.md index 0a452315..28f40f95 100644 --- a/packages/lint/docs/getting-started.md +++ b/packages/lint/docs/getting-started.md @@ -61,7 +61,13 @@ import { lint } from "comark-linter"; import annotate, { components } from "comark-annotate/html"; const diagnostics = await lint(markdown, { plugins: [annotate()], components }); -// [{ rule: "comark/unclosed-block", line: 8, column: 1, message: "…", fix: "…" }] +// [{ rule: "comark/unclosed-block", severity: "warn", line: 8, column: 1, message: "…", fix: "…" }] +``` + +Each diagnostic carries its rule's `severity`, `error` or `warn`, the same one Vite Doctor and ESLint report, so a script can fail on errors alone: + +```ts +if (diagnostics.some((diagnostic) => diagnostic.severity === "error")) process.exitCode = 1; ``` `lint()` never throws. If a rule crashes on a document, that rule reports one diagnostic saying so, and the others still run. diff --git a/packages/lint/src/cli.ts b/packages/lint/src/cli.ts index 06cf5ee5..bfc062f0 100644 --- a/packages/lint/src/cli.ts +++ b/packages/lint/src/cli.ts @@ -66,8 +66,8 @@ const results: (Omit & { file: string })[] = []; for (const { file, text, diagnostics } of await lintFiles([...new Set(files)], options)) { if (text === undefined) fail(`can't read ${file}`); // Exceptions can contain cycles; the CLI keeps its results serializable. - for (const { rule, line, column, message, fix } of diagnostics) - results.push({ file, rule, line, column, message, fix }); + for (const { rule, severity, line, column, message, fix } of diagnostics) + results.push({ file, rule, severity, line, column, message, fix }); } if (values.format === "json") console.log(JSON.stringify(results, null, 2)); diff --git a/packages/lint/src/eslint.ts b/packages/lint/src/eslint.ts index 4411aab4..da92f442 100644 --- a/packages/lint/src/eslint.ts +++ b/packages/lint/src/eslint.ts @@ -18,7 +18,7 @@ export type Settings = { }; export type Request = { text: string; file?: string; root: string; config?: string; urls: boolean }; -export type Reply = { diagnostics: Omit[] } | { error: string }; +export type Reply = { diagnostics: Omit[] } | { error: string }; // SAFETY: built from every entry of the catalog, whose keys are exactly the rules. const ids = Object.keys(catalog) as Rule[]; @@ -61,7 +61,7 @@ function call(request: Request): Reply { } // Every rule reads one result per file, and `comark/dead-url` its own, since only it requests URLs. -const results = new WeakMap[]>>(); +const results = new WeakMap[]>>(); function diagnosticsOf(context: RuleModule.RuleContext, urls: boolean) { const { sourceCode } = context; diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 422dbcae..687a57ec 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -7,11 +7,12 @@ import { checkPending, type Context, type Files, + type Finding, type Link, type LinkOptions, type Pending, } from "./links.ts"; -import { catalog } from "./rules/index.ts"; +import { catalog, type RuleInfo } from "./rules/index.ts"; import { find, findAny, read, type Source } from "./source.ts"; import { closest, decode, listIds } from "./words.ts"; @@ -30,6 +31,8 @@ export const rules = Object.fromEntries( export type Diagnostic = { rule: Rule; + /** The rule's severity, as its Doctor page gives it: `error` when the document doesn't render as written. */ + severity: "error" | "warn"; /** 1-based, counting the frontmatter. */ line: number; /** 1-based, when the spot is known. Components inside a paragraph are found by searching its lines. */ @@ -115,10 +118,15 @@ export async function lintFiles( type Parsed = { document: MarkdownDocument; tokens: Token[]; offset: number }; -type Document = { file?: string; source: Source; found: Diagnostic[]; parsed?: Parsed }; +type Document = { file?: string; source: Source; found: Finding[]; parsed?: Parsed }; -const sorted = (found: Diagnostic[]) => - found.sort((a, b) => a.line - b.line || (a.column ?? 0) - (b.column ?? 0)); +const finished = (found: Finding[]): Diagnostic[] => + found + .sort((a, b) => a.line - b.line || (a.column ?? 0) - (b.column ?? 0)) + .map((finding) => ({ ...finding, severity: severityOf(finding.rule) })); + +const info: Record = catalog; +const severityOf = (rule: Rule) => info[rule].severity ?? "warn"; async function lintAll( inputs: readonly { path?: string; text?: string }[], @@ -164,7 +172,11 @@ async function lintAll( } return inputs.map(({ path }, index) => { const document = documents[index]; - return { path, text: document?.markdown, diagnostics: document ? sorted(document.found) : [] }; + return { + path, + text: document?.markdown, + diagnostics: document ? finished(document.found) : [], + }; }); } @@ -222,13 +234,13 @@ function createContext(options: LintOptions): Context & { async function prepare(markdown: string, options: LintOptions): Promise { const source = read(markdown); - const found: Diagnostic[] = []; + const found: Finding[] = []; const parsed = await parse(source, options, found); return { source, found, parsed }; } function checkDocument({ source, found, parsed }: Document, options: LintOptions): void { - const check = (rule: Rule, run: () => Diagnostic[]) => { + const check = (rule: Rule, run: () => Finding[]) => { try { found.push(...run()); } catch (error) { @@ -258,7 +270,7 @@ function checkDocument({ source, found, parsed }: Document, options: LintOptions async function parse( source: Source, { plugins = [], headingIds }: LintOptions, - found: Diagnostic[], + found: Finding[], ): Promise { let tokens: Token[] = []; const probe: ComarkPlugin = { @@ -308,7 +320,7 @@ async function parse( } } -const couldNotParse = (error: unknown): Diagnostic => ({ +const couldNotParse = (error: unknown): Finding => ({ rule: "comark/plugin-issue", cause: error, line: 1, @@ -316,7 +328,7 @@ const couldNotParse = (error: unknown): Diagnostic => ({ fix: "Fix what the error names; a plugin that throws stops the whole document from rendering.", }); -const crashed = (rule: Rule, error: unknown): Diagnostic => ({ +const crashed = (rule: Rule, error: unknown): Finding => ({ rule, cause: error, line: 1, @@ -340,7 +352,7 @@ function rawLines(tokens: Token[], offset: number): Set { return lines; } -function unclosedBlocks(source: Source, raw: Set): Diagnostic[] { +function unclosedBlocks(source: Source, raw: Set): Finding[] { const open: (Block & { colons: string })[] = []; const unclosed: typeof open = []; for (const [index, line] of source.prose.entries()) { @@ -370,8 +382,8 @@ function unclosedBlocks(source: Source, raw: Set): Diagnostic[] { })); } -function malformedAttributes(source: Source, raw: Set): Diagnostic[] { - const found: Diagnostic[] = []; +function malformedAttributes(source: Source, raw: Set): Finding[] { + const found: Finding[] = []; // Attributes follow a component name, a `[span]`, or a link or image's `(url)`. const opener = /(?:^ {0,3}(:{2,}[A-Za-z][\w-]*)|(?, -): Diagnostic[] { +): Finding[] { const names = [ ...new Set((Array.isArray(components) ? components : Object.keys(components)).map(kebab)), ].sort(); @@ -610,7 +622,7 @@ function locator(source: Source, attribute = false, needles: readonly string[] = }; } -function brokenAnchors(nodes: Node[], source: Source): Diagnostic[] { +function brokenAnchors(nodes: Node[], source: Source): Finding[] { const ids = new Set(); const links: string[] = []; for (const [, attributes] of elements(nodes)) { @@ -730,9 +742,9 @@ function written(source: Source, value: string): string[] { return [...found]; } -function unsafeUrls(nodes: Node[], source: Source): Diagnostic[] { +function unsafeUrls(nodes: Node[], source: Source): Finding[] { const locate = locator(source, true); - const found: Diagnostic[] = []; + const found: Finding[] = []; for (const [tag, attributes] of elements(nodes)) { for (const key of ["href", "src"]) { const value = text(attributes[key]); @@ -752,13 +764,13 @@ function unsafeUrls(nodes: Node[], source: Source): Diagnostic[] { return found; } -function duplicateIds(nodes: Node[], source: Source, headingIds?: HeadingIdsOption): Diagnostic[] { +function duplicateIds(nodes: Node[], source: Source, headingIds?: HeadingIdsOption): Finding[] { const tagged = [...elements(nodes)].flatMap(([tag, attributes]) => { const id = text(attributes.id); return id ? [{ tag, id }] : []; }); const seen = new Map(); - const found: Diagnostic[] = []; + const found: Finding[] = []; const locations = (id: string): { line: number; column: number }[] => { const matches: { line: number; column: number }[] = []; const explicit = [ @@ -832,9 +844,9 @@ function duplicateIds(nodes: Node[], source: Source, headingIds?: HeadingIdsOpti return found; } -function missingAlts(nodes: Node[], source: Source): Diagnostic[] { +function missingAlts(nodes: Node[], source: Source): Finding[] { const locate = locator(source); - const found: Diagnostic[] = []; + const found: Finding[] = []; const occurrences = new Map(); for (const [tag, attributes] of elements(nodes)) { const src = text(attributes.src); @@ -855,9 +867,9 @@ function missingAlts(nodes: Node[], source: Source): Diagnostic[] { return found; } -function emptyLinks(nodes: Node[], source: Source): Diagnostic[] { +function emptyLinks(nodes: Node[], source: Source): Finding[] { const locate = locator(source); - const found: Diagnostic[] = []; + const found: Finding[] = []; const occurrences = new Map(); for (const [tag, attributes, ...children] of elements(nodes)) { const href = text(attributes.href); @@ -881,11 +893,7 @@ function emptyLinks(nodes: Node[], source: Source): Diagnostic[] { return found; } -function pluginIssues( - meta: Record, - source: Source, - blocks: Block[], -): Diagnostic[] { +function pluginIssues(meta: Record, source: Source, blocks: Block[]): Finding[] { const issues = Array.isArray(meta.issues) ? meta.issues.flatMap((issue) => text(issue) || []) : []; diff --git a/packages/lint/src/links.ts b/packages/lint/src/links.ts index 84aa388d..c748bfb9 100644 --- a/packages/lint/src/links.ts +++ b/packages/lint/src/links.ts @@ -1,6 +1,9 @@ import { basename, dirname, join, relative, resolve, sep } from "node:path"; import { pathToFileURL } from "node:url"; import type { Diagnostic } from "./index.ts"; + +/** A diagnostic before `lint()` gives it its rule's severity. */ +export type Finding = Omit; import { checkUrls, isTls, type UrlOptions } from "./urls.ts"; import { closest, decode, listIds } from "./words.ts"; @@ -52,7 +55,7 @@ export type Context = { }; /** An external URL waiting for its network check. */ -export type Pending = { url: string; link: Link; found: Diagnostic[] }; +export type Pending = { url: string; link: Link; found: Finding[] }; type Kind = "local" | "private" | "file" | "tunnel" | "preview"; @@ -139,7 +142,7 @@ export async function checkLinks( links: readonly Link[], file: string | undefined, context: Context, - found: Diagnostic[], + found: Finding[], pending: Pending[], ): Promise { const { skip = [], allow, block = [] } = context.links; @@ -198,7 +201,7 @@ export async function checkLinks( type Target = { found: string | true } | { missing: string[]; routed: boolean }; -async function checkLocal(link: Link, file: string, context: Context, found: Diagnostic[]) { +async function checkLocal(link: Link, file: string, context: Context, found: Finding[]) { const { value, line, column } = link; const [, written = "", fragment] = /^([^?#]*)(?:\?[^#]*)?(?:#(.*))?$/.exec(value) ?? []; if (!written) return; diff --git a/packages/lint/test/lint.test.ts b/packages/lint/test/lint.test.ts index 3264fcba..1d631f90 100644 --- a/packages/lint/test/lint.test.ts +++ b/packages/lint/test/lint.test.ts @@ -20,6 +20,20 @@ const check = async (markdown: string) => message: `${message} ${fix}`, })); +describe("severity", () => { + it("gives each problem its rule's severity, a package's rules included", async () => { + const found = await lint( + `x\n\n![](a.png)\n\n::screenshot{src="a.png" alt="A"}\n:::annotation{x=120 y=10}\nA.\n:::\n::`, + options, + ); + expect(Object.fromEntries(found.map(({ rule, severity }) => [rule, severity]))).toEqual({ + "comark/unsafe-url": "error", + "comark/missing-alt": "warn", + "comark/annotate/invalid-position": "warn", + }); + }); +}); + describe("comark/unclosed-block", () => { it("passes closed blocks, nested ones included", async () => { expect( @@ -572,6 +586,7 @@ describe("lint-comark CLI", () => { { file: join(dir, "page.md"), rule: "comark/plugin-issue", + severity: "warn", line: 1, message: "Comark could not parse this document: Plugin failed", fix: "Fix what the error names; a plugin that throws stops the whole document from rendering.",