Plugin Authoring Guide
crackdown rules are unified/remark plugins. Any plugin that calls file.message() on a VFile is a valid crackdown rule.
Anatomy of a rule
import { lintRule } from 'unified-lint-rule'import type { Root } from 'mdast'import { visit } from 'unist-util-visit'
export const myRule = lintRule( { origin: 'my-plugin:my-rule', url: 'https://example.com/docs/my-rule' }, (tree: Root, file) => { visit(tree, 'heading', (node) => { // Report a violation on every H1 heading if (node.depth === 1) { file.message('My rule fires on H1 headings', node.position) } }) },)Key parts:
origin—namespace:rule-name. crackdown surfaces this asruleIdin violations (e.g.my-plugin:my-rule).url— optional link to documentation for this rule.tree: Root— the mdast root. Useunist-util-visitto traverse nodes.file— a VFile. Callfile.message(message, position?)to report a warning. Usefile.fail()for a fatal error (stops processing).
Register the rule
import { myRule } from './my-rule.js'import type { MarkyConfig } from '@crackdown/core'
const config: MarkyConfig = { plugins: [myRule],}
export default configRule options (via plugin tuple)
crackdown supports the unified [plugin, options] tuple convention:
import { lintRule } from 'unified-lint-rule'import type { Root } from 'mdast'
export interface MyRuleOptions { maxLevel?: number}
export const myRule = lintRule<Root, MyRuleOptions>( { origin: 'my-plugin:my-rule' }, (tree, file, options) => { const max = options?.maxLevel ?? 2 // ... },)Register with options:
import type { MarkyConfig } from '@crackdown/core'import { myRule } from './my-configurable-rule.js'
const config: MarkyConfig = { plugins: [ [myRule, { maxLevel: 3 }], // [plugin, options] tuple ],}
export default configWriting an auto-fixer
A Fixer is a pure (content: string) => string function. Register it in config.fixers:
import type { MarkyConfig, Fixer } from '@crackdown/core'import { myRule } from './my-rule.js'
const myFixer: Fixer = (content) => { // Remove all H1 headings (contrived example) return content.replace(/^# .+$/gm, '')}
const config: MarkyConfig = { plugins: [myRule], fixers: [myFixer],}
export default configWhen crackdown lint --fix runs:
- Lint the original content → count violations.
- Apply each fixer in order.
- Re-lint the fixed content → report remaining violations and
fixedCount.
Available node types
crackdown uses the full mdast specification. Common node types:
| Node type | Description |
|---|---|
heading | # H1, ## H2, etc. Has depth: 1–6 |
paragraph | Body text |
code | Fenced or indented code block. Has lang property |
inlineCode | `backtick` text |
link | [text](url) |
image |  |
list | Ordered or unordered list |
listItem | A single list item |
blockquote | > quote |
thematicBreak | --- horizontal rule |
Testing your rule
Use lintString from @crackdown/core to test rules without the CLI:
import { describe, it, expect } from 'vitest'import { lintString } from '@crackdown/core'import { myRule } from './my-rule.js'
describe('myRule', () => { it('fires on H1 headings', async () => { const result = await lintString('# Heading\n', { plugins: [myRule] }) expect(result.violations).toHaveLength(1) expect(result.violations[0]?.ruleId).toBe('my-plugin:my-rule') })
it('does not fire on H2+', async () => { const result = await lintString('## Section\n', { plugins: [myRule] }) expect(result.violations).toHaveLength(0) })})