diff --git a/internal/project/effective.go b/internal/project/effective.go index ce7cced..e2a2e70 100644 --- a/internal/project/effective.go +++ b/internal/project/effective.go @@ -39,6 +39,7 @@ const ( DefaultTopDataWikiRenderer = "nodebb_tiptap_html" DefaultTopDataWikiLinkStrategy = "preserve_westgate_wiki_links" DefaultTopDataWikiNamespacesFile = "namespaces.yaml" + DefaultTopDataWikiTablesFile = "tables.yaml" DefaultTopDataWikiTemplatesDir = "templates" DefaultTopDataWikiManualSectionsDir = "manual-sections" DefaultTopDataWikiStaleDefault = "report" @@ -244,6 +245,7 @@ func (p *Project) EffectiveConfig() EffectiveConfig { Renderer: defaultString(p.Config.TopData.Wiki.Renderer, DefaultTopDataWikiRenderer), LinkStrategy: defaultString(p.Config.TopData.Wiki.LinkStrategy, DefaultTopDataWikiLinkStrategy), NamespacesFile: defaultString(p.Config.TopData.Wiki.NamespacesFile, DefaultTopDataWikiNamespacesFile), + TablesFile: defaultString(p.Config.TopData.Wiki.TablesFile, DefaultTopDataWikiTablesFile), TemplatesDir: defaultString(p.Config.TopData.Wiki.TemplatesDir, DefaultTopDataWikiTemplatesDir), ManualSectionsDir: defaultString(p.Config.TopData.Wiki.ManualSectionsDir, DefaultTopDataWikiManualSectionsDir), TitlePrefixMinLength: defaultInt(p.Config.TopData.Wiki.TitlePrefixMinLength, DefaultTopDataWikiTitlePrefixMinLength), @@ -479,6 +481,7 @@ func markMissingDefaults(provenance ConfigProvenance) { "topdata.wiki.renderer": DefaultTopDataWikiRenderer, "topdata.wiki.link_strategy": DefaultTopDataWikiLinkStrategy, "topdata.wiki.namespaces_file": DefaultTopDataWikiNamespacesFile, + "topdata.wiki.tables_file": DefaultTopDataWikiTablesFile, "topdata.wiki.templates_dir": DefaultTopDataWikiTemplatesDir, "topdata.wiki.manual_sections_dir": DefaultTopDataWikiManualSectionsDir, "topdata.wiki.title_prefix_min_length": "3", diff --git a/internal/project/project.go b/internal/project/project.go index 49c7d8b..3088db9 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -228,6 +228,7 @@ type TopDataWikiConfig struct { Renderer string `json:"renderer" yaml:"renderer"` LinkStrategy string `json:"link_strategy" yaml:"link_strategy"` NamespacesFile string `json:"namespaces_file" yaml:"namespaces_file"` + TablesFile string `json:"tables_file" yaml:"tables_file"` TemplatesDir string `json:"templates_dir" yaml:"templates_dir"` ManualSectionsDir string `json:"manual_sections_dir" yaml:"manual_sections_dir"` TitlePrefixMinLength int `json:"title_prefix_min_length" yaml:"title_prefix_min_length"` @@ -514,6 +515,7 @@ func (p *Project) ValidateLayout() error { "topdata.wiki.state_file": p.Config.TopData.Wiki.StateFile, "topdata.wiki.deploy_manifest": p.Config.TopData.Wiki.DeployManifest, "topdata.wiki.deploy_edit_summary": p.Config.TopData.Wiki.DeployEditSummary, + "topdata.wiki.tables_file": p.Config.TopData.Wiki.TablesFile, "extract.layout": p.Config.Extract.Layout, "extract.hak_discovery": p.Config.Extract.HAKDiscovery, "autogen.cache.root": p.Config.Autogen.Cache.Root, @@ -546,6 +548,7 @@ func (p *Project) ValidateLayout() error { failures = append(failures, validateRelativePath("topdata.wiki.deploy_manifest", effective.TopData.Wiki.DeployManifest)...) failures = append(failures, validateTreeRootPath("topdata.wiki.source", effective.TopData.Wiki.Source)...) failures = append(failures, validateRelativePath("topdata.wiki.namespaces_file", effective.TopData.Wiki.NamespacesFile)...) + failures = append(failures, validateRelativePath("topdata.wiki.tables_file", effective.TopData.Wiki.TablesFile)...) failures = append(failures, validateTreeRootPath("topdata.wiki.templates_dir", effective.TopData.Wiki.TemplatesDir)...) failures = append(failures, validateTreeRootPath("topdata.wiki.manual_sections_dir", effective.TopData.Wiki.ManualSectionsDir)...) failures = append(failures, validateRelativePath("autogen.cache.root", effective.Autogen.Cache.Root)...) diff --git a/internal/project/project_test.go b/internal/project/project_test.go index bb326c5..004dc72 100644 --- a/internal/project/project_test.go +++ b/internal/project/project_test.go @@ -262,6 +262,7 @@ topdata: renderer: nodebb_tiptap_html link_strategy: preserve_westgate_wiki_links namespaces_file: namespaces.yaml + tables_file: custom-tables.yaml templates_dir: templates manual_sections_dir: manual-sections deploy_manifest: wiki-manifest.json @@ -319,6 +320,9 @@ autogen: if got, want := effective.TopData.Wiki.NamespacesFile, "namespaces.yaml"; got != want { t.Fatalf("expected wiki namespaces file %q, got %q", want, got) } + if got, want := effective.TopData.Wiki.TablesFile, "custom-tables.yaml"; got != want { + t.Fatalf("expected wiki tables file %q, got %q", want, got) + } if got, want := effective.TopData.Wiki.TemplatesDir, "templates"; got != want { t.Fatalf("expected wiki templates dir %q, got %q", want, got) } @@ -758,6 +762,7 @@ func TestValidateLayoutRejectsInvalidTopDataWikiConfig(t *testing.T) { Renderer: "dokuwiki", LinkStrategy: "rewrite_links", NamespacesFile: "../namespaces.yaml", + TablesFile: "../tables.yaml", TemplatesDir: ".", ManualSectionsDir: "../manual", StalePages: TopDataWikiStalePagesConfig{ @@ -778,6 +783,7 @@ func TestValidateLayoutRejectsInvalidTopDataWikiConfig(t *testing.T) { "topdata.wiki.renderer", "topdata.wiki.link_strategy", "topdata.wiki.namespaces_file", + "topdata.wiki.tables_file", "topdata.wiki.templates_dir", "topdata.wiki.manual_sections_dir", "topdata.wiki.stale_pages.default", diff --git a/internal/topdata/wiki_native.go b/internal/topdata/wiki_native.go index 6dd3e83..fc54aa8 100644 --- a/internal/topdata/wiki_native.go +++ b/internal/topdata/wiki_native.go @@ -89,27 +89,30 @@ type wikiTable struct { } type wikiContext struct { - dialog map[string]string - rowsByKey map[string]map[string]any - featRows map[string]map[string]any - featIDToKey map[int]string - skillRows map[string]map[string]any - skillIDToKey map[int]string - spellRows map[string]map[string]any - baseitemRows map[string]map[string]any - classRows map[string]map[string]any - classIDToKey map[int]string - raceRows map[string]map[string]any - raceIDToKey map[int]string - masterfeatRows map[string]map[string]any - classFeatTables map[string]wikiTable - classSkillTables map[string]wikiTable - classBonusTables map[string]wikiTable - raceFeatTables map[string]wikiTable - statuses map[string]map[string]string - implementedFeats map[string]struct{} - manualSections []wikiManualSection - templateDir string + dialog map[string]string + rowsByKey map[string]map[string]any + featRows map[string]map[string]any + featIDToKey map[int]string + skillRows map[string]map[string]any + skillIDToKey map[int]string + spellRows map[string]map[string]any + baseitemRows map[string]map[string]any + classRows map[string]map[string]any + classIDToKey map[int]string + raceRows map[string]map[string]any + raceIDToKey map[int]string + masterfeatRows map[string]map[string]any + classFeatTables map[string]wikiTable + classSkillTables map[string]wikiTable + classBonusTables map[string]wikiTable + classSaveTables map[string]wikiTable + raceFeatTables map[string]wikiTable + statuses map[string]map[string]string + implementedFeats map[string]struct{} + manualSections []wikiManualSection + templateDir string + wikiTablesPath string + wikiTableDefinitions map[string]wikiTableDefinition } func buildWiki(p *project.Project, nativeResult BuildResult, force bool, progress func(string)) (wikiResult, error) { @@ -143,6 +146,12 @@ func buildWiki(p *project.Project, nativeResult BuildResult, force bool, progres manualSections := loadWikiManualSections(p) ctx.manualSections = manualSections ctx.templateDir = filepath.Join(p.TopDataWikiSourceDir(), filepath.FromSlash(p.EffectiveConfig().TopData.Wiki.TemplatesDir), "pages") + ctx.wikiTablesPath = filepath.Join(p.TopDataWikiSourceDir(), filepath.FromSlash(p.EffectiveConfig().TopData.Wiki.TablesFile)) + tableDefinitions, err := loadWikiTableDefinitions(ctx.wikiTablesPath) + if err != nil { + return wikiResult{}, err + } + ctx.wikiTableDefinitions = tableDefinitions if err := os.RemoveAll(outputDir); err != nil { return wikiResult{}, fmt.Errorf("clean wiki output: %w", err) @@ -535,6 +544,10 @@ func loadWikiContext(dataDir, sourceDir string) (*wikiContext, error) { if err != nil { return nil, err } + classSaveTables, err := loadWikiTables(filepath.Join(dataDir, "classes", "savthr")) + if err != nil { + return nil, err + } ctx := &wikiContext{ dialog: dialog, @@ -544,6 +557,7 @@ func loadWikiContext(dataDir, sourceDir string) (*wikiContext, error) { classFeatTables: classFeatTables, classSkillTables: classSkillTables, classBonusTables: classBonusTables, + classSaveTables: classSaveTables, } if ok { ctx.featRows, ctx.featIDToKey = rowsByKeyAndID(featDataset.Rows) @@ -672,6 +686,9 @@ func loadWikiTables(root string) (map[string]wikiTable, error) { Rows: rows, } tables[table.OutputStem] = table + if table.Key != "" { + tables[table.Key] = table + } return nil }) if err != nil { diff --git a/internal/topdata/wiki_native_test.go b/internal/topdata/wiki_native_test.go index ceb1578..cf30351 100644 --- a/internal/topdata/wiki_native_test.go +++ b/internal/topdata/wiki_native_test.go @@ -206,6 +206,176 @@ func TestWikiTemplateMissingFieldFailsUnlessDefaultProvided(t *testing.T) { } } +func TestWikiTemplateRendersConfiguredClassProgressionTable(t *testing.T) { + ctx := testWikiTableContext(t, ` +tables: + ClassProgression: + source: class_progression + class: "wiki-table wiki-class-progression" + rows: + levels: 1-4 + when: "{{Level >= 1 && Level <= 4}}" + columns: + - key: level + label: Lvl + value: "{{ordinal(Level)}}" + - key: bab + label: BAB + value: "{{BAB}}" + - key: saves + label: Saves + children: + - key: fort + label: Fort + value: "{{FortSave}}" + - key: reflex + label: Ref + value: "{{RefSave}}" + - key: will + label: Will + value: "{{WillSave}}" + - key: feats + label: Feats + value: "{{join(GrantedFeats, \", \")}}" + empty: "" + allow_wiki_markup: true + - key: hp_range + label: HP range + value: "{{range(HPMin, HPMax)}}" + - key: notes + label: Notes + value: "{{if(Level == 4 && BonusFeat != \"0\", link(\"feat:weapon_specialization\", \"Weapon Specialization\") + \" first available.\", Notes)}}" + empty: "" + allow_wiki_markup: true +`) + page := wikiTemplatePage{ + PageID: "classes:fighter", + Category: "classes", + Key: "classes:fighter", + Title: "Fighter", + Row: map[string]any{ + "AttackBonusTable": "cls_atk_1", + "BonusFeatsTable": map[string]any{"table": "classes/bonusfeats:fighter"}, + "FeatsTable": map[string]any{"table": "classes/feats:fighter"}, + "HitDie": 10, + "SavingThrowTable": map[string]any{"table": "classes/saves:fortitude"}, + }, + } + + got, err := ctx.renderWikiTemplateString("classes.html", `

{{title}}

+{{table:ClassProgression}}`, page) + if err != nil { + t.Fatalf("render table template: %v", err) + } + for _, expected := range []string{ + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + } { + if !strings.Contains(got, expected) { + t.Fatalf("expected %q in rendered table:\n%s", expected, got) + } + } +} + +func TestWikiTableMissingFieldReportsTableAndColumn(t *testing.T) { + ctx := testWikiTableContext(t, ` +tables: + Broken: + source: class_progression + rows: + levels: 1 + columns: + - key: missing + label: Missing + value: "{{NoSuchField}}" +`) + page := wikiTemplatePage{ + PageID: "classes:fighter", + Category: "classes", + Key: "classes:fighter", + Row: map[string]any{ + "AttackBonusTable": "cls_atk_1", + "HitDie": 10, + "SavingThrowTable": map[string]any{"table": "classes/saves:fortitude"}, + }, + } + + _, err := ctx.renderWikiTemplateString("classes.html", `{{table:Broken}}`, page) + if err == nil { + t.Fatal("expected missing field error") + } + for _, expected := range []string{`classes:fighter`, `table Broken`, `column missing`, `NoSuchField`} { + if !strings.Contains(err.Error(), expected) { + t.Fatalf("expected error to contain %q, got %v", expected, err) + } + } +} + +func TestWikiTableRejectsImpossibleNestedHeader(t *testing.T) { + _, err := parseWikiTableDefinitions([]byte(` +tables: + Broken: + source: class_progression + rows: + levels: 1 + columns: + - key: bad + label: Bad + value: "{{Level}}" + children: + - key: child + label: Child + value: "{{Level}}" +`), "test-tables.yaml") + if err == nil || !strings.Contains(err.Error(), "cannot have both value and children") { + t.Fatalf("expected impossible nested header error, got %v", err) + } +} + +func testWikiTableContext(t *testing.T, yamlSource string) *wikiContext { + t.Helper() + ctx := &wikiContext{ + featRows: map[string]map[string]any{}, + featIDToKey: map[int]string{}, + classFeatTables: map[string]wikiTable{}, + classBonusTables: map[string]wikiTable{}, + classSaveTables: map[string]wikiTable{}, + wikiTablesPath: "test-tables.yaml", + } + ctx.featRows["feat:literate"] = map[string]any{"FEAT": map[string]any{"tlk": map[string]any{"text": "Literate"}}} + ctx.featRows["feat:weapon_proficiency_simple"] = map[string]any{"FEAT": map[string]any{"tlk": map[string]any{"text": "Simple Weapon Proficiency"}}} + ctx.featRows["feat:weapon_specialization"] = map[string]any{"FEAT": map[string]any{"tlk": map[string]any{"text": "Weapon Specialization"}}} + ctx.classFeatTables["cls_feat_fight"] = wikiTable{OutputStem: "cls_feat_fight", Rows: []map[string]any{ + {"FeatIndex": map[string]any{"id": "feat:literate"}, "GrantedOnLevel": "1", "List": "3"}, + {"FeatIndex": map[string]any{"id": "feat:weapon_proficiency_simple"}, "GrantedOnLevel": "1", "List": "3"}, + }} + ctx.classFeatTables["classes/feats:fighter"] = ctx.classFeatTables["cls_feat_fight"] + ctx.classBonusTables["cls_bfeat_fight"] = wikiTable{OutputStem: "cls_bfeat_fight", Rows: []map[string]any{ + {"id": 1, "Bonus": "1"}, {"id": 2, "Bonus": "1"}, {"id": 3, "Bonus": "0"}, {"id": 4, "Bonus": "1"}, + }} + ctx.classBonusTables["classes/bonusfeats:fighter"] = ctx.classBonusTables["cls_bfeat_fight"] + ctx.classSaveTables["cls_savthr_fort"] = wikiTable{OutputStem: "cls_savthr_fort", Rows: []map[string]any{ + {"Level": "1", "FortSave": "2", "RefSave": "0", "WillSave": "0"}, + {"Level": "2", "FortSave": "3", "RefSave": "0", "WillSave": "0"}, + {"Level": "3", "FortSave": "3", "RefSave": "1", "WillSave": "1"}, + {"Level": "4", "FortSave": "4", "RefSave": "1", "WillSave": "1"}, + }} + ctx.classSaveTables["classes/saves:fortitude"] = ctx.classSaveTables["cls_savthr_fort"] + tables, err := parseWikiTableDefinitions([]byte(yamlSource), ctx.wikiTablesPath) + if err != nil { + t.Fatalf("parse table definitions: %v", err) + } + ctx.wikiTableDefinitions = tables + return ctx +} + func TestWikiManagedHashIgnoresPreservedSectionBody(t *testing.T) { first := strings.Join([]string{ ``, diff --git a/internal/topdata/wiki_tables.go b/internal/topdata/wiki_tables.go new file mode 100644 index 0000000..4755fe2 --- /dev/null +++ b/internal/topdata/wiki_tables.go @@ -0,0 +1,957 @@ +package topdata + +import ( + "fmt" + "html" + "os" + "strconv" + "strings" + "unicode" + + "gopkg.in/yaml.v3" +) + +type wikiTableDefinitionsDocument struct { + Tables map[string]wikiTableDefinition `json:"tables" yaml:"tables"` +} + +type wikiTableDefinition struct { + Source string `json:"source" yaml:"source"` + Class string `json:"class" yaml:"class"` + When string `json:"when" yaml:"when"` + Rows wikiTableRowsConfig `json:"rows" yaml:"rows"` + Columns []wikiTableColumn `json:"columns" yaml:"columns"` +} + +type wikiTableRowsConfig struct { + Levels string `json:"levels" yaml:"levels"` + When string `json:"when" yaml:"when"` +} + +type wikiTableColumn struct { + Key string `json:"key" yaml:"key"` + Label string `json:"label" yaml:"label"` + Value string `json:"value" yaml:"value"` + Empty string `json:"empty" yaml:"empty"` + Class string `json:"class" yaml:"class"` + HeaderClass string `json:"header_class" yaml:"header_class"` + AllowWikiMarkup bool `json:"allow_wiki_markup" yaml:"allow_wiki_markup"` + When string `json:"when" yaml:"when"` + Children []wikiTableColumn `json:"children" yaml:"children"` +} + +type wikiTableRenderContext struct { + Page wikiTemplatePage + TableName string + Path string +} + +func loadWikiTableDefinitions(path string) (map[string]wikiTableDefinition, error) { + raw, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return map[string]wikiTableDefinition{}, nil + } + return nil, fmt.Errorf("read wiki table definitions %s: %w", path, err) + } + return parseWikiTableDefinitions(raw, path) +} + +func parseWikiTableDefinitions(raw []byte, path string) (map[string]wikiTableDefinition, error) { + var doc wikiTableDefinitionsDocument + if err := yaml.Unmarshal(raw, &doc); err != nil { + return nil, fmt.Errorf("parse wiki table definitions %s: %w", path, err) + } + if len(doc.Tables) == 0 { + return map[string]wikiTableDefinition{}, nil + } + for name, def := range doc.Tables { + if strings.TrimSpace(def.Source) == "" { + return nil, fmt.Errorf("wiki table definitions %s: table %s missing source", path, name) + } + if len(def.Columns) == 0 { + return nil, fmt.Errorf("wiki table definitions %s: table %s missing columns", path, name) + } + for _, col := range def.Columns { + if err := validateWikiTableColumn(path, name, col); err != nil { + return nil, err + } + } + } + return doc.Tables, nil +} + +func validateWikiTableColumn(path, tableName string, col wikiTableColumn) error { + if strings.TrimSpace(col.Key) == "" { + return fmt.Errorf("wiki table definitions %s: table %s has column without key", path, tableName) + } + if strings.TrimSpace(col.Value) != "" && len(col.Children) > 0 { + return fmt.Errorf("wiki table definitions %s: table %s column %s cannot have both value and children", path, tableName, col.Key) + } + for _, child := range col.Children { + if err := validateWikiTableColumn(path, tableName, child); err != nil { + return err + } + } + return nil +} + +func (ctx *wikiContext) renderConfiguredWikiTable(templatePath, tableName string, page wikiTemplatePage) (string, error) { + def, ok := ctx.wikiTableDefinitions[tableName] + if !ok { + return "", fmt.Errorf("render wiki template %s for %s: missing table definition %q in %s", templatePath, page.PageID, tableName, ctx.wikiTablesPath) + } + renderCtx := wikiTableRenderContext{Page: page, TableName: tableName, Path: ctx.wikiTablesPath} + if strings.TrimSpace(def.When) != "" { + value, err := ctx.evalWikiTableTemplate(def.When, page.Row, renderCtx) + if err != nil { + return "", err + } + if !truthyWikiTableValue(value) { + return "", nil + } + } + rows, err := ctx.wikiTableSourceRows(def, page) + if err != nil { + return "", fmt.Errorf("render wiki table %s for %s: %w", tableName, page.PageID, err) + } + if strings.TrimSpace(def.Rows.When) != "" { + filtered := rows[:0] + for _, row := range rows { + value, err := ctx.evalWikiTableTemplate(def.Rows.When, row, renderCtx) + if err != nil { + return "", fmt.Errorf("render wiki table %s for %s row condition %q: %w", tableName, page.PageID, def.Rows.When, err) + } + if truthyWikiTableValue(value) { + filtered = append(filtered, row) + } + } + rows = filtered + } + if len(rows) == 0 { + return "", nil + } + columns := visibleWikiTableColumns(def.Columns, map[string]any{}, ctx, renderCtx) + if len(columns) == 0 { + return "", nil + } + headerRows, leaves := buildWikiTableHeaders(columns) + if len(leaves) == 0 { + return "", nil + } + var out strings.Builder + class := strings.TrimSpace(def.Class) + if class != "" { + out.WriteString(`
LvlSaves1st+12[[feat:literate|Literate]], [[feat:weapon:proficiency:simple|Simple Weapon Proficiency]]1-10[[feat:weapon:specialization|Weapon Specialization]] first available.
`) + } else { + out.WriteString("
") + } + out.WriteString("\n \n") + for _, headerRow := range headerRows { + out.WriteString(" ") + for _, cell := range headerRow { + out.WriteString(cell) + } + out.WriteString("\n") + } + out.WriteString(" \n \n") + for _, row := range rows { + out.WriteString(" ") + for _, col := range leaves { + cell, err := ctx.renderWikiTableCell(col, row, renderCtx) + if err != nil { + return "", err + } + out.WriteString(cell) + } + out.WriteString("\n") + } + out.WriteString(" \n
") + return out.String(), nil +} + +func visibleWikiTableColumns(columns []wikiTableColumn, row map[string]any, ctx *wikiContext, renderCtx wikiTableRenderContext) []wikiTableColumn { + out := []wikiTableColumn{} + for _, col := range columns { + if strings.TrimSpace(col.When) != "" { + value, err := ctx.evalWikiTableTemplate(col.When, row, renderCtx) + if err != nil || !truthyWikiTableValue(value) { + continue + } + } + if len(col.Children) > 0 { + col.Children = visibleWikiTableColumns(col.Children, row, ctx, renderCtx) + if len(col.Children) == 0 { + continue + } + } + out = append(out, col) + } + return out +} + +type wikiTableHeaderCell struct { + HTML string +} + +func buildWikiTableHeaders(columns []wikiTableColumn) ([][]string, []wikiTableColumn) { + hasGroups := false + for _, col := range columns { + if len(col.Children) > 0 { + hasGroups = true + break + } + } + first := []string{} + second := []string{} + leaves := []wikiTableColumn{} + for _, col := range columns { + label := html.EscapeString(defaultStringValue(col.Label, col.Key)) + headerClass := attrClass(col.HeaderClass) + if len(col.Children) == 0 { + leaves = append(leaves, col) + rowspan := "" + if hasGroups { + rowspan = ` rowspan="2"` + } + first = append(first, ``+label+``) + continue + } + childLeaves := leafWikiTableColumns(col.Children) + first = append(first, ``+label+``) + for _, child := range col.Children { + childLabel := html.EscapeString(defaultStringValue(child.Label, child.Key)) + second = append(second, ``+childLabel+``) + } + leaves = append(leaves, childLeaves...) + } + if hasGroups { + return [][]string{first, second}, leaves + } + return [][]string{first}, leaves +} + +func leafWikiTableColumns(columns []wikiTableColumn) []wikiTableColumn { + out := []wikiTableColumn{} + for _, col := range columns { + if len(col.Children) == 0 { + out = append(out, col) + continue + } + out = append(out, leafWikiTableColumns(col.Children)...) + } + return out +} + +func (ctx *wikiContext) renderWikiTableCell(col wikiTableColumn, row map[string]any, renderCtx wikiTableRenderContext) (string, error) { + value, err := ctx.evalWikiTableTemplate(col.Value, row, renderCtx) + if err != nil { + return "", fmt.Errorf("render wiki table %s for %s column %s expression %q: %w", renderCtx.TableName, renderCtx.Page.PageID, col.Key, col.Value, err) + } + text := stringifyWikiTableValue(value) + if strings.TrimSpace(text) == "" { + text = col.Empty + } + if !col.AllowWikiMarkup { + text = html.EscapeString(text) + } + return `` + text + ``, nil +} + +func attrClass(class string) string { + class = strings.TrimSpace(class) + if class == "" { + return "" + } + return ` class="` + html.EscapeString(class) + `"` +} + +func defaultStringValue(value, fallback string) string { + if strings.TrimSpace(value) != "" { + return value + } + return fallback +} + +func (ctx *wikiContext) wikiTableSourceRows(def wikiTableDefinition, page wikiTemplatePage) ([]map[string]any, error) { + switch strings.TrimSpace(def.Source) { + case "class_progression": + if page.Category != "classes" { + return nil, nil + } + return ctx.classProgressionRows(def, page.Row) + case "class_feats": + if page.Category != "classes" { + return nil, nil + } + return ctx.classFeatRows(page.Row), nil + case "spell_progression": + if page.Category != "classes" { + return nil, nil + } + if !hasSpellProgression(page.Row) { + return nil, nil + } + return []map[string]any{}, nil + case "class_summary": + if page.Category != "classes" { + return nil, nil + } + return ctx.classSummaryRows(page.Row), nil + default: + return nil, fmt.Errorf("unknown source %q", def.Source) + } +} + +func (ctx *wikiContext) classProgressionRows(def wikiTableDefinition, classRow map[string]any) ([]map[string]any, error) { + start, end, err := parseLevelRange(def.Rows.Levels) + if err != nil { + return nil, err + } + saveRows := map[string]map[string]any{} + if table := ctx.tableForValue(fieldValue(classRow, "SavingThrowTable"), ctx.classSaveTables); table != nil { + for _, row := range table.Rows { + level := stringValue(row, "Level") + if level != "" { + saveRows[level] = row + } + } + } + granted := map[int][]string{} + if table := ctx.tableForValue(fieldValue(classRow, "FeatsTable"), ctx.classFeatTables); table != nil { + for _, featRow := range table.Rows { + level, err := strconv.Atoi(stringValue(featRow, "GrantedOnLevel")) + if err != nil || level < start || level > end { + continue + } + name := ctx.renderReference(fieldValue(featRow, "FeatIndex"), ctx.featIDToKey, ctx.resolveFeatName) + if name != "" { + granted[level] = append(granted[level], name) + } + } + } + bonusByLevel := map[int]string{} + if table := ctx.tableForValue(fieldValue(classRow, "BonusFeatsTable"), ctx.classBonusTables); table != nil { + for _, bonusRow := range table.Rows { + level, err := asInt(fieldValue(bonusRow, "id")) + if err != nil { + continue + } + bonusByLevel[level] = stringValue(bonusRow, "Bonus") + } + } + hitDie, _ := asInt(fieldValue(classRow, "HitDie")) + out := []map[string]any{} + for level := start; level <= end; level++ { + row := cloneRowMap(classRow) + row["Level"] = level + row["BAB"] = formatSignedInt(classBABAtLevel(stringValue(classRow, "AttackBonusTable"), level)) + if saveRow := saveRows[strconv.Itoa(level)]; saveRow != nil { + for key, value := range saveRow { + row[key] = value + } + } + row["GrantedFeats"] = granted[level] + row["BonusFeat"] = bonusByLevel[level] + row["HPMin"] = level + if hitDie > 0 { + row["HPMax"] = level * hitDie + } + row["Notes"] = "" + row["HasSpellProgression"] = hasSpellProgression(classRow) + out = append(out, row) + } + return out, nil +} + +func parseLevelRange(raw string) (int, int, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return 1, 20, nil + } + if !strings.Contains(raw, "-") { + level, err := strconv.Atoi(raw) + if err != nil || level < 1 { + return 0, 0, fmt.Errorf("bad row range %q", raw) + } + return level, level, nil + } + left, right, _ := strings.Cut(raw, "-") + start, err1 := strconv.Atoi(strings.TrimSpace(left)) + end, err2 := strconv.Atoi(strings.TrimSpace(right)) + if err1 != nil || err2 != nil || start < 1 || end < start { + return 0, 0, fmt.Errorf("bad row range %q", raw) + } + return start, end, nil +} + +func classBABAtLevel(table string, level int) int { + switch strings.ToLower(strings.TrimSpace(table)) { + case "cls_atk_1": + return level + case "cls_atk_2": + return (level * 3) / 4 + case "cls_atk_3": + return level / 2 + default: + return 0 + } +} + +func formatSignedInt(value int) string { + if value > 0 { + return "+" + strconv.Itoa(value) + } + return strconv.Itoa(value) +} + +func (ctx *wikiContext) classFeatRows(classRow map[string]any) []map[string]any { + table := ctx.tableForValue(fieldValue(classRow, "FeatsTable"), ctx.classFeatTables) + if table == nil { + return nil + } + out := []map[string]any{} + for _, source := range table.Rows { + row := cloneRowMap(source) + key := resolveReferenceKey(fieldValue(source, "FeatIndex"), ctx.featIDToKey) + if key == "" { + key = resolveReferenceKey(fieldValue(source, "FeatIndex"), nil) + } + row["FeatKey"] = key + row["FeatName"] = ctx.resolveFeatName(key) + out = append(out, row) + } + return out +} + +func (ctx *wikiContext) classSummaryRows(classRow map[string]any) []map[string]any { + return []map[string]any{ + {"Label": "Hit Die", "Value": "d" + stringValue(classRow, "HitDie")}, + {"Label": "Skill Points", "Value": stringValue(classRow, "SkillPointBase")}, + {"Label": "Primary Ability", "Value": stringValue(classRow, "PrimaryAbil")}, + } +} + +func hasSpellProgression(row map[string]any) bool { + return stringValue(row, "SpellGainTable") != "" || stringValue(row, "SpellKnownTable") != "" +} + +func (ctx *wikiContext) evalWikiTableTemplate(template string, row map[string]any, renderCtx wikiTableRenderContext) (any, error) { + template = strings.TrimSpace(template) + if strings.HasPrefix(template, "{{") && strings.HasSuffix(template, "}}") { + expr := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(template, "{{"), "}}")) + return evalWikiTableExpression(expr, row, ctx, renderCtx) + } + if strings.Contains(template, "{{") { + return nil, fmt.Errorf("mixed literal/expression templates are not supported") + } + return template, nil +} + +func evalWikiTableExpression(expr string, row map[string]any, ctx *wikiContext, renderCtx wikiTableRenderContext) (any, error) { + parser := wikiExprParser{tokens: tokenizeWikiExpr(expr), row: row, ctx: ctx, renderCtx: renderCtx} + value, err := parser.parseComparison() + if err != nil { + return nil, err + } + if parser.peek().kind != wikiExprEOF { + return nil, fmt.Errorf("unexpected token %q", parser.peek().text) + } + return value, nil +} + +type wikiExprTokenKind int + +const ( + wikiExprEOF wikiExprTokenKind = iota + wikiExprIdent + wikiExprString + wikiExprNumber + wikiExprLParen + wikiExprRParen + wikiExprComma + wikiExprPlus + wikiExprEq + wikiExprNE + wikiExprGT + wikiExprGE + wikiExprLT + wikiExprLE + wikiExprAnd + wikiExprOr +) + +type wikiExprToken struct { + kind wikiExprTokenKind + text string +} + +func tokenizeWikiExpr(expr string) []wikiExprToken { + tokens := []wikiExprToken{} + for i := 0; i < len(expr); { + r := rune(expr[i]) + if unicode.IsSpace(r) { + i++ + continue + } + switch expr[i] { + case '(': + tokens = append(tokens, wikiExprToken{kind: wikiExprLParen, text: "("}) + i++ + case ')': + tokens = append(tokens, wikiExprToken{kind: wikiExprRParen, text: ")"}) + i++ + case ',': + tokens = append(tokens, wikiExprToken{kind: wikiExprComma, text: ","}) + i++ + case '+': + tokens = append(tokens, wikiExprToken{kind: wikiExprPlus, text: "+"}) + i++ + case '>': + if i+1 < len(expr) && expr[i+1] == '=' { + tokens = append(tokens, wikiExprToken{kind: wikiExprGE, text: ">="}) + i += 2 + } else { + tokens = append(tokens, wikiExprToken{kind: wikiExprGT, text: ">"}) + i++ + } + case '<': + if i+1 < len(expr) && expr[i+1] == '=' { + tokens = append(tokens, wikiExprToken{kind: wikiExprLE, text: "<="}) + i += 2 + } else { + tokens = append(tokens, wikiExprToken{kind: wikiExprLT, text: "<"}) + i++ + } + case '!': + if i+1 < len(expr) && expr[i+1] == '=' { + tokens = append(tokens, wikiExprToken{kind: wikiExprNE, text: "!="}) + i += 2 + } else { + tokens = append(tokens, wikiExprToken{kind: wikiExprEOF, text: expr[i:]}) + return tokens + } + case '&': + if i+1 < len(expr) && expr[i+1] == '&' { + tokens = append(tokens, wikiExprToken{kind: wikiExprAnd, text: "&&"}) + i += 2 + } else { + tokens = append(tokens, wikiExprToken{kind: wikiExprEOF, text: expr[i:]}) + return tokens + } + case '|': + if i+1 < len(expr) && expr[i+1] == '|' { + tokens = append(tokens, wikiExprToken{kind: wikiExprOr, text: "||"}) + i += 2 + } else { + tokens = append(tokens, wikiExprToken{kind: wikiExprEOF, text: expr[i:]}) + return tokens + } + case '=': + if i+1 < len(expr) && expr[i+1] == '=' { + tokens = append(tokens, wikiExprToken{kind: wikiExprEq, text: "=="}) + i += 2 + } else { + tokens = append(tokens, wikiExprToken{kind: wikiExprEOF, text: expr[i:]}) + return tokens + } + case '"': + j := i + 1 + var b strings.Builder + for ; j < len(expr); j++ { + if expr[j] == '\\' && j+1 < len(expr) { + j++ + b.WriteByte(expr[j]) + continue + } + if expr[j] == '"' { + break + } + b.WriteByte(expr[j]) + } + if j >= len(expr) { + tokens = append(tokens, wikiExprToken{kind: wikiExprEOF, text: expr[i:]}) + return tokens + } + tokens = append(tokens, wikiExprToken{kind: wikiExprString, text: b.String()}) + i = j + 1 + default: + if unicode.IsDigit(r) || expr[i] == '-' { + j := i + 1 + for j < len(expr) && unicode.IsDigit(rune(expr[j])) { + j++ + } + tokens = append(tokens, wikiExprToken{kind: wikiExprNumber, text: expr[i:j]}) + i = j + continue + } + if unicode.IsLetter(r) || expr[i] == '_' { + j := i + 1 + for j < len(expr) { + rr := rune(expr[j]) + if !unicode.IsLetter(rr) && !unicode.IsDigit(rr) && expr[j] != '_' && expr[j] != '.' { + break + } + j++ + } + tokens = append(tokens, wikiExprToken{kind: wikiExprIdent, text: expr[i:j]}) + i = j + continue + } + tokens = append(tokens, wikiExprToken{kind: wikiExprEOF, text: expr[i:]}) + return tokens + } + } + tokens = append(tokens, wikiExprToken{kind: wikiExprEOF}) + return tokens +} + +type wikiExprParser struct { + tokens []wikiExprToken + pos int + row map[string]any + ctx *wikiContext + renderCtx wikiTableRenderContext +} + +func (p *wikiExprParser) peek() wikiExprToken { + if p.pos >= len(p.tokens) { + return wikiExprToken{kind: wikiExprEOF} + } + return p.tokens[p.pos] +} + +func (p *wikiExprParser) take(kind wikiExprTokenKind) (wikiExprToken, bool) { + token := p.peek() + if token.kind != kind { + return token, false + } + p.pos++ + return token, true +} + +func (p *wikiExprParser) parseComparison() (any, error) { + return p.parseOr() +} + +func (p *wikiExprParser) parseOr() (any, error) { + left, err := p.parseAnd() + if err != nil { + return nil, err + } + for p.peek().kind == wikiExprOr { + p.pos++ + right, err := p.parseAnd() + if err != nil { + return nil, err + } + left = truthyWikiTableValue(left) || truthyWikiTableValue(right) + } + return left, nil +} + +func (p *wikiExprParser) parseAnd() (any, error) { + left, err := p.parseRelational() + if err != nil { + return nil, err + } + for p.peek().kind == wikiExprAnd { + p.pos++ + right, err := p.parseRelational() + if err != nil { + return nil, err + } + left = truthyWikiTableValue(left) && truthyWikiTableValue(right) + } + return left, nil +} + +func (p *wikiExprParser) parseRelational() (any, error) { + left, err := p.parseConcat() + if err != nil { + return nil, err + } + switch p.peek().kind { + case wikiExprEq, wikiExprNE, wikiExprGT, wikiExprGE, wikiExprLT, wikiExprLE: + op := p.peek().kind + p.pos++ + right, err := p.parseConcat() + if err != nil { + return nil, err + } + switch op { + case wikiExprEq: + return stringifyWikiTableValue(left) == stringifyWikiTableValue(right), nil + case wikiExprNE: + return stringifyWikiTableValue(left) != stringifyWikiTableValue(right), nil + case wikiExprGT: + return numericWikiTableValue(left) > numericWikiTableValue(right), nil + case wikiExprGE: + return numericWikiTableValue(left) >= numericWikiTableValue(right), nil + case wikiExprLT: + return numericWikiTableValue(left) < numericWikiTableValue(right), nil + case wikiExprLE: + return numericWikiTableValue(left) <= numericWikiTableValue(right), nil + } + return false, nil + default: + return left, nil + } +} + +func (p *wikiExprParser) parseConcat() (any, error) { + left, err := p.parsePrimary() + if err != nil { + return nil, err + } + for p.peek().kind == wikiExprPlus { + p.pos++ + right, err := p.parsePrimary() + if err != nil { + return nil, err + } + left = stringifyWikiTableValue(left) + stringifyWikiTableValue(right) + } + return left, nil +} + +func (p *wikiExprParser) parsePrimary() (any, error) { + token := p.peek() + switch token.kind { + case wikiExprString: + p.pos++ + return token.text, nil + case wikiExprNumber: + p.pos++ + value, err := strconv.Atoi(token.text) + if err != nil { + return nil, fmt.Errorf("invalid number %q", token.text) + } + return value, nil + case wikiExprIdent: + p.pos++ + if p.peek().kind == wikiExprLParen { + p.pos++ + args := []any{} + if p.peek().kind != wikiExprRParen { + for { + arg, err := p.parseComparison() + if err != nil { + return nil, err + } + args = append(args, arg) + if _, ok := p.take(wikiExprComma); !ok { + break + } + } + } + if _, ok := p.take(wikiExprRParen); !ok { + return nil, fmt.Errorf("missing closing parenthesis for %s", token.text) + } + return p.callHelper(token.text, args) + } + return p.resolveField(token.text) + case wikiExprLParen: + p.pos++ + value, err := p.parseComparison() + if err != nil { + return nil, err + } + if _, ok := p.take(wikiExprRParen); !ok { + return nil, fmt.Errorf("missing closing parenthesis") + } + return value, nil + default: + return nil, fmt.Errorf("unexpected token %q", token.text) + } +} + +func (p *wikiExprParser) resolveField(name string) (any, error) { + name = strings.TrimPrefix(name, "row.") + if name == "HasSpellProgression" { + return hasSpellProgression(p.renderCtx.Page.Row), nil + } + if value, ok := lookupField(p.row, name); ok { + return value, nil + } + if value, ok := lookupField(p.renderCtx.Page.Row, name); ok { + return value, nil + } + return nil, fmt.Errorf("missing field %q", name) +} + +func (p *wikiExprParser) callHelper(name string, args []any) (any, error) { + switch name { + case "ordinal": + if len(args) != 1 { + return nil, fmt.Errorf("ordinal expects 1 argument") + } + return ordinalWikiTableValue(numericWikiTableValue(args[0])), nil + case "range": + if len(args) != 2 { + return nil, fmt.Errorf("range expects 2 arguments") + } + left := stringifyWikiTableValue(args[0]) + right := stringifyWikiTableValue(args[1]) + if left == "" || right == "" { + return "", nil + } + return left + "-" + right, nil + case "link": + if len(args) == 1 { + key := stringifyWikiTableValue(args[0]) + if pageID := wikiPageIDForKey(key); pageID != "" { + key = pageID + } + return "[[" + key + "]]", nil + } + if len(args) == 2 { + key := stringifyWikiTableValue(args[0]) + label := stringifyWikiTableValue(args[1]) + if key == "" { + return label, nil + } + if pageID := wikiPageIDForKey(key); pageID != "" { + key = pageID + } + return "[[" + key + "|" + label + "]]", nil + } + return nil, fmt.Errorf("link expects 1 or 2 arguments") + case "if": + if len(args) != 3 { + return nil, fmt.Errorf("if expects 3 arguments") + } + if truthyWikiTableValue(args[0]) { + return args[1], nil + } + return args[2], nil + case "join": + if len(args) != 2 { + return nil, fmt.Errorf("join expects 2 arguments") + } + sep := stringifyWikiTableValue(args[1]) + switch typed := args[0].(type) { + case []string: + return strings.Join(typed, sep), nil + case []any: + parts := make([]string, 0, len(typed)) + for _, value := range typed { + if text := stringifyWikiTableValue(value); text != "" { + parts = append(parts, text) + } + } + return strings.Join(parts, sep), nil + default: + return stringifyWikiTableValue(args[0]), nil + } + case "nonzero": + if len(args) != 1 { + return nil, fmt.Errorf("nonzero expects 1 argument") + } + if numericWikiTableValue(args[0]) == 0 { + return "", nil + } + return args[0], nil + case "eq": + if len(args) != 2 { + return nil, fmt.Errorf("eq expects 2 arguments") + } + return stringifyWikiTableValue(args[0]) == stringifyWikiTableValue(args[1]), nil + case "field": + if len(args) != 1 { + return nil, fmt.Errorf("field expects 1 argument") + } + return p.resolveField(stringifyWikiTableValue(args[0])) + case "default": + if len(args) != 2 { + return nil, fmt.Errorf("default expects 2 arguments") + } + if strings.TrimSpace(stringifyWikiTableValue(args[0])) == "" { + return args[1], nil + } + return args[0], nil + default: + return nil, fmt.Errorf("unknown helper %q", name) + } +} + +func ordinalWikiTableValue(value int) string { + suffix := "th" + if value%100 < 11 || value%100 > 13 { + switch value % 10 { + case 1: + suffix = "st" + case 2: + suffix = "nd" + case 3: + suffix = "rd" + } + } + return strconv.Itoa(value) + suffix +} + +func stringifyWikiTableValue(value any) string { + switch typed := value.(type) { + case nil: + return "" + case string: + if typed == nullValue { + return "" + } + return typed + case bool: + if typed { + return "true" + } + return "" + case int: + return strconv.Itoa(typed) + case float64: + return strconv.Itoa(int(typed)) + case []string: + return strings.Join(typed, ", ") + case []any: + parts := make([]string, 0, len(typed)) + for _, value := range typed { + if text := stringifyWikiTableValue(value); text != "" { + parts = append(parts, text) + } + } + return strings.Join(parts, ", ") + default: + return fmt.Sprint(typed) + } +} + +func numericWikiTableValue(value any) int { + switch typed := value.(type) { + case int: + return typed + case float64: + return int(typed) + case string: + parsed, _ := strconv.Atoi(strings.TrimSpace(typed)) + return parsed + default: + parsed, _ := strconv.Atoi(strings.TrimSpace(stringifyWikiTableValue(value))) + return parsed + } +} + +func truthyWikiTableValue(value any) bool { + switch typed := value.(type) { + case bool: + return typed + case nil: + return false + case string: + trimmed := strings.TrimSpace(strings.ToLower(typed)) + return trimmed != "" && trimmed != "0" && trimmed != "false" && trimmed != nullValue + case int: + return typed != 0 + case float64: + return typed != 0 + default: + return strings.TrimSpace(stringifyWikiTableValue(value)) != "" + } +} diff --git a/internal/topdata/wiki_template.go b/internal/topdata/wiki_template.go index a05d16b..02621a6 100644 --- a/internal/topdata/wiki_template.go +++ b/internal/topdata/wiki_template.go @@ -131,6 +131,10 @@ func (ctx *wikiContext) renderWikiTemplateToken(path, token string, page wikiTem 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