// Package frontmatter lints and repairs the YAML frontmatter block of a // markdown note. // // Notes written directly — hand-authored, or promoted from brain/raw/ — do not // pass through pipeline.BuildPages, so nothing quotes their scalars. A title // containing an unescaped ": " then produces a block that yaml.Unmarshal // rejects with "mapping values are not allowed here", and every consumer that // reads frontmatter silently skips the note. // // Fix is deliberately conservative: it only adds quoting, never reorders or // rewrites, and it refuses to return a document that still does not parse. package frontmatter import ( "bytes" "fmt" "regexp" "strings" "gopkg.in/yaml.v3" ) const fence = "---" // Lint reports whether doc's frontmatter block parses as YAML. // // A document with no frontmatter block, or with an unterminated one, is not an // error: there is nothing claiming to be YAML. func Lint(doc []byte) error { b, ok := block(doc) if !ok { return nil } var v any if err := yaml.Unmarshal(b, &v); err != nil { return fmt.Errorf("frontmatter: %w", err) } return nil } // Fix quotes unquoted scalars whose value breaks YAML parsing and returns the // repaired document. // // It returns (doc, false, nil) when the document already parses, and // (doc, false, err) when quoting cannot rescue it — an unfixable document is // always returned byte-identical, never half-repaired. func Fix(doc []byte) ([]byte, bool, error) { if err := Lint(doc); err == nil { return doc, false, nil } b, ok := block(doc) if !ok { // Lint only fails when there is a block, so this is unreachable in // practice; return the original rather than guess. return doc, false, nil } fixed := quoteScalars(b) if bytes.Equal(fixed, b) { return doc, false, fmt.Errorf("frontmatter: not repairable by quoting: %w", Lint(doc)) } var v any if err := yaml.Unmarshal(fixed, &v); err != nil { return doc, false, fmt.Errorf("frontmatter: quoting did not repair the block: %w", err) } _, rest, _ := split(doc) out := []byte(fence + "\n") out = append(out, fixed...) out = append(out, []byte(fence)...) out = append(out, rest...) return out, true, nil } // mappingLine matches "key: value" at any indentation, capturing the prefix so // it can be re-emitted untouched. // // Sequence items ("- value") are deliberately not handled: "- foo: bar" is // valid YAML, so a document containing one does not reach Fix in the first // place. See the Lint table for why that gap is left open. var mappingLine = regexp.MustCompile(`^(\s*[A-Za-z_][\w.-]*:[ \t]+)(\S.*)$`) // quoteScalars single-quotes every unquoted scalar value in the block that // contains a colon. Values that are already quoted, or that carry YAML syntax // this package has no business rewriting, are left alone. func quoteScalars(block []byte) []byte { lines := strings.Split(string(block), "\n") for i, line := range lines { m := mappingLine.FindStringSubmatch(line) if m == nil { continue } prefix, value := m[1], m[2] if !needsQuoting(value) { continue } lines[i] = prefix + "'" + strings.ReplaceAll(value, "'", "''") + "'" } return []byte(strings.Join(lines, "\n")) } // needsQuoting reports whether a scalar value has to be quoted to load as the // string it plainly is. // // The test is YAML's own parser rather than a pattern: ask whether the value // stands alone as the right-hand side of a mapping. Heuristics get this wrong // in both directions — `title: "Foo" — bar` opens with a quote but is not // quoted, and a value may contain a colon inside quotes and be perfectly fine. // // Anything that already parses is left alone, whatever it parses into — a flow // collection, a number, a bool. It is not broken, and quoting would change its // type. func needsQuoting(value string) bool { if strings.HasPrefix(value, "|") || strings.HasPrefix(value, ">") { return false // block scalar; its content is not parsed as YAML } var probe map[string]any return yaml.Unmarshal([]byte("v: "+value+"\n"), &probe) != nil } // block returns the raw YAML between the opening and closing fences. func block(doc []byte) ([]byte, bool) { b, _, ok := split(doc) return b, ok } // split returns the frontmatter block and everything from the closing fence // onward (the closing fence itself excluded, its trailing newline included). func split(doc []byte) (block, rest []byte, ok bool) { s := string(doc) if !strings.HasPrefix(s, fence+"\n") { return nil, nil, false } body := s[len(fence)+1:] idx := strings.Index(body, "\n"+fence) if idx < 0 { return nil, nil, false } return []byte(body[:idx+1]), []byte(body[idx+1+len(fence):]), true }