@neomanex/vitepress-docs-lint
VitePress Docs Lint
@neomanex/vitepress-docs-lint is a dependency-light linter for VitePress markdown sources. It catches the two content faults that break a VitePress build late and with a misleading error: frontmatter YAML that fails to parse, and bare <placeholder> text that the Vue template compiler reads as an unclosed HTML element. Unlike the build itself, it reports every fault in every file at once, each as path:line: reason pointing at the line the author wrote. Published by Neomanex.
npx @neomanex/vitepress-docs-lint
What it checks
| Check | Fault | Why the build cannot tell you |
|---|---|---|
| Frontmatter | YAML that does not parse, typically an unquoted colon: title: Setup: the basics |
VitePress fails deep inside the bundler with a parser stack trace that names no page |
| Body | A bare angle-bracket construct such as <slug> or <user id> that names no known HTML or VitePress tag and never closes |
VitePress compiles each page as a Vue component; the error position is in the transformed component, not your file, and the build stops at the first offending page |
The body check parses each page with markdown-it, configured as VitePress configures it, and inspects only the raw HTML the parser passes through to the Vue template compiler. Markdown that the parser turns into text or code never reaches the check.
What is never a fault:
- Known HTML and VitePress tags (
<br>,<wbr>,<details>...</details>,<Badge>), self-closing elements (<Foo/>) and any element whose closing tag appears in the page - Code spans, fenced blocks (
```or~~~) and indented code blocks. Code spans end where CommonMark ends them, at a paragraph, heading, list item or table cell, so a single stray backtick never hides a fault elsewhere - Autolinks (
<https://example.com>,<ops@example.com>), backslash-escaped brackets (\<id>), HTML comments, and prose such asR<G<Bthat is not a valid HTML tag
Known limitation: a known HTML tag that is opened and never closed (a <div> with no </div>) is not reported, although it also breaks the build.
A page with both a frontmatter fault and a body fault reports both. One faulty file never hides another.
Install
Requires Node.js 20 or newer.
npm install --save-dev @neomanex/vitepress-docs-lint
Which files are linted
The linter reads docs.config.json at the repository root and lints the markdown under each declared source root:
{
"name": "example",
"sources": [
{ "name": "docs", "root": "docs" },
{ "name": "guides", "root": "./guides" }
]
}
Only sources[].root is read. Without a docs.config.json the linter prints no docs.config.json, nothing to lint and exits 0, so the same hook is safe to install in repositories that publish no docs.
Withheld paths are left out of the body check (their frontmatter is still checked), because a site that does not publish them never compiles them. The defaults are:
**/security/** **/results/** **/_TEMPLATE.md **/RUN-*.md
Globs match the path relative to the source root, with numeric ordering prefixes stripped from each segment (01-guide/_TEMPLATE.md is matched as guide/_TEMPLATE.md). Supported syntax: * and ? within one segment, and ** as a whole segment for zero or more directories.
CLI
vitepress-docs-lint [--root <dir>] [--withhold <glob>]... [--no-default-withhold] [files...]
| Option | Effect |
|---|---|
--root <dir> |
Repository root holding docs.config.json (default: current directory) |
--withhold <glob> |
Also leave matching files out of the body check. Repeatable |
--no-default-withhold |
Drop the default withhold globs |
-h, --help |
Show usage |
files... |
Lint only these files. Non-.md files and files outside every declared root are ignored. Passing docs.config.json lints every root. Paths resolve against the current directory |
With no files, every declared source root is linted.
Output:
docs/setup.md:3: invalid frontmatter YAML: bad indentation of a mapping entry (quote values that contain ": ")
docs/setup.md:42: unclosed element <slug>: Vue reads it as an HTML tag; wrap it in backticks or close it
2 fault(s) in 1 file(s)
Exit codes
| Code | Meaning |
|---|---|
| 0 | No faults, or no docs.config.json (nothing to lint) |
| 1 | One or more faults, all listed |
| 2 | Unexpected error: malformed docs.config.json, bad arguments, unreadable file |
Exit 1 is reserved for content faults, so a script can tell "fix these pages" from "the tool could not run". A malformed manifest or bad arguments print one line naming the problem; only a genuinely unexpected error prints a stack trace.
Pre-commit
pre-commit installs Node and the package itself, so contributors need nothing preinstalled:
repos:
- repo: local
hooks:
- id: vitepress-docs-lint
name: vitepress-docs-lint
language: node
entry: vitepress-docs-lint
additional_dependencies: ['@neomanex/vitepress-docs-lint@0.1.0']
files: (\.md|(^|/)docs\.config\.json)$
stages: [pre-commit]
Only staged files are linted. Staging docs.config.json lints every declared root.
A docs.config.json change, in the hook or in CI, lints every declared root as it is on disk, tracked or not, so an untracked draft under a root is linted too.
CI
Lint only the markdown a change touches (any CI system with git and Node, POSIX sh or later):
list="$(mktemp)" || exit 2
git diff -z --name-only --diff-filter=ACMR "$BASE_SHA" HEAD -- \
':(top,glob)**/*.md' ':(top,literal)docs.config.json' > "$list" || exit 2
if [ -s "$list" ]; then
xargs -0 npx --yes @neomanex/vitepress-docs-lint@0.1.0 < "$list"
fi
File names travel NUL-separated, so a name with spaces or quotes reaches the linter as one argument. The list goes through a file rather than a pipe, so a failing git diff fails the step even where the shell has no pipefail. A change with no markdown exits 0 without running the linter (run with no files, it lints every declared root). When the linter reports faults or cannot run, the step fails (xargs exits non-zero: 123 with GNU xargs) and the linter's own output names each fault.
GitHub Actions, linting the whole corpus:
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
- run: npx --yes @neomanex/vitepress-docs-lint@0.1.0
Programmatic API
import {
lintFrontmatter,
findUnclosedElements,
lintFiles,
lintRoot,
readManifest,
DEFAULT_WITHHOLD
} from '@neomanex/vitepress-docs-lint'
lintFrontmatter(source) // {line, reason} | null
findUnclosedElements(source) // [{line, text, reason}, ...]
lintFiles(paths, { skip }) // [{path, line, reason}, ...] sorted by path, then line
lintRoot(dir, { recursive, skip, withhold })
readManifest(dir) // parsed docs.config.json | null; throws ManifestError when malformed
skip(path) returning true leaves a file out of the body check only. withhold takes globs, matched as described above. Results are deterministic: sorted by path, then line.
License
MIT. See LICENSE.
About Neomanex
Neomanex is an AI-native company. We run our own business on an AI Operating Model, publish the evidence, and help other companies become AI native: agents, operations, and the infrastructure underneath them, built and governed in production.
This project is part of that work. It is the same code we run ourselves.
- Website: https://neomanex.com
- Work with us: https://neomanex.com/contact
- Products: Gnosari (conversational data collection: AI agents that turn conversations into structured data) and ConvOps (AI-first operations)
Related Packages
A VitePress plugin that automatically identifies and links glossary terms with i...
Official VitePress & Vite plugin for auditing llms.txt link health and compiling...
Version History
| Version | Released | License |
|---|---|---|
| 0.1.0 | MIT |
Pre-releases (1)
| Version | Released | License |
|---|---|---|
| 0.0.0-stage |