Authoring plugins
Add namespaced rules, diagnostics, and leaf tags to mediva without forking it — package them as a plugin.
Request permission to dock a third-party module, Captain. MOTHER doesn't need to know every ship in the fleet by name — she just needs the module to declare its callsign, pass inspection, and speak her protocol. A mediva plugin is that module: your own rules, your own diagnostics, loaded into the same registry MOTHER validates against.
Why plugins
The built-in rules cover general document shape — presence, word counts, tables, checklists. A house style rule ("no marketing weasel words"), a domain-specific leaf ("a compliance attestation"), or an org-specific check doesn't belong in mediva core, but it should behave exactly like a core rule: same directive grammar, same severity overrides, same autofix contract. A plugin is a definePlugin({...}) manifest that extends the rule registry with exactly that.
The full working example this page is based on lives at tests/e2e/plugin-compliance — copy that directory to start your own.
A minimal plugin
A plugin package needs one manifest, built with definePlugin, and at least one rule, built with defineFieldRule:
// src/index.mjs
import { definePlugin, defineFieldRule } from "mediva";
const noWeasel = defineFieldRule({
name: "compliance/noWeasel",
scope: "field",
syntax: "flag",
category: "content",
status: "stable",
order: 10,
doc: {
summary: "Reject vague marketing weasel words (leverage, synergy, world-class) from the body.",
example: "<!-- mdv: section compliance/noWeasel -->",
},
fix: "none",
diagnostics: ["compliance/weasel-words"],
run(ctx) {
const prose = ctx.target.proseText();
for (const match of prose.matchAll(/\b(leverage|synerg\w+|world-class)\b/gi)) {
ctx.report(
"compliance/weasel-words",
`The "${ctx.target.label}" section uses the weasel word "${match[0]}" — say the specific, verifiable thing instead.`,
);
}
},
});
export default definePlugin({
name: "compliance",
apiVersion: 2,
fieldRules: [noWeasel],
diagnostics: {
"compliance/weasel-words": {
severity: "error",
title: "Weasel word",
suggestion: "Replace the vague marketing word with the specific, verifiable claim it's standing in for.",
concern: "content",
},
},
});Wire it into a contract like any core rule — compliance/noWeasel reads and writes as one atom:
<!-- mdv: section required compliance/noWeasel -->
## Summary
<!-- mdv: endsection -->A manifest can also carry documentRules, repairActions, unsupportedAutofix, and any combination of leaves, sectionPresets, and compositeSections — the same contribution shape RuleRegistry.extend() takes internally.
Namespacing and apiVersion
Every rule name, diagnostic code, and repairActions key must be pre-prefixed ${name}/ — here, compliance/. definePlugin validates this eagerly at module load, so a typo fails in the plugin's own tests, not silently at first use in a consumer. The one exception is a rule marked override: true, which is allowed to use the exact name of an existing core (or earlier-plugin) rule — it replaces that rule in place instead of colliding with it.
apiVersion: 2 must equal the loading mediva's PLUGIN_API_VERSION; a mismatch is a loud PluginError at load time (plugin-api-mismatch), not a silent skip. There is no compatibility adapter — a plugin still built against the retired apiVersion: 1 defineBlock API fails to load with the same plugin-api-mismatch error, not a degraded shim. Pin your package's peerDependencies.mediva to the range you've tested against so npm/bun warn a consumer before they ever hit that mismatch at runtime.
Every violation of the manifest contract — bad name, apiVersion mismatch, unprefixed rule/code, missing repair coverage, colliding leaf tag — throws PluginError at load time, with the same diagnostic shape the CLI already knows how to render.
Diagnostics: concern and fill
Every diagnostic entry declares a concern: "syntax" (a shape problem, potentially autofixable), "content" (a judgment call about what's actually said), "attestation" (a checkbox or selection asserting real work), "external" (checked against something outside the document), or "schema-author" (the contract itself is broken).
A concern: "syntax" diagnostic additionally requires a fill: "mechanical" (the fix is a pure transform of text already present) or "facts" (the fix needs information the document may not contain). It must also have either a repairActions entry — the instruction handed to an LLM autofixer when the code fires — or be listed in unsupportedAutofix. definePlugin enforces this coverage at load time; concern: "content" codes don't need one, since there's nothing mechanical to card.
diagnostics: {
"compliance/too-many-acronyms": {
severity: "error",
title: "Too many acronyms",
suggestion: "Spell out the least-common acronyms on first use until the section stays under the limit.",
concern: "syntax",
fill: "mechanical",
},
},
repairActions: {
"compliance/too-many-acronyms":
"Spell out the least-common acronyms in place (e.g. `API` -> `application programming interface (API)` on first use) until at most the allowed number of distinct acronyms remain.",
},Defining a leaf
A plugin can contribute its own leaf tag — a self-contained construct like signoff, headingless and always living inside a section — with defineLeaf:
import { definePlugin, defineFieldRule, defineLeaf } from "mediva";
const signoff = defineLeaf({
name: "compliance/signoff",
locator: { kind: "linePrefix", label: "Signed-off-by:" },
rules: { noPlaceholder: true },
docs: {
summary: "A compliance attestation line.",
example: '<!-- mdv: compliance/signoff required -->',
},
});
export default definePlugin({
name: "compliance",
apiVersion: 2,
leaves: [signoff],
// ...fieldRules, diagnostics
});A leaf declares exactly one of family or locator. family ("list", "table", or "code") binds it positionally, so it takes the next unclaimed construct of that family inside its section and participates in ordinal binding exactly like a core leaf — including the compile-time rejection of ambiguous same-family layouts. locator binds it by content instead of position; { kind: "linePrefix", label } is the same mechanism the core line leaf uses. rules are ordinary registry rule-name → value settings, validated when the plugin loads.
name is closed by its end twin and is collision-validated against the whole atom vocabulary — a plugin tag cannot reuse a core tag, closer, meta-atom, or reserved (retired) name. And it can never own a heading: that belongs to section alone, with no loophole for plugins.
defineSectionPreset is the other common shape: instead of a new construct, it bundles rules onto an ordinary section under one name, so a house style ("every incident postmortem needs minWords=50 noPlaceholder") ships as a single atom. Its rules always apply; its optional defaults are the overridable tier a contract author's own atoms win over. defineCompositeSection goes further still — its expand() returns a section spec plus child leaf specs, so a shape a team reuses often enough to name once (a "decision record" that always expands to a section wrapping a choice and a list) becomes one atom. It is a structural expansion, never raw text splicing.
Wire the leaf into a contract like any core tag — inside its own section, just like a built-in leaf:
<!-- mdv: section required -->
## Attestation
<!-- mdv: compliance/signoff required compliance/noWeasel -->
Signed-off-by: Priya Raman, release manager
<!-- mdv: end compliance/signoff -->
<!-- mdv: endsection -->Testing with mediva/testing
The mediva/testing subpath gives plugin authors the same fixture-based regression testing the core uses — no bespoke harness required.
expectDiagnostics(template, document, codes, { plugins, context }) compiles a template, validates a document, and asserts that every code in codes appears in the result (extra diagnostics are fine; missing ones fail).
runCorpus / collectCorpus walk a <domain>/<use-case>/{templates,samples} directory convention — the same one mediva's own usecases/ library uses:
corpus/style-guide/report/
templates/report.mdv.md the contract under test
samples/pass/good.md must validate clean
samples/fail/weasel.md <!-- expect: compliance/weasel-words -->A <!-- expect: code-a, code-b --> comment on the first line of a fail sample declares which codes must fire; <!-- title: ... --> supplies context.title for title rules. Run the whole corpus in one call:
import { test } from "bun:test"; // or your runner's registration function
import { runCorpus } from "mediva/testing";
import plugin from "../src/index.mjs";
runCorpus(new URL("../corpus", import.meta.url).pathname, { plugins: [plugin], test });The harness is runner-agnostic — you inject the test function, so the same call works under bun, vitest, or node:test.
Wiring into a project
Drop mediva.config.mjs at your project root — the CLI's check, explain <path>, rule explain, and highlight commands auto-discover the NEAREST config walking up from the invoked directory to the git root (a run from a subdirectory loads the same config CI loads at the repo root), no flag needed:
// mediva.config.mjs
// @ts-check
import { defineConfig } from "mediva";
export default defineConfig({
plugins: ["mediva-plugin-compliance"], // npm package name, or a relative path to a local module
});defineConfig is an identity helper — it adds no runtime behavior, just typed editor completion
for every config key. A plain export default { ... } object still works exactly the same way.
Each string entry resolves as a module specifier — a relative path against the config file's own directory, or a bare package name through the config file's own node_modules. A manifest object works too, inline.
Use --config FILE to point at an explicit config file instead of auto-discovery, and --no-plugins to skip config discovery entirely and validate with only mediva's built-in rules — the escape hatch for a checkout you don't trust, since a config file is executable code loaded via dynamic import().
npx mediva check README.md --schema readme.mdv.md --config ./tools/mediva.config.mjs
npx mediva check README.md --schema readme.mdv.md --no-pluginsOverriding a plugin diagnostic's severity
Plugin rules and codes are ordinary severity override targets — warn=, error=, off=, and disable-next-line all accept a plugin rule name or diagnostic code exactly like a core one:
<!-- mdv: section required compliance/noWeasel warn=compliance/weasel-words -->
## Summary
<!-- mdv: endsection -->Next steps
Start from the copyable template: tests/e2e/plugin-compliance on GitHub ships the full two-rule, one-leaf plugin from this page plus its corpus and test file. See the TypeScript API for compile/validate's plugins option, and Tags & rules for how core rules and tags fit together.
TypeScript API
Compile a contract and validate Markdown programmatically, split into syntax (shape) and state (attestation/content) so LLM autofix is safe by construction.
Autofix
Repair a broken Markdown document so it satisfies a contract, with a model you provide — validator-in-the-loop, provider-agnostic, safe by construction.