// Copyright (c) 2026 Petr BalvĂ­n (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 }