Skip to content

Parse and emit frontmatter as real YAML - #131

Merged
willkg merged 5 commits into
mainfrom
frontmatter-yaml
Sep 6, 2026
Merged

Parse and emit frontmatter as real YAML#131
willkg merged 5 commits into
mainfrom
frontmatter-yaml

Conversation

@willkg

@willkg willkg commented Sep 6, 2026

Copy link
Copy Markdown
Member

Closes #130. Replaces the hand-rolled flat-key frontmatter parser in internal/frontmatter with goccy/go-yaml. Design record: _plans/032_frontmatter-yaml.md. Adds C2 (frontmatter-is-valid-yaml) to docs/guarantees.md.

The bug

renderValue decided whether to quote a value by asking "would our own ParseValue round-trip this?" rather than "is this valid YAML?". A colon round-trips through a first-colon line split, so a title containing ": " was written bare:

---
title: Deploy Runbook: Part 2
page_id: 123
---

markfluence read that back perfectly and every real YAML parser rejected it, which is why it went unnoticed — VSCode's YAML extension is what surfaced it. The colon was one member of a class: true, 123, null, ~, [draft] Foo, @home, *star, %pct, - dash were all written bare and read back as the wrong type or not at all. frontmatter_test.go had a test pinning title: a: b as correct output, so this was a deliberate decision being reversed rather than an oversight.

The writer verifies its own output

This is the part worth reviewing closely. goccy's default emission is not round-trip-safe for four shapes, found by probing ~80 strings:

written goccy emits result
"a\tb" title: a<TAB>b reads back "ab" — silent loss
"? q" title: ? q goccy refuses to parse its own output
".inf" title: .inf a float to any conforming reader
".nan" title: .nan same

So the writer does not predict which values goccy mishandles — it checks: emit with goccy's chosen style, re-parse, compare, and fall back to a forced double-quoted scalar when the two disagree (15/15 probed cases round-trip that way). A hand-written predicate listing those shapes would have been incomplete, since three of the four only turned up after two review rounds. Checking beats predicting, and the check keeps holding if goccy regresses.

The comparison looks at the re-read node kind, not just its text. Comparing text alone said .inf round-tripped — scalarValue flattens every scalar to its token, so markfluence reads it back as .inf either way while the file still says "float" to everyone else. That is the same mistake renderValue made, one level down.

Quoting is goccy's, typing is ours

page_id and parent are written as YAML integers and nulls, because page_id: "123" is valid YAML that says the wrong thing. Confined to two keys whose value domains are closed — a title of "123" is still a string.

Reads enforce the flat contract instead of assuming it

The "no nesting, lists, or multi-line values" restriction was documented but never checked; the old parser silently produced "" for a violation. Now:

  • A whitelist of scalar node kinds, because an anchor, alias, tag or | block each reports its indicator character as its token value — a blacklist of sequences and mappings would silently read &.
  • A scalar whose source spans lines is refused. This has to be enforced, because an untouched key is re-emitted from the node the parser produced and goccy's re-emission is not identity: title: plain\n continued came back as a |- block the parser then rejected, so a write produced a file markfluence could not read — and in create, only after the page had been made. A multi-line single-quoted scalar was quieter and worse, re-emitting on one line and turning "sq\nline" into "sq line".
  • A ... line is refused, since reading only the first document dropped every later key in silence.
  • Duplicate keys and tab indentation now error, where the old parser took the last value and mangled the tab.

Every null spelling is unset

null, Null, ~, and an absent value all read as "". The old parser matched only the literal "null", so parent: ~ read as though it were a page id — a bug that predates this change. That had one sharp consequence in fix: a present-but-blank coordinate took the "(none)" branch and planned parent: (none) -> null on every run, wrote it, and read "" again, so a correct top-level page was reported changed forever. TestPlannedChangesParentNullNormalizes guarded exactly this and would have kept passing — it fed {"parent": "null"}, a map the new parser can never produce.

Behaviour changes

  • update rejects a present-but-empty title (title:, title: null), before any request. An absent title keeps today's fallback to the live page title: no title key is a positive statement that a file does not manage its page's title, while a present-but-empty one is a half-finished edit that should not silently publish under whatever the page is called now. --title still wins over both.
  • check reports a present-but-empty title as broken. Its "deliberately narrow" justification — that it cannot know whether create or update is coming — stops covering title once both verbs reject an empty one.
  • page_width: null was an invalid-width error and now means unset. It used to reach pagewidth.Declared as the string "null". Follows from the uniform null rule; check no longer reports it.
  • A file that no longer parses is a hard error, with no lenient fallback and no repair path in fixfix reconciles from the live page and needs page_id, which it cannot read out of a file it cannot parse, so the remedy would be circular for exactly the files needing it.

