// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 package frontmatter import ( "fmt" "strings" "testing" "time" "sourcedock.dev/petrbalvin/interpres/v2" "sourcedock.dev/petrbalvin/volumen/internal/tomlfile" ) const sample = `+++ title = "Ahoj" slug = "ahoj" tags = ["go", "blog"] draft = false date = 2026-08-18 [translations] en = "hello" +++ # Nadpis Tělo článku. ` func TestParse(t *testing.T) { meta, body, _ := Parse(sample) if got, _ := meta.Get("title"); got != "Ahoj" { t.Fatalf("title = %v", got) } if got, _ := meta.Get("draft"); got != false { t.Fatalf("draft = %v", got) } if !strings.HasPrefix(body, "# Nadpis") { t.Fatalf("body = %q", body) } trans, ok := meta.Get("translations") if !ok { t.Fatal("translations missing") } tr := trans.(map[string]any) if tr["en"] != "hello" { t.Fatalf("translations = %v", tr) } } func TestParseKeyOrderPreserved(t *testing.T) { meta, _, _ := Parse(sample) want := []string{"title", "slug", "tags", "draft", "date", "translations"} got := meta.Keys() if len(got) != len(want) { t.Fatalf("keys = %v, want %v", got, want) } for i := range want { if got[i] != want[i] { t.Fatalf("keys = %v, want %v", got, want) } } } func TestParseWithoutFrontmatter(t *testing.T) { meta, body, _ := Parse("just text\n") if meta.Len() != 0 { t.Fatalf("metadata = %v, want empty", meta.Map()) } if body != "just text\n" { t.Fatalf("body = %q", body) } } func TestParseUnclosedFrontmatter(t *testing.T) { meta, body, _ := Parse("+++\ntitle = \"x\"\nno closing\n") if meta.Len() != 0 { t.Fatalf("metadata = %v, want empty", meta.Map()) } if body != "+++\ntitle = \"x\"\nno closing\n" { t.Fatalf("body = %q", body) } } func TestParseInvalidToml(t *testing.T) { meta, body, err := Parse("+++\nnot valid = = =\n+++\nbody\n") if err == nil { t.Fatalf("want error for invalid TOML, got metadata=%v body=%q", meta.Map(), body) } } func TestParseCRLF(t *testing.T) { crlf := strings.ReplaceAll(sample, "\n", "\r\n") meta, body, _ := Parse(crlf) if got, _ := meta.Get("title"); got != "Ahoj" { t.Fatalf("title = %v", got) } if !strings.HasPrefix(body, "# Nadpis") { t.Fatalf("body = %q", body) } } func TestParseMetadataBodyOffset(t *testing.T) { _, bodyStart, _ := ParseMetadata("+++\ntitle = \"x\"\n+++\n\ntext") if bodyStart < 0 { t.Fatal("bodyStart < 0") } rest := ("+++\ntitle = \"x\"\n+++\n\ntext")[bodyStart:] if rest != "text" { t.Fatalf("rest = %q, want %q", rest, "text") } } func TestDumpRoundTrip(t *testing.T) { meta, body, _ := Parse(sample) out, err := Dump(meta, body) if err != nil { t.Fatalf("Dump: %v", err) } meta2, body2, _ := Parse(out) if body2 != body { t.Fatalf("body changed:\n%q\nvs\n%q", body2, body) } for _, key := range []string{"title", "slug", "draft"} { a, _ := meta.Get(key) b, _ := meta2.Get(key) if a != b { t.Fatalf("key %q: %v -> %v", key, a, b) } } tags1, _ := meta.Get("tags") tags2, _ := meta2.Get("tags") if len(tags1.([]any)) != len(tags2.([]any)) { t.Fatalf("tags changed: %v -> %v", tags1, tags2) } tr1 := meta.Map()["translations"].(map[string]any) tr2 := meta2.Map()["translations"].(map[string]any) if tr1["en"] != tr2["en"] { t.Fatalf("translations changed: %v -> %v", tr1, tr2) } // Round-trip must be stable: dumping again yields identical bytes. out2, err := Dump(meta2, body2) if err != nil { t.Fatalf("Dump 2: %v", err) } if out != out2 { t.Fatalf("round-trip not stable:\n%s\nvs\n%s", out, out2) } } func TestDumpEmptyMetadata(t *testing.T) { out, err := Dump(NewMeta(), "body") if err != nil { t.Fatalf("Dump: %v", err) } if out != "+++\n+++\n\nbody\n" { t.Fatalf("out = %q", out) } } func TestDumpAddsTrailingNewline(t *testing.T) { out, err := Dump(NewMeta(), "body without newline") if err != nil { t.Fatalf("Dump: %v", err) } if !strings.HasSuffix(out, "body without newline\n") { t.Fatalf("out = %q", out) } } func TestDumpValueTypes(t *testing.T) { meta := NewMeta() meta.Set("s", "řetězec s \"") meta.Set("n", int64(42)) meta.Set("f", 3.5) meta.Set("fint", 3.0) meta.Set("b", true) meta.Set("d", interpres.LocalDate{Time: time.Date(2026, 8, 18, 0, 0, 0, 0, time.UTC)}) meta.Set("arr", []any{"a", "b"}) out, err := Dump(meta, "x") if err != nil { t.Fatalf("Dump: %v", err) } for _, want := range []string{ `s = "řetězec s \""`, "n = 42", "f = 3.5", "fint = 3.0", "b = true", "d = 2026-08-18", `arr = ["a", "b"]`, } { if !strings.Contains(out, want) { t.Fatalf("missing %q in:\n%s", want, out) } } } func TestDumpRejectsNil(t *testing.T) { meta := NewMeta() meta.Set("bad", nil) if _, err := Dump(meta, "x"); err == nil { t.Fatal("want error for nil value") } } func TestDumpQuotedKeys(t *testing.T) { meta := NewMeta() meta.Set("with space", "v") out, err := Dump(meta, "x") if err != nil { t.Fatalf("Dump: %v", err) } if !strings.Contains(out, `"with space" = "v"`) { t.Fatalf("out = %q", out) } } func TestMetaOperations(t *testing.T) { meta := NewMeta() meta.Set("a", int64(1)) meta.Set("b", int64(2)) meta.Set("a", int64(3)) if meta.Len() != 2 { t.Fatalf("Len = %d", meta.Len()) } if got, _ := meta.Get("a"); got != int64(3) { t.Fatalf("a = %v", got) } if _, ok := meta.Get("b"); !ok { t.Fatal("Has(b) = false") } meta.Delete("a") meta.Delete("missing") if keys := meta.Keys(); len(keys) != 1 || keys[0] != "b" { t.Fatalf("keys = %v", keys) } } func TestMetaFromMapUnknownOrderKeysSorted(t *testing.T) { m := map[string]any{"b": int64(2), "a": int64(1), "c": int64(3)} meta := MetaFromMap(m, []string{"c"}) keys := meta.Keys() if keys[0] != "c" || keys[1] != "a" || keys[2] != "b" { t.Fatalf("keys = %v", keys) } } func TestParseClosingDelimiterWithoutTrailingNewline(t *testing.T) { // A file ending exactly on the closing delimiter must not panic. meta, body, err := Parse("+++\nslug = \"x\"\n+++") if err != nil { t.Fatalf("Parse: %v", err) } if got, _ := meta.Get("slug"); got != "x" { t.Fatalf("slug = %v", got) } if body != "" { t.Fatalf("body = %q, want empty", body) } // The metadata-only variant behaves the same. if _, offset, err := ParseMetadata("+++\n+++"); err != nil || offset > len("+++\n+++") { t.Fatalf("offset = %d, err = %v", offset, err) } } // A byte-order mark must not demote the frontmatter to body: draft and // publish_at live there, and a file saved as UTF-8 with BOM keeps its // meaning. func TestParseStripsByteOrderMark(t *testing.T) { meta, body, err := Parse("\ufeff+++\ntitle = \"BOM\"\nslug = \"bom\"\ndraft = true\n+++\n\nbody text\n") if err != nil { t.Fatalf("Parse: %v", err) } if title, _ := meta.Get("title"); title != "BOM" { t.Fatalf("title = %v, want BOM", title) } if !strings.Contains(body, "body text") || strings.Contains(body, "+++") { t.Fatalf("body = %q", body) } if v, present := meta.Get("draft"); !present || v != true { t.Fatal("draft flag lost behind the BOM") } } // An array of tables in the frontmatter survives a save: it parses, so // it must also serialise, or the post can never be edited again. func TestDumpArraysOfTables(t *testing.T) { src := "+++\ntitle = \"T\"\nslug = \"t\"\ninline = [{a = \"b\", n = 3}]\n\n[[chapters]]\nx = 1\n\n[[chapters]]\nx = 2\n+++\n\nbody\n" meta, _, err := Parse(src) if err != nil { t.Fatalf("Parse: %v", err) } out, err := Dump(meta, "body\n") if err != nil { t.Fatalf("Dump rejected an array of tables: %v", err) } // The array of tables keeps its header form instead of being // flattened into inline tables. if !strings.Contains(out, "[[chapters]]") { t.Fatalf("chapters header form lost:\n%s", out) } meta2, body2, err := Parse(out) if err != nil { t.Fatalf("round-trip parse: %v\n%s", err, out) } if body2 != "body\n" { t.Fatalf("body = %q", body2) } chapters, _ := meta2.Get("chapters") if got := len(tomlfile.Tables(chapters)); got != 2 { t.Fatalf("chapters hold %d tables, want 2:\n%s", got, out) } inline, _ := meta2.Get("inline") if got := len(tomlfile.Tables(inline)); got != 1 { t.Fatalf("inline holds %d tables, want 1:\n%s", got, out) } } // Comments in the frontmatter survive a save: they sit above the key // the author explained, and a save has no business deleting them. func TestDumpKeepsComments(t *testing.T) { src := "+++\n# the visible name\ntitle = \"T\"\nslug = \"c\"\n\n# where it also lives\n[translations]\nen = \"hello\"\n+++\n\nbody\n" meta, body, err := Parse(src) if err != nil { t.Fatalf("Parse: %v", err) } out, err := Dump(meta, body) if err != nil { t.Fatalf("Dump: %v", err) } for _, want := range []string{"# the visible name", "# where it also lives"} { if !strings.Contains(out, want) { t.Fatalf("missing %q in:\n%s", want, out) } } // Round-trip must be stable: dumping again yields identical bytes. meta2, _, err := Parse(out) if err != nil { t.Fatalf("round-trip parse: %v\n%s", err, out) } out2, err := Dump(meta2, body) if err != nil { t.Fatalf("Dump 2: %v", err) } if out != out2 { t.Fatalf("round-trip not stable:\n%s\nvs\n%s", out, out2) } } // A nested table keeps the order its keys were written in, not the // sorted order a map would give. func TestDumpKeepsNestedKeyOrder(t *testing.T) { src := "+++\ntitle = \"T\"\nslug = \"o\"\n[translations]\nzz = \"last\"\naa = \"first\"\n+++\n\nbody\n" meta, body, err := Parse(src) if err != nil { t.Fatalf("Parse: %v", err) } out, err := Dump(meta, body) if err != nil { t.Fatalf("Dump: %v", err) } zz := strings.Index(out, "zz = ") aa := strings.Index(out, "aa = ") if zz < 0 || aa < 0 || zz > aa { t.Fatalf("nested order re-sorted:\n%s", out) } } // A clone is a deep copy: it carries the comments with it, and mutating // it leaves the original untouched. func TestCloneKeepsCommentsAndIsolates(t *testing.T) { src := "+++\ntitle = \"T\"\nslug = \"c\"\n\n# the visible name\ntitle_note = \"x\"\n[translations]\nen = \"hello\"\n+++\n\nbody\n" meta, _, err := Parse(src) if err != nil { t.Fatalf("Parse: %v", err) } clone := meta.Clone() clone.Set("title", "Changed") tr, _ := clone.Get("translations") tr.(map[string]any)["en"] = "mutated" orig, _ := meta.Get("title") if orig != "T" { t.Fatalf("original title = %v, want T", orig) } origTr, _ := meta.Get("translations") if origTr.(map[string]any)["en"] != "hello" { t.Fatalf("original translations mutated: %v", origTr) } out, err := Dump(clone, "body\n") if err != nil { t.Fatalf("Dump: %v", err) } if !strings.Contains(out, "# the visible name") || !strings.Contains(out, `title = "Changed"`) { t.Fatalf("clone lost a comment or the new value:\n%s", out) } } // A line reading "+++" inside a multi-line string is content, not the // closing delimiter; the frontmatter ends at the real one. func TestParseDelimiterInsideMultilineString(t *testing.T) { src := "+++\ntitle = \"T\"\nslug = \"ml\"\nbody = \"\"\"\n+++\nnot the end\n\"\"\"\n+++\nreal body\n" meta, body, err := Parse(src) if err != nil { t.Fatalf("Parse: %v", err) } value, _ := meta.Get("body") if got := fmt.Sprintf("%v", value); !strings.Contains(got, "not the end") { t.Fatalf("multiline value = %q", got) } if !strings.HasPrefix(body, "real body") { t.Fatalf("body = %q", body) } literal := "+++\ntitle = \"T\"\nslug = \"ml\"\nnote = '''\n+++\nliteral content\n'''\n+++\nreal body\n" _, body2, err := Parse(literal) if err != nil { t.Fatalf("Parse literal: %v", err) } if !strings.HasPrefix(body2, "real body") { t.Fatalf("literal body = %q", body2) } }