Class skillls/list splitting
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
# Wiki List Columns Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a template-driven topdata wiki option for splitting provider-backed lists into configured columns with a fixed item count and optional forced empty columns.
|
||||
|
||||
**Architecture:** The toolkit wiki renderer gains a generic `columns` data-provider source that wraps another provider and exposes one row per rendered column. Each column row exposes `Items`, `Index`, and count metadata so page templates own markup while YAML owns row selection and chunking behavior. The module class page uses this provider for `ClassSkills`.
|
||||
|
||||
**Tech Stack:** Go `sow-toolkit`, YAML topdata wiki config, local HTML templates, Go tests.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Renderer Provider
|
||||
|
||||
**Files:**
|
||||
- Modify: `toolkit/internal/topdata/wiki_data_providers.go`
|
||||
- Modify: `toolkit/internal/topdata/wiki_template.go`
|
||||
- Test: `toolkit/internal/topdata/wiki_native_test.go`
|
||||
|
||||
- [ ] Write failing tests for a `columns` provider that chunks `ClassSkills` into rows with nested `Items`, including forced empty columns.
|
||||
- [ ] Run `go test ./internal/topdata -run 'TestWikiTemplate.*Column|TestBuildNativeWikiRendersClassSkillColumns'` and confirm the new assertions fail because `columns` is not supported.
|
||||
- [ ] Add `provider`, `columns`, `items_per_column`, and `force_columns` fields to `wikiDataProviderDefinition`.
|
||||
- [ ] Validate that `columns` providers name an existing provider, use positive integer `columns` and `items_per_column`, and do not point at themselves.
|
||||
- [ ] Allow nested `{{#each Items}}` loops by teaching template `#each` to iterate an array field from the current row before falling back to named providers.
|
||||
- [ ] Implement deterministic chunking: each column receives up to `items_per_column`; extra source items continue into additional overflow columns; `force_columns: true` emits empty configured columns when there are fewer items.
|
||||
|
||||
### Task 2: Module Template
|
||||
|
||||
**Files:**
|
||||
- Modify: `module/topdata/wiki/data.yaml`
|
||||
- Modify: `module/topdata/wiki/templates/pages/classes.html`
|
||||
- Modify: `module/topdata/wiki/README.md`
|
||||
|
||||
- [ ] Add `ClassSkillColumns` using `source: columns`, `provider: ClassSkills`, `columns: 3`, `items_per_column: 12`, and `force_columns: true`.
|
||||
- [ ] Replace the flat class skill `<ul>` with a `.wiki-list-columns .wiki-list-columns--3` wrapper and nested `{{#each Items}}` loops.
|
||||
- [ ] Document the generic `columns` provider fields for template authors.
|
||||
|
||||
### Task 3: Verification
|
||||
|
||||
**Files:**
|
||||
- Verify: `toolkit/internal/topdata/...`
|
||||
- Verify: `module/topdata/wiki/...`
|
||||
|
||||
- [ ] Run `go test ./internal/topdata -run 'TestWikiTemplate|TestBuildNativeWiki'`.
|
||||
- [ ] Run `./build-tool.sh` from `toolkit/`.
|
||||
- [ ] Run `SOW_TOOLS_DEV_BINARY=../toolkit/tools/sow-toolkit ./build-wiki.sh --force` from `module/`.
|
||||
- [ ] Inspect generated class HTML for `wiki-list-columns--3`, three configured class-skill lists for shorter classes, and overflow columns for long lists.
|
||||
@@ -13,9 +13,13 @@ type wikiDataProvidersDocument struct {
|
||||
}
|
||||
|
||||
type wikiDataProviderDefinition struct {
|
||||
Source string `json:"source" yaml:"source"`
|
||||
Levels string `json:"levels" yaml:"levels"`
|
||||
ClassFeats wikiDataProviderClassFeatConfig `json:"class_feats" yaml:"class_feats"`
|
||||
Source string `json:"source" yaml:"source"`
|
||||
Levels string `json:"levels" yaml:"levels"`
|
||||
ClassFeats wikiDataProviderClassFeatConfig `json:"class_feats" yaml:"class_feats"`
|
||||
Provider string `json:"provider" yaml:"provider"`
|
||||
Columns int `json:"columns" yaml:"columns"`
|
||||
ItemsPerColumn int `json:"items_per_column" yaml:"items_per_column"`
|
||||
ForceColumns bool `json:"force_columns" yaml:"force_columns"`
|
||||
}
|
||||
|
||||
type wikiDataProviderClassFeatConfig struct {
|
||||
@@ -45,7 +49,8 @@ func loadWikiDataProviders(path string) (map[string]wikiDataProviderDefinition,
|
||||
if strings.TrimSpace(def.Source) == "" {
|
||||
return nil, fmt.Errorf("wiki data providers %s: provider %s missing source", path, name)
|
||||
}
|
||||
switch strings.TrimSpace(def.Source) {
|
||||
source := strings.TrimSpace(def.Source)
|
||||
switch source {
|
||||
case "class_progression":
|
||||
for field, projection := range def.ClassFeats.Fields {
|
||||
if strings.TrimSpace(field) == "" {
|
||||
@@ -55,6 +60,22 @@ func loadWikiDataProviders(path string) (map[string]wikiDataProviderDefinition,
|
||||
return nil, fmt.Errorf("wiki data providers %s: provider %s class feat projection %s missing when", path, name, field)
|
||||
}
|
||||
}
|
||||
case "columns":
|
||||
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)
|
||||
}
|
||||
if strings.TrimSpace(def.Provider) == "" {
|
||||
return nil, fmt.Errorf("wiki data providers %s: provider %s columns provider missing provider", path, name)
|
||||
}
|
||||
if strings.TrimSpace(def.Provider) == name {
|
||||
return nil, fmt.Errorf("wiki data providers %s: provider %s columns provider must not reference itself", path, name)
|
||||
}
|
||||
if def.Columns <= 0 {
|
||||
return nil, fmt.Errorf("wiki data providers %s: provider %s columns must be a positive integer", path, name)
|
||||
}
|
||||
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 "class_feats", "class_skills", "class_summary", "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)
|
||||
@@ -63,6 +84,14 @@ func loadWikiDataProviders(path string) (map[string]wikiDataProviderDefinition,
|
||||
return nil, fmt.Errorf("wiki data providers %s: provider %s has unknown source %q", path, name, def.Source)
|
||||
}
|
||||
}
|
||||
for name, def := range doc.Providers {
|
||||
if strings.TrimSpace(def.Source) != "columns" {
|
||||
continue
|
||||
}
|
||||
if _, ok := doc.Providers[strings.TrimSpace(def.Provider)]; !ok {
|
||||
return nil, fmt.Errorf("wiki data providers %s: provider %s columns provider references missing provider %q", path, name, def.Provider)
|
||||
}
|
||||
}
|
||||
if doc.Providers == nil {
|
||||
return map[string]wikiDataProviderDefinition{}, nil
|
||||
}
|
||||
@@ -75,6 +104,12 @@ func (ctx *wikiContext) wikiDataProviderRows(name string, page wikiTemplatePage)
|
||||
return nil, fmt.Errorf("missing wiki data provider %q in %s", name, ctx.wikiDataPath)
|
||||
}
|
||||
switch strings.TrimSpace(def.Source) {
|
||||
case "columns":
|
||||
sourceRows, err := ctx.wikiDataProviderRows(strings.TrimSpace(def.Provider), page)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return wikiColumnProviderRows(sourceRows, def.Columns, def.ItemsPerColumn, def.ForceColumns), nil
|
||||
case "class_progression":
|
||||
if page.Category != "classes" {
|
||||
return nil, nil
|
||||
@@ -110,6 +145,44 @@ func (ctx *wikiContext) wikiDataProviderRows(name string, page wikiTemplatePage)
|
||||
}
|
||||
}
|
||||
|
||||
func wikiColumnProviderRows(sourceRows []map[string]any, configuredColumns, itemsPerColumn int, forceColumns bool) []map[string]any {
|
||||
if configuredColumns <= 0 || itemsPerColumn <= 0 {
|
||||
return nil
|
||||
}
|
||||
needed := 0
|
||||
if len(sourceRows) > 0 {
|
||||
needed = (len(sourceRows) + itemsPerColumn - 1) / itemsPerColumn
|
||||
}
|
||||
total := needed
|
||||
if forceColumns && configuredColumns > total {
|
||||
total = configuredColumns
|
||||
}
|
||||
if total == 0 {
|
||||
return nil
|
||||
}
|
||||
rows := make([]map[string]any, 0, total)
|
||||
for i := 0; i < total; i++ {
|
||||
start := i * itemsPerColumn
|
||||
end := start + itemsPerColumn
|
||||
if start > len(sourceRows) {
|
||||
start = len(sourceRows)
|
||||
}
|
||||
if end > len(sourceRows) {
|
||||
end = len(sourceRows)
|
||||
}
|
||||
items := make([]map[string]any, 0, end-start)
|
||||
for _, source := range sourceRows[start:end] {
|
||||
items = append(items, cloneRowMap(source))
|
||||
}
|
||||
rows = append(rows, map[string]any{
|
||||
"Index": i + 1,
|
||||
"Count": len(items),
|
||||
"Items": items,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func (ctx *wikiContext) classSkillRows(classRow map[string]any) []map[string]any {
|
||||
table := ctx.tableForValue(fieldValue(classRow, "SkillsTable"), ctx.classSkillTables)
|
||||
if table == nil {
|
||||
|
||||
@@ -516,6 +516,31 @@ providers:
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiDataProvidersRejectInvalidColumnProviderConfig(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
dataPath := filepath.Join(root, "data.yaml")
|
||||
writeFile(t, dataPath, `
|
||||
providers:
|
||||
ClassSkills:
|
||||
source: class_skills
|
||||
BrokenColumns:
|
||||
source: columns
|
||||
provider: ClassSkills
|
||||
columns: 0
|
||||
items_per_column: 2
|
||||
`+"\n")
|
||||
|
||||
_, err := loadWikiDataProviders(dataPath)
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid column provider config")
|
||||
}
|
||||
for _, expected := range []string{"data.yaml", "BrokenColumns", "columns"} {
|
||||
if !strings.Contains(err.Error(), expected) {
|
||||
t.Fatalf("expected error to contain %q, got %v", expected, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiTemplateEachBlockRendersClassSkillsAsIndividualListItems(t *testing.T) {
|
||||
ctx := &wikiContext{
|
||||
wikiDataProviders: map[string]wikiDataProviderDefinition{
|
||||
@@ -576,6 +601,139 @@ func TestWikiTemplateEachBlockRendersClassSkillsAsIndividualListItems(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiTemplateColumnProviderRendersNestedItemsAndForcedEmptyColumns(t *testing.T) {
|
||||
ctx := &wikiContext{
|
||||
wikiDataProviders: map[string]wikiDataProviderDefinition{
|
||||
"ClassSkills": {
|
||||
Source: "class_skills",
|
||||
},
|
||||
"ClassSkillColumns": {
|
||||
Source: "columns",
|
||||
Provider: "ClassSkills",
|
||||
Columns: 3,
|
||||
ItemsPerColumn: 2,
|
||||
ForceColumns: true,
|
||||
},
|
||||
},
|
||||
classSkillTables: map[string]wikiTable{
|
||||
"classes/skills:fighter": {
|
||||
Rows: []map[string]any{
|
||||
{"SkillIndex": "1", "ClassSkill": "1"},
|
||||
{"SkillIndex": "2", "ClassSkill": "1"},
|
||||
{"SkillIndex": "3", "ClassSkill": "1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
skillIDToKey: map[int]string{
|
||||
1: "skills:athletics",
|
||||
2: "skills:parry",
|
||||
3: "skills:ride",
|
||||
},
|
||||
skillRows: map[string]map[string]any{
|
||||
"skills:athletics": {"Name": "Athletics"},
|
||||
"skills:parry": {"Name": "Parry"},
|
||||
"skills:ride": {"Name": "Ride"},
|
||||
},
|
||||
}
|
||||
page := wikiTemplatePage{
|
||||
PageID: "classes:fighter",
|
||||
Category: "classes",
|
||||
Key: "classes:fighter",
|
||||
Title: "Fighter",
|
||||
Row: map[string]any{
|
||||
"SkillsTable": map[string]any{"table": "classes/skills:fighter"},
|
||||
},
|
||||
}
|
||||
|
||||
template := `<div class="wiki-list-columns wiki-list-columns--3">
|
||||
{{#each ClassSkillColumns}}
|
||||
<ul data-index="{{Index}}" data-count="{{Count}}" class="wiki-list wiki-class-skills">
|
||||
{{#each Items}}
|
||||
<li>{{SkillKey|link:SkillName|wiki}}</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{/each}}
|
||||
</div>`
|
||||
|
||||
got, err := ctx.renderWikiTemplateString("classes.html", template, page)
|
||||
if err != nil {
|
||||
t.Fatalf("render template: %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`<ul data-index="1" data-count="2" class="wiki-list wiki-class-skills">`,
|
||||
`<li>[[Skills/Athletics|Athletics]]</li>`,
|
||||
`<li>[[Skills/Parry|Parry]]</li>`,
|
||||
`<ul data-index="2" data-count="1" class="wiki-list wiki-class-skills">`,
|
||||
`<li>[[Skills/Ride|Ride]]</li>`,
|
||||
`<ul data-index="3" data-count="0" class="wiki-list wiki-class-skills">`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("expected rendered output to contain %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if gotCount := strings.Count(got, `<ul data-index=`); gotCount != 3 {
|
||||
t.Fatalf("expected exactly 3 forced columns, got %d:\n%s", gotCount, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiTemplateColumnProviderAddsOverflowColumnsBeyondConfiguredCount(t *testing.T) {
|
||||
ctx := &wikiContext{
|
||||
wikiDataProviders: map[string]wikiDataProviderDefinition{
|
||||
"ClassSkills": {
|
||||
Source: "class_skills",
|
||||
},
|
||||
"ClassSkillColumns": {
|
||||
Source: "columns",
|
||||
Provider: "ClassSkills",
|
||||
Columns: 2,
|
||||
ItemsPerColumn: 2,
|
||||
},
|
||||
},
|
||||
classSkillTables: map[string]wikiTable{
|
||||
"classes/skills:fighter": {
|
||||
Rows: []map[string]any{
|
||||
{"SkillIndex": "1", "ClassSkill": "1"},
|
||||
{"SkillIndex": "2", "ClassSkill": "1"},
|
||||
{"SkillIndex": "3", "ClassSkill": "1"},
|
||||
{"SkillIndex": "4", "ClassSkill": "1"},
|
||||
{"SkillIndex": "5", "ClassSkill": "1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
skillIDToKey: map[int]string{
|
||||
1: "skills:athletics",
|
||||
2: "skills:parry",
|
||||
3: "skills:ride",
|
||||
4: "skills:survival",
|
||||
5: "skills:heal",
|
||||
},
|
||||
skillRows: map[string]map[string]any{
|
||||
"skills:athletics": {"Name": "Athletics"},
|
||||
"skills:parry": {"Name": "Parry"},
|
||||
"skills:ride": {"Name": "Ride"},
|
||||
"skills:survival": {"Name": "Survival"},
|
||||
"skills:heal": {"Name": "Heal"},
|
||||
},
|
||||
}
|
||||
page := wikiTemplatePage{
|
||||
PageID: "classes:fighter",
|
||||
Category: "classes",
|
||||
Key: "classes:fighter",
|
||||
Title: "Fighter",
|
||||
Row: map[string]any{
|
||||
"SkillsTable": map[string]any{"table": "classes/skills:fighter"},
|
||||
},
|
||||
}
|
||||
|
||||
got, err := ctx.renderWikiTemplateString("classes.html", `{{#each ClassSkillColumns}}[{{Index}}:{{Count}}]{{/each}}`, page)
|
||||
if err != nil {
|
||||
t.Fatalf("render template: %v", err)
|
||||
}
|
||||
if got != "[1:2][2:2][3:1]" {
|
||||
t.Fatalf("expected overflow column after configured columns, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiTemplateListFilterRendersSliceAsListItems(t *testing.T) {
|
||||
ctx := &wikiContext{}
|
||||
page := wikiTemplatePage{
|
||||
|
||||
@@ -147,13 +147,14 @@ func (ctx *wikiContext) renderWikiTemplateRange(source string, offset int, stopB
|
||||
return "", offset, false, err
|
||||
}
|
||||
if state.active {
|
||||
rows, err := ctx.wikiDataProviderRows(providerName, state.page)
|
||||
items, err := ctx.resolveWikiTemplateEachItems(providerName, state)
|
||||
if err != nil {
|
||||
return "", offset, false, fmt.Errorf("render wiki template %s for %s: %w", state.path, state.page.PageID, err)
|
||||
}
|
||||
for _, row := range rows {
|
||||
for _, item := range items {
|
||||
child := *state
|
||||
child.row = row
|
||||
child.row = item.row
|
||||
child.dot = item.dot
|
||||
rendered, childOffset, closed, err := ctx.renderWikiTemplateRange(body, 0, "", &child)
|
||||
if err != nil {
|
||||
return "", offset, false, err
|
||||
@@ -239,6 +240,55 @@ func (ctx *wikiContext) renderWikiTemplateRange(source string, offset int, stopB
|
||||
}
|
||||
}
|
||||
|
||||
type wikiTemplateEachItem struct {
|
||||
row map[string]any
|
||||
dot any
|
||||
}
|
||||
|
||||
func (ctx *wikiContext) resolveWikiTemplateEachItems(name string, state *wikiTemplateRenderState) ([]wikiTemplateEachItem, error) {
|
||||
if value, ok := ctx.resolveWikiTemplateScopedValue(name, state); ok {
|
||||
return wikiTemplateEachItemsFromValue(value), nil
|
||||
}
|
||||
rows, err := ctx.wikiDataProviderRows(name, state.page)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]wikiTemplateEachItem, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, wikiTemplateEachItem{row: row, dot: row})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func wikiTemplateEachItemsFromValue(value any) []wikiTemplateEachItem {
|
||||
switch typed := value.(type) {
|
||||
case []map[string]any:
|
||||
items := make([]wikiTemplateEachItem, 0, len(typed))
|
||||
for _, row := range typed {
|
||||
items = append(items, wikiTemplateEachItem{row: row, dot: row})
|
||||
}
|
||||
return items
|
||||
case []any:
|
||||
items := make([]wikiTemplateEachItem, 0, len(typed))
|
||||
for _, value := range typed {
|
||||
if row, ok := value.(map[string]any); ok {
|
||||
items = append(items, wikiTemplateEachItem{row: row, dot: row})
|
||||
continue
|
||||
}
|
||||
items = append(items, wikiTemplateEachItem{dot: value})
|
||||
}
|
||||
return items
|
||||
case []string:
|
||||
items := make([]wikiTemplateEachItem, 0, len(typed))
|
||||
for _, value := range typed {
|
||||
items = append(items, wikiTemplateEachItem{dot: value})
|
||||
}
|
||||
return items
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (ctx *wikiContext) renderWikiTemplateTokenWithState(token string, state *wikiTemplateRenderState) (string, string, error) {
|
||||
if strings.HasPrefix(token, "format:") || strings.HasPrefix(token, "table:") || strings.HasPrefix(token, "field:") || strings.HasPrefix(token, "section:") {
|
||||
rendered, sectionID, err := ctx.renderWikiTemplateToken(state.path, token, state.page)
|
||||
|
||||
Reference in New Issue
Block a user