--json output is unchanged from main. The only schema edit is one description string.

Not in this PR

Field-order normalization (Normalize, fix's reordered, create --persist normalizing) is split out to _plans/033 and a follow-up branch stacked on this one. It is a feature rather than a consequence of #130, it carries the only --json contract change, and bundling the two buried the part that fixes the bug.

gopkg.in/yaml.v3 was measured against every requirement and clears them all, but it is archived at v3.0.1 (2022); goccy is maintained.

Review notes

Three design passes and one code review, which turned up four real defects now fixed and pinned: the multi-line scalar case above, .inf/.nan being written bare while the hazard test passed vacuously, the ... document case, and Render being able to emit a duplicate key.

One limit C2 states rather than papers over: the verification is a self-check. It proves goccy can re-read what goccy wrote, not that another implementation can — 1e3 is a string to goccy and a float to yaml.v3, and no self-check can see that. There is deliberately no second YAML library in go.mod; a divergence reported by a real tool is an issue to fix, with the offending frontmatter as evidence.

Testing

make check passes, and every commit is independently green. Frontmatter round-tripping is pinned by a hazard table plus FuzzUpdateFieldRoundTrips, which runs seed-corpus-only under plain go test and is the only thing that could find a value the double-quote fallback also fails on.

Replace the hand-rolled flat-key parser in internal/frontmatter with
goccy/go-yaml, so the frontmatter markfluence writes is valid YAML.

The bug (#130): renderValue decided whether to quote by asking "would our
own ParseValue round-trip this?", not "is this valid YAML?". A colon
round-trips through our first-colon split, so a title containing ": " was
written bare and no other tool could read the file. The colon is one
member of a class that also includes booleans, numbers, nulls, flow
collections and the reserved indicators.

The plan records what was probed rather than assumed: goccy's quoting,
comment round-tripping, the token-vs-String() read gotcha, null identified
by node type rather than token text, and four shapes goccy emits wrongly.
One of them, a title starting "? ", makes goccy refuse to parse its own
output -- so the writer verifies and retries rather than trusting the
serializer, which is what lets C2 claim more than "goccy is correct".

Field-order normalization was designed here and split out to _plans/033:
it is a separate feature that merely needs a surgical UpdateField, it
carries the only --json contract change, and keeping it here made a
~400-line change into a ~600-line one. The decisions it rests on stay
recorded and marked, since 033 builds on them.

Adds C2 (frontmatter-is-valid-yaml) to docs/guarantees.md.
Closes #130. The hand-rolled parser split each line at the first ":", so
it read its own `title: Deploy Runbook: Part 2` back perfectly while every
real YAML parser rejected the file. renderValue decided quoting by asking
"would our own ParseValue round-trip this?" rather than "is this valid
YAML?", and the colon was one member of a class: booleans, numbers, nulls,
flow collections and the reserved indicators were all written bare.

goccy/go-yaml does the parsing and emitting now. Deleted: ParseValue,
scanQuoted, stripInlineComment, quoteValue, renderValue, splitFrontmatter,
Extract -- the last two exported but called only by this package's tests.

The writer verifies its own output rather than trusting the serializer:
emit with goccy's chosen style, re-read, fall back to a double-quoted
scalar when the two disagree. That fallback is load-bearing. goccy drops a
tab, emits a value beginning "? " as a document it then refuses to parse,
and writes .inf/.nan bare where any conforming reader sees a float. A
hand-written predicate listing those shapes would be incomplete; they
turned up only by probing ~80 strings, so checking beats predicting.

The check compares the re-read node *kind*, not just its text. Comparing
text alone says ".inf" round-trips -- markfluence reads it back as ".inf"
either way -- while the file still says "float" to every other tool. That
is the same mistake renderValue made, one level down.

Quoting is goccy's, typing is ours: page_id and parent are written as YAML
integers and nulls, because `page_id: "123"` is valid YAML that says the
wrong thing. Confined to two keys whose domains are closed -- a title of
"123" is still a string.

Reads enforce the flat contract instead of assuming it. A whitelist of
scalar kinds, because an anchor, alias, tag or "|" block each reports its
*indicator character* as its token value. A scalar whose source spans
lines is refused: an untouched key is re-emitted from the node the parser
produced, and goccy's re-emission is not identity, so `title: plain\n
continued` came back as a "|-" block the parser then rejected -- writing a
file markfluence cannot read, in create only after making the page. A
multi-line single-quoted scalar was quieter and worse, re-emitting on one
line and turning "sq\nline" into "sq line". A "..." line is refused too,
since reading only the first document would drop every later key in
silence.

Every null spelling is unset. The old parser matched only the literal
"null", so `parent: ~` read as though it were a page id -- a bug that
predates this change. In fix, that made a present-but-blank coordinate
take the "(none)" branch and plan `parent: (none) -> null` on every run,
write it, and read "" again: a correct top-level page reported changed
forever. plannedChanges now falls through to norm, which equates them.
TestPlannedChangesParentNullNormalizes guarded exactly this and would have
kept passing -- it fed {"parent": "null"}, a map the parser can no longer
produce.

Two entry points, deliberately separate. Render builds from scratch and
cannot fail, keeping a parse error out of pagedoc/read/export, and a
repeated key resolves last-wins rather than emitting a duplicate the
parser would reject. UpdateField edits and returns an error, surgically:
an existing key keeps its own key node, since a blank line before it lives
in that node's token origin and swapping the whole pair would delete it.

page_width: null was an invalid-width error and now means unset, since
pagewidth.Declared sees "" rather than "null". A create --persist
frontmatter failure routes through failKeepingPage, the path the existing
os.WriteFile failure uses, so the new page is not orphaned.
An empty title used to fall back to the live page's title whether the key
was absent or present. The two mean different things: no title key is a
positive statement that a file does not manage its page's title, which is
the shape fix.go's own "fill in a missing title" branch already reasons
about, while `title:` present and empty is a half-finished edit that
should not silently publish under whatever the page happens to be called
now.

Reads as empty for every null spelling too, so `title: null` is caught
alongside `title:` -- which is new: the old parser kept a literal "null"
as a legal title, so it would have published a page named "null".

The check fires before GetPageOrNil, matching the IsDigits pre-flight
above it: a local defect should not cost a round trip. --title still wins,
as every other override does, so it satisfies a present-but-empty
frontmatter title rather than tripping over it.

resolveTitlePageID needs a third return to say this at all; two strings
cannot distinguish absent from present-and-empty.

create already errors on any empty title (create.go:554) and is unchanged.
check's frontmatter validation is deliberately narrow, and the stated
reason is that it cannot know whether the caller is about to create or
update, so a false positive is worse than a miss. That reasoning stops
covering title once both verbs reject an empty one: there is no verb under
which it is valid, so there is no false positive to have, and leaving it
out means check passes a file that update then refuses -- the exact
failure check exists to catch, without credentials or a network.

An absent title stays unreported. update accepts one and keeps the live
page's title, so it is a legitimate shape rather than a defect.

Reported as broken rather than failed, matching the convention that
broken/warnings are document defects while failed is a file that never
reached the converter. Collected before the conversion, because a name
collision aborts it: gathering the frontmatter check afterwards made it
unreachable for exactly the files with two defects.

checkResult's schema description enumerates the failed causes and now
mentions the YAML and flat-mapping ones as well.
C2 sits in Conformance rather than Laws because that section is defined as
agreement with an external specification, and because its own note says C1
is the guarantee that could in principle be traded away. markfluence did
trade YAML conformance away, for years, which is what #130 is.

Kept separate from L7 (output-is-valid-markdown) because the two are
checked against different specs. A file with broken frontmatter still
renders as markdown -- GitHub shows it, and it was VSCode's YAML extension
that complained -- so folding this into L7 would leave its status
ambiguous about which spec had failed.

Holds, with three limits stated rather than papered over: typing is ours
even though quoting is goccy's; the flat contract is enforced on read
because goccy's re-emission of a parsed node is not identity; and the
verification is a self-check -- it proves goccy can re-read what goccy
wrote, not that another implementation can. There is deliberately no
second YAML library in go.mod to settle that last one, so a divergence
reported by a real tool is an issue to fix, with the frontmatter as
evidence.

README's frontmatter section described our own quoting rules; it now
describes YAML's, and says which values need quoting when hand-writing a
block.
@willkg
willkg merged commit 8ac9673 into main Sep 6, 2026
1 check passed
@willkg
willkg deleted the frontmatter-yaml branch September 6, 2026 21:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Frontmatter we write is not valid YAML when a value contains a colon

1 participant