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:
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
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
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
+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 "" }
}