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
+16
View File
@@ -2,3 +2,19 @@
description: description:
alwaysApply: true alwaysApply: true
--- ---
# Toolkit Agent Guide
`toolkit/` owns the shared `sow-toolkit` implementation consumed by the sibling
module and assets repositories.
When a task touches generated topdata wiki behavior under
`internal/topdata/wiki*`, read the module-side template authority contract
before editing:
- [../module/topdata/wiki/TEMPLATE_AUTHORITY_CONTRACT.md](/home/vicky/Projects/nwnee-shadowsoverwestgate/module/topdata/wiki/TEMPLATE_AUTHORITY_CONTRACT.md)
For wiki page structure, displayed values, headings, page-specific fact tables,
and wording, expose generic YAML/template surfaces instead of hardcoding module
layout in Go. Toolkit code should own generic rendering mechanics, validation,
references, visibility, deterministic output, and compatibility fallbacks.
+7
View File
@@ -379,6 +379,13 @@ resolved under the configured wiki output root. `templates_dir` and
`page_templates_dir` and `manual_sections_file` when the newer explicit paths `page_templates_dir` and `manual_sections_file` when the newer explicit paths
are omitted. are omitted.
Generated wiki page structure should be owned by repository templates and YAML
where possible. `topdata/wiki/data.yaml` can expose named providers for page
templates, including `source: facts` providers whose `facts.rows` declare
ordered `label` and `value` expressions. Empty and null-like fact values are
omitted, so templates can render category fact tables with `{{#each
ProviderName}}` while YAML controls which 2DA-backed values are shown.
`paths.source` and `paths.assets` must name real repository subtrees when they `paths.source` and `paths.assets` must name real repository subtrees when they
are configured. They may be omitted for repositories that do not own that class are configured. They may be omitted for repositories that do not own that class
of source, but they must not resolve to the repository root (`.`). Output and of source, but they must not resolve to the repository root (`.`). Output and
+100
View File
@@ -18,6 +18,7 @@ type wikiDataProviderDefinition struct {
Levels string `json:"levels" yaml:"levels"` Levels string `json:"levels" yaml:"levels"`
ClassFeats wikiDataProviderClassFeatConfig `json:"class_feats" yaml:"class_feats"` ClassFeats wikiDataProviderClassFeatConfig `json:"class_feats" yaml:"class_feats"`
Attacks wikiDataProviderAttacksConfig `json:"attacks" yaml:"attacks"` Attacks wikiDataProviderAttacksConfig `json:"attacks" yaml:"attacks"`
Facts wikiDataProviderFactsConfig `json:"facts" yaml:"facts"`
Provider string `json:"provider" yaml:"provider"` Provider string `json:"provider" yaml:"provider"`
Columns int `json:"columns" yaml:"columns"` Columns int `json:"columns" yaml:"columns"`
ItemsPerColumn int `json:"items_per_column" yaml:"items_per_column"` ItemsPerColumn int `json:"items_per_column" yaml:"items_per_column"`
@@ -32,6 +33,17 @@ type wikiDataProviderProjectionField struct {
When string `json:"when" yaml:"when"` 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 { type wikiDataProviderAttacksConfig struct {
Field string `json:"field" yaml:"field"` Field string `json:"field" yaml:"field"`
CountField string `json:"count_field" yaml:"count_field"` CountField string `json:"count_field" yaml:"count_field"`
@@ -106,6 +118,18 @@ func loadWikiDataProviders(path string) (map[string]wikiDataProviderDefinition,
if def.ItemsPerColumn <= 0 { if def.ItemsPerColumn <= 0 {
return nil, fmt.Errorf("wiki data providers %s: provider %s items_per_column must be a positive integer", path, name) 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": case "class_feats", "class_skills", "class_summary", "class_spellbook", "race_feats", "race_name_forms":
if len(def.ClassFeats.Fields) > 0 { 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) 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 nil, nil
} }
return ctx.classProgressionRows(def.Levels, def.ClassFeats.Fields, def.Attacks, page.Key, page.Row) 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": case "class_feats":
if page.Category != "classes" { if page.Category != "classes" {
return nil, nil 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 { func validateWikiDataProviderAttacks(path, provider string, cfg wikiDataProviderAttacksConfig) error {
if strings.TrimSpace(cfg.Mode) == "" { if strings.TrimSpace(cfg.Mode) == "" {
return nil return nil
+14
View File
@@ -1317,6 +1317,20 @@ func (ctx *wikiContext) resolveClassName(key string) string {
return "" 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 { func (ctx *wikiContext) wikiGenerateOverride(row map[string]any) string {
if meta, err := parseExistingMetadata(row); err == nil && meta != nil { if meta, err := parseExistingMetadata(row); err == nil && meta != nil {
if wiki, ok := meta["wiki"].(map[string]any); ok { 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) { func TestWikiTemplateClassProgressionProviderUsesDefaultNWNAttackSequences(t *testing.T) {
ctx := &wikiContext{ ctx := &wikiContext{
wikiDataPath: "test-data.yaml", 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 nil, fmt.Errorf("alignments expects 1 argument")
} }
return p.ctx.formatWikiTemplateAlignments(args[0]) 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": case "ability_adjustments":
if len(args) != 0 { if len(args) != 0 {
return nil, fmt.Errorf("ability_adjustments expects 0 arguments") 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 return ctx.resolveFeatName
case "skills": case "skills":
return ctx.resolveSkillName return ctx.resolveSkillName
case "spells":
return ctx.resolveSpellName
case "racialtypes":
return ctx.resolveRaceName
case "baseitems":
return ctx.resolveBaseItemName
default: default:
return func(string) string { return "" } return func(string) string { return "" }
} }