340 lines
9.2 KiB
Go
340 lines
9.2 KiB
Go
package editor
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// Composing a page file from an edit. Front matter changes are applied to the
|
|
// original YAML's node tree, so keys the form didn't touch keep their order,
|
|
// formatting and comments; only the fields that changed are rewritten.
|
|
|
|
// splitSource returns a page file's front matter (without the --- lines)
|
|
// and its body.
|
|
func splitSource(raw []byte) (string, string) {
|
|
s := strings.TrimPrefix(string(raw), "\ufeff")
|
|
s = strings.ReplaceAll(s, "\r\n", "\n")
|
|
if strings.HasPrefix(s, "---\n") {
|
|
rest := s[4:]
|
|
if i := strings.Index(rest, "\n---"); i >= 0 {
|
|
body := strings.TrimPrefix(rest[i+4:], "\n")
|
|
return rest[:i], body
|
|
}
|
|
if strings.HasPrefix(rest, "---") { // empty front matter
|
|
return "", strings.TrimPrefix(rest[3:], "\n")
|
|
}
|
|
}
|
|
return "", s
|
|
}
|
|
|
|
// Edit is what the editor sends to save or preview a page.
|
|
type Edit struct {
|
|
// Set changes front matter fields: a value replaces the field (or adds
|
|
// it), null removes it. Values stay raw JSON until they're written, so an
|
|
// object's keys keep the order they came in (a section's fields, say).
|
|
Set map[string]json.RawMessage `json:"set,omitempty"`
|
|
// FrontMatter, if not nil, replaces the whole front matter with this
|
|
// YAML (the "edit as YAML" view). Set is then ignored.
|
|
FrontMatter *string `json:"frontMatter,omitempty"`
|
|
Body string `json:"body"`
|
|
}
|
|
|
|
// compose builds the new file from the original and an edit.
|
|
func compose(original []byte, e Edit) ([]byte, error) {
|
|
fmText, _ := splitSource(original)
|
|
var out string
|
|
switch {
|
|
case e.FrontMatter != nil:
|
|
var check map[string]any
|
|
if err := yaml.Unmarshal([]byte(*e.FrontMatter), &check); err != nil {
|
|
return nil, fmt.Errorf("front matter isn't valid YAML: %w", err)
|
|
}
|
|
out = strings.TrimRight(*e.FrontMatter, "\n")
|
|
default:
|
|
var err error
|
|
if out, err = applySet(fmText, e.Set); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
var b bytes.Buffer
|
|
b.WriteString("---\n")
|
|
if out != "" {
|
|
b.WriteString(out)
|
|
b.WriteString("\n")
|
|
}
|
|
b.WriteString("---\n")
|
|
body := strings.ReplaceAll(e.Body, "\r\n", "\n")
|
|
if body != "" {
|
|
if !strings.HasPrefix(body, "\n") {
|
|
b.WriteString("\n")
|
|
}
|
|
b.WriteString(strings.TrimRight(body, "\n"))
|
|
b.WriteString("\n")
|
|
}
|
|
return b.Bytes(), nil
|
|
}
|
|
|
|
func applySet(fmText string, set map[string]json.RawMessage) (string, error) {
|
|
var doc yaml.Node
|
|
if strings.TrimSpace(fmText) != "" {
|
|
if err := yaml.Unmarshal([]byte(fmText), &doc); err != nil {
|
|
// Front matter that isn't strict YAML (tags: [#Linux], say): read
|
|
// it the way the build does and write it back as YAML.
|
|
var m map[string]any
|
|
if err2 := yaml.Unmarshal([]byte(fmText), &m); err2 != nil {
|
|
return "", fmt.Errorf("the existing front matter can't be edited as fields; use the YAML view: %w", err)
|
|
}
|
|
}
|
|
}
|
|
if doc.Kind == 0 {
|
|
doc = yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{{Kind: yaml.MappingNode}}}
|
|
}
|
|
m := doc.Content[0]
|
|
if m.Kind != yaml.MappingNode {
|
|
return "", fmt.Errorf("front matter isn't a list of fields")
|
|
}
|
|
for _, key := range sortedKeys(set) {
|
|
raw := bytes.TrimSpace(set[key])
|
|
idx := -1
|
|
for i := 0; i+1 < len(m.Content); i += 2 {
|
|
if m.Content[i].Value == key {
|
|
idx = i
|
|
break
|
|
}
|
|
}
|
|
if len(raw) == 0 || string(raw) == "null" {
|
|
if idx >= 0 {
|
|
m.Content = append(m.Content[:idx], m.Content[idx+2:]...)
|
|
}
|
|
continue
|
|
}
|
|
dec := json.NewDecoder(bytes.NewReader(raw))
|
|
dec.UseNumber()
|
|
vn, err := jsonNode(dec)
|
|
if err != nil {
|
|
return "", fmt.Errorf("field %s: %w", key, err)
|
|
}
|
|
if idx >= 0 {
|
|
vn = keep(m.Content[idx+1], vn)
|
|
vn.HeadComment, vn.LineComment = m.Content[idx+1].HeadComment, m.Content[idx+1].LineComment
|
|
m.Content[idx+1] = vn
|
|
} else {
|
|
m.Content = append(m.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: key}, vn)
|
|
}
|
|
}
|
|
var buf bytes.Buffer
|
|
enc := yaml.NewEncoder(&buf)
|
|
enc.SetIndent(2)
|
|
if err := enc.Encode(&doc); err != nil {
|
|
return "", err
|
|
}
|
|
out := strings.TrimRight(buf.String(), "\n")
|
|
if out == "{}" {
|
|
out = ""
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// jsonNode turns JSON into YAML nodes, keeping objects' key order. Scalars
|
|
// are encoded by the YAML library itself, so a string that looks like a date
|
|
// or a number is quoted exactly as it would be anywhere else.
|
|
func jsonNode(dec *json.Decoder) (*yaml.Node, error) {
|
|
tok, err := dec.Token()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
scalar := func(v any) (*yaml.Node, error) {
|
|
var n yaml.Node
|
|
if err := n.Encode(v); err != nil {
|
|
return nil, err
|
|
}
|
|
if s, ok := v.(string); ok && strings.Contains(s, "\n") {
|
|
n.Style = yaml.LiteralStyle
|
|
}
|
|
return &n, nil
|
|
}
|
|
switch t := tok.(type) {
|
|
case json.Delim:
|
|
n := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"}
|
|
if t == '{' {
|
|
n = &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
|
|
}
|
|
for dec.More() {
|
|
if t == '{' {
|
|
kt, err := dec.Token()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
key, _ := kt.(string)
|
|
kn, err := scalar(key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
n.Content = append(n.Content, kn)
|
|
}
|
|
v, err := jsonNode(dec)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
n.Content = append(n.Content, v)
|
|
}
|
|
if _, err := dec.Token(); err != nil { // the closing } or ]
|
|
return nil, err
|
|
}
|
|
return n, nil
|
|
case json.Number:
|
|
if i, err := strconv.ParseInt(string(t), 10, 64); err == nil {
|
|
return scalar(i)
|
|
}
|
|
f, err := t.Float64()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return scalar(f)
|
|
case nil:
|
|
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"}, nil
|
|
default: // string, bool
|
|
return scalar(t)
|
|
}
|
|
}
|
|
|
|
// keep returns the new value, reusing the original's nodes wherever they say
|
|
// the same thing, so what didn't change keeps its style (a one-line
|
|
// { text: …, href: … }) and its comments, and a diff shows only the change.
|
|
// Items of a list are matched by content, so moving one doesn't rewrite it.
|
|
func keep(orig, nw *yaml.Node) *yaml.Node {
|
|
if orig == nil || nw == nil {
|
|
return nw
|
|
}
|
|
same := func(a, b *yaml.Node) bool {
|
|
x, err1 := orderedJSON(a)
|
|
y, err2 := orderedJSON(b)
|
|
return err1 == nil && err2 == nil && bytes.Equal(x, y)
|
|
}
|
|
if same(orig, nw) {
|
|
return orig
|
|
}
|
|
switch {
|
|
case orig.Kind == yaml.MappingNode && nw.Kind == yaml.MappingNode:
|
|
for i := 0; i+1 < len(nw.Content); i += 2 {
|
|
for j := 0; j+1 < len(orig.Content); j += 2 {
|
|
if orig.Content[j].Value == nw.Content[i].Value {
|
|
nw.Content[i] = orig.Content[j] // the key, with its comments
|
|
nw.Content[i+1] = keep(orig.Content[j+1], nw.Content[i+1])
|
|
break
|
|
}
|
|
}
|
|
}
|
|
nw.Style = orig.Style
|
|
nw.HeadComment, nw.LineComment, nw.FootComment = orig.HeadComment, orig.LineComment, orig.FootComment
|
|
case orig.Kind == yaml.SequenceNode && nw.Kind == yaml.SequenceNode:
|
|
used := map[int]bool{}
|
|
for i, item := range nw.Content {
|
|
found := false
|
|
for j, o := range orig.Content {
|
|
if !used[j] && same(o, item) {
|
|
nw.Content[i], used[j], found = o, true, true
|
|
break
|
|
}
|
|
}
|
|
if found || item.Kind != yaml.MappingNode || len(item.Content) < 2 {
|
|
continue
|
|
}
|
|
// An item that changed, and perhaps moved: the original with the
|
|
// same first key (a section's type) keeps what's unchanged inside.
|
|
for j, o := range orig.Content {
|
|
if !used[j] && o.Kind == yaml.MappingNode && len(o.Content) >= 2 && o.Content[0].Value == item.Content[0].Value {
|
|
nw.Content[i], used[j] = keep(o, item), true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
nw.Style = orig.Style
|
|
nw.HeadComment, nw.LineComment, nw.FootComment = orig.HeadComment, orig.LineComment, orig.FootComment
|
|
}
|
|
return nw
|
|
}
|
|
|
|
// orderedJSON writes YAML as JSON with every mapping's keys in the order the
|
|
// file has them, so the editor shows and returns fields as they were written.
|
|
func orderedJSON(n *yaml.Node) (json.RawMessage, error) {
|
|
for n.Kind == yaml.DocumentNode && len(n.Content) == 1 {
|
|
n = n.Content[0]
|
|
}
|
|
if n.Kind == yaml.AliasNode && n.Alias != nil {
|
|
return orderedJSON(n.Alias)
|
|
}
|
|
var b bytes.Buffer
|
|
switch n.Kind {
|
|
case yaml.MappingNode:
|
|
b.WriteByte('{')
|
|
for i := 0; i+1 < len(n.Content); i += 2 {
|
|
if i > 0 {
|
|
b.WriteByte(',')
|
|
}
|
|
k, _ := json.Marshal(n.Content[i].Value)
|
|
b.Write(k)
|
|
b.WriteByte(':')
|
|
v, err := orderedJSON(n.Content[i+1])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
b.Write(v)
|
|
}
|
|
b.WriteByte('}')
|
|
case yaml.SequenceNode:
|
|
b.WriteByte('[')
|
|
for i, c := range n.Content {
|
|
if i > 0 {
|
|
b.WriteByte(',')
|
|
}
|
|
v, err := orderedJSON(c)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
b.Write(v)
|
|
}
|
|
b.WriteByte(']')
|
|
default:
|
|
var v any
|
|
if err := n.Decode(&v); err != nil {
|
|
return nil, err
|
|
}
|
|
out, err := json.Marshal(v)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
b.Write(out)
|
|
}
|
|
return b.Bytes(), nil
|
|
}
|
|
|
|
func sortedKeys(m map[string]json.RawMessage) []string {
|
|
keys := make([]string, 0, len(m))
|
|
for k := range m {
|
|
keys = append(keys, k)
|
|
}
|
|
// Stable output: the order fields were added doesn't depend on map order.
|
|
for i := 1; i < len(keys); i++ {
|
|
for j := i; j > 0 && keys[j] < keys[j-1]; j-- {
|
|
keys[j], keys[j-1] = keys[j-1], keys[j]
|
|
}
|
|
}
|
|
return keys
|
|
}
|
|
|
|
// rawSet turns Go values into an edit's Set.
|
|
func rawSet(m map[string]any) map[string]json.RawMessage {
|
|
out := map[string]json.RawMessage{}
|
|
for k, v := range m {
|
|
b, _ := json.Marshal(v)
|
|
out[k] = b
|
|
}
|
|
return out
|
|
}
|