If youโve ever worked on a large-scale documentation site, you know the silent killer of consistency: human error in Markdown files.
In webpack-doc-kit (the coming documentation engine for Webpack), every Markdown page has a YAML frontmatter title and a Markdown H1 heading (# Title). Ideally, they should always match ๐
:
---
title: "TypeScript"
---
# TypeScript
But in an open-source project with a lot of pages and contributors, things quickly drift out of sync. You end up with pages where the frontmatter says title: Typescript (lowercase 's'), but the H1 says # TypeScript.
This mismatch breaks page titles, confuses search indexing, and ruins the overall consistency of the docs. We needed a way to enforce this automatically in our CI/CD pipeline and fix it effortlessly.
Here is the story of how I first solved this with a standalone Node.js script, why that approach wasn't enough, and how a single code review review pushed me to build a Custom ESLint Rule powered by Markdown ASTs.
Attempt 1: The "Brute Force" Node.js Script
When you need to validate files, I'm often writing a custom CLI script.
My first iteration (scripts/lint/frontmatter-title.mjs) was a Node.js tool. It recursively traversed the pages/ directory, read each .md file, and parsed it using gray-matter library:
// A simplified look at the first approach
const content = await readFile(fullPath, 'utf8');
const parsed = matter(content);
const frontmatterTitle = parsed.data.title;
const h1Match = parsed.content.match(/^#\s+(.+)$/m);
const h1Title = h1Match ? h1Match[1].trim() : null;
if (frontmatterTitle && h1Title && frontmatterTitle !== h1Title) {
if (isFixMode) {
parsed.data.title = h1Title;
await writeFile(
fullPath,
matter.stringify(parsed.content, parsed.data, { lineWidth: -1 }),
'utf8'
);
console.log(
`[lint] Fixed: Updated frontmatter title to "${h1Title} in ${file}`
);
} else {
console.error(`[lint] Mismatch in ${file}`);
hasErrors = true;
}
}
I hooked this into package.json as npm run lint:md and npm run lint:md:fix.
Why wasn't this enough?
It worked! It caught mismatches and fixed them. But from an architectural and Developer Experience (DX) standpoint, it had major drawbacks:
Regex Fragility: Using regular expressions (
/^#\s+(.+)$/m) to find Markdown H1s can easily break on edge cases, I don't know one, but it's software engineering sense ๐No IDE Integration: Because it was an external CLI script, developers didn't see red error lines inside VS Code while typing. They only found out they made a mistake when they ran the command (or worse, when CI failed ๐คฆ๐ปโโ๏ธ).
The POV: "Can we make this an ESLint plugin?"
When I opened the PR#189, Webpack maintainer Aviv Keller reviewed the approach and asked a very nice question:
"Can we make this an ESLint plugin?"
He shared a skeleton showing how we could hook into ESLint instead. That review changed the entire path of the task.
Instead of treating Markdown as "just text" to be scraped with Regex, why not treat it as an Abstract Syntax Tree (AST)? With modern ESLint (specifically ESLint v9+ Flat Config and @eslint/markdown), ESLint isn't just a JavaScript linter anymore. it can natively parse Markdown AST nodes!
By migrating to an ESLint rule:
True AST Accuracy: We parse actual Markdown headings.
Instant IDE Feedback: Errors appear inline in the developer's editor instantly.
Building the Custom Rule (eslint-local-rules/frontmatter-title.mjs)
We deleted the standalone script and created a custom local ESLint rule. But to make it production-ready for Webpack's ecosystem, we had to handle edge cases that a basic implementation would miss.
Here is how the rule works under the hood:
1. Hooking into Markdown AST Nodes
We use the heading(node) visitor to locate the H1 node (depth === 1), and then evaluate the whole file when exiting the AST root ("root:exit"):
import matter from 'gray-matter';
export default {
meta: {
type: 'problem',
fixable: 'code',
messages: {
mismatch: 'Frontmatter title "{{fm}}" does not match H1 "{{h1}}".',
missingFrontmatterTitle: 'Missing frontmatter title.',
missingH1Title: 'Missing H1 title.',
},
},
create(context) {
const { sourceCode } = context;
let h1Node = null;
return {
heading(node) {
if (node.depth === 1 && !h1Node) h1Node = node;
},
'root:exit'() {
const fmTitle = matter(sourceCode.text).data.title;
const h1Title = h1Node
? sourceCode.getText(h1Node).replace(/^#\s+/, '').trim()
: null;
// Validation logic and fixers go here...
}
};
}
};
2. Smart Fixers (Auto-Fixing Edge Cases)
A linter that only complains is annoying; a linter that fixes your code automatically is a superpower.
We designed the fix(fixer) method to handle two distinct scenarios:
-
Scenario A: Title Mismatch: If both titles exist but don't match, we replace the
title:line in the YAML frontmatter to match the H1:
if (fmTitle && h1Title && fmTitle !== h1Title) {
context.report({
node: h1Node,
messageId: 'mismatch',
data: { fm: fmTitle, h1: h1Title },
fix(fixer) {
const match = /^title:.*$/m.exec(sourceCode.text);
if (match) {
return fixer.replaceTextRange(
[match.index, match.index + match[0].length],
`title: ${h1Title}`
);
}
},
});
}
-
Scenario B: Missing Frontmatter Title Entirely: What if a contributor wrote
# TypeScriptbut forgot to addtitle:in the frontmatter? Our fixer dynamically checks if the Markdown file already has a---YAML block. If it does, it injectstitle: ...right after the opening dashes. If the file has no frontmatter at all, it prepends a brand-new YAML block to the top of the file:
} else if (!fmTitle && h1Title) {
context.report({
node: h1Node,
messageId: 'missingFrontmatterTitle',
fix(fixer) {
const hasFrontmatter = sourceCode.text.startsWith('---');
if (hasFrontmatter) {
const match = /^---\r?\n/.exec(sourceCode.text);
if (match) {
return fixer.insertTextAfterRange(
[0, match[0].length],
`title: ${h1Title}\n`
);
}
} else {
// Prepend a completely new frontmatter block
return fixer.insertTextBeforeRange(
[0, 0],
`---\ntitle: ${h1Title}\n---\n\n`
);
}
},
});
}
-
Scenario C: Knowing When NOT to Auto-Fix: What if the
titleexists in the frontmatter, but the contributor completely forgot to write anH1heading in the Markdown body? We can't safely auto-generate an H1 because we don't know where the author intended to place it. For this edge case, we explicitly report the error without attempting a destructive auto-fix, requiring manual action:
} else if (!h1Title) {
// Missing H1, should put it manually.
context.report({
loc: { line: 1, column: 0 },
messageId: 'missingH1Title',
});
}
- Scenario D: There's no title at all: we do nothing for that scenario. The reason is simple: we can't guess ๐
if (!fmTitle && !h1Title) return;
Tying It All Together in eslint.config.mjs
With ESLintโs Flat Config, integrating @eslint/markdown and our custom rule took just a few lines.
We also made sure to exclude auto-generated documentation directories (docs/api, docs/loaders) and layout-only pages (like 404.md or index.md) that intentionally don't follow the standard H1 structure:
import markdown from '@eslint/markdown';
import frontmatterTitle from './eslint-local-rules/frontmatter-title.mjs';
export default [
// ... JS configs ...
{
files: ['pages/**/*.md'],
ignores: [
'pages/docs/api',
'pages/docs/loaders',
'pages/docs/plugins',
'pages/404.md',
'pages/index.md',
],
plugins: {
markdown,
local: {
rules: {
'frontmatter-title': frontmatterTitle,
},
},
},
language: 'markdown/commonmark',
rules: {
'local/frontmatter-title': 'error',
},
},
];
Why This mattered
Checking if two strings match seems like a simple task. But in large-scale open-source engineering, how you integrate a solution is just as important as the solution itself.
By moving from a standalone script to an ESLint Markdown rule:
- We eliminated an extra CI step and deleted over 100 lines of manual file-traversal code.
- We gave contributors immediate visual feedback inside their IDEs.
- We took advantage of modern AST parsing instead of fragile regular expressions.
A massive thank you to Aviv Keller for pointing me toward the ESLint AST approach during the PR review. It was a great reminder that open-source code reviews aren't just about catching bugs; they're about leveling up your architectural thinking ๐ค
Have you ever built custom ESLint rules for non-JS files like Markdown or JSON? Let me know in the comments below ๐
Top comments (0)