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