Co-authored-by: Mathias <mthbqv@gmail.com>
This commit was merged in pull request #126.
This commit is contained in:
@@ -3,6 +3,7 @@ package wiki
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -38,7 +39,7 @@ type section struct {
|
||||
// not present in a are appended.
|
||||
func Merge(a, b Page) Page {
|
||||
fmA, preambleA, secsA := parseSections(a.Content)
|
||||
_, _, secsB := parseSections(b.Content)
|
||||
fmB, _, secsB := parseSections(b.Content)
|
||||
|
||||
idx := make(map[string]int, len(secsA))
|
||||
for i, s := range secsA {
|
||||
@@ -61,7 +62,151 @@ func Merge(a, b Page) Page {
|
||||
}
|
||||
}
|
||||
|
||||
return Page{Path: a.Path, Content: rebuildContent(fmA, preambleA, secsA)}
|
||||
return Page{Path: a.Path, Content: rebuildContent(refreshOwned(fmA, fmB), preambleA, secsA)}
|
||||
}
|
||||
|
||||
// generatorOwned names the frontmatter fields the ingest pipeline may refresh
|
||||
// on a page it is revising. Everything else on disk wins.
|
||||
//
|
||||
// #103 made the on-disk frontmatter win wholesale, which is what stops a
|
||||
// regenerating writer from overwriting human-set metadata — a title someone
|
||||
// fixed, or the claim-property envelope (brain#21). The cost was that fields
|
||||
// the generator legitimately owns froze at their first-ingest values: when it
|
||||
// last ran, and what it last saw.
|
||||
//
|
||||
// An explicit allowlist rather than a carve-out: the ownership boundary is
|
||||
// nameable, and a field nobody has thought about stays human-owned by default,
|
||||
// which is the safe direction.
|
||||
var generatorOwned = map[string]bool{
|
||||
"last_updated": true,
|
||||
"source_hash": true,
|
||||
}
|
||||
|
||||
// fmLine is one line of a frontmatter block: a parsed key/value pair, or a
|
||||
// raw line (blank, comment, or anything else that isn't a simple "key:
|
||||
// value") kept verbatim. Same shape as api.frontmatter's fmLine — this
|
||||
// package doesn't import that one (it's unexported there, and a's/b's
|
||||
// frontmatter here always arrives as a string, not a parsed struct), so the
|
||||
// small, already-proven set()-appends-if-absent behavior is reproduced
|
||||
// locally rather than string-index surgery reinvented per call site.
|
||||
type fmLine struct {
|
||||
key, value, raw string
|
||||
}
|
||||
|
||||
// parseFrontmatterBlock parses a "---\n...\n---\n" fenced block (the shape
|
||||
// parseSections produces) into an ordered line list. A block with no
|
||||
// opening/closing fence — including the empty string, a page with no
|
||||
// frontmatter at all — returns nil, ok=false: there is nothing to update in
|
||||
// place, only something to potentially create.
|
||||
func parseFrontmatterBlock(block string) (lines []fmLine, ok bool) {
|
||||
if !strings.HasPrefix(block, "---\n") {
|
||||
return nil, false
|
||||
}
|
||||
rest := block[len("---\n"):]
|
||||
end := strings.Index(rest, "\n---\n")
|
||||
if end < 0 {
|
||||
return nil, false
|
||||
}
|
||||
for _, line := range strings.Split(rest[:end], "\n") {
|
||||
// An indented line is a continuation (a YAML block-list item, a
|
||||
// wrapped value) never a top-level key, regardless of whether it
|
||||
// happens to contain a colon — " - 'Chapter 3: The Reckoning'"
|
||||
// is not a key named "- 'Chapter 3". Checked before Cut so a
|
||||
// colon inside one never reaches the key/value path at all.
|
||||
if line != "" && (line[0] == ' ' || line[0] == '\t') {
|
||||
lines = append(lines, fmLine{raw: line})
|
||||
continue
|
||||
}
|
||||
key, val, found := strings.Cut(line, ":")
|
||||
key = strings.TrimSpace(key)
|
||||
if !found || key == "" || strings.HasPrefix(strings.TrimSpace(line), "#") {
|
||||
lines = append(lines, fmLine{raw: line})
|
||||
continue
|
||||
}
|
||||
lines = append(lines, fmLine{key: key, value: strings.TrimSpace(val)})
|
||||
}
|
||||
return lines, true
|
||||
}
|
||||
|
||||
// setLine updates key's value in place if lines already has it, or appends
|
||||
// a new entry if it doesn't — the one behavior the original refreshOwned
|
||||
// got wrong (PR #126 review, round 2: it only ever updated in place).
|
||||
func setLine(lines []fmLine, key, value string) []fmLine {
|
||||
for i := range lines {
|
||||
if lines[i].key == key {
|
||||
lines[i].value = value
|
||||
return lines
|
||||
}
|
||||
}
|
||||
return append(lines, fmLine{key: key, value: value})
|
||||
}
|
||||
|
||||
func renderFrontmatterBlock(lines []fmLine) string {
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString("---\n")
|
||||
for _, l := range lines {
|
||||
switch {
|
||||
case l.key == "":
|
||||
sb.WriteString(l.raw)
|
||||
case l.value == "":
|
||||
// A key with nothing after the colon (the YAML block-list shape:
|
||||
// "aliases:" followed by indented "- item" lines on their own
|
||||
// raw lines) must stay bare — "key: " with a trailing space
|
||||
// is a different, if invisible, byte sequence, and this file's
|
||||
// whole purpose is byte-stability across an unrelated merge.
|
||||
sb.WriteString(l.key)
|
||||
sb.WriteByte(':')
|
||||
default:
|
||||
sb.WriteString(l.key)
|
||||
sb.WriteString(": ")
|
||||
sb.WriteString(l.value)
|
||||
}
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
sb.WriteString("---\n")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// refreshOwned returns a's frontmatter with the generator-owned fields taken
|
||||
// from b where b supplies them. Fields absent from b keep a's value, so a
|
||||
// generator that stops emitting a field cannot blank it.
|
||||
//
|
||||
// A field b supplies that a doesn't have yet — every page ingested before
|
||||
// this mechanism existed has no source_hash line at all — is appended, not
|
||||
// dropped (round 1 of review: dropping it silently meant source_hash never
|
||||
// populated for the pre-existing corpus). If a has no frontmatter fence at
|
||||
// all, one is created to hold the new fields rather than splicing raw
|
||||
// "key: value" text into the body (round 2: that corrupted the page and the
|
||||
// fields would duplicate on every subsequent merge, since unfenced text
|
||||
// isn't recognized as frontmatter by parseSections on the next pass).
|
||||
func refreshOwned(a, b string) string {
|
||||
bLines, _ := parseFrontmatterBlock(b)
|
||||
bVals := make(map[string]string)
|
||||
for _, l := range bLines {
|
||||
if l.key != "" && generatorOwned[l.key] {
|
||||
bVals[l.key] = l.value
|
||||
}
|
||||
}
|
||||
if len(bVals) == 0 {
|
||||
return a
|
||||
}
|
||||
|
||||
aLines, _ := parseFrontmatterBlock(a) // ok==false (no fence) -> aLines nil, same as "start fresh"
|
||||
|
||||
// Sorted for a deterministic, byte-stable result when more than one
|
||||
// field is appended in the same call — map iteration order isn't.
|
||||
keys := make([]string, 0, len(bVals))
|
||||
for k := range bVals {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
aLines = setLine(aLines, k, bVals[k])
|
||||
}
|
||||
return renderFrontmatterBlock(aLines)
|
||||
}
|
||||
|
||||
// parseSections splits markdown into its frontmatter, its preamble (content
|
||||
|
||||
Reference in New Issue
Block a user