fix(api): frontmatter parser must not misparse indented list items as keys #135
@@ -40,6 +40,15 @@ func parseFrontmatter(src string) (frontmatter, string) {
|
||||
body := rest[end+len("\n---\n"):]
|
||||
|
||||
for _, line := range strings.Split(block, "\n") {
|
||||
// An indented line is a continuation (a YAML block-list item, a
|
||||
// wrapped value) never a top-level key, regardless of whether it
|
||||
// happens to contain a colon — " - 'Chapter 3: The Reckoning'" is
|
||||
// not a key named "- 'Chapter 3" (hyperguild#128). Checked before
|
||||
// Cut so a colon inside one never reaches the key/value path.
|
||||
if line != "" && (line[0] == ' ' || line[0] == '\t') {
|
||||
fm.lines = append(fm.lines, fmLine{raw: line})
|
||||
continue
|
||||
}
|
||||
key, val, ok := strings.Cut(line, ":")
|
||||
key = strings.TrimSpace(key)
|
||||
if !ok || key == "" || strings.HasPrefix(strings.TrimSpace(line), "#") {
|
||||
@@ -83,9 +92,19 @@ func (f *frontmatter) render() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("---\n")
|
||||
for _, l := range f.lines {
|
||||
if l.key == "" {
|
||||
switch {
|
||||
case l.key == "":
|
||||
b.WriteString(l.raw)
|
||||
} else {
|
||||
case l.value == "":
|
||||
// A key with nothing after the colon (the YAML block-list
|
||||
// shape: "aliases:" followed by indented "- item" lines on
|
||||
// their own raw lines) must stay bare — "key: " with a
|
||||
// trailing space is a different byte sequence than the
|
||||
// original, and this type's whole purpose (see the package
|
||||
// doc) is a byte-preserving round-trip.
|
||||
b.WriteString(l.key)
|
||||
b.WriteByte(':')
|
||||
default:
|
||||
b.WriteString(l.key)
|
||||
b.WriteString(": ")
|
||||
b.WriteString(l.value)
|
||||
|
||||
@@ -59,3 +59,37 @@ func TestFrontmatterRenderRoundTrips(t *testing.T) {
|
||||
fm, _ := parseFrontmatter(src)
|
||||
assert.Equal(t, src, fm.render())
|
||||
}
|
||||
|
||||
func TestParseFrontmatterIndentedListItemWithColonIsNotMisparsedAsKey(t *testing.T) {
|
||||
// hyperguild#128: an indented YAML block-list item that happens to
|
||||
// contain a colon (e.g. a title with a subtitle) is a continuation
|
||||
// line, never a top-level key — strings.Cut doesn't know that just
|
||||
// because TrimSpace(key) happens to be non-empty. Misparsing it as a
|
||||
// key/value pair and re-rendering strips its indentation, de-indenting
|
||||
// it out of the list it belongs to. Same bug class already found and
|
||||
// fixed in the sibling internal/wiki package's parseFrontmatterBlock
|
||||
// (hyperguild#126 review round 3) — this package was the original the
|
||||
// reimplementation was modeled on and had the gap too.
|
||||
src := "---\naliases:\n - 'Chapter 3: The Reckoning'\ntitle: 'X'\n---\nbody\n"
|
||||
fm, _ := parseFrontmatter(src)
|
||||
fm.set("updated_at", "2026-09-23T00:00:00Z")
|
||||
|
||||
out := fm.render()
|
||||
assert.Contains(t, out, " - 'Chapter 3: The Reckoning'",
|
||||
"the indented list item must survive verbatim, not get re-emitted as a de-indented fake key")
|
||||
assert.NotContains(t, out, "\n- 'Chapter 3",
|
||||
"must not be split into a fake key/value pair at column 0 (as opposed to surviving indented, asserted above)")
|
||||
}
|
||||
|
||||
func TestFrontmatterRenderEmptyValueKeyStaysBareNoTrailingSpace(t *testing.T) {
|
||||
// Companion to the indented-list-item fix: "aliases:" (a key with
|
||||
// nothing after the colon, the YAML block-list header) must round-trip
|
||||
// bare, not as "aliases: " with a trailing space — a different byte
|
||||
// sequence than the original, breaking this type's whole reason for
|
||||
// being a line-preserving view rather than a YAML re-marshal.
|
||||
// render() returns only the frontmatter block (see
|
||||
// TestFrontmatterRenderRoundTrips), no trailing body.
|
||||
src := "---\naliases:\n - one\n - two\n---\n"
|
||||
fm, _ := parseFrontmatter(src)
|
||||
assert.Equal(t, src, fm.render())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user