Custom audits and API
A custom audit is an object with a name, a description and an async run function. Add it to plugins and it behaves like a built-in one: it shows up in npx vidimus list, runs with all or by name, and its findings go through severity, ignore, the baseline and every reporter.
import { defineConfig, type Audit } from 'vidimus';
const noLoremIpsum: Audit = {
name: 'lorem',
description: 'no placeholder copy in the build',
requires: 'server',
async run({ pageUrls }) {
const findings = [];
for (const url of pageUrls()) {
if ((await (await fetch(url)).text()).includes('Lorem ipsum')) {
findings.push({
message: 'placeholder copy',
where: [new URL(url).pathname],
fix: 'Replace the Lorem ipsum text before publishing.',
});
}
}
return { summary: `${findings.length} page(s) with placeholder copy`, findings };
},
};
export default defineConfig({
plugins: [noLoremIpsum],
audits: ['i18n', 'csp', 'a11y', 'links', 'r12s', 'lorem'],
});A plugin with the same name as a built-in audit replaces it.
The Audit object
| Field | |
|---|---|
name | what the CLI, severity, ignore and reports call it |
description | one line, shown by npx vidimus list |
requires | source, dist or server (default) |
exclusive | true to run alone, after the other audits |
run(context) | returns { summary, findings?, status? } |
requires is source (no build), dist (reads files) or server (the default: the build is served over HTTP). It decides what the run prepares: the build must exist unless every selected audit is source, and the static server starts only when some audit is server and no --origin is given.
Audits run concurrently. Set exclusive: true for work that should not share the machine, such as timing measurements; the built-in lighthouse does.
The run context
run receives an AuditContext:
| Field | |
|---|---|
config | the resolved VidimusConfig, every key filled in |
root | absolute project root |
dist | absolute path of the build output (distDir resolved from root) |
origin | where the build is served, without a trailing slash, base path included (http://localhost:4322/project) |
resolve(...segments) | resolves a path from root |
pageUrls(query?) | absolute URLs of the built pages under origin |
builtPages(exclude?) | the built HTML files as { file, rel, path, html }, minus files matching exclude |
log(line?) | adds lines to the audit's log; multi-line strings are split |
importPeer(name) | imports an optional dependency, with an install hint if it is missing |
launchBrowser(options?) | launches puppeteer with the browser config |
pageUrls() lists every HTML file in the build, minus the top-level exclude, and minus translated pages unless allLocales is set. index.html becomes a trailing slash. It takes an optional query:
| Query | |
|---|---|
exclude | URL path patterns to leave out, matched without the base path (^/drafts/) |
allLocales | include translated pages; defaults to the allLocales config |
It throws <dist> has no HTML pages. Rebuild. when nothing is left, which makes the audit error.
importPeer('name') caches the module. When the package is not installed it throws a MissingPeerError: the audit errors with "name" is not installed. Add it as a dev dependency: npm i -D name, and the other audits carry on.
builtPages() reads each file once per run and shares it between audits. exclude patterns match the path relative to dist (blog/index.html); the top-level exclude is not applied, so pass config.exclude to honour it.
launchBrowser(options) needs puppeteer installed. It passes puppeteer's LaunchOptions through, uses browser.executablePath when set, and appends options.args to browser.args. Without options, audits running at the same time share one browser process, each in its own browser context (separate cookies and storage); close() releases your share and the browser closes when the last audit is done. Pass options to get a browser of your own. Either way, close it yourself, in a finally.
The run gives no audit-specific config section to plugins: unknown keys are a config error. Take options through a function instead:
const maxTitle = (max: number): Audit => ({
name: 'title-length',
description: `titles up to ${max} characters`,
requires: 'dist',
async run() {
/* ... */
return { summary: 'ok' };
},
});
export default defineConfig({ plugins: [maxTitle(55)] });Findings
| Field | |
|---|---|
message | what is wrong, one line; also what ignore.message and the baseline match |
where | URL paths the problem was found on; what ignore.where matches |
file | a file the problem is in; shown as in:, used for annotations and the baseline |
details | extra lines, shown under the message |
fix | what to do, shown as → … |
severity | 'warn' for a warning; anything else is an error |
Group identical problems into one finding with many pages in where, as the built-in audits do: the output stays short, and an ignore rule with where can drop single pages. Keep changing numbers out of message and details when you can, because the baseline matches them exactly; see Adopting on an existing site.
Built-in audits write where paths without the base path. new URL(url).pathname, as in the first example, includes it when siteUrl has one; url.slice(origin.length) does not.
Status rules
An audit passes with no findings, warns when every finding is a warning, and fails otherwise, unless it returns a status. In full, after ignore rules and the baseline:
| Returned | Findings left | Status |
|---|---|---|
status: 'skipped' or 'passed' | any | that status; findings are still reported |
| no status | none | passed |
| no status | only warnings | warned (failed with --strict) |
| no status | at least one error | failed |
status: 'failed' | any | failed |
run throws | errored, with the error message as summary |
With severity: 'warn' for the audit, every finding becomes a warning and a failed result becomes warned, unless --strict is set. Return skipped when there is nothing to check, so an unconfigured audit does not look like a pass.
Plugin audits get severity, ignore and the findings baseline for free.
Reading the build
A dist audit reads files and needs no server or browser:
import { globSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { defineConfig, type Audit, type Finding } from 'vidimus';
const noDevUrls: Audit = {
name: 'dev-urls',
description: 'no localhost or staging URLs in the build',
requires: 'dist',
async run({ dist, log }) {
const pattern = /https?:\/\/(localhost|127\.0\.0\.1|staging\.example\.org)[^"'\s)]*/g;
const byUrl = new Map<string, Finding & { where: string[] }>();
const files = globSync('**/*', { cwd: dist }).filter((file) => /\.(html|js|css|xml)$/.test(file));
for (const file of files) {
const text = readFileSync(join(dist, file), 'utf8');
for (const [url] of text.matchAll(pattern)) {
const finding = byUrl.get(url) ?? {
message: `development URL ${url}`,
where: [],
fix: 'Build with the production environment, or replace the hard-coded URL.',
};
const path = `/${file.replace(/(^|\/)index\.html$/, '$1')}`;
if (!finding.where.includes(path)) finding.where.push(path);
byUrl.set(url, finding);
}
}
log(`${files.length} files scanned`);
const findings = [...byUrl.values()];
return {
summary: findings.length ? `${findings.length} development URL(s)` : 'none found',
findings,
};
},
};
export default defineConfig({ plugins: [noDevUrls], audits: ['seo', 'dev-urls'] });Using the server and the browser
A server audit gets origin and pageUrls(), and can drive Chrome through launchBrowser:
import { defineConfig, type Audit, type Finding } from 'vidimus';
const noConsoleErrors: Audit = {
name: 'console',
description: 'no console errors on page load',
async run({ origin, pageUrls, launchBrowser }) {
const browser = await launchBrowser();
const findings: Finding[] = [];
try {
for (const url of pageUrls({ exclude: ['^/embed/'] })) {
const page = await browser.newPage();
const errors: string[] = [];
page.on('console', (message) => {
if (message.type() === 'error') errors.push(message.text());
});
await page.goto(url, { waitUntil: 'networkidle0' });
await page.close();
if (errors.length) {
findings.push({
message: 'console errors on load',
where: [url.slice(origin.length) || '/'],
details: errors,
fix: 'Open the page with the browser console and fix the errors listed.',
});
}
}
} finally {
await browser.close();
}
return { summary: `${findings.length} page(s) with console errors`, findings };
},
};
export default defineConfig({ plugins: [noConsoleErrors] });Custom reporters
reporters also accepts objects with onStart, onAuditEnd and onEnd hooks. See Reporters.
Programmatic use
Programmatic use: import { run } from 'vidimus' and await run({ audits: ['links'] }).
import { run } from 'vidimus';
const report = await run({
cwd: '/path/to/site',
audits: ['seo', 'links'],
set: ['links.checkExternal=false'],
overrides: { siteUrl: 'https://example.org' },
reporters: [],
});
if (!report.ok) process.exitCode = 1;run loads the config as the CLI does, selects the audits, runs them and resolves with the RunReport (the shape of the JSON reporter). It does not set the exit code. Its options:
| Option | |
|---|---|
audits | names or ['all']; empty means the config's audits |
cwd | where the config is searched and relative paths resolve; default process.cwd() |
configFile | a config file path, or false for none |
env | environment for VIDIMUS_* variables and config functions; default process.env |
set | key.path=value strings, as with --set |
overrides | a partial config applied last, like CLI flags |
config | a complete VidimusConfig, skipping loading altogether |
reporters | Reporter objects; default: the config's reporters, or pretty (plus github on GitHub Actions) |
Pass reporters: [] for a silent run. Usage and config problems reject with a UsageError; audit failures and errors are in the report, not thrown.
The lower-level pieces are exported too, for tools that need their own flow:
import { auditRegistry, createReporters, loadConfig, runAudits, selectAudits } from 'vidimus';
const { config } = await loadConfig({ configFile: 'vidimus.config.ts' });
const audits = selectAudits(auditRegistry(config), ['seo'], config);
const reporters = createReporters(['pretty', 'json:report.json'], { cwd: process.cwd(), env: process.env });
const report = await runAudits(config, audits, reporters);Exports
| Export | |
|---|---|
defineConfig | returns the config or config function unchanged, typed |
run, runAudits, selectAudits, auditRegistry | run audits, see above |
loadConfig, defaults(cwd), CONFIG_FILES, DEFAULT_AUDITS | config loading, every default, the file names searched, the default audit list |
createReporters, REPORTERS | build reporters from names, the built-in names |
builtinAudits, and each audit: i18n, csp, a11y, links, r12s, seo, security, html, budget, assets, privacy, shots, lighthouse | the built-in Audit objects, to wrap or reuse |
UsageError, MissingPeerError | error classes; MissingPeerError has a peer field |
Types: Audit, AuditContext, AuditOutcome, AuditRequirement, AuditResult, AuditStatus, Finding, PageQuery, RunReport, Severity, RunOptions, Reporter, RunInfo, VidimusConfig, UserConfig, ConfigInput, ConfigEnv, AuditSeverity, IgnoreRule, HeaderRule, Pattern, Range, ViewportSize, LoadConfigOptions, LoadedConfig, AcceptedFinding, BaselineFile, and puppeteer's Browser, Page and LaunchOptions. The puppeteer types resolve to any when puppeteer is not installed, so the published types compile without it.