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) { state := &wikiTemplateRenderState{ path: path, page: page, seenSections: map[string]struct{}{}, active: true, } rendered, offset, closed, err := ctx.renderWikiTemplateRange(source, 0, "", state) if err != nil { return "", err } if closed { return "", fmt.Errorf("render wiki template %s for %s: unexpected closing block", path, page.PageID) } if offset != len(source) { return "", fmt.Errorf("render wiki template %s for %s: template parse stopped early", path, page.PageID) } return strings.TrimSpace(rendered), nil } type wikiTemplateRenderState struct { path string page wikiTemplatePage row map[string]any dot any seenSections map[string]struct{} active bool } func (ctx *wikiContext) renderWikiTemplateRange(source string, offset int, stopBlock string, state *wikiTemplateRenderState) (string, int, bool, error) { var out strings.Builder for { start := strings.Index(source[offset:], "{{") if start < 0 { if stopBlock != "" { return "", offset, false, fmt.Errorf("render wiki template %s for %s: missing {{/%s}}", state.path, state.page.PageID, stopBlock) } if state.active { out.WriteString(source[offset:]) } return out.String(), len(source), false, nil } start += offset end := strings.Index(source[start+2:], "}}") if end < 0 { return "", offset, false, fmt.Errorf("render wiki template %s for %s: unclosed placeholder", state.path, state.page.PageID) } end += start + 2 if state.active { out.WriteString(source[offset:start]) } token := strings.TrimSpace(source[start+2 : end]) if strings.HasPrefix(token, "/") { name := strings.TrimSpace(strings.TrimPrefix(token, "/")) if stopBlock == "" || name != stopBlock { return "", offset, false, fmt.Errorf("render wiki template %s for %s: unexpected {{/%s}}", state.path, state.page.PageID, name) } return out.String(), end + 2, true, nil } if strings.HasPrefix(token, "#each ") { providerName := strings.TrimSpace(strings.TrimPrefix(token, "#each ")) body, nextOffset, err := extractWikiTemplateBlock(source, end+2, "each", state.path, state.page.PageID) if err != nil { return "", offset, false, err } if state.active { items, err := ctx.resolveWikiTemplateEachItems(providerName, state) if err != nil { return "", offset, false, fmt.Errorf("render wiki template %s for %s: %w", state.path, state.page.PageID, err) } for _, item := range items { child := *state child.row = item.row child.dot = item.dot rendered, childOffset, closed, err := ctx.renderWikiTemplateRange(body, 0, "", &child) if err != nil { return "", offset, false, err } if closed || childOffset != len(body) { return "", offset, false, fmt.Errorf("render wiki template %s for %s: malformed each body", state.path, state.page.PageID) } out.WriteString(rendered) } } offset = nextOffset continue } if strings.HasPrefix(token, "#if ") { expr := strings.TrimSpace(strings.TrimPrefix(token, "#if ")) body, nextOffset, err := extractWikiTemplateBlock(source, end+2, "if", state.path, state.page.PageID) if err != nil { return "", offset, false, err } if state.active { value, err := ctx.evalWikiTemplateExpression(expr, state) if err != nil { return "", offset, false, err } if truthyWikiTableValue(value) { child := *state rendered, childOffset, closed, err := ctx.renderWikiTemplateRange(body, 0, "", &child) if err != nil { return "", offset, false, err } if closed || childOffset != len(body) { return "", offset, false, fmt.Errorf("render wiki template %s for %s: malformed if body", state.path, state.page.PageID) } out.WriteString(rendered) } } offset = nextOffset continue } if strings.HasPrefix(token, "#with ") { expr := strings.TrimSpace(strings.TrimPrefix(token, "#with ")) body, nextOffset, err := extractWikiTemplateBlock(source, end+2, "with", state.path, state.page.PageID) if err != nil { return "", offset, false, err } if state.active { value, err := ctx.evalWikiTemplateExpression(expr, state) if err != nil { return "", offset, false, err } if truthyWikiTableValue(value) { child := *state child.dot = value rendered, childOffset, closed, err := ctx.renderWikiTemplateRange(body, 0, "", &child) if err != nil { return "", offset, false, err } if closed || childOffset != len(body) { return "", offset, false, fmt.Errorf("render wiki template %s for %s: malformed with body", state.path, state.page.PageID) } out.WriteString(rendered) } } offset = nextOffset continue } if !state.active { offset = end + 2 continue } rendered, sectionID, err := ctx.renderWikiTemplateTokenWithState(token, state) if err != nil { return "", offset, false, err } if sectionID != "" { if _, exists := state.seenSections[sectionID]; exists { return "", offset, false, fmt.Errorf("render wiki template %s for %s: duplicate preserved section %q", state.path, state.page.PageID, sectionID) } state.seenSections[sectionID] = struct{}{} } out.WriteString(rendered) offset = end + 2 } } type wikiTemplateEachItem struct { row map[string]any dot any } func (ctx *wikiContext) resolveWikiTemplateEachItems(name string, state *wikiTemplateRenderState) ([]wikiTemplateEachItem, error) { if value, ok := ctx.resolveWikiTemplateScopedValue(name, state); ok { return wikiTemplateEachItemsFromValue(value), nil } rows, err := ctx.wikiDataProviderRows(name, state.page) if err != nil { return nil, err } items := make([]wikiTemplateEachItem, 0, len(rows)) for _, row := range rows { items = append(items, wikiTemplateEachItem{row: row, dot: row}) } return items, nil } func wikiTemplateEachItemsFromValue(value any) []wikiTemplateEachItem { switch typed := value.(type) { case []map[string]any: items := make([]wikiTemplateEachItem, 0, len(typed)) for _, row := range typed { items = append(items, wikiTemplateEachItem{row: row, dot: row}) } return items case []any: items := make([]wikiTemplateEachItem, 0, len(typed)) for _, value := range typed { if row, ok := value.(map[string]any); ok { items = append(items, wikiTemplateEachItem{row: row, dot: row}) continue } items = append(items, wikiTemplateEachItem{dot: value}) } return items case []string: items := make([]wikiTemplateEachItem, 0, len(typed)) for _, value := range typed { items = append(items, wikiTemplateEachItem{dot: value}) } return items default: return nil } } func (ctx *wikiContext) renderWikiTemplateTokenWithState(token string, state *wikiTemplateRenderState) (string, string, error) { if strings.HasPrefix(token, "format:") || strings.HasPrefix(token, "table:") || strings.HasPrefix(token, "field:") || strings.HasPrefix(token, "section:") { rendered, sectionID, err := ctx.renderWikiTemplateToken(state.path, token, state.page) return rendered, sectionID, err } if strings.Contains(token, "|") || strings.Contains(token, "(") || token == "." || strings.HasPrefix(token, "page.") { value, err := ctx.evalWikiTemplateExpression(token, state) if err != nil { return "", "", err } return stringifyWikiTemplateOutput(value), "", nil } if state.row != nil { if _, ok := ctx.resolveWikiTemplateScopedValue(token, state); ok { value, err := ctx.evalWikiTemplateExpression(token, state) if err != nil { return "", "", err } return stringifyWikiTemplateOutput(value), "", nil } } rendered, sectionID, err := ctx.renderWikiTemplateToken(state.path, token, state.page) return rendered, sectionID, err } 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, "section:") { sectionName := strings.TrimSpace(strings.TrimPrefix(token, "section:")) if section, ok := wikiManualSectionForAlias(page.ManualSections, sectionName); ok { return renderWikiManualSection(section), section.ID, nil } return "", "", 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 == "user_bottom" { 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 != "" } }