ANTIGRAVITY LABJP
Articles/Editor View
Editor View/2026-09-01Beginner

When YAML frontmatter rewrites your values without raising an error

Antigravity 2.11.0 renders frontmatter as a formatted card. Chasing the one file that refused to become a card turned up five patterns that parse cleanly but change the value. Here are the measured rules and a 40-line checker.

frontmatterYAMLMarkdownAntigravity 2.112

Since moving to Antigravity 2.11.0, opening a Markdown file looks different. The --- block at the top is drawn as a formatted card of keys and values instead of raw text.

For a while my only reaction was that it read better. Then one day a file opened with its frontmatter still sitting there as plain text, refusing to become a card.

The cause was a colon inside the title. But rather than stop at the fix, I went looking around it, and found something less comfortable: the patterns that parse without complaint and quietly change your value outnumber the ones that fail loudly. A break that fails is visible — no card, no ambiguity. The awkward case is a card that renders perfectly while holding something you did not write.

Here is what I measured, and the check I now run before publishing.

"Colons break it" turned out to be wrong

My working assumption was that any colon inside a value breaks the block. That is not accurate.

I fed the same line to PyYAML 6.0.3, varying only the punctuation.

LineResult
title: Antigravity 2.11.0: what changedScannerError (fails)
title: Antigravity 2.11.0:what changedParses as a string
note: starts at 12:30Parses as a string
title: Antigravity 2.11.0:what changed (full-width colon)Parses as a string
title: "Antigravity 2.11.0: what changed"Parses as a string

The dividing line is not the colon. It is whether a space follows it. That is why 12:30 survives, and why a full-width colon survives too. YAML treats "colon plus space" — or a colon at end of line — as the key/value separator, not the character on its own.

So far, so forgiving: it fails where it should. The next set was the problem.

Five patterns that change the value without an error

Widening the same experiment turned up cases where parsing succeeds and the value you get back is not the value you wrote.

LineValue read backWhat happened
title: Release #1 notes"Release"Space plus # starts a comment; the rest is dropped
title: #1 notesNoneThe whole line is a comment, so the value vanishes
draft: yesTrue (boolean)A string turns into a boolean
draft: noFalse (boolean)Same
code: 012383 (integer)A leading zero means octal

That last row made me look twice. A value typed as 0123 comes back as 83. Octal 0123 is decimal 83, so YAML is being perfectly consistent. But if you keep product codes or model numbers in frontmatter, consistency is cold comfort — the value silently became a different one.

yes and no behave the same way. Keep a language code or a short answer in a value and something downstream that expects a string receives a boolean instead.

And the one I actually tripped over is not in that table at all.

Inside a list item, a colon changes the type itself

Keys like highlights or tags hold lists. Put a colon inside one of those items and you get this:

highlights:
  - decide fast: you learn where to split the problem

No error. It reads back as:

{'highlights': [{'decide fast': 'you learn where to split the problem'}]}

The list member is not a string. It is a single-key dictionary. Wrap the item in double quotes and it reads back the way you meant:

highlights:
  - "decide fast: you learn where to split the problem"

What makes this one nasty is that the difference is nearly invisible on screen. Indentation is right, punctuation is right, the YAML is valid, and the build succeeds.

So what happens when that value reaches a template? I ran the dictionary through string handling in Node.js v22.

const item = { "decide fast": "you learn where to split the problem" };
 
console.log(`${item}`);                        // [object Object]
console.log([item, "a normal item"].join(" / ")); // [object Object] / a normal item

Anything that goes through string concatenation puts [object Object] straight onto the page. Anything that throws when handed an object as a child — React, for instance — fails at render time. Either way, the place you wrote the mistake and the place it surfaces are far apart, and that distance is the real cost.

One small mercy: write two colons in a list item, as in - a: b: c, and it fails at parse time. Only the single colon stays quiet. That is roughly the rule worth remembering.

Knowing the loud failures speeds up triage

I have dwelt on the quiet cases, but it helps to recognise the loud ones too, so that a missing card points somewhere useful. Same environment, same method:

LineResult
title: heading: (colon at end of line)ScannerError
title: followed by a tab characterScannerError
owner: @doliceScannerError (@ is reserved)
title: *emphasis mattersScannerError (* is alias syntax)
title: [draft] stepsParserError (read as a flow sequence)
title: {draft} stepsParserError (read as a flow mapping)

Meanwhile title: -3 correction parses fine as a string: a leading hyphen only starts a list item when a space follows it.

