From 89508b3c0307868b0a2b769a4c97abc7bba250b7 Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Wed, 27 May 2026 08:26:14 +0200 Subject: [PATCH] Expose template options --- AGENTS.md | 16 ++++ README.md | 7 ++ internal/topdata/wiki_data_providers.go | 100 ++++++++++++++++++++++++ internal/topdata/wiki_native.go | 14 ++++ internal/topdata/wiki_native_test.go | 68 ++++++++++++++++ internal/topdata/wiki_tables.go | 25 ++++++ internal/topdata/wiki_template_expr.go | 6 ++ 7 files changed, 236 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 41f42a8..af0aabb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md index 8a8644d..1dae028 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/internal/topdata/wiki_data_providers.go b/internal/topdata/wiki_data_providers.go index be92fa4..287bd4b 100644 --- a/internal/topdata/wiki_data_providers.go +++ b/internal/topdata/wiki_data_providers.go @@ -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 diff --git a/internal/topdata/wiki_native.go b/internal/topdata/wiki_native.go index fabfd01..c435a86 100644 --- a/internal/topdata/wiki_native.go +++ b/internal/topdata/wiki_native.go @@ -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 { diff --git a/internal/topdata/wiki_native_test.go b/internal/topdata/wiki_native_test.go index c1e4d1a..075fde9 100644 --- a/internal/topdata/wiki_native_test.go +++ b/internal/topdata/wiki_native_test.go @@ -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", ` + + {{#each FeatFacts}} + + {{/each}} + +
{{Label}}{{Value|wiki}}
`, page) + if err != nil { + t.Fatalf("render template: %v", err) + } + for _, expected := range []string{ + `PrerequisitesStr 13, [[Feats/Power_Attack|Power Attack]], [[Skills/Athletics|Athletics]] 4 ranks`, + `Minimum Attack Bonus2`, + `Armor Check PenaltyYes`, + } { + 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", diff --git a/internal/topdata/wiki_tables.go b/internal/topdata/wiki_tables.go index b811ba0..466681b 100644 --- a/internal/topdata/wiki_tables.go +++ b/internal/topdata/wiki_tables.go @@ -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") diff --git a/internal/topdata/wiki_template_expr.go b/internal/topdata/wiki_template_expr.go index 6ac3feb..267febc 100644 --- a/internal/topdata/wiki_template_expr.go +++ b/internal/topdata/wiki_template_expr.go @@ -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 "" } }