package topdata import ( "fmt" "html" "strconv" "strings" "unicode" ) type wikiTemplateHTML string func extractWikiTemplateBlock(source string, offset int, blockName, path, pageID string) (string, int, error) { depth := 1 bodyStart := offset for offset < len(source) { start := strings.Index(source[offset:], "{{") if start < 0 { break } start += offset end := strings.Index(source[start+2:], "}}") if end < 0 { return "", offset, fmt.Errorf("render wiki template %s for %s: unclosed placeholder", path, pageID) } end += start + 2 token := strings.TrimSpace(source[start+2 : end]) if token == "#"+blockName || strings.HasPrefix(token, "#"+blockName+" ") { depth++ } else if token == "/"+blockName { depth-- if depth == 0 { return source[bodyStart:start], end + 2, nil } } offset = end + 2 } return "", offset, fmt.Errorf("render wiki template %s for %s: missing {{/%s}}", path, pageID, blockName) } func (ctx *wikiContext) evalWikiTemplateExpression(expr string, state *wikiTemplateRenderState) (any, error) { parts := splitWikiTemplatePipes(expr) if len(parts) == 0 { return "", nil } value, err := ctx.evalWikiTemplateBase(strings.TrimSpace(parts[0]), state) if err != nil { return nil, err } for _, rawFilter := range parts[1:] { value, err = ctx.applyWikiTemplateFilter(value, strings.TrimSpace(rawFilter), state) if err != nil { return nil, err } } return value, nil } func splitWikiTemplatePipes(expr string) []string { parts := []string{} start := 0 inQuote := false escaped := false for i := 0; i < len(expr); i++ { switch { case escaped: escaped = false case expr[i] == '\\' && inQuote: escaped = true case expr[i] == '"': inQuote = !inQuote case expr[i] == '|' && !inQuote: parts = append(parts, expr[start:i]) start = i + 1 } } parts = append(parts, expr[start:]) return parts } func (ctx *wikiContext) evalWikiTemplateBase(expr string, state *wikiTemplateRenderState) (any, error) { if expr == "." { return state.dot, nil } if value, ok := ctx.resolveWikiTemplateScopedValue(expr, state); ok { return value, nil } renderCtx := wikiTableRenderContext{Page: state.page, Path: state.path} row := state.page.Row if state.row != nil { row = state.row } parser := wikiExprParser{tokens: tokenizeWikiExpr(expr), row: row, ctx: ctx, renderCtx: renderCtx} value, err := parser.parseComparison() if err != nil { return nil, fmt.Errorf("render wiki template %s for %s expression %q: %w", state.path, state.page.PageID, expr, err) } if parser.peek().kind != wikiExprEOF { return nil, fmt.Errorf("render wiki template %s for %s expression %q: unexpected token %q", state.path, state.page.PageID, expr, parser.peek().text) } return value, nil } func (ctx *wikiContext) resolveWikiTemplateScopedValue(name string, state *wikiTemplateRenderState) (any, bool) { name = strings.TrimSpace(name) if strings.HasPrefix(name, "page.") { field := strings.TrimPrefix(name, "page.") switch field { case "title", "Title": return state.page.Title, true case "page_id", "PageID": return state.page.PageID, true case "status", "Status": return state.page.Status, true case "category", "Category": return state.page.Category, true case "key", "Key": return state.page.Key, true default: if value, ok := lookupField(state.page.Row, field); ok { return value, true } return nil, false } } if state.row != nil { if value, ok := lookupField(state.row, name); ok { return value, true } } if value, ok := lookupField(state.page.Row, name); ok { return value, true } switch name { case "title": return state.page.Title, true case "page_id": return state.page.PageID, true case "status": return state.page.Status, true } return nil, false } func (ctx *wikiContext) applyWikiTemplateFilter(value any, filter string, state *wikiTemplateRenderState) (any, error) { name, argText, hasArg := strings.Cut(filter, ":") name = strings.TrimSpace(name) args, err := parseWikiTemplateFilterArgs(argText) if hasArg && err != nil { return nil, fmt.Errorf("render wiki template %s for %s filter %q: %w", state.path, state.page.PageID, filter, err) } switch name { case "text": return ctx.resolveTextValue(value, nil), nil case "trim": return strings.TrimSpace(stringifyWikiTemplatePlain(value)), nil case "after": return afterFirstMarker(stringifyWikiTemplatePlain(value), firstArg(args)), nil case "before": return beforeFirstMarker(stringifyWikiTemplatePlain(value), firstArg(args)), nil case "between": text := stringifyWikiTemplatePlain(value) return beforeFirstMarker(afterFirstMarker(text, firstArg(args)), secondArg(args)), nil case "paragraph": return selectWikiTemplateParagraphs(stringifyWikiTemplatePlain(value), firstArg(args)), nil case "paragraphs": return selectWikiTemplateParagraphs(stringifyWikiTemplatePlain(value), firstArg(args)), nil case "linebreaks": return wikiTemplateHTML(strings.ReplaceAll(html.EscapeString(stringifyWikiTemplatePlain(value)), "\n", "
")), nil case "html": return wikiTemplateHTML(renderWikiTemplatePlainHTML(stringifyWikiTemplatePlain(value))), nil case "wiki": return wikiTemplateHTML(renderWikiLinks(stringifyWikiTemplatePlain(value))), nil case "lower": return strings.ToLower(stringifyWikiTemplatePlain(value)), nil case "title": return strings.Title(stringifyWikiTemplatePlain(value)), nil case "sentence": text := stringifyWikiTemplatePlain(value) for i, r := range text { if unicode.IsLetter(r) { return text[:i] + string(unicode.ToUpper(r)) + text[i+len(string(r)):], nil } } return text, nil case "join": return joinWikiTemplateValue(value, firstArg(args)), nil case "count": return countWikiTemplateValue(value), nil case "alignments": return ctx.formatWikiTemplateAlignments(value) case "list": return wikiTemplateHTML(renderWikiTemplateList(value, firstArg(args))), nil case "ordinal": return ordinalWikiTableValue(numericWikiTableValue(value)), nil case "default": if strings.TrimSpace(stringifyWikiTemplatePlain(value)) == "" { return firstArg(args), nil } return value, nil case "ref": return ctx.renderTemplateReference(value, firstArg(args)), nil case "link": return ctx.renderTemplateLink(value, firstArg(args), state), nil default: return nil, fmt.Errorf("render wiki template %s for %s: unknown filter %q", state.path, state.page.PageID, name) } } func parseWikiTemplateFilterArgs(raw string) ([]string, error) { raw = strings.TrimSpace(raw) if raw == "" { return nil, nil } args := []string{} for len(raw) > 0 { raw = strings.TrimSpace(raw) if !strings.HasPrefix(raw, `"`) { if idx := strings.Index(raw, ","); idx >= 0 { args = append(args, strings.TrimSpace(raw[:idx])) raw = raw[idx+1:] continue } args = append(args, strings.TrimSpace(raw)) break } var b strings.Builder escaped := false i := 1 for ; i < len(raw); i++ { if escaped { b.WriteByte(raw[i]) escaped = false continue } if raw[i] == '\\' { escaped = true continue } if raw[i] == '"' { break } b.WriteByte(raw[i]) } if i >= len(raw) { return nil, fmt.Errorf("unterminated quoted argument") } args = append(args, b.String()) raw = strings.TrimSpace(raw[i+1:]) if strings.HasPrefix(raw, ",") { raw = raw[1:] } else if raw != "" { return nil, fmt.Errorf("unexpected argument text %q", raw) } } return args, nil } func firstArg(args []string) string { if len(args) == 0 { return "" } return args[0] } func secondArg(args []string) string { if len(args) < 2 { return "" } return args[1] } func afterFirstMarker(text, marker string) string { if marker == "" { return text } idx := strings.Index(text, marker) if idx < 0 { return "" } return text[idx+len(marker):] } func beforeFirstMarker(text, marker string) string { if marker == "" { return text } idx := strings.Index(text, marker) if idx < 0 { return text } return text[:idx] } func selectWikiTemplateParagraphs(text, selector string) string { paragraphs := splitWikiTemplateParagraphs(text) if len(paragraphs) == 0 { return "" } switch strings.TrimSpace(selector) { case "first": return paragraphs[0] case "last": return paragraphs[len(paragraphs)-1] case "all", "": return strings.Join(paragraphs, "\n\n") default: selected := []string{} for _, part := range strings.Split(selector, ",") { switch strings.TrimSpace(part) { case "first": selected = append(selected, paragraphs[0]) case "last": selected = append(selected, paragraphs[len(paragraphs)-1]) default: if idx, err := strconv.Atoi(strings.TrimSpace(part)); err == nil && idx > 0 && idx <= len(paragraphs) { selected = append(selected, paragraphs[idx-1]) } } } return strings.Join(selected, "\n\n") } } func splitWikiTemplateParagraphs(text string) []string { normalized := strings.ReplaceAll(text, "\r\n", "\n") chunks := strings.Split(normalized, "\n\n") out := []string{} for _, chunk := range chunks { if trimmed := strings.TrimSpace(chunk); trimmed != "" { out = append(out, trimmed) } } return out } func renderWikiTemplatePlainHTML(text string) string { paragraphs := splitWikiTemplateParagraphs(text) if len(paragraphs) == 0 { return "" } out := strings.Builder{} for _, paragraph := range paragraphs { escaped := html.EscapeString(paragraph) escaped = strings.ReplaceAll(escaped, "\n", "
") out.WriteString("

") out.WriteString(escaped) out.WriteString("

") } return out.String() } func joinWikiTemplateValue(value any, sep string) string { switch typed := value.(type) { case []string: return strings.Join(typed, sep) case []any: parts := make([]string, 0, len(typed)) for _, item := range typed { if text := stringifyWikiTemplatePlain(item); text != "" { parts = append(parts, text) } } return strings.Join(parts, sep) default: return stringifyWikiTemplatePlain(value) } } func (ctx *wikiContext) formatWikiTemplateAlignments(value any) (any, error) { values, err := wikiTemplateAlignmentValues(value) if err != nil { return "", err } if len(values) == 0 || (len(values) == 1 && values[0] == 0) { return "Any", nil } labels := make([]string, 0, len(values)) for _, value := range values { if value == 0 { continue } label, ok := wikiTemplateAlignmentLabel(value) if !ok { return "", fmt.Errorf("unknown alignment value 0x%02X", value) } labels = append(labels, ctx.renderWikiTemplateAlignmentLabel(label)) } if len(labels) == 0 { return "Any", nil } rendered := strings.Join(labels, ", ") if ctx != nil && strings.TrimSpace(ctx.alignmentLinkFormat) == "html_anchor" { return wikiTemplateHTML(rendered), nil } return rendered, nil } func (ctx *wikiContext) renderWikiTemplateAlignmentLabel(label string) string { pattern := "" format := "wiki_markup" if ctx != nil { pattern = strings.TrimSpace(ctx.alignmentLinkPattern) if strings.TrimSpace(ctx.alignmentLinkFormat) != "" { format = strings.TrimSpace(ctx.alignmentLinkFormat) } } if pattern == "" { return label } target := strings.ReplaceAll(pattern, "{alignment}", wikiTemplateAlignmentTargetSegment(label)) target = strings.ReplaceAll(target, "{label}", label) if strings.TrimSpace(target) == "" { return label } if format == "html_anchor" { return `` + html.EscapeString(label) + `` } return "[[" + target + "|" + label + "]]" } func wikiTemplateAlignmentTargetSegment(label string) string { return strings.ReplaceAll(strings.TrimSpace(label), " ", "_") } func wikiTemplateAlignmentValues(value any) ([]int, error) { if isNullLike(value) { return []int{0}, nil } switch typed := value.(type) { case map[string]any: _, hasAlignments := typed["alignments"] _, hasList := typed["list"] if hasAlignments && hasList { return nil, fmt.Errorf("alignment list object cannot contain both alignments and list") } for key := range typed { if key != "alignments" && key != "list" { return nil, fmt.Errorf("alignment list object contains unsupported key %q", key) } } if hasAlignments { return wikiTemplateAlignmentValues(typed["alignments"]) } return wikiTemplateAlignmentValues(typed["list"]) case string: trimmed := strings.TrimSpace(typed) if trimmed == "" || trimmed == "0" || strings.EqualFold(trimmed, "0x00") { return []int{0}, nil } if strings.HasPrefix(strings.ToLower(trimmed), "0x") { hexText := trimmed[2:] if len(hexText)%2 != 0 { return nil, fmt.Errorf("alignment hex list %q must contain two-character chunks", typed) } values := make([]int, 0, len(hexText)/2) for index := 0; index < len(hexText); index += 2 { parsed, err := strconv.ParseInt(hexText[index:index+2], 16, 0) if err != nil { return nil, fmt.Errorf("alignment hex list %q contains invalid chunk %q", typed, hexText[index:index+2]) } values = append(values, int(parsed)) } return values, nil } if strings.Contains(trimmed, ",") { parts := strings.Split(trimmed, ",") values := make([]int, 0, len(parts)) for _, part := range parts { parsed, err := parseConfiguredAlignmentValue(part) if err != nil { return nil, err } values = append(values, parsed) } return values, nil } parsed, err := parseConfiguredAlignmentValue(trimmed) if err != nil { return nil, err } return []int{parsed}, nil case []any: values := make([]int, 0, len(typed)) for _, item := range typed { parsed, err := parseConfiguredAlignmentValue(item) if err != nil { return nil, err } values = append(values, parsed) } return values, nil default: parsed, err := parseConfiguredAlignmentValue(value) if err != nil { return nil, err } return []int{parsed}, nil } } func wikiTemplateAlignmentLabel(value int) (string, bool) { switch value { case 0x0A: return "Lawful Good", true case 0x09: return "Neutral Good", true case 0x0C: return "Chaotic Good", true case 0x03: return "Lawful Neutral", true case 0x01: return "True Neutral", true case 0x05: return "Chaotic Neutral", true case 0x12: return "Lawful Evil", true case 0x11: return "Neutral Evil", true case 0x14: return "Chaotic Evil", true default: return "", false } } func renderWikiTemplateList(value any, class string) string { items := wikiTemplateListItems(value) if len(items) == 0 { return "" } var out strings.Builder out.WriteString("") for _, item := range items { out.WriteString("
  • ") out.WriteString(renderWikiLinks(item)) out.WriteString("
  • ") } out.WriteString("") return out.String() } func wikiTemplateListItems(value any) []string { switch typed := value.(type) { case []string: out := make([]string, 0, len(typed)) for _, item := range typed { if text := strings.TrimSpace(item); text != "" { out = append(out, text) } } return out case []any: out := make([]string, 0, len(typed)) for _, item := range typed { if text := strings.TrimSpace(stringifyWikiTemplatePlain(item)); text != "" { out = append(out, text) } } return out default: if text := strings.TrimSpace(stringifyWikiTemplatePlain(value)); text != "" { return []string{text} } return nil } } func (ctx *wikiContext) renderTemplateReference(value any, dataset string) string { return ctx.renderReference(value, ctx.wikiIDMapForDataset(dataset), ctx.wikiNameResolverForDataset(dataset)) } func (ctx *wikiContext) wikiNameResolverForDataset(dataset string) func(string) string { switch dataset { case "classes": return ctx.resolveClassName case "feat": return ctx.resolveFeatName case "skills": return ctx.resolveSkillName case "spells": return ctx.resolveSpellName case "racialtypes": return ctx.resolveRaceName case "baseitems": return ctx.resolveBaseItemName default: return func(string) string { return "" } } } func (ctx *wikiContext) renderTemplateLink(value any, labelField string, state *wikiTemplateRenderState) string { key := stringifyWikiTemplatePlain(value) label := labelField if state.row != nil { if raw, ok := lookupField(state.row, labelField); ok { label = stringifyWikiTemplatePlain(raw) } } if label == "" { label = key } if key == "" { return label } if !ctx.canReferenceWikiKey(key) { return "" } if target := ctx.publicWikiTargetForKey(key, label); target != "" { key = target } return "[[" + key + "|" + label + "]]" } func stringifyWikiTemplateOutput(value any) string { if htmlValue, ok := value.(wikiTemplateHTML); ok { return string(htmlValue) } return html.EscapeString(stringifyWikiTemplatePlain(value)) } func stringifyWikiTemplatePlain(value any) string { if htmlValue, ok := value.(wikiTemplateHTML); ok { return string(htmlValue) } return stringifyWikiTableValue(value) }