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{
+ ``,
+ `| Lvl | `,
+ `Saves | `,
+ `1st | `,
+ `+1 | `,
+ `2 | `,
+ `[[feat:literate|Literate]], [[feat:weapon:proficiency:simple|Simple Weapon Proficiency]] | `,
+ `1-10 | `,
+ `[[feat:weapon:specialization|Weapon Specialization]] first available. | `,
+ } {
+ 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(``)
+ } 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