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
Two days 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 (both 1.1 and 1.2 reserve it for future use) and strict parsers throw on it rather than returning the string. My pipeline called yaml.parse() on every frontmatter block and got:
YAMLParseError: Plain value cannot start with reserved character @ at line 1, column 8
I had three options. Quote the 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 both YAML 1.1 and 1.2. 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: why sitemap-0.xml and not sitemap.xml on small sites"
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.
It turned out to be the only article with that pattern — a grep across the repo finds no other unquoted @ titles. Rather than touch it, 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 };
}
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 article that needs the fallback predates the summary_data multi-line block format. 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;
}
The key insight is that the @ title article has 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:
-
summary_data(a nested object withtitle_html,stats,pipeline) would collapse to an unreadable one-liner - Simple fields like
titleandtagswould 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",
];
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);
}
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) — and as an object it would actually satisfy the isComplex test. It stays inline because it's listed in INLINE_KEYS, so the block loop skips it entirely. But it contains colons and URLs:
published_urls: {"devto":"https://dev.to/morinaga/...","bluesky":"https://bsky.app/..."}
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 one legacy article that actually fails the strict parse — the other early articles predate the gate too, but their frontmatter parses strictly anyway.
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:
-
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. -
One-time migration: add quotes to the one legacy title. 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 that legacy article 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 it, 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 the legacy article needs 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 spec (1.1 and 1.2 alike) 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?
No — I originally assumed it would, but YAML 1.2 (spec §5.10) still reserves @ and the backtick for future use. eemeli/yaml with the version: "1.2" option throws the same "Plain value cannot start with reserved character @" error as the default. Switching the spec version doesn't help; quoting the value is the fix.
Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.
Top comments (0)