diff --git a/internal/topdata/wiki_deploy.go b/internal/topdata/wiki_deploy.go index 616ea90..ce591d1 100644 --- a/internal/topdata/wiki_deploy.go +++ b/internal/topdata/wiki_deploy.go @@ -702,6 +702,7 @@ func computeManagedHash(content string) string { if !ok { managed = content } + managed = normalizePreservedSectionBodies(managed) h := sha256.Sum256([]byte(strings.TrimSpace(managed))) return fmt.Sprintf("%x", h[:]) } diff --git a/internal/topdata/wiki_deploy_test.go b/internal/topdata/wiki_deploy_test.go index b9d5686..b437dc5 100644 --- a/internal/topdata/wiki_deploy_test.go +++ b/internal/topdata/wiki_deploy_test.go @@ -147,6 +147,49 @@ func TestDeployWikiCreatesNodeBBTopicAndWritesManifest(t *testing.T) { } } +func TestMergeManagedContentPreservesUserTopAndBottomSections(t *testing.T) { + existing := strings.Join([]string{ + ``, + ``, + `

Acolyte

`, + ``, + `

User-written overview.

`, + ``, + `

Old generated body.

`, + ``, + `

Notes

User note.

`, + ``, + ``, + }, "\n") + generated := strings.Join([]string{ + ``, + ``, + `

Acolyte

`, + ``, + `

`, + ``, + `

New generated body.

`, + ``, + `

Notes

`, + ``, + ``, + }, "\n") + + merged := mergeManagedContent(existing, generated) + for _, expected := range []string{ + "

User-written overview.

", + "

Notes

User note.

", + "

New generated body.

", + } { + 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) { root := t.TempDir() sourceDir := filepath.Join(root, "pages") diff --git a/internal/topdata/wiki_formatters.go b/internal/topdata/wiki_formatters.go new file mode 100644 index 0000000..35913d4 --- /dev/null +++ b/internal/topdata/wiki_formatters.go @@ -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 +} diff --git a/internal/topdata/wiki_native.go b/internal/topdata/wiki_native.go index f10dec3..6dd3e83 100644 --- a/internal/topdata/wiki_native.go +++ b/internal/topdata/wiki_native.go @@ -26,7 +26,7 @@ const ( legacyWikiRootDirName = ".wiki" wikiPagesDirName = "pages" wikiStateFileName = "state.json" - wikiGeneratorVersion = "nodebb-tiptap-html-v1" + wikiGeneratorVersion = "nodebb-tiptap-html-v2" wikiGeneratedStatus = "generated" wikiSkippedStatus = "skipped" ) @@ -68,6 +68,7 @@ type wikiManualSectionsDocument struct { type wikiManualSection struct { ID string `json:"id" yaml:"id"` + Alias string `json:"alias" yaml:"alias"` Heading string `json:"heading" yaml:"heading"` Placement string `json:"placement" yaml:"placement"` InitialHTML string `json:"initial_html" yaml:"initial_html"` @@ -107,6 +108,8 @@ type wikiContext struct { raceFeatTables map[string]wikiTable statuses map[string]map[string]string implementedFeats map[string]struct{} + manualSections []wikiManualSection + templateDir string } 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 { 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 { 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 { return err } - content = renderNodeBBManagedHTML(pageID, content, loadWikiManualSections(p)) + content = renderNodeBBManagedHTML(pageID, content, manualSections) if err := os.WriteFile(path, []byte(content), 0o644); err != nil { return err } @@ -182,7 +188,7 @@ func buildWiki(p *project.Project, nativeResult BuildResult, force bool, progres if err := generateEntityPages(outputDir, ctx, writePage); err != nil { 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 } 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 { - defaults := []wikiManualSection{ - {ID: "notes", Heading: "Notes", InitialHTML: "

Notes

"}, - {ID: "see_also", Heading: "See Also", InitialHTML: "

See Also

"}, - } + defaults := defaultWikiManualSections() cfg := p.EffectiveConfig().TopData.Wiki path := filepath.Join(p.TopDataWikiSourceDir(), filepath.FromSlash(cfg.ManualSectionsDir), "default.yaml") raw, err := os.ReadFile(path) @@ -229,6 +232,15 @@ func loadWikiManualSections(p *project.Project) []wikiManualSection { if section.ID == "" { 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) == "" { heading := strings.TrimSpace(section.Heading) if heading == "" { @@ -358,13 +370,15 @@ func computeWikiSourceDigest(sourceDir string) (string, error) { filepath.Join(sourceDir, "data", "baseitems"), filepath.Join(sourceDir, "data", "classes"), filepath.Join(sourceDir, "data", "racialtypes"), + filepath.Join(sourceDir, "wiki"), } for _, dir := range relevantSourceDirs { _ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return err } - if strings.EqualFold(filepath.Ext(path), ".json") { + switch strings.ToLower(filepath.Ext(path)) { + case ".json", ".yaml", ".yml", ".html": files = append(files, path) } 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) { - sections := []string{ - "====== " + title + " ======", - "", - buildWikiStatusBlock(ctx.pageStatus(category, key, row), category), - "", - toDokuWiki(ctx.resolveRowDescription(category, row)), - "", - ctx.renderFacts(category, key, row), - "", - ctx.renderProgression(category, row), - "", - ctx.renderRaceNameForms(row), + manualSections := ctx.manualSections + if len(manualSections) == 0 { + manualSections = defaultWikiManualSections() } - 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 { @@ -1787,11 +1801,13 @@ func renderNodeBBManagedHTML(pageID, sourceText string, manualSections []wikiMan strings.TrimSpace(body), "", } + renderedSections := extractManualRegions(body) for _, section := range manualSections { + if _, ok := renderedSections[section.ID]; ok { + continue + } parts = append(parts, - "", - strings.TrimSpace(section.InitialHTML), - "", + renderWikiManualSection(section), ) } return ensureTrailingNewline(strings.Join(parts, "\n")) diff --git a/internal/topdata/wiki_native_test.go b/internal/topdata/wiki_native_test.go index 02af3a8..ceb1578 100644 --- a/internal/topdata/wiki_native_test.go +++ b/internal/topdata/wiki_native_test.go @@ -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: "

"}, + {ID: "notes", Alias: "UserBottomSection", InitialHTML: "

Notes

"}, + }, + } + + got, err := ctx.renderWikiTemplateString("classes.html", `

{{title}}

+{{UserTopSection}} +

Hit Die: {{HitDie}}

+

Skill Points: {{SkillPointBase}}

+{{format:StatusCallout}} +{{format:Description}} +{{UserBottomSection}}`, page) + if err != nil { + t.Fatalf("render template: %v", err) + } + + if !strings.Contains(got, "

Acolyte

\n") { + t.Fatalf("expected preserved top section directly after title, got:\n%s", got) + } + for _, expected := range []string{ + "

Hit Die: 6

", + "

Skill Points: 4

", + "This is a new class!", + "

Acolyte description.

", + ``, + } { + 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", `

{{HitDie}}

`, 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", `

{{field:HitDie|default=unknown}}

`, page) + if err != nil { + t.Fatalf("expected defaulted field to render, got %v", err) + } + if got != "

unknown

" { + t.Fatalf("expected default value, got %q", got) + } +} + +func TestWikiManagedHashIgnoresPreservedSectionBody(t *testing.T) { + first := strings.Join([]string{ + ``, + ``, + `

Acolyte

`, + ``, + `

Original user edit.

`, + ``, + `

Generated.

`, + ``, + }, "\n") + second := strings.Replace(first, "

Original user edit.

", "

Changed by a user.

", 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, `

{{title}}

`+"\n") + + before, err := computeWikiSourceDigest(root) + if err != nil { + t.Fatalf("compute initial digest: %v", err) + } + writeFile(t, templatePath, `

{{title}}

Changed

`+"\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) { got := wikiPageIDForKey("feat:special/attacks_bull_rush") if want := "feat:special:attacks:bull:rush"; got != want { diff --git a/internal/topdata/wiki_preserve.go b/internal/topdata/wiki_preserve.go new file mode 100644 index 0000000..5359bab --- /dev/null +++ b/internal/topdata/wiki_preserve.go @@ -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\n") + out.WriteString(endMarker) + offset = end + len(endMarker) + } + return out.String() +} + +func defaultWikiManualSections() []wikiManualSection { + return []wikiManualSection{ + {ID: "user_top", Alias: "UserTopSection", Heading: "", InitialHTML: "

"}, + {ID: "notes", Alias: "UserBottomSection", Heading: "Notes", InitialHTML: "

Notes

"}, + {ID: "see_also", Heading: "See Also", InitialHTML: "

See Also

"}, + } +} diff --git a/internal/topdata/wiki_template.go b/internal/topdata/wiki_template.go new file mode 100644 index 0000000..a05d16b --- /dev/null +++ b/internal/topdata/wiki_template.go @@ -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 `

{{title}}

+{{UserTopSection}} +{{format:StatusCallout}} +{{format:Description}} +{{format:FactsTable}} +{{format:SkillList}} +{{format:FeatProgression}} +{{format:UserBottomFallback}}` + case "racialtypes": + return `

{{title}}

+{{UserTopSection}} +{{format:StatusCallout}} +{{format:Description}} +{{format:FactsTable}} +{{format:RaceNameForms}} +{{format:UserBottomFallback}}` + default: + return `

{{title}}

+{{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{ + "", + strings.TrimSpace(section.InitialHTML), + "", + }, "\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 != "" + } +}