Expose template options
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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 "" }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user