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