Most markdown pipelines pull in a YAML library to read the block at the top of the file. If your frontmatter is a handful of strings, a boolean, and a list of tags, you can skip it. Here is the version I use, and the parts I got wrong first.
What the block actually contains
---
title: "My Post"
tags: [webdev, node]
canonical_url: https://example.com/blog/my-post
published: true
---
Strings, one array, one boolean. That is a tiny subset of YAML. Real YAML has anchors, multi-line scalars, nested maps, and a type-coercion table that has surprised people for years. None of it is in my files.
Splitting the file
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
Three details in that regex, each learned the hard way:
-
\r?\neverywhere, because a file edited on Windows and committed without normalization has CRLF line endings and a\n-only pattern silently fails to match. -
[\s\S]*?non-greedy, so a---horizontal rule later in the body does not become the closing delimiter. - The
^anchor, so a---in the middle of a document is not mistaken for an opening block.
And before any of that:
const raw = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '');
A byte order mark at the start of the file means the first character is not -, the regex does not match, and the error message says the frontmatter is missing when you can see it right there. That one cost me a genuinely embarrassing amount of time.
Parsing the keys
Line by line, with one piece of state for list continuation:
function parseFrontmatterBlock(block) {
const out = {};
let currentKey = null;
for (const line of block.split(/\r?\n/)) {
if (!line.trim()) continue;
const dashItem = line.match(/^\s*-\s+(.*)$/);
if (dashItem && currentKey) {
if (!Array.isArray(out[currentKey])) out[currentKey] = [];
out[currentKey].push(unquote(dashItem[1]));
continue;
}
const kv = line.match(/^([A-Za-z0-9_]+)\s*:\s*(.*)$/);
if (!kv) continue;
const [, key, rawValue] = kv;
currentKey = key;
const value = rawValue.trim();
if (value === '') {
out[key] = [];
} else if (value.startsWith('[') && value.endsWith(']')) {
out[key] = value.slice(1, -1).split(',').map((v) => unquote(v.trim())).filter(Boolean);
} else if (value === 'true' || value === 'false') {
out[key] = value === 'true';
} else {
out[key] = unquote(value);
}
}
return out;
}
The dashItem branch has to come first. A dashed list item like - something: else also matches the key-value pattern, and if you check that one first you get a key named - something.
The URL trap
The obvious key-value regex is /^(\w+):\s*(.*)$/. Now parse this:
canonical_url: https://example.com/blog/post
Split on the first colon and you are fine. Split on every colon, or use a lazy value group, and you get https as the value. Match the key against [A-Za-z0-9_]+ explicitly and take the rest of the line as the value, whatever is in it.
Validate at the boundary
Parsing is the cheap part. The value comes from failing loudly:
if (!meta.title) throw new Error(`${basename}: "title" is required`);
if (!body.trim()) throw new Error(`${basename}: body is empty`);
A file with a typo in a key name should stop the run, not publish an untitled post. My CI runs a check command over every file before anything touches the network, so a bad file fails in two seconds instead of halfway through a batch.
When to just use the library
Reach for a real parser the moment you need nested objects, multi-line values, dates as actual Date objects, or files written by people who did not write the parser. Sixty lines is worth it when you control both ends and the schema is five fields. It is not worth it as a matter of principle.
The point is not that dependencies are bad. It is that "read five known keys from a fenced block" is a smaller problem than "parse YAML", and the small problem has a small solution.
Top comments (0)