Files

317 lines
8.5 KiB
Go
Raw Permalink Normal View History

2026-09-18 12:03:35 +02:00
// 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
}