| 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 |
|
} |