Writing lumen plugins
A lumen plugin is a local module exporting rules that run inside the audit engine — same scoring, same gates, same provenance. Node-only (plugins never load in the Worker).
The rule shape
// lumen-rules.mjs
export const myRule = {
id: 'my-rule', // unique, kebab-case
severity: 'warning', // error | warning | info
categories: ['custom'], // --only <category> runs it
async check(page, o) { // page: url/status/headers/dom/bytes/timingMs
return page.dom('a[href]').length > 500
? [{ ruleId: 'my-rule', severity: 'warning',
message: '500+ links on one page',
evidence: { selector: 'a[href]' },
fixHint: 'prune the link list to what users actually click' }]
: [];
},
};
{ "plugins": ["./lumen-rules.mjs"] }
severityOverrides in your config override any plugin's severity, exactly like built-ins.
The three kinds
page rules run per fetched page (check(page)).crawl and site rules are audit-local extensions: crawl rules receive the post-crawl index (checkCrawl(index, o)), site rules run once per audit against retained robots/sitemap evidence (checkSite(ctx)). Every crawl/site issue MUST carry url so it lands on an owning page. Rules NEVER fetch — fetch loops belong to the engine.
fixHint + helpUrl
Built-in rules ship a fixHint and a stable docs anchor; plugin rules get whatever fixHint they emit (no anchor — helpUrl is engine-controlled). Renderers print "no fix hint provided" for plugin issues without one, so always include one.
The test kit
$ npm i @lumen-seo/audit @lumen-seo/audit/testing
@lumen-seo/audit/testing exports the fake fetcher (the core-fetcher contract, zero network), deterministic crawl deps, and the cheerio page factory — the same kit lumen's own suite uses.
Rendered DOM (SPA)
The optional @lumen-seo/plugin-render package wraps the engine with a Playwright transport (peer dependency, explicitly outside core). Reports it produces carry the honesty label renderer: "rendered" — rendered evidence is different evidence.
Inherited honesty rules
- Never fetched → never judged; report unknown, not a pass.
- Sample sizes and thresholds belong in the message.
- Heuristics are labeled as heuristics.
- No page-derived text beyond the sanitize allowlist survives into reports.
A starter template lives at nitishagar/lumen-plugin-template — clone it, rename the rule, npm test.