Phase 1 Wiki Template refactor

This commit is contained in:
2026-05-20 13:31:27 +02:00
parent 92770bbaa0
commit 11f0863d0a
7 changed files with 580 additions and 24 deletions
+1
View File
@@ -702,6 +702,7 @@ func computeManagedHash(content string) string {
if !ok { if !ok {
managed = content managed = content
} }
managed = normalizePreservedSectionBodies(managed)
h := sha256.Sum256([]byte(strings.TrimSpace(managed))) h := sha256.Sum256([]byte(strings.TrimSpace(managed)))
return fmt.Sprintf("%x", h[:]) return fmt.Sprintf("%x", h[:])
} }
+43
View File
@@ -147,6 +147,49 @@ func TestDeployWikiCreatesNodeBBTopicAndWritesManifest(t *testing.T) {
} }
} }
func TestMergeManagedContentPreservesUserTopAndBottomSections(t *testing.T) {
existing := strings.Join([]string{
`<!-- sow-topdata-wiki:page=classes:acolyte -->`,
`<!-- sow-topdata-wiki:managed:start hash="sha256:old" -->`,
`<h1>Acolyte</h1>`,
`<!-- sow-topdata-wiki:manual:start id="user_top" -->`,
`<p>User-written overview.</p>`,
`<!-- sow-topdata-wiki:manual:end id="user_top" -->`,
`<p>Old generated body.</p>`,
`<!-- sow-topdata-wiki:manual:start id="notes" -->`,
`<h2>Notes</h2><p>User note.</p>`,
`<!-- sow-topdata-wiki:manual:end id="notes" -->`,
`<!-- sow-topdata-wiki:managed:end -->`,
}, "\n")
generated := strings.Join([]string{
`<!-- sow-topdata-wiki:page=classes:acolyte -->`,
`<!-- sow-topdata-wiki:managed:start hash="sha256:new" -->`,
`<h1>Acolyte</h1>`,
`<!-- sow-topdata-wiki:manual:start id="user_top" -->`,
`<p></p>`,
`<!-- sow-topdata-wiki:manual:end id="user_top" -->`,
`<p>New generated body.</p>`,
`<!-- sow-topdata-wiki:manual:start id="notes" -->`,
`<h2>Notes</h2><p></p>`,
`<!-- sow-topdata-wiki:manual:end id="notes" -->`,
`<!-- sow-topdata-wiki:managed:end -->`,
}, "\n")
merged := mergeManagedContent(existing, generated)
for _, expected := range []string{
"<p>User-written overview.</p>",
"<h2>Notes</h2><p>User note.</p>",
"<p>New generated body.</p>",
} {
if !strings.Contains(merged, expected) {
t.Fatalf("expected merged content to contain %q:\n%s", expected, merged)
}
}
if strings.Contains(merged, "Old generated body") {
t.Fatalf("expected old generated body to be replaced:\n%s", merged)
}
}
func TestDeployWikiCreatesNodeBBTopicWithoutFallbackForDefaultThreeCharacterTitle(t *testing.T) { func TestDeployWikiCreatesNodeBBTopicWithoutFallbackForDefaultThreeCharacterTitle(t *testing.T) {
root := t.TempDir() root := t.TempDir()
sourceDir := filepath.Join(root, "pages") sourceDir := filepath.Join(root, "pages")
+113
View File
@@ -0,0 +1,113 @@
package topdata
import (
"fmt"
"strings"
)
func (ctx *wikiContext) renderWikiFormatter(spec string, page wikiTemplatePage) (string, error) {
name := strings.Fields(spec)
if len(name) == 0 {
return "", fmt.Errorf("render wiki template for %s: empty formatter", page.PageID)
}
switch name[0] {
case "Description":
return dokuWikiToNodeBBHTML(toDokuWiki(ctx.resolveRowDescription(page.Category, page.Row))), nil
case "FactsTable":
return dokuWikiToNodeBBHTML(ctx.renderFacts(page.Category, page.Key, page.Row)), nil
case "Prerequisites":
return dokuWikiToNodeBBHTML(formatFact("Prerequisites", ctx.renderFeatPrerequisites(page.Row))), nil
case "SkillList":
if page.Category != "classes" {
return "", nil
}
return dokuWikiToNodeBBHTML(ctx.renderClassSkillList(page.Row)), nil
case "ClassFeatureTable":
if page.Category != "classes" {
return "", nil
}
return dokuWikiToNodeBBHTML(ctx.renderClassFeatureTable(page.Row)), nil
case "FeatProgression":
if page.Category != "classes" {
return "", nil
}
return dokuWikiToNodeBBHTML(ctx.renderClassFeatProgression(page.Row)), nil
case "SpellTable", "SavesProgression", "BABProgression":
return "", nil
case "RaceFeatList":
return dokuWikiToNodeBBHTML(formatFact("Racial Feats", ctx.renderRaceFeatList(page.Row))), nil
case "StatusCallout":
return buildWikiStatusBlock(page.Status, page.Category), nil
case "RaceNameForms":
return dokuWikiToNodeBBHTML(ctx.renderRaceNameForms(page.Row)), nil
case "UserBottomFallback":
return "", nil
default:
return "", fmt.Errorf("render wiki template for %s: unknown formatter %q", page.PageID, name[0])
}
}
func (ctx *wikiContext) renderClassSkillList(row map[string]any) string {
table := ctx.tableForValue(fieldValue(row, "SkillsTable"), ctx.classSkillTables)
if table == nil {
return ""
}
skills := []string{}
for _, skillRow := range table.Rows {
if stringValue(skillRow, "ClassSkill") != "1" {
continue
}
name := ctx.renderReference(fieldValue(skillRow, "SkillIndex"), ctx.skillIDToKey, ctx.resolveSkillName)
if name != "" {
skills = append(skills, name)
}
}
if len(skills) == 0 {
return ""
}
return "==== Class Skills ====\n\n * " + strings.Join(skills, ", ")
}
func (ctx *wikiContext) renderClassFeatProgression(row map[string]any) string {
table := ctx.tableForValue(fieldValue(row, "FeatsTable"), ctx.classFeatTables)
if table == nil {
return ""
}
lines := []string{}
byLevel := map[string][]string{}
selectable := []string{}
for _, featRow := range table.Rows {
name := ctx.renderReference(fieldValue(featRow, "FeatIndex"), ctx.featIDToKey, ctx.resolveFeatName)
if name == "" {
name = ctx.renderReference(fieldValue(featRow, "FeatIndex"), nil, func(string) string { return "" })
}
if name == "" {
continue
}
level := stringValue(featRow, "GrantedOnLevel")
if level == "" || strings.HasPrefix(level, "-") {
selectable = append(selectable, name)
continue
}
byLevel[level] = append(byLevel[level], name)
}
if len(byLevel) > 0 {
lines = append(lines, "==== Granted Feats ====", "")
for _, level := range sortedKeys(byLevel) {
lines = append(lines, " * Level "+level+": "+strings.Join(byLevel[level], ", "))
}
lines = append(lines, "")
}
if len(selectable) > 0 {
lines = append(lines, "==== Selectable Feats ====", "", " * "+strings.Join(selectable, ", "), "")
}
return strings.TrimSpace(strings.Join(lines, "\n"))
}
func (ctx *wikiContext) renderClassFeatureTable(row map[string]any) string {
table := ctx.tableForValue(fieldValue(row, "BonusFeatsTable"), ctx.classBonusTables)
if table == nil || len(table.Rows) == 0 {
return ""
}
return "==== Bonus Feats ====\n\n * Bonus feat table: " + table.OutputStem
}
+40 -24
View File
@@ -26,7 +26,7 @@ const (
legacyWikiRootDirName = ".wiki" legacyWikiRootDirName = ".wiki"
wikiPagesDirName = "pages" wikiPagesDirName = "pages"
wikiStateFileName = "state.json" wikiStateFileName = "state.json"
wikiGeneratorVersion = "nodebb-tiptap-html-v1" wikiGeneratorVersion = "nodebb-tiptap-html-v2"
wikiGeneratedStatus = "generated" wikiGeneratedStatus = "generated"
wikiSkippedStatus = "skipped" wikiSkippedStatus = "skipped"
) )
@@ -68,6 +68,7 @@ type wikiManualSectionsDocument struct {
type wikiManualSection struct { type wikiManualSection struct {
ID string `json:"id" yaml:"id"` ID string `json:"id" yaml:"id"`
Alias string `json:"alias" yaml:"alias"`
Heading string `json:"heading" yaml:"heading"` Heading string `json:"heading" yaml:"heading"`
Placement string `json:"placement" yaml:"placement"` Placement string `json:"placement" yaml:"placement"`
InitialHTML string `json:"initial_html" yaml:"initial_html"` InitialHTML string `json:"initial_html" yaml:"initial_html"`
@@ -107,6 +108,8 @@ type wikiContext struct {
raceFeatTables map[string]wikiTable raceFeatTables map[string]wikiTable
statuses map[string]map[string]string statuses map[string]map[string]string
implementedFeats map[string]struct{} implementedFeats map[string]struct{}
manualSections []wikiManualSection
templateDir string
} }
func buildWiki(p *project.Project, nativeResult BuildResult, force bool, progress func(string)) (wikiResult, error) { func buildWiki(p *project.Project, nativeResult BuildResult, force bool, progress func(string)) (wikiResult, error) {
@@ -137,6 +140,9 @@ func buildWiki(p *project.Project, nativeResult BuildResult, force bool, progres
if err != nil { if err != nil {
return wikiResult{}, err return wikiResult{}, err
} }
manualSections := loadWikiManualSections(p)
ctx.manualSections = manualSections
ctx.templateDir = filepath.Join(p.TopDataWikiSourceDir(), filepath.FromSlash(p.EffectiveConfig().TopData.Wiki.TemplatesDir), "pages")
if err := os.RemoveAll(outputDir); err != nil { if err := os.RemoveAll(outputDir); err != nil {
return wikiResult{}, fmt.Errorf("clean wiki output: %w", err) return wikiResult{}, fmt.Errorf("clean wiki output: %w", err)
@@ -154,7 +160,7 @@ func buildWiki(p *project.Project, nativeResult BuildResult, force bool, progres
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err return err
} }
content = renderNodeBBManagedHTML(pageID, content, loadWikiManualSections(p)) content = renderNodeBBManagedHTML(pageID, content, manualSections)
if err := os.WriteFile(path, []byte(content), 0o644); err != nil { if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
return err return err
} }
@@ -182,7 +188,7 @@ func buildWiki(p *project.Project, nativeResult BuildResult, force bool, progres
if err := generateEntityPages(outputDir, ctx, writePage); err != nil { if err := generateEntityPages(outputDir, ctx, writePage); err != nil {
return wikiResult{}, err return wikiResult{}, err
} }
if err := generateStatusPages(outputDir, pageStatuses, pageTitles, p.EffectiveConfig().TopData.Wiki.ManagedNamespaces, loadWikiManualSections(p)); err != nil { if err := generateStatusPages(outputDir, pageStatuses, pageTitles, p.EffectiveConfig().TopData.Wiki.ManagedNamespaces, manualSections); err != nil {
return wikiResult{}, err return wikiResult{}, err
} }
if err := indexUnregisteredWikiPages(rootDir, outputDir, p.EffectiveConfig().TopData.Wiki.StalePages.Default, &pageIndex); err != nil { if err := indexUnregisteredWikiPages(rootDir, outputDir, p.EffectiveConfig().TopData.Wiki.StalePages.Default, &pageIndex); err != nil {
@@ -209,10 +215,7 @@ func buildWiki(p *project.Project, nativeResult BuildResult, force bool, progres
} }
func loadWikiManualSections(p *project.Project) []wikiManualSection { func loadWikiManualSections(p *project.Project) []wikiManualSection {
defaults := []wikiManualSection{ defaults := defaultWikiManualSections()
{ID: "notes", Heading: "Notes", InitialHTML: "<h2>Notes</h2><p></p>"},
{ID: "see_also", Heading: "See Also", InitialHTML: "<h2>See Also</h2><p></p>"},
}
cfg := p.EffectiveConfig().TopData.Wiki cfg := p.EffectiveConfig().TopData.Wiki
path := filepath.Join(p.TopDataWikiSourceDir(), filepath.FromSlash(cfg.ManualSectionsDir), "default.yaml") path := filepath.Join(p.TopDataWikiSourceDir(), filepath.FromSlash(cfg.ManualSectionsDir), "default.yaml")
raw, err := os.ReadFile(path) raw, err := os.ReadFile(path)
@@ -229,6 +232,15 @@ func loadWikiManualSections(p *project.Project) []wikiManualSection {
if section.ID == "" { if section.ID == "" {
continue continue
} }
section.Alias = strings.TrimSpace(section.Alias)
if section.Alias == "" {
switch section.ID {
case "user_top":
section.Alias = "UserTopSection"
case "notes":
section.Alias = "UserBottomSection"
}
}
if strings.TrimSpace(section.InitialHTML) == "" { if strings.TrimSpace(section.InitialHTML) == "" {
heading := strings.TrimSpace(section.Heading) heading := strings.TrimSpace(section.Heading)
if heading == "" { if heading == "" {
@@ -358,13 +370,15 @@ func computeWikiSourceDigest(sourceDir string) (string, error) {
filepath.Join(sourceDir, "data", "baseitems"), filepath.Join(sourceDir, "data", "baseitems"),
filepath.Join(sourceDir, "data", "classes"), filepath.Join(sourceDir, "data", "classes"),
filepath.Join(sourceDir, "data", "racialtypes"), filepath.Join(sourceDir, "data", "racialtypes"),
filepath.Join(sourceDir, "wiki"),
} }
for _, dir := range relevantSourceDirs { for _, dir := range relevantSourceDirs {
_ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { _ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() { if err != nil || d.IsDir() {
return err return err
} }
if strings.EqualFold(filepath.Ext(path), ".json") { switch strings.ToLower(filepath.Ext(path)) {
case ".json", ".yaml", ".yml", ".html":
files = append(files, path) files = append(files, path)
} }
return nil return nil
@@ -760,20 +774,20 @@ func (ctx *wikiContext) shouldGeneratePage(category, key string, row map[string]
} }
func (ctx *wikiContext) renderPage(category, key string, row map[string]any, title string) (string, error) { func (ctx *wikiContext) renderPage(category, key string, row map[string]any, title string) (string, error) {
sections := []string{ manualSections := ctx.manualSections
"====== " + title + " ======", if len(manualSections) == 0 {
"", manualSections = defaultWikiManualSections()
buildWikiStatusBlock(ctx.pageStatus(category, key, row), category),
"",
toDokuWiki(ctx.resolveRowDescription(category, row)),
"",
ctx.renderFacts(category, key, row),
"",
ctx.renderProgression(category, row),
"",
ctx.renderRaceNameForms(row),
} }
return ensureTrailingNewline(strings.Join(sections, "\n")), nil page := wikiTemplatePage{
PageID: wikiPageIDForKey(key),
Category: category,
Key: key,
Title: title,
Status: ctx.pageStatus(category, key, row),
Row: row,
ManualSections: manualSections,
}
return ctx.renderWikiPageTemplate(category, page)
} }
func (ctx *wikiContext) renderFacts(category, key string, row map[string]any) string { func (ctx *wikiContext) renderFacts(category, key string, row map[string]any) string {
@@ -1787,11 +1801,13 @@ func renderNodeBBManagedHTML(pageID, sourceText string, manualSections []wikiMan
strings.TrimSpace(body), strings.TrimSpace(body),
"<!-- sow-topdata-wiki:managed:end -->", "<!-- sow-topdata-wiki:managed:end -->",
} }
renderedSections := extractManualRegions(body)
for _, section := range manualSections { for _, section := range manualSections {
if _, ok := renderedSections[section.ID]; ok {
continue
}
parts = append(parts, parts = append(parts,
"<!-- sow-topdata-wiki:manual:start id=\""+html.EscapeString(section.ID)+"\" -->", renderWikiManualSection(section),
strings.TrimSpace(section.InitialHTML),
"<!-- sow-topdata-wiki:manual:end id=\""+html.EscapeString(section.ID)+"\" -->",
) )
} }
return ensureTrailingNewline(strings.Join(parts, "\n")) return ensureTrailingNewline(strings.Join(parts, "\n"))
+105
View File
@@ -141,6 +141,111 @@ func TestWikiClassNamePrefersFullNameInsteadOfShortCode(t *testing.T) {
} }
} }
func TestWikiTemplateRendersFieldsFormattersAndPreservedTopSection(t *testing.T) {
ctx := &wikiContext{}
page := wikiTemplatePage{
PageID: "classes:acolyte",
Category: "classes",
Key: "classes:acolyte",
Title: "Acolyte",
Status: wikiStatusNew,
Row: map[string]any{
"HitDie": 6,
"SkillPointBase": 4,
"Description": map[string]any{"tlk": map[string]any{
"text": "Acolyte description.",
}},
},
ManualSections: []wikiManualSection{
{ID: "user_top", Alias: "UserTopSection", InitialHTML: "<p></p>"},
{ID: "notes", Alias: "UserBottomSection", InitialHTML: "<h2>Notes</h2><p></p>"},
},
}
got, err := ctx.renderWikiTemplateString("classes.html", `<h1>{{title}}</h1>
{{UserTopSection}}
<p>Hit Die: {{HitDie}}</p>
<p>Skill Points: {{SkillPointBase}}</p>
{{format:StatusCallout}}
{{format:Description}}
{{UserBottomSection}}`, page)
if err != nil {
t.Fatalf("render template: %v", err)
}
if !strings.Contains(got, "<h1>Acolyte</h1>\n<!-- sow-topdata-wiki:manual:start id=\"user_top\" -->") {
t.Fatalf("expected preserved top section directly after title, got:\n%s", got)
}
for _, expected := range []string{
"<p>Hit Die: 6</p>",
"<p>Skill Points: 4</p>",
"This is a new class!",
"<p>Acolyte description.</p>",
`<!-- sow-topdata-wiki:manual:start id="notes" -->`,
} {
if !strings.Contains(got, expected) {
t.Fatalf("expected %q in rendered template:\n%s", expected, got)
}
}
}
func TestWikiTemplateMissingFieldFailsUnlessDefaultProvided(t *testing.T) {
ctx := &wikiContext{}
page := wikiTemplatePage{PageID: "classes:acolyte", Category: "classes", Title: "Acolyte", Row: map[string]any{}}
if _, err := ctx.renderWikiTemplateString("classes.html", `<p>{{HitDie}}</p>`, page); err == nil || !strings.Contains(err.Error(), `missing field "HitDie"`) {
t.Fatalf("expected missing field error, got %v", err)
}
got, err := ctx.renderWikiTemplateString("classes.html", `<p>{{field:HitDie|default=unknown}}</p>`, page)
if err != nil {
t.Fatalf("expected defaulted field to render, got %v", err)
}
if got != "<p>unknown</p>" {
t.Fatalf("expected default value, got %q", got)
}
}
func TestWikiManagedHashIgnoresPreservedSectionBody(t *testing.T) {
first := strings.Join([]string{
`<!-- sow-topdata-wiki:page=classes:acolyte -->`,
`<!-- sow-topdata-wiki:managed:start hash="sha256:first" -->`,
`<h1>Acolyte</h1>`,
`<!-- sow-topdata-wiki:manual:start id="user_top" -->`,
`<p>Original user edit.</p>`,
`<!-- sow-topdata-wiki:manual:end id="user_top" -->`,
`<p>Generated.</p>`,
`<!-- sow-topdata-wiki:managed:end -->`,
}, "\n")
second := strings.Replace(first, "<p>Original user edit.</p>", "<p>Changed by a user.</p>", 1)
if computeManagedHash(first) != computeManagedHash(second) {
t.Fatalf("expected preserved section body not to affect managed hash")
}
}
func TestWikiSourceDigestIncludesTemplates(t *testing.T) {
root := t.TempDir()
mkdirAll(t, filepath.Join(root, "data", "skills"))
mkdirAll(t, filepath.Join(root, "wiki", "templates", "pages"))
writeFile(t, filepath.Join(root, "data", "skills", "base.json"), `{"rows":[]}`+"\n")
templatePath := filepath.Join(root, "wiki", "templates", "pages", "skills.html")
writeFile(t, templatePath, `<h1>{{title}}</h1>`+"\n")
before, err := computeWikiSourceDigest(root)
if err != nil {
t.Fatalf("compute initial digest: %v", err)
}
writeFile(t, templatePath, `<h1>{{title}}</h1><p>Changed</p>`+"\n")
after, err := computeWikiSourceDigest(root)
if err != nil {
t.Fatalf("compute changed digest: %v", err)
}
if before == after {
t.Fatalf("expected template edit to change wiki source digest")
}
}
func TestWikiPageIDForKeyNormalizesSlashAndUnderscoreSeparators(t *testing.T) { func TestWikiPageIDForKeyNormalizesSlashAndUnderscoreSeparators(t *testing.T) {
got := wikiPageIDForKey("feat:special/attacks_bull_rush") got := wikiPageIDForKey("feat:special/attacks_bull_rush")
if want := "feat:special:attacks:bull:rush"; got != want { if want := "feat:special:attacks:bull:rush"; got != want {
+55
View File
@@ -0,0 +1,55 @@
package topdata
import "strings"
func normalizePreservedSectionBodies(content string) string {
var out strings.Builder
offset := 0
for {
startRel := strings.Index(content[offset:], manualStartMarkerPrefix)
if startRel < 0 {
out.WriteString(content[offset:])
break
}
start := offset + startRel
startEndRel := strings.Index(content[start:], "-->")
if startEndRel < 0 {
out.WriteString(content[offset:])
break
}
startMarker := content[start : start+startEndRel+len("-->")]
id := markerID(startMarker)
if id == "" {
out.WriteString(content[offset : start+len(startMarker)])
offset = start + len(startMarker)
continue
}
endPrefix := manualEndMarkerPrefix + " id=\"" + id + "\""
endRel := strings.Index(content[start+len(startMarker):], endPrefix)
if endRel < 0 {
out.WriteString(content[offset:])
break
}
end := start + len(startMarker) + endRel
endEndRel := strings.Index(content[end:], "-->")
if endEndRel < 0 {
out.WriteString(content[offset:])
break
}
endMarker := content[end : end+endEndRel+len("-->")]
out.WriteString(content[offset:start])
out.WriteString(startMarker)
out.WriteString("\n<!-- preserved-section-body -->\n")
out.WriteString(endMarker)
offset = end + len(endMarker)
}
return out.String()
}
func defaultWikiManualSections() []wikiManualSection {
return []wikiManualSection{
{ID: "user_top", Alias: "UserTopSection", Heading: "", InitialHTML: "<p></p>"},
{ID: "notes", Alias: "UserBottomSection", Heading: "Notes", InitialHTML: "<h2>Notes</h2><p></p>"},
{ID: "see_also", Heading: "See Also", InitialHTML: "<h2>See Also</h2><p></p>"},
}
}
+223
View File
@@ -0,0 +1,223 @@
package topdata
import (
"fmt"
"html"
"os"
"path/filepath"
"strings"
)
type wikiTemplatePage struct {
PageID string
Category string
Key string
Title string
Status string
Row map[string]any
ManualSections []wikiManualSection
}
func (ctx *wikiContext) renderWikiPageTemplate(category string, page wikiTemplatePage) (string, error) {
name := wikiTemplateName(category)
source, path := ctx.loadWikiTemplateSource(name)
if source == "" && name != "default.html" {
source, path = ctx.loadWikiTemplateSource("default.html")
}
if source == "" {
source = builtinWikiTemplate(category)
path = "builtin:" + name
}
return ctx.renderWikiTemplateString(path, source, page)
}
func wikiTemplateName(category string) string {
switch category {
case "classes", "feat", "skills", "spells", "racialtypes", "baseitems":
return category + ".html"
default:
return "default.html"
}
}
func (ctx *wikiContext) loadWikiTemplateSource(name string) (string, string) {
if strings.TrimSpace(ctx.templateDir) == "" {
return "", ""
}
path := filepath.Join(ctx.templateDir, filepath.FromSlash(name))
raw, err := os.ReadFile(path)
if err != nil {
return "", path
}
return string(raw), path
}
func builtinWikiTemplate(category string) string {
switch category {
case "classes":
return `<h1>{{title}}</h1>
{{UserTopSection}}
{{format:StatusCallout}}
{{format:Description}}
{{format:FactsTable}}
{{format:SkillList}}
{{format:FeatProgression}}
{{format:UserBottomFallback}}`
case "racialtypes":
return `<h1>{{title}}</h1>
{{UserTopSection}}
{{format:StatusCallout}}
{{format:Description}}
{{format:FactsTable}}
{{format:RaceNameForms}}
{{format:UserBottomFallback}}`
default:
return `<h1>{{title}}</h1>
{{UserTopSection}}
{{format:StatusCallout}}
{{format:Description}}
{{format:FactsTable}}
{{format:UserBottomFallback}}`
}
}
func (ctx *wikiContext) renderWikiTemplateString(path, source string, page wikiTemplatePage) (string, error) {
var out strings.Builder
offset := 0
seenSections := map[string]struct{}{}
for {
start := strings.Index(source[offset:], "{{")
if start < 0 {
out.WriteString(source[offset:])
break
}
start += offset
end := strings.Index(source[start+2:], "}}")
if end < 0 {
return "", fmt.Errorf("render wiki template %s for %s: unclosed placeholder", path, page.PageID)
}
end += start + 2
out.WriteString(source[offset:start])
token := strings.TrimSpace(source[start+2 : end])
rendered, sectionID, err := ctx.renderWikiTemplateToken(path, token, page)
if err != nil {
return "", err
}
if sectionID != "" {
if _, exists := seenSections[sectionID]; exists {
return "", fmt.Errorf("render wiki template %s for %s: duplicate preserved section %q", path, page.PageID, sectionID)
}
seenSections[sectionID] = struct{}{}
}
out.WriteString(rendered)
offset = end + 2
}
return strings.TrimSpace(out.String()), nil
}
func (ctx *wikiContext) renderWikiTemplateToken(path, token string, page wikiTemplatePage) (string, string, error) {
switch token {
case "title":
return html.EscapeString(page.Title), "", nil
case "page_id":
return html.EscapeString(page.PageID), "", nil
case "status":
return html.EscapeString(page.Status), "", nil
}
if section, ok := wikiManualSectionForAlias(page.ManualSections, token); ok {
return renderWikiManualSection(section), section.ID, nil
}
if strings.HasPrefix(token, "format:") {
rendered, err := ctx.renderWikiFormatter(strings.TrimSpace(strings.TrimPrefix(token, "format:")), page)
return rendered, "", err
}
if strings.HasPrefix(token, "field:") {
rendered, err := ctx.renderExplicitWikiField(path, strings.TrimSpace(strings.TrimPrefix(token, "field:")), page)
return rendered, "", err
}
rendered, err := ctx.renderRequiredWikiField(path, token, page)
return rendered, "", err
}
func wikiManualSectionForAlias(sections []wikiManualSection, alias string) (wikiManualSection, bool) {
for _, section := range sections {
if section.Alias == alias {
return section, true
}
}
switch alias {
case "UserTopSection":
for _, section := range sections {
if section.ID == "user_top" {
return section, true
}
}
case "UserBottomSection":
for _, section := range sections {
if section.ID == "notes" {
return section, true
}
}
}
return wikiManualSection{}, false
}
func renderWikiManualSection(section wikiManualSection) string {
return strings.Join([]string{
"<!-- sow-topdata-wiki:manual:start id=\"" + html.EscapeString(section.ID) + "\" -->",
strings.TrimSpace(section.InitialHTML),
"<!-- sow-topdata-wiki:manual:end id=\"" + html.EscapeString(section.ID) + "\" -->",
}, "\n")
}
func (ctx *wikiContext) renderExplicitWikiField(path, spec string, page wikiTemplatePage) (string, error) {
fieldSpec, defaultValue, hasDefault := strings.Cut(spec, "|default=")
fieldSpec = strings.TrimSpace(fieldSpec)
value, ok := ctx.resolveWikiTemplateField(fieldSpec, page)
if !ok {
if hasDefault {
return html.EscapeString(defaultValue), nil
}
return "", fmt.Errorf("render wiki template %s for %s: missing field %q", path, page.PageID, fieldSpec)
}
return html.EscapeString(value), nil
}
func (ctx *wikiContext) renderRequiredWikiField(path, fieldSpec string, page wikiTemplatePage) (string, error) {
value, ok := ctx.resolveWikiTemplateField(fieldSpec, page)
if !ok {
return "", fmt.Errorf("render wiki template %s for %s: missing field %q", path, page.PageID, fieldSpec)
}
return html.EscapeString(value), nil
}
func (ctx *wikiContext) resolveWikiTemplateField(fieldSpec string, page wikiTemplatePage) (string, bool) {
fieldSpec = strings.TrimSpace(fieldSpec)
if strings.HasSuffix(fieldSpec, ".text") {
base := strings.TrimSuffix(fieldSpec, ".text")
value, ok := lookupField(page.Row, base)
if !ok {
return "", false
}
text := ctx.resolveTextValue(value, nil)
return text, text != ""
}
value, ok := lookupField(page.Row, fieldSpec)
if !ok || value == nil {
return "", false
}
switch typed := value.(type) {
case string:
if typed == "" || typed == nullValue {
return "", false
}
return typed, true
case int:
return fmt.Sprintf("%d", typed), true
case float64:
return fmt.Sprintf("%d", int(typed)), true
default:
text := ctx.resolveTextValue(typed, nil)
return text, text != ""
}
}