Skip to content

Normalize frontmatter field order in fix and create - #132

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

Normalize frontmatter field order in fix and create#132
willkg merged 5 commits into
mainfrom
frontmatter-field-order

Conversation

@willkg

@willkg willkg commented Sep 6, 2026

Copy link
Copy Markdown
Member

Follow-up to #131, split out of _plans/032 into _plans/033. Makes fix and create --persist leave a file's frontmatter in canonical field order, reported as a new reordered boolean.

Why this is a separate change

#131 made UpdateField surgical — an existing key keeps its position and only its value node is replaced — where the old writer rewrote the whole block in canonical order on every single write.

That is the right default for an edit: these files live in git, and a write should not churn lines nobody asked it to touch. But it means ordering is no longer normalized as a side effect, and stable ordering is worth having, so the two commands that already rewrite frontmatter wholesale now do it explicitly.

Kept out of #131 because it is a feature rather than a consequence of #130, and because it carries the only --json contract change across both branches — #131 left --json output byte-identical to main.

Behaviour

A reorder-only file is changed, not consistent. A file whose values all match its live page but whose fields are jumbled is rewritten and reported. Otherwise you run fix, are told there is nothing to do, and still have a jumbled file. It also keeps --dry-run a faithful preview, which this codebase protects elsewhere.

The cost, stated plainly: fix now touches files it previously left alone, so the first run after this lands produces a diff across the tree. README.md's "writes a file only when a field actually changed" becomes false and is rewritten — it described behaviour rather than promising anything, and it is not in docs/guarantees.md where the load-bearing promises live.

create --persist normalizes too. The minimal-diff argument does not apply there: persist rewrites all five fields by definition, so there is no untouched line left to protect. It also means the frontmatter markfluence authors is always canonical, rather than "canonical unless it came from a jumbled file and you have not run fix yet".

No flag. A tidy-ordering feature you have to opt into leaves the files untidy.

--json

fixResult gains reordered: boolean (properties and required, since additionalProperties: false needs both). This is a dedicated field rather than a pseudo-entry in changes[]: changes[].field is an actual frontmatter key name everywhere else, built from real fields, and putting a non-field there makes the slot polymorphic — a consumer doing changes | map(.field) gets a phantom key it has to know to filter. The existing "(none)" sentinel lives in old, which is explicitly a display slot (oldDisplay in the struct); field is an identity slot.

No equivalent on createResult: create writes every field, so its output is always canonical and there is nothing to report.

Normalize is a no-op when the order already holds

This is what makes the boolean mean exactly "keys moved", and what keeps the blank-line handling predictable — blank lines are dropped only as a consequence of a real reorder, never as a side effect of some unrelated field being written. A canonical file keeps its blank lines: this normalizes ordering, it is not a formatter.

Blank lines are dropped textually rather than structurally, because a blank line is not a node — it lives in the preceding value's token origin, so reordering carries it to a position that means nothing. A text filter is only safe because #131 refuses a multi-line scalar, so nothing the package emits spans more than one line and a blank line in the output is always a real one.

isCanonical checks adjacent pairs, which equals global sortedness because the parser rejects duplicate keys.

Comments travel with their key

Better than the old writer hoisting every comment to the top of the block: a comment about page_id belongs next to page_id. Not claimed to be free — a block-header comment written above a key other than title sinks with that key. Visible in the diff, and accepted.

Ordering within fix

Normalize runs last in the write path, so a key inserted above lands canonically rather than wherever the surgical insert put it. reordered is computed on pre-change content, which is stable for two reasons rather than one: a surgical UpdateField never moves an existing key, and inserting before the first key that sorts after it cannot flip canonicity in either direction — an existing inversion survives the insert, and a canonical sequence stays canonical.

Not in this PR

