How I built a two-path YAML frontmatter parser for @ directive collisions

typescript dev.to

Early in this project I wrote an article about @astrojs/sitemap and titled it exactly that way in the frontmatter:

title: @astrojs/sitemap: why sitemap-0.xml and not sitemap.xml on small sites
Enter fullscreen mode Exit fullscreen mode

Four months later, that article broke my publish pipeline. The yaml npm package (eemeli/yaml) rejects plain scalars starting with @ — it's a reserved indicator in YAML 1.1 and older parser implementations throw on it rather than returning the string. My pipeline called yaml.parse() on every frontmatter block and got:

YAMLParseError: Unexpected scalar at node end
Enter fullscreen mode Exit fullscreen mode

I had three options. Quote every legacy title retroactively. Add an escaping layer at write time. Or implement a fallback path. I chose the fallback — but the implementation has more to it than it looks, and there's a part I regret.

Why @ breaks YAML parsing

YAML reserves @ and the backtick as "reserved indicators" that mark the start of a plain scalar for future use. A plain scalar (unquoted string value) that starts with @ is technically invalid in YAML 1.1. The yaml package raises a parse error rather than silently treating it as a string.

The problem only surfaces with plain scalars. If the title were quoted:

title: "@astrojs/sitemap:whysitemap-0.xmlandnotsitemap.xmlonsmallsites"
Enter fullscreen mode Exit fullscreen mode

yaml.parse() would handle it fine. But the first article I wrote about @astrojs/sitemap predated my current tooling. It was written with a simpler template that didn't quote values, and the title went in without delimiters. Retroactively quoting it would trigger a re-publish on Dev.to (same article, same slug, but the frontmatter round-trip produces different bytes), which risks creating a duplicate.

Four other early articles had the same pattern. Rather than touch them, I added a second parse path.

The dual-path implementation

The parser in packages/publish/src/parse.ts tries strict YAML first. If that throws, it runs a hand-rolled line-by-line parser that treats every value after the colon as a raw string:

const FM_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/;

export async function loadArticle(filePath: string): Promise<Article> {
  const raw = await readFile(filePath, "utf8");
  const match = raw.match(FM_RE);
  if (!match) throw new Error(`Missing frontmatter delimiter in ${filePath}`);
  const fmRaw = match[1] ?? "";
  const body = (match[2] ?? "").trim();

  let fm: Record<string, unknown>;
  try {
    fm = (parseYaml(fmRaw) as Record<string, unknown>) ?? {};
    if (typeof fm !== "object" || fm === null) fm = {};
  } catch {
    fm = parseLegacyFrontmatter(fmRaw);
  }

  if (!fm["title"] || typeof fm["title"] !== "string") {
    throw new Error(`Missing or invalid 'title' in frontmatter of ${filePath}`);
  }
  return { filePath, frontmatter: fm as unknown as ArticleFrontmatter, body };
}
Enter fullscreen mode Exit fullscreen mode

The fallback parseLegacyFrontmatter() is deliberately limited. It handles strings, quoted strings, and inline [...] arrays. It does not handle block YAML (indented sequences and mappings), because the articles that need the fallback predate the summary_data multi-line block format by months. Trying to parse block YAML with a line scanner would be worse than not trying.

function parseLegacyFrontmatter(fmRaw: string): Record<string, unknown> {
  const fm: Record<string, unknown> = {};
  for (const line of fmRaw.split(/\r?\n/)) {
    if (!line.trim() || line.trimStart().startsWith("#")) continue;
    const m = line.match(/^([a-z_][a-z0-9_]*)\s*:\s*(.*)$/i);
    if (!m) continue;
    const key = m[1]!;
    const valRaw = (m[2] ?? "").trim();
    if (valRaw === "") continue;
    if (valRaw.startsWith("[") || valRaw.startsWith("{")) {
      try {
        fm[key] = JSON.parse(valRaw);
        continue;
      } catch { /* fall through */ }
    }
    if (
      (valRaw.startsWith('"') && valRaw.endsWith('"')) ||
      (valRaw.startsWith("'") && valRaw.endsWith("'"))
    ) {
      fm[key] = valRaw.slice(1, -1);
    } else {
      fm[key] = valRaw;
    }
  }
  return fm;
}
Enter fullscreen mode Exit fullscreen mode

The key insight is that the @ title articles have simple frontmatter — title, description, tags (inline array), publish_to (inline array). The line-by-line parser handles all of those without needing block support. The fallback is narrowly targeted at what it needs to solve.

One thing this does NOT do: parse the quality_contract: "v2" block fields — specifically search_intent and original_evidence, which can span multiple lines with YAML flow scalars. Legacy articles don't have those fields, so this is fine. New articles always pass the strict parser.

The saveArticle round-trip

Reading articles is only half the problem. The publish step also writes back to the source file — specifically, it injects published_urls and published_at after the article ships to Dev.to and Hashnode.

If I naively called yaml.stringify() on the full frontmatter object, two things would break:

  1. summary_data (a nested object with title_html, stats, pipeline) would collapse to an unreadable one-liner
  2. Simple fields like title and tags would get YAML block notation when they should stay inline

The round-trip function in saveArticle() distinguishes between two categories:

const INLINE_KEYS = new Set([
  "title", "description", "tags", "canonical_url", "cover_image",
  "summary_image", "publish_to", "published_urls", "published_at",
]);

