Initial commit
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Assisted-by: GLM 5.3
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
// Package frontmatter parses and writes TOML frontmatter in Markdown
|
||||
// post files.
|
||||
//
|
||||
// Post files start with a TOML table between "+++" delimiter lines. The
|
||||
// TOML is carried by an interpres Document, so a save keeps what the
|
||||
// author wrote: the key order at every level, the comments, and whether
|
||||
// a table was written as a header section or an inline table.
|
||||
package frontmatter
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"sourcedock.dev/petrbalvin/interpres/v2"
|
||||
)
|
||||
|
||||
// Delimiter fences the TOML frontmatter block.
|
||||
const Delimiter = "+++"
|
||||
|
||||
// Meta is an order-preserving TOML table. Keys iterate in written
|
||||
// order; values keep their interpres-decoded Go types (map[string]any,
|
||||
// []any, string, int64, float64, bool, interpres.LocalDate and
|
||||
// friends). A Meta parsed from a file keeps the file's comments and
|
||||
// nested table shapes through a save.
|
||||
type Meta struct {
|
||||
doc *interpres.Document
|
||||
}
|
||||
|
||||
// NewMeta returns an empty Meta.
|
||||
func NewMeta() *Meta {
|
||||
// Parsing nothing always succeeds and yields a document with a
|
||||
// root table, which is what an empty Meta needs; a nil doc (if the
|
||||
// parser ever failed on nothing) leaves an inert Meta whose methods
|
||||
// are all no-ops.
|
||||
doc, _ := interpres.Parse(nil)
|
||||
return &Meta{doc: doc}
|
||||
}
|
||||
|
||||
// MetaFromMap builds a Meta from a plain map with keys in the given
|
||||
// order. Keys missing from order are appended sorted, so output stays
|
||||
// deterministic.
|
||||
func MetaFromMap(m map[string]any, order []string) *Meta {
|
||||
meta := NewMeta()
|
||||
seen := map[string]bool{}
|
||||
for _, k := range order {
|
||||
if v, ok := m[k]; ok {
|
||||
meta.Set(k, v)
|
||||
seen[k] = true
|
||||
}
|
||||
}
|
||||
rest := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
if !seen[k] {
|
||||
rest = append(rest, k)
|
||||
}
|
||||
}
|
||||
slices.Sort(rest)
|
||||
for _, k := range rest {
|
||||
meta.Set(k, m[k])
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
// Len returns the number of keys.
|
||||
func (m *Meta) Len() int { return len(m.Keys()) }
|
||||
|
||||
// Keys returns the keys in written order.
|
||||
func (m *Meta) Keys() []string {
|
||||
if m.doc == nil {
|
||||
return nil
|
||||
}
|
||||
return m.doc.Root().Keys()
|
||||
}
|
||||
|
||||
// Get returns the value for key.
|
||||
func (m *Meta) Get(key string) (any, bool) {
|
||||
if m.doc == nil {
|
||||
return nil, false
|
||||
}
|
||||
entry, ok := m.doc.Get(key)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return entry.Value(), true
|
||||
}
|
||||
|
||||
// Set assigns key, appending it when new so the written order is
|
||||
// stable. A []string is stored as the []any the parser produces, so a
|
||||
// set value and a parsed one leave a save in the same shape; a map
|
||||
// value becomes a sub-table whose keys are written sorted, because a
|
||||
// plain map carries no order to keep.
|
||||
func (m *Meta) Set(key string, value any) {
|
||||
if list, ok := value.([]string); ok {
|
||||
anyList := make([]any, len(list))
|
||||
for i, s := range list {
|
||||
anyList[i] = s
|
||||
}
|
||||
value = anyList
|
||||
}
|
||||
m.doc.Set(key, value)
|
||||
}
|
||||
|
||||
// Delete removes key and its position in the order.
|
||||
func (m *Meta) Delete(key string) {
|
||||
m.doc.Delete(key)
|
||||
}
|
||||
|
||||
// Map returns a plain copy of the metadata.
|
||||
func (m *Meta) Map() map[string]any {
|
||||
if m.doc == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(m.doc.Map()))
|
||||
maps.Copy(out, m.doc.Map())
|
||||
return out
|
||||
}
|
||||
|
||||
// Clone returns a deep copy that shares no value with the original.
|
||||
// The document is re-marshalled and re-parsed, which copies every value
|
||||
// and carries the comments, the key order and the table shapes with
|
||||
// them. A Meta holding a value no parse could produce (a nil set by
|
||||
// hand) falls back to a plain value copy without comments.
|
||||
func (m *Meta) Clone() *Meta {
|
||||
if m.doc != nil {
|
||||
if raw, err := interpres.Marshal(m.doc); err == nil {
|
||||
if doc, err := interpres.Parse(raw); err == nil {
|
||||
return &Meta{doc: doc}
|
||||
}
|
||||
}
|
||||
}
|
||||
out := NewMeta()
|
||||
for _, key := range m.Keys() {
|
||||
value, _ := m.Get(key)
|
||||
out.Set(key, deepCopyValue(value))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// deepCopyValue copies the containers so a clone shares no mutable
|
||||
// value with its original.
|
||||
func deepCopyValue(value any) any {
|
||||
switch v := value.(type) {
|
||||
case map[string]any:
|
||||
out := make(map[string]any, len(v))
|
||||
for key, item := range v {
|
||||
out[key] = deepCopyValue(item)
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
out := make([]any, len(v))
|
||||
for i, item := range v {
|
||||
out[i] = deepCopyValue(item)
|
||||
}
|
||||
return out
|
||||
case []map[string]any:
|
||||
out := make([]map[string]any, len(v))
|
||||
for i, item := range v {
|
||||
out[i] = deepCopyValue(item).(map[string]any)
|
||||
}
|
||||
return out
|
||||
case []string:
|
||||
return slices.Clone(v)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// Parse splits content into metadata and body. Without frontmatter it
|
||||
// returns empty metadata and the full content as body. Line endings are
|
||||
// normalised to \n. Invalid frontmatter TOML is an error.
|
||||
func Parse(content string) (*Meta, string, error) {
|
||||
text := normaliseFile(content)
|
||||
meta, bodyStart, err := ParseMetadata(text)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if bodyStart < 0 {
|
||||
return meta, text, nil
|
||||
}
|
||||
return meta, text[bodyStart:], nil
|
||||
}
|
||||
|
||||
// normaliseFile strips a leading byte-order mark and normalises line
|
||||
// endings. A BOM before the opening delimiter would otherwise make the
|
||||
// whole frontmatter (draft and publish_at included) silently count as
|
||||
// body.
|
||||
func normaliseFile(content string) string {
|
||||
text := strings.TrimPrefix(content, "\ufeff")
|
||||
return strings.ReplaceAll(text, "\r\n", "\n")
|
||||
}
|
||||
|
||||
// ParseMetadata parses only the frontmatter, returning the metadata and
|
||||
// the byte offset where the body begins, or -1 when there is no
|
||||
// frontmatter. Line endings are normalised to \n before parsing.
|
||||
// Invalid frontmatter TOML is an error.
|
||||
func ParseMetadata(content string) (*Meta, int, error) {
|
||||
text := normaliseFile(content)
|
||||
lines := strings.Split(text, "\n")
|
||||
if len(lines) == 0 || !isDelimiter(lines[0]) {
|
||||
return NewMeta(), -1, nil
|
||||
}
|
||||
closing := closingIndex(lines)
|
||||
if closing < 0 {
|
||||
return NewMeta(), -1, nil
|
||||
}
|
||||
tomlText := strings.Join(lines[1:closing], "\n")
|
||||
meta := NewMeta()
|
||||
if strings.TrimSpace(tomlText) != "" {
|
||||
doc, err := interpres.Parse([]byte(tomlText))
|
||||
if err != nil {
|
||||
return nil, -1, fmt.Errorf("frontmatter: %w", err)
|
||||
}
|
||||
meta = &Meta{doc: doc}
|
||||
}
|
||||
bodyStart := 0
|
||||
for i := 0; i <= closing; i++ {
|
||||
bodyStart += len(lines[i]) + 1
|
||||
}
|
||||
// A file that ends exactly on the closing delimiter has no trailing
|
||||
// newline; clamp so the slice below can never run past the text.
|
||||
if bodyStart > len(text) {
|
||||
bodyStart = len(text)
|
||||
}
|
||||
if bodyStart < len(text) && text[bodyStart] == '\n' {
|
||||
bodyStart++
|
||||
}
|
||||
return meta, bodyStart, nil
|
||||
}
|
||||
|
||||
// Dump serialises metadata and body back into a post file string. The
|
||||
// body is written verbatim so indented code blocks and leading blank
|
||||
// lines survive a save round-trip; only a trailing newline is added.
|
||||
// The frontmatter is written from the parsed document, so the author's
|
||||
// comments, key order and table shapes survive a save.
|
||||
func Dump(meta *Meta, body string) (string, error) {
|
||||
var b strings.Builder
|
||||
b.WriteString(Delimiter)
|
||||
b.WriteByte('\n')
|
||||
if meta.Len() > 0 {
|
||||
tomlText, err := interpres.Marshal(meta.doc)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b.Write(tomlText)
|
||||
if !bytes.HasSuffix(tomlText, []byte{'\n'}) {
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
b.WriteString(Delimiter)
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(body)
|
||||
if !strings.HasSuffix(body, "\n") {
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
func isDelimiter(line string) bool {
|
||||
return strings.TrimSpace(line) == Delimiter
|
||||
}
|
||||
|
||||
// multiline tracks whether the TOML scanner sits inside a multi-line
|
||||
// basic string (three double quotes) or a literal one (three single
|
||||
// quotes), where a line reading "+++" is content, not a delimiter, and
|
||||
// a line reading "key =" is not a key.
|
||||
type multiline struct {
|
||||
basic bool
|
||||
literal bool
|
||||
}
|
||||
|
||||
// step consumes one line and reports whether that line sits inside a
|
||||
// multi-line string.
|
||||
func (m *multiline) step(line string) bool {
|
||||
if m.basic || m.literal {
|
||||
closer := `"""`
|
||||
if m.literal {
|
||||
closer = "'''"
|
||||
}
|
||||
if strings.Contains(line, closer) {
|
||||
m.basic, m.literal = false, false
|
||||
}
|
||||
return true
|
||||
}
|
||||
if eq := strings.Index(line, "="); eq > 0 {
|
||||
rest := strings.TrimSpace(line[eq+1:])
|
||||
switch {
|
||||
case rest == `"""`:
|
||||
m.basic = true
|
||||
case rest == `'''`:
|
||||
m.literal = true
|
||||
case strings.HasPrefix(rest, `"""`) && len(rest) > 5 && !strings.HasSuffix(rest, `"""`):
|
||||
m.basic = true
|
||||
case strings.HasPrefix(rest, `'''`) && len(rest) > 5 && !strings.HasSuffix(rest, `'''`):
|
||||
m.literal = true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func closingIndex(lines []string) int {
|
||||
var state multiline
|
||||
for i := 1; i < len(lines); i++ {
|
||||
if state.step(lines[i]) {
|
||||
continue
|
||||
}
|
||||
if isDelimiter(lines[i]) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user