package main import ( "bytes" "fmt" "os" "path/filepath" "strings" ) // PatchFrontmatterField rewrites the value of a single YAML frontmatter key // in the file at filePath, preserving everything else byte-for-byte. // // The file MUST begin with a "---" frontmatter delimiter (issue 0100 canonical). // The function: // - locates the frontmatter block delimited by leading "---" and closing "---" // - finds a line matching ":" at the root level (no indent) // - replaces the value portion (keeping the key) with newValue // - writes back atomically via temp file + rename // // If the key does not exist, it is inserted just before the closing "---" line. // The function does NOT validate that newValue is YAML-safe; callers should // pass plain scalars (no embedded newlines). func PatchFrontmatterField(filePath, key, newValue string) error { if key == "" { return fmt.Errorf("PatchFrontmatterField: empty key") } if strings.ContainsAny(newValue, "\n\r") { return fmt.Errorf("PatchFrontmatterField: newValue must not contain newlines") } raw, err := os.ReadFile(filePath) if err != nil { return fmt.Errorf("read %s: %w", filePath, err) } out, err := patchFrontmatterBytes(raw, key, newValue) if err != nil { return err } dir := filepath.Dir(filePath) tmp, err := os.CreateTemp(dir, ".fm.*.tmp") if err != nil { return fmt.Errorf("tmp: %w", err) } tmpName := tmp.Name() if _, err := tmp.Write(out); err != nil { tmp.Close() os.Remove(tmpName) return fmt.Errorf("write tmp: %w", err) } if err := tmp.Close(); err != nil { os.Remove(tmpName) return fmt.Errorf("close tmp: %w", err) } // Preserve original file mode if possible. if info, err := os.Stat(filePath); err == nil { _ = os.Chmod(tmpName, info.Mode()) } if err := os.Rename(tmpName, filePath); err != nil { os.Remove(tmpName) return fmt.Errorf("rename: %w", err) } return nil } func patchFrontmatterBytes(raw []byte, key, newValue string) ([]byte, error) { // Detect line ending convention (default to LF). eol := []byte("\n") if bytes.Contains(raw, []byte("\r\n")) { eol = []byte("\r\n") } // Find frontmatter boundaries. First line must be "---". lines := splitKeepEOL(raw) if len(lines) == 0 { return nil, fmt.Errorf("empty file") } if !isDashDashDash(lines[0]) { return nil, fmt.Errorf("no frontmatter (file does not start with '---')") } closeIdx := -1 for i := 1; i < len(lines); i++ { if isDashDashDash(lines[i]) { closeIdx = i break } } if closeIdx < 0 { return nil, fmt.Errorf("no frontmatter close delimiter") } keyPrefix := key + ":" found := -1 for i := 1; i < closeIdx; i++ { trimmed := strings.TrimRight(strings.TrimRight(string(lines[i]), "\n"), "\r") // Only match top-level keys (no leading whitespace). if !strings.HasPrefix(trimmed, keyPrefix) { continue } // Ensure next char after key is ':' followed by space or EOL (avoid prefix collisions). afterKey := trimmed[len(keyPrefix):] if afterKey != "" && afterKey[0] != ' ' && afterKey[0] != '\t' { continue } found = i break } var buf bytes.Buffer if found >= 0 { // Replace just the value on that line, preserving EOL. original := lines[found] // Compute EOL preserved at end. lineEOL := []byte{} if bytes.HasSuffix(original, []byte("\r\n")) { lineEOL = []byte("\r\n") } else if bytes.HasSuffix(original, []byte("\n")) { lineEOL = []byte("\n") } newLine := []byte(key + ": " + newValue) newLine = append(newLine, lineEOL...) for i, l := range lines { if i == found { buf.Write(newLine) } else { buf.Write(l) } } } else { // Insert just before closeIdx. insertion := []byte(key + ": " + newValue) insertion = append(insertion, eol...) for i, l := range lines { if i == closeIdx { buf.Write(insertion) } buf.Write(l) } } return buf.Bytes(), nil } // splitKeepEOL splits raw into lines, preserving the trailing EOL on each line. func splitKeepEOL(raw []byte) [][]byte { var lines [][]byte start := 0 for i := 0; i < len(raw); i++ { if raw[i] == '\n' { lines = append(lines, raw[start:i+1]) start = i + 1 } } if start < len(raw) { lines = append(lines, raw[start:]) } return lines } func isDashDashDash(line []byte) bool { s := strings.TrimRight(strings.TrimRight(string(line), "\n"), "\r") return s == "---" }