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, "table:") { rendered, err := ctx.renderConfiguredWikiTable(path, strings.TrimSpace(strings.TrimPrefix(token, "table:")), 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 != "" } }