const INLINE_ORDER = [
  "title", "description", "tags", "canonical_url", "cover_image",
  "summary_image", "publish_to", "published_urls", "published_at",
];
Enter fullscreen mode Exit fullscreen mode

Fields in INLINE_ORDER emit as a single JSON-like line (using JSON.stringify for the value if it contains colons or quotes, raw string otherwise). Everything else — including summary_data, quality_contract, primary_keyword, search_intent, verified_at, original_evidence — goes through yaml.stringify() as a block.

The check that routes a value to block format:

const isComplex =
  (typeof v === "object" && !Array.isArray(v)) ||
  (Array.isArray(v) && v.some((x) => typeof x === "object" && x !== null));

if (isComplex) {
  lines.push(...blockLines(k, v));
} else {
  const line = inlineLine(k, v);
  if (line) lines.push(line);
}
Enter fullscreen mode Exit fullscreen mode

Nested objects go to block; flat values stay inline. This preserves Dev.to and Hashnode's frontmatter parsing expectations, which vary from strict YAML (and I wrote about some of the edge cases in the canonical URL chain article).

The published_urls field is worth a specific note. It's a flat object (string values only), so it doesn't trigger the isComplex branch — it stays inline. But it contains colons and URLs:

published_urls: {"devto":"https://dev.to/morinaga/...","bluesky":"https://bsky.app/..."}
Enter fullscreen mode Exit fullscreen mode

That's JSON.stringify() applied to a string value containing colons. It's ugly but it survives every YAML parser I've tested it against, and the internal link resolution that reads back published_urls uses the same yaml.parse() path, so it round-trips correctly.

What the quality gate enforces

The audit-articles.mjs quality gate runs yaml.parse() on every article's frontmatter before publish. An article that would fail strict YAML parse is caught at the gate, not at runtime.

This is the enforcement boundary: the gate rejects articles with reserved-indicator values at write time. An article that passes the gate will parse correctly through the strict path. The fallback path exists for the 20-odd legacy articles that predate the gate.

What the gate does NOT enforce: whether titles are YAML-safe at the template level. I write articles through a generation routine, and nothing in the prompt template says "do not start the title with @". That's a runtime catch, not a constraint.

The quality_contract v2 fields added in August 2026 include primary_keyword and search_intent, which can contain colons. The generation routine wraps those in double quotes when writing. That's the right fix in the right place — write-time quoting rather than read-time fallback. I got there eventually.

What I'd do differently

The fallback parser solves the problem but it's the wrong abstraction. What I actually want is:

  1. Write-time validation: when the routine writes a new article, run yaml.parse() on the frontmatter before saving. If it fails, quote the offending values automatically.
  2. One-time migration: add quotes to the five legacy titles. The risk of duplicate Dev.to posts is smaller than I thought — the platform deduplicates on the canonical_url, not on raw body bytes.

The dual-path parser is a form of backwards compatibility shim. It works, but every time I read the code I wonder whether the legacy articles will eventually need block fields, and the answer is: probably not, but "probably not" is not "definitely not." If I ever add summary_data to a legacy article, the fallback parser would silently discard it. There's no error; the article just publishes without the Bluesky visual card. That's a class of bug I wouldn't catch until I noticed the Bluesky post looked wrong.

Comparison: strict YAML vs. line-by-line parser

Capability yaml.parse() Line-by-line fallback
Nested objects (summary_data) Yes No
Multi-line flow scalars Yes No
Inline arrays Yes Yes (JSON.parse)
@ reserved indicators Throws Treats as string
Backtick at value start Throws Treats as string
YAML anchors / aliases Yes No

The fallback handles exactly what legacy articles need and nothing more. If an article needing a feature in the left column passes through the fallback path, that feature silently disappears. The current code has no warning for that.

The right long-term fix is to make the strict path the only path, which means quoting all titles at the source. That work is on the list, but it hasn't moved above the content strategy changes that are running right now.

FAQ

Why doesn't yaml.parse() just return the @ value as a string?

The YAML 1.1 spec reserves @ and the backtick as "reserved indicators" — the idea being that future YAML versions could assign them meaning. Strict parsers (including the yaml npm package) choose to throw on reserved indicators in plain scalars rather than silently treating them as strings. This is spec-compliant but surprising when you first hit it.

Why not use gray-matter instead of yaml?

gray-matter has its own frontmatter parser that internally calls js-yaml, which also throws on reserved indicators. The problem isn't the library, it's the YAML spec decision. Any strict YAML parser has the same behavior.

What happens if both parse paths fail?

The loadArticle function throws "Missing or invalid 'title' in frontmatter". The publish step fails fast and the article stays unpublished. The pipeline failure handling treats this as an article-specific error (not a systemic one), so other articles in the same run still ship.

Does this affect the Dev.to or Hashnode APIs?

No. By the time the article reaches the publish pipeline, the frontmatter has been parsed and the body is plain Markdown. Both platforms receive the body string and a structured metadata object — they never see the YAML source.

Could you use YAML 1.2 to avoid the restriction?

Yes — YAML 1.2 removes the reserved indicator status of @. Some parsers support a version: "1.2" option. I tested this; eemeli/yaml at version: 1.2 does accept @ plain scalars. I didn't switch because I wasn't sure how all the platform APIs would handle the resulting output differences in edge cases. The fallback was lower-risk for a production pipeline mid-run.

Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.

Source: dev.to

arrow_back Back to Tutorials