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.