DEV Community

mrnoyy
mrnoyy

Posted on

Writing a Frontmatter Parser in 60 Lines Instead of Adding a Dependency

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
---
Enter fullscreen mode Exit fullscreen mode

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]*)$/);
Enter fullscreen mode Exit fullscreen mode

Three details in that regex, each learned the hard way:

  • \r?\n everywhere, 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/, '');
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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`);
Enter fullscreen mode Exit fullscreen mode

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)