coverage~bigbes/sr-ht-spec3cb1c03ddoc/front.go

Coverage
91.5% 54/59 statements
Δ
Blob
3ee7bbe
1 package doc
2
3 import (
4 "bytes"
5 "strings"
6
7 "gopkg.in/yaml.v3"
8
9 "sourcecraft.dev/bigbes/sr-ht-spec/core"
10 )
11
12 // Front is a document's YAML frontmatter as the read plane sees it: everything
13 // core models, plus the three keys core deliberately does not and the ordered
14 // key list used for display and for search text.
15 //
16 // The modelled fields all come from core.Frontmatter. Nothing here re-derives
17 // `id`, `title`, `status`, `tags`, `type`, `summary`, `supersedes` or `owners`
18 // from the YAML a second time — the write plane and the push hook validate
19 // against core's reading of those keys, and a second interpretation of them
20 // here would be a disagreement waiting to be discovered in the ID registry.
21 type Front struct {
22 core.Frontmatter
23
24 // Parent is the raw `parent:` value, still in wikilink form
25 // ("[[storage-model]]"). Resolved to an ID by the archive's hierarchy pass.
26 Parent string
27 // Aliases are alternative names that resolve to this document. They are what
28 // keeps a link working across a rename in a corpus whose links are written
29 // by hand.
30 Aliases []string
31 // Planned lists wikilink targets a document deliberately leaves unresolved
32 // because the target is meant to be written later.
33 Planned []string
34 // Props is every top-level frontmatter key in document order, flattened to
35 // text. It is a projection for display and search, not an interpretation:
36 // nothing reads meaning out of it.
37 Props []DocProperty
38 }
39
40 // ParseFront splits a document into its frontmatter and its markdown body.
41 //
42 // It is deliberately more forgiving than core.ParseDocument, and the two
43 // failure modes are kept apart:
44 //
45 // - No frontmatter block, or one that is never closed, yields a zero Front and
46 // the source unchanged as the body. A document that opens with a thematic
47 // break is not a document with a broken header.
48 // - A block that is present and closed but that core rejects — malformed YAML,
49 // a duplicate key, a non-mapping — yields a zero Front and the body after
50 // the closing fence. The header is dropped, not rendered as prose.
51 //
52 // Neither case is an error here. This is the read path, and a document that a
53 // `--push-option=skip-validation` push put on the approved branch with a broken
54 // header is still a document worth serving and searching; rejecting it belongs
55 // at the door, where core is used directly.
56 63 func ParseFront(src []byte) (Front, []byte) {
57 63 front, body, err := core.SplitFrontmatter(src)
58 63 if err != nil {
59 22 return Front{}, src
60 22 }
61 41 body = bytes.TrimLeft(body, "\r\n")
62 41
63 41 fm, err := core.ParseFrontmatter(front)
64 41 if err != nil {
65 3 return Front{}, body
66 3 }
67 38 return Front{Frontmatter: fm}.withExtras(front), body
68 }
69
70 // withExtras fills the keys core does not model, walking the YAML block a
71 // second time for document order. Only blocks core already accepted reach this,
72 // so the walk can assume a well-formed mapping and simply skip what it is not
73 // looking at.
74 38 func (f Front) withExtras(block []byte) Front {
75 38 var node yaml.Node
76 38 if err := yaml.Unmarshal(block, &node); err != nil {
77 0 return f
78 0 }
79 38 if len(node.Content) == 0 || node.Content[0].Kind != yaml.MappingNode {
80 0 return f
81 0 }
82 38 m := node.Content[0]
83 38
84 102 for i := 0; i+1 < len(m.Content); i += 2 {
85 102 key := m.Content[i].Value
86 102 val := m.Content[i+1]
87 102 list := nodeList(val)
88 102
89 102 switch strings.ToLower(key) {
90 10 case "parent":
91 10 f.Parent = val.Value
92 2 case "aliases", "alias":
93 2 f.Aliases = list
94 1 case "planned":
95 1 f.Planned = list
96 }
97 102 if text := strings.Join(list, ", "); text != "" {
98 102 f.Props = append(f.Props, DocProperty{Name: key, Value: text})
99 102 }
100 }
101 38 return f
102 }
103
104 // nodeList flattens a YAML value to a list of strings: a scalar becomes one
105 // entry, a sequence one entry per element, and a nested mapping is skipped.
106 108 func nodeList(n *yaml.Node) []string {
107 108 switch n.Kind {
108 103 case yaml.ScalarNode:
109 103 if v := strings.TrimSpace(n.Value); v != "" {
110 103 return []string{v}
111 103 }
112 5 case yaml.SequenceNode:
113 5 out := make([]string, 0, len(n.Content))
114 6 for _, c := range n.Content {
115 6 out = append(out, nodeList(c)...)
116 6 }
117 5 return out
118 }
119 0 return nil
120 }
121
122 // Prop returns the first of the named keys that carries a value, or "" when
123 // none do. Key matching is case-insensitive, matching how documents are
124 // actually written.
125 1 func (f Front) Prop(keys ...string) string {
126 1 for _, want := range keys {
127 3 for _, p := range f.Props {
128 3 if strings.EqualFold(p.Name, want) {
129 1 return p.Value
130 1 }
131 }
132 }
133 0 return ""
134 }
135
136 // SearchText renders the frontmatter as "key: value" lines for the keyword
137 // index, so a search for a tag, an owner or a summary phrase finds the document
138 // even though those render as chips rather than prose.
139 1 func (f Front) SearchText() string {
140 1 if len(f.Props) == 0 {
141 0 return ""
142 0 }
143 1 var b strings.Builder
144 3 for _, p := range f.Props {
145 3 b.WriteString(p.Name)
146 3 b.WriteString(": ")
147 3 b.WriteString(stripWikiBrackets(p.Value))
148 3 b.WriteByte('\n')
149 3 }
150 1 b.WriteByte('\n')
151 1 return b.String()
152 }
153
154 // stripWikiBrackets removes [[ ]] wrappers from a frontmatter value so the
155 // keyword index sees "storage-model" rather than "[[storage-model]]".
156 3 func stripWikiBrackets(s string) string {
157 3 s = strings.ReplaceAll(s, "[[", "")
158 3 return strings.ReplaceAll(s, "]]", "")
159 3 }
160
161 // LinkTarget reduces a frontmatter wikilink value ("\"[[storage|Storage]]\"") to
162 // its bare target ("storage"). A value that is not a wikilink is returned
163 // trimmed, since `parent: storage` is written that way too.
164 56 func LinkTarget(v string) string {
165 56 v = strings.TrimSpace(v)
166 56 v = strings.Trim(v, `"'`)
167 56 v = strings.TrimSpace(v)
168 56 if inner, ok := strings.CutPrefix(v, "[["); ok {
169 13 if inner, ok = strings.CutSuffix(inner, "]]"); ok {
170 13 v = inner
171 13 }
172 }
173 56 if i := strings.IndexByte(v, '|'); i >= 0 {
174 1 v = v[:i]
175 1 }
176 56 return strings.TrimSpace(v)
177 }