~bigbes/sr-ht-spec

ref: 643fa0b5d34a047923791d23f11ab585dd7487d5 sr-ht-spec/doc/front.go -rw-r--r-- 5.6 KiB
643fa0b5 — Eugene Blikh logging: log through slog and scribe rather than stdlib log 9 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
package doc

import (
	"bytes"
	"strings"

	"gopkg.in/yaml.v3"

	"sourcecraft.dev/bigbes/sr-ht-spec/core"
)

// Front is a document's YAML frontmatter as the read plane sees it: everything
// core models, plus the three keys core deliberately does not and the ordered
// key list used for display and for search text.
//
// The modelled fields all come from core.Frontmatter. Nothing here re-derives
// `id`, `title`, `status`, `tags`, `type`, `summary`, `supersedes` or `owners`
// from the YAML a second time — the write plane and the push hook validate
// against core's reading of those keys, and a second interpretation of them
// here would be a disagreement waiting to be discovered in the ID registry.
type Front struct {
	core.Frontmatter

	// Parent is the raw `parent:` value, still in wikilink form
	// ("[[storage-model]]"). Resolved to an ID by the archive's hierarchy pass.
	Parent string
	// Aliases are alternative names that resolve to this document. They are what
	// keeps a link working across a rename in a corpus whose links are written
	// by hand.
	Aliases []string
	// Planned lists wikilink targets a document deliberately leaves unresolved
	// because the target is meant to be written later.
	Planned []string
	// Props is every top-level frontmatter key in document order, flattened to
	// text. It is a projection for display and search, not an interpretation:
	// nothing reads meaning out of it.
	Props []DocProperty
}

// ParseFront splits a document into its frontmatter and its markdown body.
//
// It is deliberately more forgiving than core.ParseDocument, and the two
// failure modes are kept apart:
//
//   - No frontmatter block, or one that is never closed, yields a zero Front and
//     the source unchanged as the body. A document that opens with a thematic
//     break is not a document with a broken header.
//   - A block that is present and closed but that core rejects — malformed YAML,
//     a duplicate key, a non-mapping — yields a zero Front and the body after
//     the closing fence. The header is dropped, not rendered as prose.
//
// Neither case is an error here. This is the read path, and a document that a
// `--push-option=skip-validation` push put on the approved branch with a broken
// header is still a document worth serving and searching; rejecting it belongs
// at the door, where core is used directly.
func ParseFront(src []byte) (Front, []byte) {
	front, body, err := core.SplitFrontmatter(src)
	if err != nil {
		return Front{}, src
	}
	body = bytes.TrimLeft(body, "\r\n")

	fm, err := core.ParseFrontmatter(front)
	if err != nil {
		return Front{}, body
	}
	return Front{Frontmatter: fm}.withExtras(front), body
}

// withExtras fills the keys core does not model, walking the YAML block a
// second time for document order. Only blocks core already accepted reach this,
// so the walk can assume a well-formed mapping and simply skip what it is not
// looking at.
func (f Front) withExtras(block []byte) Front {
	var node yaml.Node
	if err := yaml.Unmarshal(block, &node); err != nil {
		return f
	}
	if len(node.Content) == 0 || node.Content[0].Kind != yaml.MappingNode {
		return f
	}
	m := node.Content[0]

	for i := 0; i+1 < len(m.Content); i += 2 {
		key := m.Content[i].Value
		val := m.Content[i+1]
		list := nodeList(val)

		switch strings.ToLower(key) {
		case "parent":
			f.Parent = val.Value
		case "aliases", "alias":
			f.Aliases = list
		case "planned":
			f.Planned = list
		}
		if text := strings.Join(list, ", "); text != "" {
			f.Props = append(f.Props, DocProperty{Name: key, Value: text})
		}
	}
	return f
}

// nodeList flattens a YAML value to a list of strings: a scalar becomes one
// entry, a sequence one entry per element, and a nested mapping is skipped.
func nodeList(n *yaml.Node) []string {
	switch n.Kind {
	case yaml.ScalarNode:
		if v := strings.TrimSpace(n.Value); v != "" {
			return []string{v}
		}
	case yaml.SequenceNode:
		out := make([]string, 0, len(n.Content))
		for _, c := range n.Content {
			out = append(out, nodeList(c)...)
		}
		return out
	}
	return nil
}

// Prop returns the first of the named keys that carries a value, or "" when
// none do. Key matching is case-insensitive, matching how documents are
// actually written.
func (f Front) Prop(keys ...string) string {
	for _, want := range keys {
		for _, p := range f.Props {
			if strings.EqualFold(p.Name, want) {
				return p.Value
			}
		}
	}
	return ""
}

// SearchText renders the frontmatter as "key: value" lines for the keyword
// index, so a search for a tag, an owner or a summary phrase finds the document
// even though those render as chips rather than prose.
func (f Front) SearchText() string {
	if len(f.Props) == 0 {
		return ""
	}
	var b strings.Builder
	for _, p := range f.Props {
		b.WriteString(p.Name)
		b.WriteString(": ")
		b.WriteString(stripWikiBrackets(p.Value))
		b.WriteByte('\n')
	}
	b.WriteByte('\n')
	return b.String()
}

// stripWikiBrackets removes [[ ]] wrappers from a frontmatter value so the
// keyword index sees "storage-model" rather than "[[storage-model]]".
func stripWikiBrackets(s string) string {
	s = strings.ReplaceAll(s, "[[", "")
	return strings.ReplaceAll(s, "]]", "")
}

// LinkTarget reduces a frontmatter wikilink value ("\"[[storage|Storage]]\"") to
// its bare target ("storage"). A value that is not a wikilink is returned
// trimmed, since `parent: storage` is written that way too.
func LinkTarget(v string) string {
	v = strings.TrimSpace(v)
	v = strings.Trim(v, `"'`)
	v = strings.TrimSpace(v)
	if inner, ok := strings.CutPrefix(v, "[["); ok {
		if inner, ok = strings.CutSuffix(inner, "]]"); ok {
			v = inner
		}
	}
	if i := strings.IndexByte(v, '|'); i >= 0 {
		v = v[:i]
	}
	return strings.TrimSpace(v)
}