blog-validate
Use when checking a blog post in data/blog before committing, publishing, or after any edit to a post body or frontmatter — catches the MDX silent-drop bug, unencoded DOIs, and Velite schema violation
SKILL.md.claude/skills/blog-validate/SKILL.md
GitHubValidate a blog post
Velite type-checks frontmatter but never inspects the body. MDX parses a raw < in prose
as the start of a JSX tag, so a post can vanish from the built site with no build error.
That is the bug this guards against. It has shipped here before — commit 974922b.
Run it
yarn validate data/blog/2026/my-post.mdx # one post
yarn validate:all # all 33 posts across every year
Exit code is 1 if there are errors, 0 if only warnings. Warnings never block.
Run this before blog-publish and after any edit to a post body.
Reading the output
Errors — these break the site
| Rule | Meaning | Fix |
|---|---|---|
raw-angle-bracket |
A bare < in prose. MDX reads it as JSX and drops the post. |
Escape as \<, wrap in backticks, or percent-encode if in a URL. |
doi-encoding |
Unencoded </> inside a DOI. |
< → %3C, > → %3E. Legacy SICI DOIs hit this constantly. |
unknown-element |
Lowercase tag that is not real HTML — usually prose, not a tag. | Escape the <. |
undefined-component |
Capitalized tag. JSX resolves it as a variable, and it is not defined. | Only Image and TOCInline exist (components/MDXComponents.tsx). |
html-comment |
<!-- --> is invalid in MDX v2. |
Use {/* ... */}. |
required-field |
Missing title or date. |
Velite requires both; the build fails. |
date-format |
Date.parse cannot read the date, so s.isodate() rejects it. |
Use YYYY-MM-DD: date: '2026-07-17'. |
unknown-author |
authors: entry with no file in data/authors/. |
Use default, or add the author file. |
Warnings — worth knowing, never fatal
| Rule | Meaning |
|---|---|
draft-post |
draft: true — the post is deliberately held back from listings, its own route, the sitemap, and RSS. Expected while drafting; check it before publishing. |
dead-frontmatter |
tags:. Not in the Velite schema and not read anywhere; silently stripped at build. The tag UI was removed. |
unknown-frontmatter |
Key outside the schema; silently stripped at build. |
unquoted-date |
YAML parsed the date as a timestamp. Valid, but quote it to match other posts. |
nonstandard-date |
Parseable but not YYYY-MM-DD (e.g. '2024-06-04 00:00:00'). Velite accepts and normalizes it; three 2024 posts use this shape. House style only. |
missing-summary |
No summary; post listings will have nothing to show. |
year-mismatch |
File is in data/blog/<year>/ that disagrees with date. Convention only. |
Interpreting results for the user
- Report errors plainly and stop. Do not publish a post with errors.
- Do not auto-fix silently. Show the offending line and the fix, then let the user confirm — an escaped character changes rendered prose.
- Do not strip
tags:from existing posts as a side effect of validating. Most posts carry it; removing it is a separate decision. - Never flip
drafton the author's behalf. Adraft-postwarning is information, not a defect — publishing is their call, and clearing the flag puts the post on the live site.
Adding a rule
Rules live in scripts/validate-post.mjs, tests in scripts/validate-post.test.mjs.
Every rule must have a test, and the whole real corpus must stay at zero errors:
yarn test
A validator that cries wolf on real posts gets ignored, which is worse than no validator. If a new rule flags any of the 33 published posts, the rule is wrong — not the post. The corpus test walks every year directory for exactly this reason: checking only the recent years once hid a false positive in the 2024 posts.
Next step
Clean? → blog-publish to build, verify, and commit.