Expose template options

This commit is contained in:
2026-05-27 08:26:14 +02:00
parent 59c8e407e9
commit 89508b3c03
7 changed files with 236 additions and 0 deletions
+100
View File
@@ -18,6 +18,7 @@ type wikiDataProviderDefinition struct {
Levels string `json:"levels" yaml:"levels"`
ClassFeats wikiDataProviderClassFeatConfig `json:"class_feats" yaml:"class_feats"`
Attacks wikiDataProviderAttacksConfig `json:"attacks" yaml:"attacks"`
Facts wikiDataProviderFactsConfig `json:"facts" yaml:"facts"`
Provider string `json:"provider" yaml:"provider"`
Columns int `json:"columns" yaml:"columns"`
ItemsPerColumn int `json:"items_per_column" yaml:"items_per_column"`
@@ -32,6 +33,17 @@ type wikiDataProviderProjectionField struct {
When string `json:"when" yaml:"when"`
}
type wikiDataProviderFactsConfig struct {
Rows []wikiDataProviderFactRow `json:"rows" yaml:"rows"`
}
type wikiDataProviderFactRow struct {
Key string `json:"key" yaml:"key"`
Label string `json:"label" yaml:"label"`
Value string `json:"value" yaml:"value"`
When string `json:"when" yaml:"when"`
}
type wikiDataProviderAttacksConfig struct {
Field string `json:"field" yaml:"field"`
CountField string `json:"count_field" yaml:"count_field"`
@@ -106,6 +118,18 @@ func loadWikiDataProviders(path string) (map[string]wikiDataProviderDefinition,
if def.ItemsPerColumn <= 0 {
return nil, fmt.Errorf("wiki data providers %s: provider %s items_per_column must be a positive integer", path, name)
}
case "facts":
if len(def.Facts.Rows) == 0 {
return nil, fmt.Errorf("wiki data providers %s: provider %s facts source requires rows", path, name)
}
for index, row := range def.Facts.Rows {
if strings.TrimSpace(row.Label) == "" {
return nil, fmt.Errorf("wiki data providers %s: provider %s facts row %d missing label", path, name, index+1)
}
if strings.TrimSpace(row.Value) == "" {
return nil, fmt.Errorf("wiki data providers %s: provider %s facts row %d missing value", path, name, index+1)
}
}
case "class_feats", "class_skills", "class_summary", "class_spellbook", "race_feats", "race_name_forms":
if len(def.ClassFeats.Fields) > 0 {
return nil, fmt.Errorf("wiki data providers %s: provider %s class feat projections require source class_progression", path, name)
@@ -148,6 +172,8 @@ func (ctx *wikiContext) wikiDataProviderRows(name string, page wikiTemplatePage)
return nil, nil
}
return ctx.classProgressionRows(def.Levels, def.ClassFeats.Fields, def.Attacks, page.Key, page.Row)
case "facts":
return ctx.wikiFactRows(name, def.Facts, page)
case "class_feats":
if page.Category != "classes" {
return nil, nil
@@ -183,6 +209,80 @@ func (ctx *wikiContext) wikiDataProviderRows(name string, page wikiTemplatePage)
}
}
func (ctx *wikiContext) wikiFactRows(providerName string, cfg wikiDataProviderFactsConfig, page wikiTemplatePage) ([]map[string]any, error) {
out := []map[string]any{}
renderCtx := wikiTableRenderContext{Page: page, TableName: providerName, Path: ctx.wikiDataPath}
for index, spec := range cfg.Rows {
if strings.TrimSpace(spec.When) != "" {
value, err := ctx.evalWikiDataProviderTemplate(spec.When, page.Row, renderCtx)
if err != nil {
return nil, fmt.Errorf("facts provider %s row %d condition %q: %w", providerName, index+1, spec.When, err)
}
if !truthyWikiTableValue(value) {
continue
}
}
value, err := ctx.evalWikiDataProviderTemplate(spec.Value, page.Row, renderCtx)
if err != nil {
return nil, fmt.Errorf("facts provider %s row %d value %q: %w", providerName, index+1, spec.Value, err)
}
text := stringifyWikiTableValue(value)
if strings.TrimSpace(text) == "" || strings.TrimSpace(text) == nullValue {
continue
}
key := strings.TrimSpace(spec.Key)
if key == "" {
key = wikiFactKeyFromLabel(spec.Label)
}
out = append(out, map[string]any{
"Key": key,
"Label": spec.Label,
"Value": text,
})
}
return out, nil
}
func (ctx *wikiContext) evalWikiDataProviderTemplate(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, "{{"), "}}"))
parser := wikiExprParser{tokens: tokenizeWikiExpr(expr), row: row, ctx: ctx, renderCtx: renderCtx, allowMissingFields: true}
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
}
if strings.Contains(template, "{{") {
return nil, fmt.Errorf("mixed literal/expression templates are not supported")
}
return template, nil
}
func wikiFactKeyFromLabel(label string) string {
key := strings.ToLower(strings.TrimSpace(label))
key = strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z':
return r
case r >= '0' && r <= '9':
return r
case r == ' ' || r == '-' || r == '_':
return '_'
default:
return -1
}
}, key)
for strings.Contains(key, "__") {
key = strings.ReplaceAll(key, "__", "_")
}
return strings.Trim(key, "_")
}
func validateWikiDataProviderAttacks(path, provider string, cfg wikiDataProviderAttacksConfig) error {
if strings.TrimSpace(cfg.Mode) == "" {
return nil
+14
View File
@@ -1317,6 +1317,20 @@ func (ctx *wikiContext) resolveClassName(key string) string {
return ""
}
func (ctx *wikiContext) resolveRaceName(key string) string {
if row := ctx.raceRows[key]; row != nil {
return ctx.resolveRowName("racialtypes", row)
}
return ""
}
func (ctx *wikiContext) resolveBaseItemName(key string) string {
if row := ctx.baseitemRows[key]; row != nil {
return ctx.resolveRowName("baseitems", row)
}
return ""
}
func (ctx *wikiContext) wikiGenerateOverride(row map[string]any) string {
if meta, err := parseExistingMetadata(row); err == nil && meta != nil {
if wiki, ok := meta["wiki"].(map[string]any); ok {
+68
View File
@@ -710,6 +710,74 @@ providers:
}
}
func TestWikiTemplateFactsProviderRendersYAMLDeclaredFactRows(t *testing.T) {
root := t.TempDir()
dataPath := filepath.Join(root, "data.yaml")
writeFile(t, dataPath, `
providers:
FeatFacts:
source: facts
facts:
rows:
- label: Prerequisites
value: "{{feat_prerequisites()}}"
- label: Minimum Attack Bonus
value: "{{MINATTACKBONUS}}"
- label: Armor Check Penalty
value: "{{yes_no(ArmorCheckPenalty)}}"
- label: Missing Optional
value: "{{MissingField}}"
`+"\n")
providers, err := loadWikiDataProviders(dataPath)
if err != nil {
t.Fatalf("load data providers: %v", err)
}
ctx := testWikiTableContext(t, "")
ctx.wikiDataPath = dataPath
ctx.wikiDataProviders = providers
ctx.featRows["feat:power_attack"] = map[string]any{"FEAT": map[string]any{"tlk": map[string]any{"text": "Power Attack"}}}
ctx.featIDToKey[28] = "feat:power_attack"
ctx.skillRows = map[string]map[string]any{"skills:athletics": {"Name": map[string]any{"tlk": map[string]any{"text": "Athletics"}}}}
ctx.skillIDToKey = map[int]string{3: "skills:athletics"}
page := wikiTemplatePage{
PageID: "feat:cleave",
Category: "feat",
Key: "feat:cleave",
Title: "Cleave",
Row: map[string]any{
"MINSTR": "13",
"PREREQFEAT1": 28,
"REQSKILL": 3,
"ReqSkillMinRanks": "4",
"MINATTACKBONUS": "2",
"ArmorCheckPenalty": "1",
},
}
got, err := ctx.renderWikiTemplateString("feat.html", `<table class="wiki-facts">
<tbody>
{{#each FeatFacts}}
<tr><th scope="row">{{Label}}</th><td>{{Value|wiki}}</td></tr>
{{/each}}
</tbody>
</table>`, page)
if err != nil {
t.Fatalf("render template: %v", err)
}
for _, expected := range []string{
`<th scope="row">Prerequisites</th><td>Str 13, [[Feats/Power_Attack|Power Attack]], [[Skills/Athletics|Athletics]] 4 ranks</td>`,
`<th scope="row">Minimum Attack Bonus</th><td>2</td>`,
`<th scope="row">Armor Check Penalty</th><td>Yes</td>`,
} {
if !strings.Contains(got, expected) {
t.Fatalf("expected %q in rendered facts:\n%s", expected, got)
}
}
if strings.Contains(got, "Missing Optional") {
t.Fatalf("expected empty optional fact to be omitted:\n%s", got)
}
}
func TestWikiTemplateClassProgressionProviderUsesDefaultNWNAttackSequences(t *testing.T) {
ctx := &wikiContext{
wikiDataPath: "test-data.yaml",
+25
View File
@@ -1304,6 +1304,31 @@ func (p *wikiExprParser) callHelper(name string, args []any) (any, error) {
return nil, fmt.Errorf("alignments expects 1 argument")
}
return p.ctx.formatWikiTemplateAlignments(args[0])
case "yes_no":
if len(args) != 1 {
return nil, fmt.Errorf("yes_no expects 1 argument")
}
return yesNoValue(stringifyWikiTableValue(args[0])), nil
case "text":
if len(args) != 1 {
return nil, fmt.Errorf("text expects 1 argument")
}
return p.ctx.resolveTextValue(args[0], nil), nil
case "ref":
if len(args) != 2 {
return nil, fmt.Errorf("ref expects 2 arguments")
}
return p.ctx.renderReference(args[0], p.ctx.wikiIDMapForDataset(stringifyWikiTableValue(args[1])), p.ctx.wikiNameResolverForDataset(stringifyWikiTableValue(args[1]))), nil
case "feat_prerequisites":
if len(args) != 0 {
return nil, fmt.Errorf("feat_prerequisites expects 0 arguments")
}
return p.ctx.renderFeatPrerequisites(p.renderCtx.Page.Row), nil
case "required_feats":
if len(args) != 0 {
return nil, fmt.Errorf("required_feats expects 0 arguments")
}
return p.ctx.renderReferenceList(p.ctx.wikiReqFeats(p.renderCtx.Page.Row)), nil
case "ability_adjustments":
if len(args) != 0 {
return nil, fmt.Errorf("ability_adjustments expects 0 arguments")
+6
View File
@@ -580,6 +580,12 @@ func (ctx *wikiContext) wikiNameResolverForDataset(dataset string) func(string)
return ctx.resolveFeatName
case "skills":
return ctx.resolveSkillName
case "spells":
return ctx.resolveSpellName
case "racialtypes":
return ctx.resolveRaceName
case "baseitems":
return ctx.resolveBaseItemName
default:
return func(string) string { return "" }
}