Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/lint/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:

Expand Down
8 changes: 7 additions & 1 deletion packages/lint/docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions packages/lint/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@ const results: (Omit<Diagnostic, "cause"> & { 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));
Expand Down
4 changes: 2 additions & 2 deletions packages/lint/src/eslint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Diagnostic, "cause">[] } | { error: string };
export type Reply = { diagnostics: Omit<Diagnostic, "cause" | "severity">[] } | { error: string };

// SAFETY: built from every entry of the catalog, whose keys are exactly the rules.
const ids = Object.keys(catalog) as Rule[];
Expand Down Expand Up @@ -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<object, Map<boolean, Omit<Diagnostic, "cause">[]>>();
const results = new WeakMap<object, Map<boolean, Omit<Diagnostic, "cause" | "severity">[]>>();

function diagnosticsOf(context: RuleModule.RuleContext, urls: boolean) {
const { sourceCode } = context;
Expand Down
64 changes: 36 additions & 28 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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. */
Expand Down Expand Up @@ -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<Rule, RuleInfo> = catalog;
const severityOf = (rule: Rule) => info[rule].severity ?? "warn";

async function lintAll(
inputs: readonly { path?: string; text?: string }[],
Expand Down Expand Up @@ -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) : [],
};
});
}

Expand Down Expand Up @@ -222,13 +234,13 @@ function createContext(options: LintOptions): Context & {

async function prepare(markdown: string, options: LintOptions): Promise<Document> {
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) {
Expand Down Expand Up @@ -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<Parsed | undefined> {
let tokens: Token[] = [];
const probe: ComarkPlugin = {
Expand Down Expand Up @@ -308,15 +320,15 @@ async function parse(
}
}

const couldNotParse = (error: unknown): Diagnostic => ({
const couldNotParse = (error: unknown): Finding => ({
rule: "comark/plugin-issue",
cause: error,
line: 1,
message: `Comark could not parse this document: ${error instanceof Error ? error.message : String(error)}`,
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,
Expand All @@ -340,7 +352,7 @@ function rawLines(tokens: Token[], offset: number): Set<number> {
return lines;
}

function unclosedBlocks(source: Source, raw: Set<number>): Diagnostic[] {
function unclosedBlocks(source: Source, raw: Set<number>): Finding[] {
const open: (Block & { colons: string })[] = [];
const unclosed: typeof open = [];
for (const [index, line] of source.prose.entries()) {
Expand Down Expand Up @@ -370,8 +382,8 @@ function unclosedBlocks(source: Source, raw: Set<number>): Diagnostic[] {
}));
}

function malformedAttributes(source: Source, raw: Set<number>): Diagnostic[] {
const found: Diagnostic[] = [];
function malformedAttributes(source: Source, raw: Set<number>): 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-]*)|(?<![\w:])(:[A-Za-z][\w-]*)(?:\[[^\]]*\])?|[\])])\{/g;
Expand Down Expand Up @@ -458,7 +470,7 @@ function unknownComponents(
source: Source,
blocks: Block[],
components: NonNullable<LintOptions["components"]>,
): Diagnostic[] {
): Finding[] {
const names = [
...new Set((Array.isArray(components) ? components : Object.keys(components)).map(kebab)),
].sort();
Expand Down Expand Up @@ -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<string>();
const links: string[] = [];
for (const [, attributes] of elements(nodes)) {
Expand Down Expand Up @@ -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]);
Expand All @@ -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<string, number>();
const found: Diagnostic[] = [];
const found: Finding[] = [];
const locations = (id: string): { line: number; column: number }[] => {
const matches: { line: number; column: number }[] = [];
const explicit = [
Expand Down Expand Up @@ -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<string, number>();
for (const [tag, attributes] of elements(nodes)) {
const src = text(attributes.src);
Expand All @@ -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<string, number>();
for (const [tag, attributes, ...children] of elements(nodes)) {
const href = text(attributes.href);
Expand All @@ -881,11 +893,7 @@ function emptyLinks(nodes: Node[], source: Source): Diagnostic[] {
return found;
}

function pluginIssues(
meta: Record<string, unknown>,
source: Source,
blocks: Block[],
): Diagnostic[] {
function pluginIssues(meta: Record<string, unknown>, source: Source, blocks: Block[]): Finding[] {
const issues = Array.isArray(meta.issues)
? meta.issues.flatMap((issue) => text(issue) || [])
: [];
Expand Down
9 changes: 6 additions & 3 deletions packages/lint/src/links.ts
Original file line number Diff line number Diff line change
@@ -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<Diagnostic, "severity">;
import { checkUrls, isTls, type UrlOptions } from "./urls.ts";
import { closest, decode, listIds } from "./words.ts";

Expand Down Expand Up @@ -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";

Expand Down Expand Up @@ -139,7 +142,7 @@ export async function checkLinks(
links: readonly Link[],
file: string | undefined,
context: Context,
found: Diagnostic[],
found: Finding[],
pending: Pending[],
): Promise<void> {
const { skip = [], allow, block = [] } = context.links;
Expand Down Expand Up @@ -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;
Expand Down
15 changes: 15 additions & 0 deletions packages/lint/test/lint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
`<a href="javascript:x">x</a>\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(
Expand Down Expand Up @@ -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.",
Expand Down
Loading