diff --git a/docs/pages/lint.md b/docs/pages/lint.md index c88ecf6..79654b0 100644 --- a/docs/pages/lint.md +++ b/docs/pages/lint.md @@ -48,7 +48,7 @@ const diagnostics = await lint(markdown, { plugins: [annotate()], components }); // [{ rule: "comark/unclosed-block", line: 8, column: 1, message: "…", fix: "…" }] ``` -`lint()` never throws. Pass the same plugins and components you render with. Without `components`, `comark/unknown-component` is off, since any name could be valid; HTML elements are always known. `resolveConfig()` returns the installed packages' plugins and components, as the command line uses them. +`lint()` never throws; if a rule crashes on a document, that rule reports one diagnostic saying so and the others still run. Pass the same plugins and components you render with. Without `components`, `comark/unknown-component` is off, since any name could be valid; HTML elements are always known. `resolveConfig()` returns the installed packages' plugins and components, as the command line uses them. ### Vite Doctor @@ -75,7 +75,7 @@ It lints `**/*.{md,mdc}` except `node_modules` and `dist`; set `include` and `ex ### `comark/unclosed-block` {#comark0001} -A `::` block needs a closing line with the same number of colons. Comark closes it at the end of the document, or at the next closer of an outer block, so everything after it moves inside. Fenced code is skipped. +A `::` block needs a closing line with the same number of colons. Comark closes it at the end of the document, or at the next closer of an outer block, so everything after it moves inside. Code and HTML blocks are skipped, since Comark reads them as text. ### `comark/unknown-component` {#comark0002} @@ -103,4 +103,4 @@ A `javascript:`, `vbscript:`, or `data:` URL in an `href` or `src`. `data:` imag ## Positions -Comark gives parsed nodes no source positions. comark-lint takes block components' lines from the parser's tokens and finds everything else by searching the source, so a component inside a paragraph gets its line and the first matching column, and a repeated value is matched in order. +Comark gives parsed nodes no source positions. comark-lint takes block components' lines from the parser's tokens and finds everything else by searching the source, so a component inside a paragraph gets its line and the first matching column, a URL is matched where it starts an attribute or link rather than in prose, and a repeated value is matched in order. diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index e60ef7f..f029623 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -62,18 +62,28 @@ type Block = { name: string; line: number; column: number }; export async function lint(markdown: string, options: LintOptions = {}): Promise { const source = read(markdown); const found: Diagnostic[] = []; + const check = (rule: Rule, run: () => Diagnostic[]) => { + try { + found.push(...run()); + } catch (error) { + found.push(crashed(rule, error)); + } + }; const parsed = await parse(source, options.plugins ?? [], found); - found.push(...unclosedBlocks(source), ...malformedAttributes(source)); + const raw = parsed ? rawLines(parsed.tokens, parsed.offset) : new Set(); + check("comark/unclosed-block", () => unclosedBlocks(source, raw)); + check("comark/malformed-attributes", () => malformedAttributes(source, raw)); if (parsed) { const { document, tokens, offset } = parsed; - const blocks = blockComponents(tokens, offset, source); - if (options.components) - found.push(...unknownComponents(tokens, offset, source, blocks, options.components)); - found.push( - ...brokenAnchors(document.nodes, source), - ...unsafeUrls(document.nodes, source), - ...pluginIssues(document.meta, source, blocks), - ); + const blocks = () => blockComponents(tokens, offset, source); + const { components } = options; + if (components) + check("comark/unknown-component", () => + unknownComponents(tokens, offset, source, blocks(), components), + ); + check("comark/broken-anchor", () => brokenAnchors(document.nodes, source)); + check("comark/unsafe-url", () => unsafeUrls(document.nodes, source)); + check("comark/plugin-issue", () => pluginIssues(document.meta, source, blocks())); } return found.sort((a, b) => a.line - b.line || (a.column ?? 0) - (b.column ?? 0)); } @@ -83,22 +93,23 @@ async function parse( plugins: NonNullable, found: Diagnostic[], ): Promise<{ document: MarkdownDocument; tokens: Token[]; offset: number } | undefined> { - const seen: { tokens: Token[]; offset: number } = { tokens: [], offset: 0 }; + let tokens: Token[] = []; const probe: ComarkPlugin = { name: "comark-lint", post(state) { // SAFETY: Comark parses with markdown-exit, whose tokens have this shape; it types them as unknown. - seen.tokens = state.tokens as Token[]; - // Token lines start after the frontmatter, which Comark strips first. - seen.offset = Number(state.parsedLines ?? 0); + tokens = state.tokens as Token[]; }, }; - const run = async (markdown: string) => ({ + const run = async (markdown: string, offset: number) => ({ document: await parseMarkdown(markdown, { plugins: [...plugins, probe] }), - ...seen, + tokens, + offset, }); try { - return await run(source.lines.join("\n")); + // Comark strips the frontmatter up to the closing `---`, so token line 0 is that line. + // Its own `parsedLines` miscounts when the YAML starts on the first line. + return await run(source.lines.join("\n"), source.frontmatter?.end ?? 0); } catch (error) { if (!source.frontmatter || !(error instanceof Error) || error.name !== "YAMLException") { found.push(couldNotParse(error)); @@ -120,6 +131,7 @@ async function parse( // Blank lines in place of the frontmatter keep every line number, so the rest is still checked. return await run( source.lines.map((line, index) => (index < source.body ? "" : line)).join("\n"), + 0, ); } catch (again) { found.push(couldNotParse(again)); @@ -135,10 +147,28 @@ const couldNotParse = (error: unknown): Diagnostic => ({ fix: "Fix what the error names; a plugin that throws stops the whole document from rendering.", }); -function unclosedBlocks(source: Source): Diagnostic[] { +const crashed = (rule: Rule, error: unknown): Diagnostic => ({ + rule, + line: 1, + message: `comark-lint crashed while checking \`${rule}\`: ${error instanceof Error ? error.message : String(error)}.`, + fix: "The other rules still ran. Report this with the document at https://github.com/onmax/comark-plugins/issues.", +}); + +// Comark reads HTML blocks and code as text, so a `::` or `{` there opens nothing. +function rawLines(tokens: Token[], offset: number): Set { + const lines = new Set(); + for (const { type, map } of tokens) { + if (!map || !["html_block", "code_block", "fence"].includes(type)) continue; + for (let line = map[0]; line < map[1]; line++) lines.add(line + offset); + } + return lines; +} + +function unclosedBlocks(source: Source, raw: Set): Diagnostic[] { const open: (Block & { colons: string })[] = []; const unclosed: typeof open = []; for (const [index, line] of source.prose.entries()) { + if (raw.has(index)) continue; const opener = /^ {0,3}(:{2,})([A-Za-z][\w-]*)/.exec(line); if (opener) { open.push({ @@ -164,17 +194,18 @@ function unclosedBlocks(source: Source): Diagnostic[] { })); } -function malformedAttributes(source: Source): Diagnostic[] { +function malformedAttributes(source: Source, raw: Set): Diagnostic[] { const found: Diagnostic[] = []; // Attributes follow a component name, a `[span]`, or a link or image's `(url)`. const opener = /(?:^ {0,3}(:{2,}[A-Za-z][\w-]*)|(? char === "}" && !depth); if (end === -1) { found.push({ rule: "comark/malformed-attributes", @@ -187,10 +218,16 @@ function malformedAttributes(source: Source): Diagnostic[] { } const seen = new Set(); const attributes = line.slice(start + 1, end); - for (const attribute of attributes.matchAll(/[^\s"'=]+(?:=(?:"[^"]*"|'[^']*'|\S*))?/g)) { - const raw = attribute[0]; - if (raw.startsWith(".")) continue; - const key = raw.startsWith("#") ? "id" : raw.split("=")[0]!.replace(/^(?::|v-bind:)/, ""); + for (let at = 0; at < attributes.length; at++) { + if (/\s/.test(attributes[at]!)) continue; + const from = at; + at = scan(attributes, at, (char, depth) => !depth && /\s/.test(char)); + if (at === -1) at = attributes.length; + const attribute = attributes.slice(from, at); + if (/^["'=.]/.test(attribute)) continue; + const key = attribute.startsWith("#") + ? "id" + : attribute.split("=")[0]!.replace(/^(?::|v-bind:)/, ""); if (!seen.has(key)) { seen.add(key); continue; @@ -198,7 +235,7 @@ function malformedAttributes(source: Source): Diagnostic[] { found.push({ rule: "comark/malformed-attributes", line: index + 1, - column: start + 2 + attribute.index, + column: start + 2 + from, message: `${owner} on line ${index + 1} ${name ? "sets" : "set"} \`${key}\` twice.`, fix: `Keep one \`${key}\`. Comark uses the last one and drops the others.`, }); @@ -208,21 +245,30 @@ function malformedAttributes(source: Source): Diagnostic[] { return found; } -function closingBrace(line: string, start: number): number { +// Values can hold quotes with `\` escapes and nested `{…}` or `[…]`, as in `{data={"a":"}"}}`, so +// a `}` or a space only counts outside them. +function scan(text: string, from: number, stop: (char: string, depth: number) => boolean): number { let quote = ""; - for (let at = start + 1; at < line.length; at++) { - const char = line[at]; + let depth = 0; + for (let at = from; at < text.length; at++) { + const char = text[at]!; if (quote) { - if (char === quote) quote = ""; + if (char === "\\") at++; + else if (char === quote) quote = ""; } else if (char === '"' || char === "'") quote = char; - else if (char === "}") return at; + else if (stop(char, depth)) return at; + else if (char === "{" || char === "[") depth++; + else if (char === "}" || char === "]") depth--; } return -1; } function blockComponents(tokens: Token[], offset: number, source: Source): Block[] { return tokens.flatMap((token) => { - if (token.type !== "mdc_block_open" || !token.map) return []; + const opens = + (token.type === "mdc_block_open" || token.type === "mdc_block_shorthand") && + token.nesting !== -1; + if (!opens || !token.map) return []; const line = token.map[0] + offset + 1; return [{ name: token.tag, line, column: (source.lines[line - 1]?.indexOf(":") ?? 0) + 1 }]; }); @@ -244,11 +290,14 @@ function unknownComponents( const used: { name: string; written: string; line: number; column?: number }[] = blocks.map( (block) => ({ ...block, - written: `${source.lines[block.line - 1]!.trim().match(/^:+/)![0]}${block.name}`, + written: `${source.lines[block.line - 1]?.trim().match(/^:+/)?.[0] ?? "::"}${block.name}`, }), ); + let range: [number, number] | null = null; for (const token of tokens) { - if (token.type !== "inline" || !token.map) continue; + // The inline content of a `:name[…]` line has no map of its own, so it takes its block's. + range = token.map ?? range; + if (token.type !== "inline" || !range) continue; const counts = new Map(); for (const child of token.children ?? []) { if (child.type !== "mdc_inline_component" || child.nesting === -1) continue; @@ -256,13 +305,13 @@ function unknownComponents( counts.set(child.tag, nth + 1); const at = find(source, `:${child.tag}`, { nth, - from: token.map[0] + offset, - to: token.map[1] + offset, + from: range[0] + offset, + to: range[1] + offset, }); used.push({ name: child.tag, written: `:${child.tag}`, - line: at?.line ?? token.map[0] + offset + 1, + line: at?.line ?? range[0] + offset + 1, column: at?.column, }); } @@ -330,12 +379,12 @@ function* elements(nodes: Node[]): Generator { } // Repeated values are matched in order, so the second bad link is placed on the second one in the source. -function locator(source: Source) { +function locator(source: Source, attribute = false) { const counts = new Map(); return (...needles: string[]) => { for (const needle of needles) { const nth = counts.get(needle) ?? 0; - const at = find(source, needle, { nth, from: source.body }); + const at = find(source, needle, { nth, from: source.body, attribute }); if (!at) continue; counts.set(needle, nth + 1); return at; @@ -355,7 +404,7 @@ function brokenAnchors(nodes: Node[], source: Source): Diagnostic[] { const href = text(attributes.href); if (href.startsWith("#") && href.length > 1) links.push(href); } - const locate = locator(source); + const locate = locator(source, true); const list = [...ids].map((id) => `\`#${id}\``); const available = list.length ? `Ids here: ${list.slice(0, LISTED).join(", ")}${list.length > LISTED ? `, and ${list.length - LISTED} more` : ""}.` @@ -388,6 +437,9 @@ function decode(value: string): string { } } +// A browser reads a reference past U+10FFFF as U+FFFD. +const character = (code: number) => (code <= 0x10ffff ? String.fromCodePoint(code) : "\uFFFD"); + const ENTITIES = new Map([ ["colon", ":"], ["tab", "\t"], @@ -399,12 +451,8 @@ function unsafeScheme(value: string): string | undefined { let url = decode(value); for (let pass = 0; pass < 5; pass++) { const next = url - .replace(/&#x([0-9a-f]+);?/gi, (_, hex: string) => - String.fromCodePoint(Number.parseInt(hex, 16) % 0x110000), - ) - .replace(/&#(\d+);?/g, (_, dec: string) => - String.fromCodePoint(Number.parseInt(dec, 10) % 0x110000), - ) + .replace(/&#x([0-9a-f]+);?/gi, (_, hex: string) => character(Number.parseInt(hex, 16))) + .replace(/&#(\d+);?/g, (_, dec: string) => character(Number.parseInt(dec, 10))) .replace( /&([a-z]+);/gi, (entity, name: string) => ENTITIES.get(name.toLowerCase()) ?? entity, @@ -419,7 +467,7 @@ function unsafeScheme(value: string): string | undefined { } function unsafeUrls(nodes: Node[], source: Source): Diagnostic[] { - const locate = locator(source); + const locate = locator(source, true); const found: Diagnostic[] = []; for (const [tag, attributes] of elements(nodes)) { for (const key of ["href", "src"]) { diff --git a/packages/lint/src/source.ts b/packages/lint/src/source.ts index a4ea508..d059c1e 100644 --- a/packages/lint/src/source.ts +++ b/packages/lint/src/source.ts @@ -5,7 +5,7 @@ export type Source = { prose: string[]; /** The 0-based index of the first line after the frontmatter. */ body: number; - /** The frontmatter between the `---` lines, when there is one. */ + /** The frontmatter, when there is one: the 0-based line its YAML starts on and the closing `---` line. */ frontmatter?: { start: number; end: number }; }; @@ -14,11 +14,12 @@ export function read(markdown: string): Source { const prose = [...lines]; let body = 0; let frontmatter: Source["frontmatter"]; - // Comark's own rule: `---` on the first line, closed by the next line that starts with `---`. + // Comark's own rule: `---` at the start, closed by the next line that starts with `---`. The YAML + // starts at the fifth character, even on the first line, and an empty one leaves the document alone. if (lines[0]?.startsWith("---")) { const end = lines.findIndex((line, index) => index > 0 && line.startsWith("---")); - if (end > 1) { - frontmatter = { start: 1, end }; + if (end > 0 && lines.slice(0, end).join("\n").slice(4)) { + frontmatter = { start: lines[0].length > 3 ? 0 : 1, end }; body = end + 1; for (let index = 0; index <= end; index++) prose[index] = ""; } @@ -43,7 +44,10 @@ export function read(markdown: string): Source { return { lines, prose, body, frontmatter }; } -/** Where `needle` appears in prose, 1-based, from the `nth` match on. A match must not run on into a longer name. */ +/** + * Where `needle` appears in prose, 1-based, from the `nth` match on. A match must not run on into a + * longer name. With `attribute`, it must also start a value: after `"`, `'`, `=`, `(`, or `<`. + */ export function find( source: Source, needle: string, @@ -51,13 +55,15 @@ export function find( nth = 0, from = 0, to = source.prose.length, - }: { nth?: number; from?: number; to?: number } = {}, + attribute = false, + }: { nth?: number; from?: number; to?: number; attribute?: boolean } = {}, ): { line: number; column: number } | undefined { let seen = 0; for (let index = from; index < Math.min(to, source.prose.length); index++) { const line = source.prose[index]!; for (let at = line.indexOf(needle); at !== -1; at = line.indexOf(needle, at + 1)) { if (/[\w-]/.test(line[at + needle.length] ?? "") && /[\w-]$/.test(needle)) continue; + if (attribute && !/["'=(<]/.test(line[at - 1] ?? "")) continue; if (seen++ === nth) return { line: index + 1, column: at + 1 }; } } diff --git a/packages/lint/test/lint.test.ts b/packages/lint/test/lint.test.ts index 525ba30..5e06742 100644 --- a/packages/lint/test/lint.test.ts +++ b/packages/lint/test/lint.test.ts @@ -53,6 +53,10 @@ describe("comark/unclosed-block", () => { it("ignores `::` inside fenced code", async () => { expect(await check("```md\n::compare\n```\n\n~~~\n::screnshot{src=\n~~~\n")).toEqual([]); }); + + it("ignores `::` inside HTML blocks, which Comark reads as text", async () => { + expect(await lint("
\n::code\n
\n\n
\n::note{a=1 a=2}\n
")).toEqual([]); + }); }); describe("comark/unknown-component", () => { @@ -86,6 +90,17 @@ describe("comark/unknown-component", () => { expect(await lint("::anything\n::")).toEqual([]); }); + it("reports `:name[…]` shorthand blocks and components inside them", async () => { + const found = await lint(":badge[x]\n\n::outer\n:pill[see :chip[y]]\n::", { + components: ["outer"], + }); + expect(found.map(({ line, column, message }) => [line, column, message])).toEqual([ + [1, 1, "`:badge` on line 1 is not a known component."], + [4, 1, "`:pill` on line 4 is not a known component."], + [4, 11, "`:chip` on line 4 is not a known component."], + ]); + }); + it("takes a list of names as well as a map", async () => { expect(await lint("::tabs\n::", { components: ["tabs"] })).toEqual([]); }); @@ -122,6 +137,21 @@ describe("comark/malformed-attributes", () => { }, ]); }); + + it("reads past nested braces and escaped quotes in values", async () => { + expect(await check(`[x]{foo={"x":"}"} foo=2}\n\n[y]{a="q\\"}" b={"c": [1, 2]} a=3}`)).toEqual([ + expect.objectContaining({ + line: 1, + column: 19, + message: expect.stringContaining("`foo` twice"), + }), + expect.objectContaining({ + line: 3, + column: 30, + message: expect.stringContaining("`a` twice"), + }), + ]); + }); }); describe("comark/broken-anchor", () => { @@ -149,6 +179,13 @@ describe("comark/invalid-frontmatter", () => { expect(await check(`---\ntitle: Hello\n---\n\n# Hello`)).toEqual([]); }); + it("follows Comark when the YAML starts on the first line", async () => { + expect(await lint("---title: x\n---\n::bad", { components: [] })).toEqual([ + expect.objectContaining({ rule: "comark/unclosed-block", line: 3, column: 1 }), + expect.objectContaining({ rule: "comark/unknown-component", line: 3, column: 1 }), + ]); + }); + it("reports the YAML error's line and still checks the rest", async () => { expect(await check(`---\ntitle: Hello\nsummary: a: b\n---\n\n::zoom\nx`)).toEqual([ { @@ -211,12 +248,48 @@ describe("comark/unsafe-url", () => { const found = await check( `Text\n\nx\n\n\n\n:zoom[x]{href="VBScript:x"}`, ); - expect(found.map(({ line, message }) => [line, message.split(".")[0]])).toEqual([ + const urls = found.filter(({ rule }) => rule === "comark/unsafe-url"); + expect(urls.map(({ line, message }) => [line, message.split(".")[0]])).toEqual([ [3, "The `href` of `a` on line 3 is a `javascript:` URL"], [5, "The `src` of `iframe` on line 5 is a `data:` URL"], [7, "The `href` of `zoom` on line 7 is a `vbscript:` URL"], ]); }); + + it("places the URL on its attribute, not on the same text in prose", async () => { + expect(await check(`Text javascript:x\n\nx`)).toEqual([ + expect.objectContaining({ rule: "comark/unsafe-url", line: 3, column: 10 }), + ]); + }); + + it("reads a character reference past U+10FFFF as U+FFFD", async () => { + expect(await check(`x y`)).toEqual( + [], + ); + }); +}); + +describe("lint()", () => { + it("turns a rule that crashes into one diagnostic and still runs the others", async () => { + const broken = { + name: "broken", + post(state: { tree: { nodes: unknown[] } }) { + state.tree.nodes.push(["a", null]); + }, + }; + const found = await lint("::compare\n", { plugins: [broken] }); + expect(found.map(({ rule, message }) => [rule, message])).toEqual([ + [ + "comark/broken-anchor", + "comark-lint crashed while checking `comark/broken-anchor`: Cannot read properties of null (reading 'id').", + ], + [ + "comark/unsafe-url", + "comark-lint crashed while checking `comark/unsafe-url`: Cannot read properties of null (reading 'href').", + ], + ["comark/unclosed-block", "`::compare` opened on line 1 is never closed."], + ]); + }); }); describe("positions", () => {