From 4495feb1283aab385a8f47072ad0162e55b777eb Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Thu, 21 May 2026 21:45:21 +0200 Subject: [PATCH] Wiki Template-First Rendering (#9) Reviewed-on: https://gitea.westgate.pw/ShadowsOverWestgate/sow-tools/pulls/9 Co-authored-by: vickydotbat Co-committed-by: vickydotbat --- internal/topdata/wiki_data_providers.go | 136 ++++++++ internal/topdata/wiki_native.go | 8 + internal/topdata/wiki_native_test.go | 171 ++++++++++ internal/topdata/wiki_tables.go | 5 + internal/topdata/wiki_template.go | 180 +++++++++- internal/topdata/wiki_template_expr.go | 416 ++++++++++++++++++++++++ 6 files changed, 904 insertions(+), 12 deletions(-) create mode 100644 internal/topdata/wiki_data_providers.go create mode 100644 internal/topdata/wiki_template_expr.go diff --git a/internal/topdata/wiki_data_providers.go b/internal/topdata/wiki_data_providers.go new file mode 100644 index 0000000..05d2798 --- /dev/null +++ b/internal/topdata/wiki_data_providers.go @@ -0,0 +1,136 @@ +package topdata + +import ( + "fmt" + "os" + "strings" + + "gopkg.in/yaml.v3" +) + +type wikiDataProvidersDocument struct { + Providers map[string]wikiDataProviderDefinition `json:"providers" yaml:"providers"` +} + +type wikiDataProviderDefinition struct { + Source string `json:"source" yaml:"source"` + Levels string `json:"levels" yaml:"levels"` +} + +func loadWikiDataProviders(path string) (map[string]wikiDataProviderDefinition, error) { + raw, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return map[string]wikiDataProviderDefinition{}, nil + } + return nil, fmt.Errorf("read wiki data providers %s: %w", path, err) + } + var doc wikiDataProvidersDocument + if err := yaml.Unmarshal(raw, &doc); err != nil { + return nil, fmt.Errorf("parse wiki data providers %s: %w", path, err) + } + for name, def := range doc.Providers { + if strings.TrimSpace(name) == "" { + return nil, fmt.Errorf("wiki data providers %s: provider name must not be empty", path) + } + if strings.TrimSpace(def.Source) == "" { + return nil, fmt.Errorf("wiki data providers %s: provider %s missing source", path, name) + } + switch strings.TrimSpace(def.Source) { + case "class_progression", "class_feats", "class_summary", "race_feats", "race_name_forms": + default: + return nil, fmt.Errorf("wiki data providers %s: provider %s has unknown source %q", path, name, def.Source) + } + } + if doc.Providers == nil { + return map[string]wikiDataProviderDefinition{}, nil + } + return doc.Providers, nil +} + +func (ctx *wikiContext) wikiDataProviderRows(name string, page wikiTemplatePage) ([]map[string]any, error) { + def, ok := ctx.wikiDataProviders[name] + if !ok { + return nil, fmt.Errorf("missing wiki data provider %q in %s", name, ctx.wikiDataPath) + } + switch strings.TrimSpace(def.Source) { + case "class_progression": + if page.Category != "classes" { + return nil, nil + } + return ctx.classProgressionRows(wikiTableDefinition{Rows: wikiTableRowsConfig{Levels: def.Levels}}, page.Row) + case "class_feats": + if page.Category != "classes" { + return nil, nil + } + return ctx.classFeatRows(page.Row), nil + case "class_summary": + if page.Category != "classes" { + return nil, nil + } + return ctx.classSummaryRows(page.Row), nil + case "race_feats": + if page.Category != "racialtypes" { + return nil, nil + } + return ctx.raceFeatRows(page.Row), nil + case "race_name_forms": + if page.Category != "racialtypes" { + return nil, nil + } + return ctx.raceNameFormRows(page.Row), nil + default: + return nil, fmt.Errorf("unknown wiki data provider source %q", def.Source) + } +} + +func (ctx *wikiContext) raceFeatRows(raceRow map[string]any) []map[string]any { + values := []any{} + switch typed := fieldValue(raceRow, "FeatsTable").(type) { + case []any: + values = append(values, typed...) + case []string: + for _, value := range typed { + values = append(values, value) + } + default: + if table := ctx.tableForValue(typed, ctx.raceFeatTables); table != nil { + for _, row := range table.Rows { + values = append(values, fieldValue(row, "FeatIndex")) + } + } + } + rows := []map[string]any{} + for _, value := range values { + key := resolveReferenceKey(value, ctx.featIDToKey) + if key == "" { + key = resolveReferenceKey(value, nil) + } + if key == "" || !ctx.canReferenceWikiKey(key) { + continue + } + rows = append(rows, map[string]any{ + "FeatKey": key, + "FeatName": ctx.resolveFeatName(key), + }) + } + return rows +} + +func (ctx *wikiContext) raceNameFormRows(raceRow map[string]any) []map[string]any { + candidates := []struct { + label string + field string + }{ + {"Plural", "NamePlural"}, + {"Converted Name", "ConverName"}, + {"Lower Name", "ConverNameLower"}, + } + rows := []map[string]any{} + for _, candidate := range candidates { + if value := ctx.resolveTextValue(fieldValue(raceRow, candidate.field), nil); value != "" { + rows = append(rows, map[string]any{"Label": candidate.label, "Value": value}) + } + } + return rows +} diff --git a/internal/topdata/wiki_native.go b/internal/topdata/wiki_native.go index 1a7a425..db7ac1e 100644 --- a/internal/topdata/wiki_native.go +++ b/internal/topdata/wiki_native.go @@ -115,6 +115,8 @@ type wikiContext struct { templateDir string wikiTablesPath string wikiTableDefinitions map[string]wikiTableDefinition + wikiDataPath string + wikiDataProviders map[string]wikiDataProviderDefinition visibilityPolicy *wikiVisibilityDefinitions visibleWikiKeys map[string]bool pageSlugOverrides map[string]string @@ -157,6 +159,12 @@ func buildWiki(p *project.Project, nativeResult BuildResult, force bool, progres return wikiResult{}, err } ctx.wikiTableDefinitions = tableDefinitions + ctx.wikiDataPath = filepath.Join(p.TopDataWikiSourceDir(), "data.yaml") + dataProviders, err := loadWikiDataProviders(ctx.wikiDataPath) + if err != nil { + return wikiResult{}, err + } + ctx.wikiDataProviders = dataProviders visibilityPath := filepath.Join(p.TopDataWikiSourceDir(), filepath.FromSlash(p.EffectiveConfig().TopData.Wiki.VisibilityFile)) if err := ctx.loadWikiVisibility(visibilityPath); err != nil { return wikiResult{}, err diff --git a/internal/topdata/wiki_native_test.go b/internal/topdata/wiki_native_test.go index c1532d5..baf9158 100644 --- a/internal/topdata/wiki_native_test.go +++ b/internal/topdata/wiki_native_test.go @@ -268,6 +268,177 @@ func TestWikiTemplateMissingFieldFailsUnlessDefaultProvided(t *testing.T) { } } +func TestWikiTemplateEachBlockRendersExplicitClassProgressionTable(t *testing.T) { + ctx := &wikiContext{ + wikiDataProviders: map[string]wikiDataProviderDefinition{ + "ClassProgression": {Source: "class_progression", Levels: "1-2"}, + }, + } + page := wikiTemplatePage{ + PageID: "classes:fighter", + Category: "classes", + Key: "classes:fighter", + Title: "Fighter", + Row: map[string]any{ + "HitDie": "10", + "AttackBonusTable": "cls_atk_1", + }, + } + + template := ` + + + + + {{#each ClassProgression}} + + {{/each}} + +
LvlBABHP
{{Level|ordinal}}{{BAB}}{{HPMin}}-{{HPMax}}
` + + got, err := ctx.renderWikiTemplateString("classes.html", template, page) + if err != nil { + t.Fatalf("render template: %v", err) + } + for _, want := range []string{ + ``, + ``, + ``, + } { + if !strings.Contains(got, want) { + t.Fatalf("expected rendered output to contain %q:\n%s", want, got) + } + } +} + +func TestWikiTemplateCanRenderRacialFactsAndFeatListInHTML(t *testing.T) { + ctx := &wikiContext{ + wikiDataProviders: map[string]wikiDataProviderDefinition{ + "RaceFeats": {Source: "race_feats"}, + }, + classIDToKey: map[int]string{7: "classes:paladin"}, + featIDToKey: map[int]string{ + 10: "feat:darkvision", + 11: "feat:daylight:aasimar", + }, + classRows: map[string]map[string]any{ + "classes:paladin": {"Name": "Paladin"}, + }, + featRows: map[string]map[string]any{ + "feat:darkvision": {"FEAT": "Darkvision"}, + "feat:daylight:aasimar": {"FEAT": "Daylight (aasimar)"}, + }, + visibleWikiKeys: map[string]bool{ + "classes:paladin": true, + "feat:darkvision": true, + "feat:daylight:aasimar": true, + }, + } + page := wikiTemplatePage{ + PageID: "racialtypes:aasimar", + Category: "racialtypes", + Key: "racialtypes:aasimar", + Title: "Aasimar", + Row: map[string]any{ + "WISAdjust": "2", + "CHAAdjust": "2", + "Favored": "7", + "FeatsTable": []any{"10", "11"}, + }, + } + + template := `
1st+11-10
2nd+22-20
+ + + {{#if present(Favored)}} + + {{/if}} + +
Ability Adjustments{{ability_adjustments()}}
Favored Class{{Favored|ref:"classes"|wiki}}
+
    +{{#each RaceFeats}} +
  • {{FeatKey|link:FeatName|wiki}}
  • +{{/each}} +
` + + got, err := ctx.renderWikiTemplateString("racialtypes.html", template, page) + if err != nil { + t.Fatalf("render template: %v", err) + } + for _, want := range []string{ + `Ability Adjustments+2 Wisdom, +2 Charisma`, + `Favored Class[[classes/paladin|Paladin]]`, + `
  • [[feat/darkvision|Darkvision]]
  • `, + `
  • [[feat/daylight-aasimar|Daylight (aasimar)]]
  • `, + } { + if !strings.Contains(got, want) { + t.Fatalf("expected rendered output to contain %q:\n%s", want, got) + } + } +} + +func TestWikiTemplateDescriptionFiltersExtractParagraphsAndSections(t *testing.T) { + ctx := &wikiContext{} + page := wikiTemplatePage{ + PageID: "feat:test", + Category: "feat", + Key: "feat:test", + Title: "Test Feat", + Row: map[string]any{ + "DESCRIPTION": map[string]any{"tlk": map[string]any{"text": strings.Join([]string{ + "Type of Feat: General", + "", + "Prerequisite: Dexterity 13+", + "", + "Specifics: You gain a +2 dodge bonus.", + "The bonus applies only while unarmored.", + "", + "Use: Automatic.", + }, "\n")}}, + }, + } + + tests := []struct { + name string + template string + want string + reject string + }{ + { + name: "first paragraph", + template: `
    {{DESCRIPTION|text|paragraph:first}}
    `, + want: `
    Type of Feat: General
    `, + reject: `Prerequisite`, + }, + { + name: "specifics after marker as html", + template: `
    {{DESCRIPTION|text|after:"Specifics:"|before:"Use:"|trim|html}}
    `, + want: `

    You gain a +2 dodge bonus.
    The bonus applies only while unarmored.

    `, + reject: `Use: Automatic`, + }, + { + name: "first and last paragraphs", + template: `
    {{DESCRIPTION|text|paragraphs:"first,last"|html}}
    `, + want: `

    Type of Feat: General

    Use: Automatic.

    `, + reject: `Specifics`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ctx.renderWikiTemplateString("feat.html", tt.template, page) + if err != nil { + t.Fatalf("render template: %v", err) + } + if !strings.Contains(got, tt.want) { + t.Fatalf("expected output to contain %q:\n%s", tt.want, got) + } + if tt.reject != "" && strings.Contains(got, tt.reject) { + t.Fatalf("expected output not to contain %q:\n%s", tt.reject, got) + } + }) + } +} + func TestWikiTemplateRendersConfiguredClassProgressionTable(t *testing.T) { ctx := testWikiTableContext(t, ` tables: diff --git a/internal/topdata/wiki_tables.go b/internal/topdata/wiki_tables.go index 8a8c377..093bf61 100644 --- a/internal/topdata/wiki_tables.go +++ b/internal/topdata/wiki_tables.go @@ -915,6 +915,11 @@ func (p *wikiExprParser) callHelper(name string, args []any) (any, error) { } } return false, nil + case "ability_adjustments": + if len(args) != 0 { + return nil, fmt.Errorf("ability_adjustments expects 0 arguments") + } + return formatAbilityAdjustments(p.renderCtx.Page.Row), nil case "hidden_ref": if len(args) != 2 { return nil, fmt.Errorf("hidden_ref expects 2 arguments") diff --git a/internal/topdata/wiki_template.go b/internal/topdata/wiki_template.go index 46bda2c..085ff2e 100644 --- a/internal/topdata/wiki_template.go +++ b/internal/topdata/wiki_template.go @@ -82,37 +82,186 @@ func builtinWikiTemplate(category string) string { } 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 - offset := 0 - seenSections := map[string]struct{}{} for { start := strings.Index(source[offset:], "{{") if start < 0 { - out.WriteString(source[offset:]) - break + 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 "", fmt.Errorf("render wiki template %s for %s: unclosed placeholder", path, page.PageID) + return "", offset, false, fmt.Errorf("render wiki template %s for %s: unclosed placeholder", state.path, state.page.PageID) } end += start + 2 - out.WriteString(source[offset:start]) + if state.active { + out.WriteString(source[offset:start]) + } token := strings.TrimSpace(source[start+2 : end]) - rendered, sectionID, err := ctx.renderWikiTemplateToken(path, token, page) + 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 { + rows, err := ctx.wikiDataProviderRows(providerName, state.page) + if err != nil { + return "", offset, false, fmt.Errorf("render wiki template %s for %s: %w", state.path, state.page.PageID, err) + } + for _, row := range rows { + child := *state + child.row = row + 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 "", err + return "", offset, false, 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) + 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) } - seenSections[sectionID] = struct{}{} + state.seenSections[sectionID] = struct{}{} } out.WriteString(rendered) offset = end + 2 } - return strings.TrimSpace(out.String()), 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) { @@ -127,6 +276,13 @@ func (ctx *wikiContext) renderWikiTemplateToken(path, token string, page wikiTem 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 diff --git a/internal/topdata/wiki_template_expr.go b/internal/topdata/wiki_template_expr.go new file mode 100644 index 0000000..a7d9f96 --- /dev/null +++ b/internal/topdata/wiki_template_expr.go @@ -0,0 +1,416 @@ +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 "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) 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 + 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) +}