check reporting a jumbled file (ordering is not a publishability defect, and check is about what would fail a publish), update normalizing (it never writes back to files, and that stays true), and normalizing anything else about the block — intra-line whitespace, blank lines in a canonical file, comment placement. fix orders fields; it is not gofmt for frontmatter.

Testing

make check passes and every commit is independently green. Pinned: a canonical block with a blank line is a no-op (the case that distinguishes "ordering" from "formatting"); a comment stays attached to its key across a reorder; a reorder-only file is changed with reordered: true and empty changes; --dry-run reports it without writing; a canonical consistent file still reports consistent and is not written; create --persist output is canonical from jumbled input; and schema conformance through the command's own jsonResult().

214 lines of Go, 99 of them tests.

Split out of _plans/032, which established the decisions this rests on.
032's surgical UpdateField is what removes ordering as a side effect of
every write; this is the explicit replacement for the two commands that
rewrite frontmatter wholesale anyway.

Separate because it is a feature rather than a consequence of #130, it
carries the only --json contract change across both plans, and bundling
the two buried the part that fixes the bug.
Reorders a block into canonical field order, reporting whether anything
moved. Nothing calls it yet.

It is a no-op when the order already holds, which is what makes the
reported boolean mean exactly "keys moved" -- and what keeps the
blank-line handling predictable: blank lines are dropped only as a
consequence of a real reorder, never as a side effect of some unrelated
field being written. A canonical file keeps its blank lines; this
normalizes ordering, it is not a formatter.

Blank lines are dropped textually rather than structurally because a
blank line is not a node: it lives in the preceding value's token origin,
so reordering carries it to a position that means nothing. A text filter
is only safe because reads refuse a multi-line scalar, so nothing this
package emits spans more than one line and a blank line in the output is
always a real one.

isCanonical checks adjacent pairs, which equals global sortedness because
the parser rejects duplicate keys.

Comments travel with their key across a reorder, which is better than the
old writer hoisting every comment to the top of the block -- a comment
about page_id belongs next to page_id. Not free: a block-header comment
written above a key other than title sinks with that key. Visible in the
diff, and accepted.
032's surgical UpdateField no longer rewrites the whole block in canonical
order as a side effect of writing one field. That is the right default for
an edit -- these files live in git and a write should not churn lines
nobody asked it to touch -- but stable ordering is still worth having, so
the commands that already rewrite frontmatter wholesale do it explicitly.

A file whose values all match its live page but whose fields are jumbled
is changed, not consistent. Reporting it consistent would mean running
fix, being told there is nothing to do, and still having a jumbled file.
It also keeps --dry-run a faithful preview.

Reported as a dedicated boolean rather than an entry in changes[]:
changes[].field is an actual frontmatter key everywhere else, and a
non-field there makes the slot polymorphic for any consumer grouping by
it. The existing "(none)" sentinel lives in old, which is a display slot;
field is an identity slot.

Normalize runs last in the write path, so a key inserted above lands
canonically rather than wherever the surgical insert put it. Computing
reordered on pre-change content is stable for two reasons, not one: a
surgical UpdateField never moves an existing key, and inserting before the
first key that sorts after it cannot flip canonicity in either direction.

The cost, stated plainly: fix now touches files it previously left alone,
so the first run after this lands produces a diff across the tree.
The minimal-diff argument behind a surgical UpdateField does not apply
here: persist rewrites all five fields by definition, so there is no
untouched line left to protect. It also means the frontmatter markfluence
authors is always canonical, rather than "canonical unless it came from a
jumbled file and you have not run fix yet".

No result field: create writes every field, so its output is always
canonical and there is nothing to report.
README's fix section promised it "writes a file only when a field actually
changed", which order normalization makes false. That sentence described
behaviour rather than promising anything, and it is not in
docs/guarantees.md where the load-bearing promises live.
@willkg
willkg merged commit c69e975 into main Sep 6, 2026
1 check passed
@willkg
willkg deleted the frontmatter-field-order branch September 6, 2026 21:43
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.

1 participant