Plugins
A plugin adds rules, presets, and syntax to the built-in ones. It is a JavaScript module whose default export is a plugin object. The plugin example is a complete plugin with a problem rule and a style rule to start from.
A plugin
Create rules.mjs beside the configuration:
export default {
name: "local",
presets: {
recommended: { rules: { "local/initial-heading": "error" } },
},
rules: {
"initial-heading": {
description: "Require the first content block to be a heading.",
kind: "problem",
check({ document }) {
const first = document.tree.children.find(
(node) => node.type !== "yaml" && node.type !== "toml",
);
return first?.type === "heading"
? []
: [{ start: 0, message: "Start this document with a heading." }];
},
},
},
};and load it from the configuration:
{
"plugins": ["./rules.mjs"],
"extends": ["recommended", "local/recommended"]
}name is the plugin's prefix: a lowercase identifier that starts with a letter and holds letters, digits, and hyphens, unique among the loaded plugins. Its rules are named name/rule and its presets name/preset; a rule ID that already exists is an error. The name engine is reserved for the engine's own diagnostics, such as engine/rule-error, and a plugin with that name is rejected. A plugin can also provide syntax, described below. The library takes plugin objects through its plugins option; a list of strings in a library configuration loads nothing.
Loading
The configuration's plugins entries are loaded when the configuration is, and run with the caller's permissions; plugins are not sandboxed. An entry that starts with . or is an absolute path names a file, relative to the configuration file's directory. Any other entry is resolved as Node.js resolves an import written in the configuration file, such as sample-plugin, @scope/sample-plugin, or sample-plugin/rules. The package's entry can be an import or default condition of its exports, or its main file. When the import finds no entry, Node.js's CommonJS rules are tried, so a package that declares only a require condition loads as well; a package with both is loaded through import.
Node.js options that change the lookup
The lookup applies the conditions that Node.js applies: its defaults, those added with --conditions or -C, and without node-addons under --no-addons, from Node.js's command line or NODE_OPTIONS. Options set in other ways, such as a Node.js configuration file, are not seen.
A plugin that cannot be loaded stops the command with exit code 2 and the message Cannot load plugin "NAME" named in FILE: REASON, where REASON is Node.js's own, such as Cannot find package 'sample-plugin' imported from FILE, or no file at PATH for a file entry. A module whose default export is not an object with a string name, or whose plugin is named engine, gives Invalid plugin "NAME" named in FILE.
Rules
A rule is an object with a description, a kind, an optional schema and phase, and a check function.
kindis"problem"for a rule that only reports, or"style"for one that can propose edits, whichformatapplies.formatignores the edits of problem rules.schemais a JSON Schema for the rule's options. The options a configuration gives are checked against it, and a rule without a schema accepts any options;falsemeans that the rule takes none. Each schema is compiled once, for all documents. Options are checked synchronously, so an asynchronous schema ($async) is refused. String options can useformat: "regex"to validate JavaScript regular-expression syntax.check({ document, options, workspace })receives the parseddocument(source,path,dialect, andtree, an mdast tree with source positions), the configuredoptions, and theworkspace, which is undefined when the host gave none. It returns a list of findings.
A finding has a message, a start offset, an optional end, and, from a style rule, an optional edit of the form { start, end, text }. Offsets are integers that count UTF-16 code units in document.source, from 0 to its length, with end not before start; range(node) from the library gives a node's offsets. check must be synchronous, must not change the document or perform I/O, and must not keep state between calls.
A rule that throws, or returns anything other than a list of such findings, is skipped for that document and reported with an engine/rule-error error that names it and the reason, as in Rule local/example failed (TypeError: …); the rule was skipped for this document. The other rules and documents are still processed, and the CLI's exit code is 2. An error that the workspace throws, such as a file that cannot be read, is not the rule's: lint and format throw it, and the CLI stops. A plugin rule without a check function, a kind of "style" or "problem", and a string description, or with a phase or schema of another kind, stops the command with an error that names it, even while the rule is off.
A returned Promise is invalid even when it fulfills with findings. The engine handles its rejection without waiting for it; unrelated background work that a plugin starts remains the plugin's responsibility.
A style rule's phase is "inline" (the default), "block", or "document". format runs the phases in that order and repeats them until nothing changes, for at most eight passes. After each phase that changes the document, it parses the result again and compares its meaning with the original's; the safety model describes what happens to edits that overlap, would change the meaning, or fall in text that the parser reads where renderers read a list. The comparison covers the syntax tree, not every application's rendering, and it constrains the text a rule returns, not what its code does.
Resolve links in a rule
A rule asks the workspace where a link leads with workspace.resolve(source, destination, dialect, wiki): source is the linking document's path (document.path), destination the destination as written, dialect the document's dialect, and wiki true for a wikilink, which never resolves to a directory. The built-in link rules use the same call, so a plugin sees links as they do. Without a workspace, a rule should report nothing.
export default {
name: "local",
rules: {
"no-dead-links": {
description: "Report links to files that are not in the workspace.",
kind: "problem",
check({ document, workspace }) {
if (!workspace) return [];
const findings = [];
const visit = (node) => {
if (node.type === "link") {
const result = workspace.resolve(document.path, node.url, document.dialect);
if (result.status === "missing")
findings.push({
start: node.position.start.offset,
message: `No file at ${node.url}.`,
});
}
for (const child of node.children ?? []) visit(child);
};
visit(document.tree);
return findings;
},
},
},
};The result's status is one of:
| Status | Meaning |
|---|---|
resolved | One file matches. target is its path from the workspace root. |
missing | No file matches. |
ambiguous | Several files match and nothing decides between them, such as two notes whose names differ only by case. |
directory | The destination names a directory. |
external | The destination is a URL with a scheme, or starts with //. It is not checked. |
unavailable | The destination cannot be judged from files: it starts with / or holds a ?, outside the Obsidian dialect. |
unreadable | The target could not be fully checked: files could not be read, or a renderer-dependent fragment remains uncertain. |
The other fields are present when they apply:
target: the file found, with statusresolved, and withunreadablewhen the file exists but its content could not be read or fully checked.fragment: the part after#, decoded, or an empty string;fragmentExists: with statusresolved, whether the target has that heading, block, or anchor by the rules ofdialect, andtruewithout a fragment and for a target that is not Markdown, whose fragments are its own.fragmentAnchor: with statusresolved, the anchor as the target spells it, when the fragment reaches it only because the renderer ignores letter case, as GitHub and Obsidian headings do; absent when the fragment matches an anchor exactly.links/valid'sexactCasereports it. It is found when first read, because that can mean searching the target's headings: until then, a fragment that may differ in case gives a property whose value can turn outundefined, and reading it settles the property.fragmentIncomplete: true, with statusunreadable, means the file was read but its renderer's fragment targets are not fully known. Nounreadablefile/directory list accompanies this result. Verified headings or explicit IDs can still resolve; an unverified fragment is not evidence that an anchor is missing.unreadable: the directories or the file that stood in the way. An entry that starts with..stands for the part of a vault outside the workspace root. In the Obsidian dialect, a search by note name that finds nothing readable resolves asunreadable, and one that finds a single note resolves withunreadableset, because the unreadable directories may hold another note of that name. A rule must not rewrite aresolvedlink whoseunreadableis set, and the engine refuses an edit that changes such a link's destination.vault: the vault's directory, when an Obsidian link was resolved in a vault below the workspace root.rooted: the target's vault-relative path, when it differs fromtarget.outside: with statusmissing, an existing file that the path names outside the vault.leavesRoot: with statusunreadable, the symbolic link or junction through which the path leads outside the workspace root.
A style rule that rewrites a destination should resolve the new destination as well, and propose the edit only when both lead to the same target. The engine checks this again and refuses an edit that changes where a link leads.
Syntax extensions
A plugin can provide syntax: { micromark, mdast }, a tokenizer extension and a tree extension from the micromark and mdast ecosystem. In TypeScript, extend mdast's node maps for the new node types. Keep source positions on the nodes, and expose the meaning of a node in its fields: the semantic comparison looks at every field except position and data. Syntax that a parser reads as plain text is not safe to format merely for that; test the extension's meaning and its source preservation.
The parser retains the original attention and strikethrough resolvers, including CJK emphasis, and container blank checks and list preparation when any syntax plugin is present. This preserves intermediate events and tokenizer observations, with their original performance limits for long paragraphs and deep lists. The other parser corrections still apply. Rule-only plugins keep the optimized inline and container routines. The syntax extension types and their order remain the same.
Wrapping keeps a paragraph as written when the proposed lines would open a different block under its syntax plugins. Safe paragraphs still wrap, including VitePress body paragraphs beginning with ---. style/wrap's reportUnreflowed option can report the retained paragraph.
This is not a remark plugin adapter. Existing micromark extensions can be reused, but a remark plugin that changes the tree must be rewritten as a rule that proposes edits. A plugin cannot replace link resolution; a library host can pass its own implementation of the Workspace interface.
Test a rule
Call the library with the plugin object, and check what it reports and what formatting produces:
import { format, lint } from "mdrefine";
import plugin from "./rules.mjs";
const options = {
plugins: [plugin],
config: { extends: [], rules: { "local/initial-heading": "error" } },
};
console.assert(lint("Text.\n", options).length === 1);
console.assert(lint("# Title\n", options).length === 0);
const once = format("Text.\n", options).output;
console.assert(format(once, options).output === once);A correct style rule leaves its own output unchanged when it runs again, and changes nothing when it is turned off. Add a case for every construct the rule could misread, such as code, HTML, and each dialect it supports.