Laid out together, the characters to watch are @ * [ { #, the tab, and "colon plus space". Rather than memorise seven rules, I settled on a simpler habit: if a value contains punctuation at all, wrap it in double quotes from the start. Quoting neutralises almost every row above.

The 40-line check I run before publishing

Quiet breakage cannot be caught by reading, so I let a script read instead. It parses every frontmatter block and separates what failed from what changed type.

#!/usr/bin/env python3
"""Parse every Markdown frontmatter block and separate hard failures from silent type drift."""
import sys, glob, yaml
 
# key name -> the type we expect
EXPECTED = {
    "title": str,
    "description": str,
    "tags": list,
    "highlights": list,
}
# list keys whose members must also be strings
STR_LIST_KEYS = {"tags", "highlights"}
 
def split_frontmatter(text):
    if not text.startswith("---"):
        return None
    end = text.find("\n---", 3)
    if end == -1:
        return None
    return text[3:end]
 
def check(path):
    problems = []
    block = split_frontmatter(open(path, encoding="utf-8").read())
    if block is None:
        return [("NO_FRONTMATTER", "no --- delimited block at the top")]
    try:
        data = yaml.safe_load(block)
    except yaml.YAMLError as e:
        line = getattr(getattr(e, "problem_mark", None), "line", None)
        where = f"near line {line + 2}" if line is not None else "location unknown"
        return [("PARSE_ERROR", f"{type(e).__name__}: {where}")]
    if not isinstance(data, dict):
        return [("NOT_A_MAP", f"top level is {type(data).__name__}")]
    for key, want in EXPECTED.items():
        if key not in data:
            continue
        got = data[key]
        if not isinstance(got, want):
            problems.append((
                "TYPE_DRIFT",
                f"{key} should be {want.__name__} but is {type(got).__name__}",
            ))
            continue
        if key in STR_LIST_KEYS:
            for i, item in enumerate(got):
                if not isinstance(item, str):
                    problems.append((
                        "SILENT_MAP",
                        f"{key}[{i}] is {type(item).__name__}; "
                        f"an unquoted colon did this -> {item}",
                    ))
    return problems
 
def main(paths):
    bad = 0
    for path in sorted(paths):
        problems = check(path)
        if not problems:
            print(f"OK          {path}")
            continue
        bad += 1
        for kind, detail in problems:
            print(f"{kind:<12}{path}: {detail}")
    print(f"\nchecked {len(paths)} / problems in {bad}")
    return 1 if bad else 0
 
if __name__ == "__main__":
    targets = sys.argv[1:] or glob.glob("content/**/*.md", recursive=True)
    sys.exit(main(targets))

Run against five deliberately broken files:

PARSE_ERROR content/at.md: ScannerError: near line 3
PARSE_ERROR content/hard.md: ScannerError: near line 3
OK          content/ok.md
SILENT_MAP  content/silent.md: highlights[0] is dict; an unquoted colon did this -> {'decide fast': 'you learn where to split the problem'}
PARSE_ERROR content/tab.md: ScannerError: near line 3
 
checked 5 / problems in 4

Three details decide whether this script is worth having.

First, EXPECTED spells out the type you want. A check that only asks "did it parse?" waves through a highlights that has become a list of dictionaries. Declaring the type is what makes SILENT_MAP reachable at all.

Second, list keys are inspected member by member. isinstance(got, list) is true for a list of dictionaries as well, so without the STR_LIST_KEYS loop the single worst case is exactly the one that escapes.

Third, a problem exits with status 1. Wire the script into your publish step and a quietly corrupted value stops before it reaches a reader. I keep several Markdown-backed sites, and before this check existed I sometimes learned about the breakage from the published page. When a check finishes in under a second locally, I would rather find out there.

This matters more now that agents edit files alongside us. I covered a neighbouring failure mode — non-ASCII content getting mangled during edits — in three byte-level checks I run before letting an agent edit Japanese files. If it is agent definitions and rule files you want validated strictly, a typo in agent.md was widening permissions is the closer match.

One next step

Open a Markdown file you already have and check whether Antigravity draws its frontmatter as a formatted card. If one file refuses, that file holds a loud failure and you have just located it.

Cards rendering cleanly across the board is not the same as being safe. In that case, save the script above and point it at your content directory once. Whatever was staying quiet will finally speak up.

Share

Thank You for Reading

Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $15 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

Editor View2026-08-29
Charts from Generative UI break the moment you leave the CDN links in
Antigravity 2.11.0 renders HTML artifacts inline, and most of them arrive with CDN references. Here is a 20-line checker that counts external refs, plus the inlining step, with measured numbers.
Editor View2026-08-27
Building a Verification Loop With Antigravity 2.10.0's Embedded Terminal
Antigravity 2.10.0 puts a terminal in the sidebar. Here is how I pinned my pre-merge checks down to three commands, scoped them to changed files only, and what the measured difference turned out to be.
Editor View2026-08-27
Three Byte-Level Checks I Run Before an Agent Edits Files That Contain Japanese
When an agent edit swaps a single multi-byte character, git shows it as an ordinary one-line change. Here is how I fold invalid UTF-8, replacement characters, and normalization drift into one pass.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →