Files
hotdog-cms/internal/siteyaml/siteyaml.go
T

141 lines
3.8 KiB
Go

// Package siteyaml changes one top-level block of a YAML file (site.yaml)
// and leaves every other line exactly as it was written.
package siteyaml
import (
"bytes"
"fmt"
"regexp"
"strings"
"gopkg.in/yaml.v3"
)
func emptyValue(v any) bool {
switch x := v.(type) {
case nil:
return true
case map[string]any:
return len(x) == 0
case []any:
return len(x) == 0
case []map[string]string:
return len(x) == 0
}
return false
}
var topKey = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_-]*\s*:`)
// SetTopLevel replaces one top-level key of a YAML file (removing it when
// value is empty), leaving every other line exactly as written: site.yaml is
// a file people read and comment, and a diff should show only the change.
func SetTopLevel(text, key string, value any) (string, error) {
var block string
if !emptyValue(value) {
var buf bytes.Buffer
enc := yaml.NewEncoder(&buf)
enc.SetIndent(2)
if err := enc.Encode(map[string]any{key: value}); err != nil {
return "", err
}
block = buf.String()
}
lines := strings.SplitAfter(text, "\n")
start, end := -1, len(lines)
for i, l := range lines {
if start < 0 {
if strings.HasPrefix(l, key+":") {
start = i
}
continue
}
// The block ends at the next top-level key or comment.
if topKey.MatchString(l) || strings.HasPrefix(l, "#") {
end = i
break
}
}
if start < 0 {
if block == "" {
return text, nil
}
if text != "" && !strings.HasSuffix(text, "\n") {
text += "\n"
}
return text + block, nil
}
if strings.HasPrefix(strings.TrimSpace(strings.TrimPrefix(lines[start], key+":")), "{") {
return "", fmt.Errorf("site.yaml writes %s: on one line; split it over several lines first", key)
}
out := strings.Join(lines[:start], "") + block + strings.Join(lines[end:], "")
var check map[string]any
if err := yaml.Unmarshal([]byte(out), &check); err != nil {
return "", fmt.Errorf("site.yaml wouldn't be valid YAML after the change: %w", err)
}
return out, nil
}
// AddEntry adds child: value to the top-level mapping key: (collections:,
// say), after its last entry, leaving every other line as written. The
// caller makes sure child isn't there already. If key: doesn't exist, it's
// added at the end.
func AddEntry(text, key, child string, value any) (string, error) {
var buf bytes.Buffer
enc := yaml.NewEncoder(&buf)
enc.SetIndent(2)
if err := enc.Encode(map[string]any{child: value}); err != nil {
return "", err
}
lines := strings.SplitAfter(text, "\n")
start := -1
for i, l := range lines {
if strings.HasPrefix(l, key+":") {
start = i
break
}
}
if start < 0 {
return SetTopLevel(text, key, map[string]any{child: value})
}
if rest := strings.TrimSpace(strings.TrimPrefix(lines[start], key+":")); rest != "" && !strings.HasPrefix(rest, "#") {
return "", fmt.Errorf("site.yaml writes %s: on one line; split it over several lines first", key)
}
// The block's entries are the indented lines after key:; new ones go
// after the last of them, at the same indent.
last, indent := start, " "
found := false
for i := start + 1; i < len(lines); i++ {
l := lines[i]
t := strings.TrimSpace(l)
if t == "" {
continue
}
if l[0] != ' ' && l[0] != '\t' {
break // the next top-level key or comment
}
if !found && !strings.HasPrefix(t, "#") {
indent = l[:len(l)-len(strings.TrimLeft(l, " \t"))]
found = true
}
last = i
}
var add strings.Builder
for _, l := range strings.SplitAfter(buf.String(), "\n") {
if l != "" {
add.WriteString(indent + l)
}
}
tail := strings.Join(lines[last+1:], "")
head := strings.Join(lines[:last+1], "")
if !strings.HasSuffix(head, "\n") {
head += "\n"
}
out := head + add.String() + tail
var check map[string]any
if err := yaml.Unmarshal([]byte(out), &check); err != nil {
return "", fmt.Errorf("site.yaml wouldn't be valid YAML after the change: %w", err)
}
return out, nil
}