Files
sow-tools/internal/topdata/wiki_native.go
T
archvillainette bc8fa3a6fe
test / test (push) Successful in 1m24s
build-binaries / build-binaries (push) Successful in 2m8s
topdata: stop hardcoding the skills dataset location (#43)
sow-topdata moved skills into a tree (skills/core, skills/specs, skillcats, skillspecranks), which broke validate/build: the generated feat families hardcoded dataset "skills".

Resolution is now data-driven: the family spec's child_source names the dataset, child slugs come from the row key's own namespace, and the wiki loader accepts both the flat and tree layouts (topdata main still uses the flat one until the overhaul merges).

Verified against the skill-grouping-overhaul topdata branch: validate/build/wiki all clean with zero lock churn, focus feats stay on their adopted rows.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #43
Co-authored-by: vickydotbat <vickydotbat@tutamail.com>
Co-committed-by: vickydotbat <vickydotbat@tutamail.com>
2026-07-19 07:33:19 +00:00

2162 lines
63 KiB
Go

package topdata
import (
"crypto/sha256"
"embed"
"encoding/json"
"fmt"
"html"
"io/fs"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"time"
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
"gopkg.in/yaml.v3"
)
const (
wikiStatusNew = "new"
wikiStatusModified = "modified"
wikiStatusVanilla = "vanilla"
wikiRootDirName = "wiki"
legacyWikiRootDirName = ".wiki"
wikiPagesDirName = "pages"
wikiStateFileName = "state.json"
wikiGeneratorVersion = "nodebb-tiptap-html-v3"
wikiGeneratedStatus = "generated"
wikiSkippedStatus = "skipped"
)
var (
wikiStatusPriority = map[string]int{wikiStatusVanilla: 0, wikiStatusModified: 1, wikiStatusNew: 2}
wikiStatusLabels = map[string]string{wikiStatusNew: "New", wikiStatusModified: "Modified", wikiStatusVanilla: "Vanilla"}
)
//go:embed wiki_templates/*.txt
var wikiTemplateFS embed.FS
type wikiStateDocument struct {
Version string `json:"version"`
Digest string `json:"digest"`
Pages int `json:"pages"`
}
type wikiPageIndex struct {
Version string `json:"version"`
Pages []wikiPageIndexEntry `json:"pages"`
}
type wikiPageIndexEntry struct {
PageID string `json:"page_id"`
Namespace string `json:"namespace"`
SourceDataset string `json:"source_dataset"`
SourceKey string `json:"source_key"`
Title string `json:"title"`
PublicPath string `json:"public_path"`
Hash string `json:"hash"`
OutputPath string `json:"output_path"`
EditPolicy string `json:"edit_policy"`
StalePolicy string `json:"stale_policy"`
}
type wikiManualSectionsDocument struct {
ManualSections []wikiManualSection `json:"manual_sections" yaml:"manual_sections"`
}
type wikiManualSection struct {
ID string `json:"id" yaml:"id"`
Alias string `json:"alias" yaml:"alias"`
Heading string `json:"heading" yaml:"heading"`
Placement string `json:"placement" yaml:"placement"`
InitialHTML string `json:"initial_html" yaml:"initial_html"`
}
type wikiResult struct {
OutputDir string
PageCount int
Status string
Digest string
}
type wikiTable struct {
Key string
Output string
OutputStem string
Rows []map[string]any
}
type wikiContext struct {
dialog map[string]string
rowsByKey map[string]map[string]any
featRows map[string]map[string]any
featIDToKey map[int]string
skillRows map[string]map[string]any
skillIDToKey map[int]string
spellRows map[string]map[string]any
baseitemRows map[string]map[string]any
classRows map[string]map[string]any
classIDToKey map[int]string
raceRows map[string]map[string]any
raceIDToKey map[int]string
masterfeatRows map[string]map[string]any
classFeatTables map[string]wikiTable
classSkillTables map[string]wikiTable
classBonusTables map[string]wikiTable
classSaveTables map[string]wikiTable
classSpellGainTables map[string]wikiTable
classSpellKnownTables map[string]wikiTable
classSpellbooks map[string]classSpellbookSpec
raceFeatTables map[string]wikiTable
statuses map[string]map[string]string
implementedFeats map[string]struct{}
manualSections []wikiManualSection
templateDir string
wikiTablesPath string
wikiTableDefinitions map[string]wikiTableDefinition
wikiDataPath string
wikiDataProviders map[string]wikiDataProviderDefinition
visibilityPolicy *wikiVisibilityDefinitions
visibleWikiKeys map[string]bool
namespaceTitles map[string]string
namespacePaths map[string]string
alignmentLinkPattern string
alignmentLinkFormat string
}
func buildWiki(p *project.Project, nativeResult BuildResult, force bool, progress func(string)) (wikiResult, error) {
if progress == nil {
progress = func(string) {}
}
rootDir := wikiOutputRootDir(p)
outputDir := wikiOutputPagesDir(p)
statePath := wikiOutputStatePath(p)
digest, err := computeWikiSourceDigest(p)
if err != nil {
return wikiResult{}, err
}
state, _ := loadWikiState(statePath)
if !force && digest != "" && state.Digest == digest && wikiCachedPagesAreCurrent(outputDir, state) {
return wikiResult{
OutputDir: outputDir,
PageCount: state.Pages,
Status: wikiSkippedStatus,
Digest: digest,
}, nil
}
progress("Building native wiki pages...")
ctx, err := loadWikiContext(filepath.Join(p.TopDataSourceDir(), "data"), p.TopDataSourceDir())
if err != nil {
return wikiResult{}, err
}
manualSections := loadWikiManualSections(p)
ctx.manualSections = manualSections
ctx.templateDir = p.TopDataWikiPageTemplatesDir()
ctx.alignmentLinkPattern = p.EffectiveConfig().TopData.Wiki.AlignmentLinks.TargetPattern
ctx.alignmentLinkFormat = p.EffectiveConfig().TopData.Wiki.AlignmentLinks.LinkFormat
ctx.wikiTablesPath = filepath.Join(p.TopDataWikiSourceDir(), filepath.FromSlash(p.EffectiveConfig().TopData.Wiki.TablesFile))
tableDefinitions, err := loadWikiTableDefinitions(ctx.wikiTablesPath)
if err != nil {
return wikiResult{}, err
}
ctx.wikiTableDefinitions = tableDefinitions
ctx.wikiDataPath = p.TopDataWikiDataPath()
dataProviders, err := loadWikiDataProviders(ctx.wikiDataPath)
if err != nil {
return wikiResult{}, err
}
ctx.wikiDataProviders = dataProviders
visibilityPath := filepath.Join(p.TopDataWikiSourceDir(), filepath.FromSlash(p.EffectiveConfig().TopData.Wiki.VisibilityFile))
if err := ctx.loadWikiVisibility(visibilityPath); err != nil {
return wikiResult{}, err
}
pageSlugOverrides, err := loadWikiPagePathDeclarations(p.TopDataWikiPagePathsPath())
if err != nil {
return wikiResult{}, err
}
_ = pageSlugOverrides
namespaceDeclarations, err := loadWikiNamespaceDeclarations(p)
if err != nil {
return wikiResult{}, err
}
ctx.namespaceTitles = wikiNamespaceTitles(namespaceDeclarations)
ctx.namespacePaths = wikiNamespacePaths(namespaceDeclarations)
if err := os.RemoveAll(outputDir); err != nil {
return wikiResult{}, fmt.Errorf("clean wiki output: %w", err)
}
if err := os.MkdirAll(outputDir, 0o755); err != nil {
return wikiResult{}, fmt.Errorf("create wiki output dir: %w", err)
}
pageTitles := map[string]string{}
pageStatuses := map[string]string{}
pageIndex := wikiPageIndex{Version: wikiGeneratorVersion}
outputPaths := map[string]string{}
writePage := func(pageID, content, title, status string) error {
relPath := wikiPageOutputRelPath(pageID, title)
if previous := outputPaths[relPath]; previous != "" {
return fmt.Errorf("duplicate wiki page output path %q for %s and %s", filepath.ToSlash(relPath), previous, pageID)
}
outputPaths[relPath] = pageID
path := filepath.Join(outputDir, relPath)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
namespace := strings.SplitN(pageID, ":", 2)[0]
publicPath := ctx.publicWikiPath(namespace, title)
content = renderNodeBBManagedHTML(pageID, content, manualSections)
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
return err
}
pageTitles[pageID] = title
pageStatuses[pageID] = status
rel, err := filepath.Rel(rootDir, path)
if err != nil {
rel = filepath.Base(path)
}
pageIndex.Pages = append(pageIndex.Pages, wikiPageIndexEntry{
PageID: pageID,
Namespace: namespace,
SourceDataset: categoryFromWikiNamespace(namespace),
SourceKey: pageID,
Title: title,
PublicPath: publicPath,
Hash: computeManagedHash(content),
OutputPath: filepath.ToSlash(rel),
EditPolicy: wikiEditPolicy(namespace),
StalePolicy: p.EffectiveConfig().TopData.Wiki.StalePages.Default,
})
return nil
}
if err := generateEntityPages(outputDir, ctx, writePage); err != nil {
return wikiResult{}, err
}
if wikiStatusPagesEnabled(p.EffectiveConfig().TopData.Wiki) {
if err := generateStatusPages(outputDir, pageStatuses, pageTitles, p.EffectiveConfig().TopData.Wiki.ManagedNamespaces, p.EffectiveConfig().TopData.Wiki.StatusListingScope, manualSections, ctx); err != nil {
return wikiResult{}, err
}
}
if err := indexUnregisteredWikiPages(rootDir, outputDir, p.EffectiveConfig().TopData.Wiki.StalePages.Default, ctx, &pageIndex); err != nil {
return wikiResult{}, err
}
if err := saveWikiPageIndex(p.TopDataWikiPageIndexPath(), pageIndex); err != nil {
return wikiResult{}, err
}
if err := os.MkdirAll(rootDir, 0o755); err != nil {
return wikiResult{}, fmt.Errorf("create wiki root dir: %w", err)
}
pageCount := len(pageIndex.Pages)
state = wikiStateDocument{Version: wikiGeneratorVersion, Digest: digest, Pages: pageCount}
if err := saveWikiState(statePath, state); err != nil {
return wikiResult{}, err
}
return wikiResult{
OutputDir: outputDir,
PageCount: pageCount,
Status: wikiGeneratedStatus,
Digest: digest,
}, nil
}
func wikiStatusPagesEnabled(cfg project.TopDataWikiConfig) bool {
return cfg.StatusPages == nil || *cfg.StatusPages
}
func wikiCachedPagesAreCurrent(outputDir string, state wikiStateDocument) bool {
if state.Version != wikiGeneratorVersion {
return false
}
if state.Pages <= 0 {
return false
}
count := 0
err := filepath.WalkDir(outputDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() && strings.EqualFold(filepath.Ext(path), ".html") {
count++
}
return nil
})
return err == nil && count == state.Pages
}
func loadWikiManualSections(p *project.Project) []wikiManualSection {
defaults := defaultWikiManualSections()
path := p.TopDataWikiManualSectionsPath()
raw, err := os.ReadFile(path)
if err != nil {
return defaults
}
var doc wikiManualSectionsDocument
if err := yaml.Unmarshal(raw, &doc); err != nil || len(doc.ManualSections) == 0 {
return defaults
}
out := []wikiManualSection{}
for _, section := range doc.ManualSections {
section.ID = strings.TrimSpace(section.ID)
if section.ID == "" {
continue
}
section.Alias = strings.TrimSpace(section.Alias)
if section.Alias == "" {
switch section.ID {
case "user_top":
section.Alias = "UserTopSection"
case "user_bottom":
section.Alias = "UserBottomSection"
}
}
if strings.TrimSpace(section.InitialHTML) == "" {
heading := strings.TrimSpace(section.Heading)
if heading == "" {
heading = section.ID
}
section.InitialHTML = "<h2>" + html.EscapeString(heading) + "</h2><p></p>"
}
out = append(out, section)
}
if len(out) == 0 {
return defaults
}
return out
}
func saveWikiPageIndex(path string, index wikiPageIndex) error {
if err := validateWikiPageIndex(index); err != nil {
return err
}
slices.SortFunc(index.Pages, func(a, b wikiPageIndexEntry) int {
return strings.Compare(a.PageID, b.PageID)
})
raw, err := json.MarshalIndent(index, "", " ")
if err != nil {
return fmt.Errorf("marshal wiki page index: %w", err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return os.WriteFile(path, append(raw, '\n'), 0o644)
}
func validateWikiPageIndex(index wikiPageIndex) error {
pageIDs := map[string]string{}
outputPaths := map[string]string{}
publicPaths := map[string]string{}
for _, entry := range index.Pages {
if previous, ok := pageIDs[entry.PageID]; ok {
return fmt.Errorf("duplicate wiki page-index page_id %q for %s and %s", entry.PageID, previous, entry.OutputPath)
}
if previous, ok := outputPaths[entry.OutputPath]; ok {
return fmt.Errorf("duplicate wiki page-index output_path %q for %s and %s", entry.OutputPath, previous, entry.PageID)
}
if entry.PublicPath != "" {
publicPathKey := canonicalWikiSlashPathFoldedKey(entry.PublicPath)
if publicPathKey == "" {
publicPathKey = entry.PublicPath
}
if previous, ok := publicPaths[publicPathKey]; ok {
return fmt.Errorf("duplicate wiki page-index public_path %q for %s and %s", entry.PublicPath, previous, entry.PageID)
}
publicPaths[publicPathKey] = entry.PageID
}
pageIDs[entry.PageID] = entry.OutputPath
outputPaths[entry.OutputPath] = entry.PageID
}
return nil
}
func indexUnregisteredWikiPages(rootDir, outputDir, stalePolicy string, ctx *wikiContext, index *wikiPageIndex) error {
seen := map[string]struct{}{}
for _, entry := range index.Pages {
seen[entry.PageID] = struct{}{}
}
if ctx == nil {
ctx = &wikiContext{}
}
return filepath.WalkDir(outputDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !strings.EqualFold(filepath.Ext(path), ".html") {
return nil
}
relToPages, err := filepath.Rel(outputDir, path)
if err != nil {
return err
}
raw, err := os.ReadFile(path)
if err != nil {
return err
}
pageID := extractWikiPageID(string(raw))
if pageID == "" {
pageID = strings.TrimSuffix(filepath.ToSlash(relToPages), filepath.Ext(relToPages))
pageID = strings.ReplaceAll(pageID, "/", ":")
}
if _, ok := seen[pageID]; ok {
return nil
}
namespace := strings.SplitN(pageID, ":", 2)[0]
relToRoot, err := filepath.Rel(rootDir, path)
if err != nil {
relToRoot = filepath.Base(path)
}
title := extractHTMLTitle(string(raw), pageID)
index.Pages = append(index.Pages, wikiPageIndexEntry{
PageID: pageID,
Namespace: namespace,
SourceDataset: categoryFromWikiNamespace(namespace),
SourceKey: pageID,
Title: title,
PublicPath: ctx.publicWikiPath(namespace, title),
Hash: computeManagedHash(string(raw)),
OutputPath: filepath.ToSlash(relToRoot),
EditPolicy: wikiEditPolicy(namespace),
StalePolicy: stalePolicy,
})
seen[pageID] = struct{}{}
return nil
})
}
func loadWikiState(path string) (wikiStateDocument, error) {
raw, err := os.ReadFile(path)
if err != nil {
return wikiStateDocument{}, err
}
var doc wikiStateDocument
if err := json.Unmarshal(raw, &doc); err != nil {
return wikiStateDocument{}, fmt.Errorf("parse wiki state: %w", err)
}
return doc, nil
}
func saveWikiState(path string, state wikiStateDocument) error {
raw, err := json.MarshalIndent(state, "", " ")
if err != nil {
return fmt.Errorf("marshal wiki state: %w", err)
}
raw = append(raw, '\n')
if err := os.WriteFile(path, raw, 0o644); err != nil {
return fmt.Errorf("write wiki state: %w", err)
}
return nil
}
func computeWikiSourceDigest(p *project.Project) (string, error) {
hasher := sha256.New()
_, _ = hasher.Write([]byte(wikiGeneratorVersion))
sourceDir := p.TopDataSourceDir()
wikiSourceDir := p.TopDataWikiSourceDir()
files := []string{}
relevantSourceDirs := []string{
filepath.Join(sourceDir, "data", "feat"),
filepath.Join(sourceDir, "data", "skills"),
filepath.Join(sourceDir, "data", "spells"),
filepath.Join(sourceDir, "data", "baseitems"),
filepath.Join(sourceDir, "data", "classes"),
filepath.Join(sourceDir, "data", "racialtypes"),
wikiSourceDir,
}
for _, dir := range relevantSourceDirs {
_ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
switch strings.ToLower(filepath.Ext(path)) {
case ".json", ".yaml", ".yml", ".html":
files = append(files, path)
}
return nil
})
}
if len(files) == 0 {
return "", nil
}
slices.Sort(files)
for _, path := range files {
if _, err := os.Stat(path); err != nil {
if os.IsNotExist(err) {
continue
}
return "", err
}
rel, err := filepath.Rel(sourceDir, path)
if err == nil && !strings.HasPrefix(rel, "..") {
rel = filepath.ToSlash(rel)
} else {
rel = filepath.Base(path)
}
_, _ = hasher.Write([]byte(rel))
raw, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read wiki digest input %s: %w", path, err)
}
_, _ = hasher.Write(raw)
}
return fmt.Sprintf("%x", hasher.Sum(nil)), nil
}
func newestRelevantWikiSource(p *project.Project) (time.Time, string, error) {
newest := time.Time{}
newestPath := ""
sourceDir := p.TopDataSourceDir()
relevantSourceDirs := []string{
filepath.Join(sourceDir, "data", "feat"),
filepath.Join(sourceDir, "data", "skills"),
filepath.Join(sourceDir, "data", "spells"),
filepath.Join(sourceDir, "data", "baseitems"),
filepath.Join(sourceDir, "data", "classes"),
filepath.Join(sourceDir, "data", "racialtypes"),
p.TopDataWikiSourceDir(),
}
for _, dir := range relevantSourceDirs {
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if d.IsDir() {
return nil
}
if !strings.EqualFold(filepath.Ext(path), ".json") {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
if newest.IsZero() || info.ModTime().After(newest) {
newest = info.ModTime()
newestPath = path
}
return nil
})
if err != nil {
return time.Time{}, "", err
}
}
return newest, newestPath, nil
}
func loadWikiContext(dataDir, sourceDir string) (*wikiContext, error) {
dialogData, err := loadBaseDialogData(filepath.Join(sourceDir, "base_dialog.json"))
if err != nil {
return nil, err
}
dialog := map[string]string{}
if dialogData != nil {
for key, entry := range dialogData.Entries {
dialog[key] = entry.Text
}
}
datasets, err := discoverNativeDatasets(dataDir)
if err != nil {
return nil, err
}
loadBase := func(names ...string) (nativeCollectedDataset, bool, error) {
for _, name := range names {
for _, dataset := range datasets {
if dataset.Name == name {
collected, err := collectNativeDataset(dataset)
return collected, true, err
}
}
}
return nativeCollectedDataset{}, false, nil
}
featDataset, ok, err := loadBase("feat")
if err != nil {
return nil, err
}
skillDataset, skillOK, err := loadBase("skills", "skills/core")
if err != nil {
return nil, err
}
spellDataset, spellOK, err := loadBase("spells")
if err != nil {
return nil, err
}
baseitemDataset, baseitemOK, err := loadBase("baseitems")
if err != nil {
return nil, err
}
classDataset, classOK, err := loadBase("classes/core")
if err != nil {
return nil, err
}
masterfeatDataset, masterfeatOK, err := loadBase("masterfeats")
if err != nil {
return nil, err
}
raceDatasets, err := collectWikiRacialtypesDatasets(dataDir, datasets)
if err != nil {
return nil, err
}
raceRows := map[string]map[string]any{}
raceIDToKey := map[int]string{}
raceFeatTables := map[string]wikiTable{}
for _, dataset := range raceDatasets {
if dataset.Dataset.OutputName == "racialtypes.2da" {
raceRows, raceIDToKey = rowsByKeyAndID(dataset.Rows)
continue
}
table := wikiTable{
Key: dataset.TableKey,
Output: dataset.Dataset.OutputName,
OutputStem: outputStem(dataset.Dataset.OutputName),
Rows: cloneRows(dataset.Rows),
}
raceFeatTables[table.OutputStem] = table
if table.Key != "" {
raceFeatTables[table.Key] = table
}
}
classFeatTables, err := loadWikiTables(filepath.Join(dataDir, "classes", "feats"))
if err != nil {
return nil, err
}
classSkillTables, err := loadWikiTables(filepath.Join(dataDir, "classes", "skills"))
if err != nil {
return nil, err
}
classBonusTables, err := loadWikiTablesFromDirs(filepath.Join(dataDir, "classes", "bonusfeats"), filepath.Join(dataDir, "classes", "bfeat"))
if err != nil {
return nil, err
}
classSaveTables, err := loadWikiTablesFromDirs(filepath.Join(dataDir, "classes", "savingthrows"), filepath.Join(dataDir, "classes", "savthr"))
if err != nil {
return nil, err
}
classSpellGainTables, err := loadWikiTables(filepath.Join(dataDir, "classes", "spellsgained"))
if err != nil {
return nil, err
}
classSpellKnownTables, err := loadWikiTables(filepath.Join(dataDir, "classes", "spellsknown"))
if err != nil {
return nil, err
}
classSpellbookList, diagnostics := loadClassSpellbookSpecs(filepath.Join(dataDir, "classes", "spellbooks"))
if len(diagnostics) > 0 {
return nil, fmt.Errorf("load class spellbooks for wiki: %s", diagnosticsTextForError(diagnostics))
}
classSpellbooks := map[string]classSpellbookSpec{}
for _, spec := range classSpellbookList {
classSpellbooks[spec.Class] = spec
}
ctx := &wikiContext{
dialog: dialog,
rowsByKey: map[string]map[string]any{},
statuses: map[string]map[string]string{},
raceFeatTables: raceFeatTables,
classFeatTables: classFeatTables,
classSkillTables: classSkillTables,
classBonusTables: classBonusTables,
classSaveTables: classSaveTables,
classSpellGainTables: classSpellGainTables,
classSpellKnownTables: classSpellKnownTables,
classSpellbooks: classSpellbooks,
}
if ok {
ctx.featRows, ctx.featIDToKey = rowsByKeyAndID(featDataset.Rows)
} else {
ctx.featRows, ctx.featIDToKey = map[string]map[string]any{}, map[int]string{}
}
if skillOK {
ctx.skillRows, ctx.skillIDToKey = rowsByKeyAndID(skillDataset.Rows)
} else {
ctx.skillRows, ctx.skillIDToKey = map[string]map[string]any{}, map[int]string{}
}
if spellOK {
ctx.spellRows, _ = rowsByKeyAndID(spellDataset.Rows)
} else {
ctx.spellRows = map[string]map[string]any{}
}
if baseitemOK {
ctx.baseitemRows, _ = rowsByKeyAndID(baseitemDataset.Rows)
} else {
ctx.baseitemRows = map[string]map[string]any{}
}
if classOK {
ctx.classRows, ctx.classIDToKey = rowsByKeyAndID(classDataset.Rows)
} else {
ctx.classRows, ctx.classIDToKey = map[string]map[string]any{}, map[int]string{}
}
if masterfeatOK {
ctx.masterfeatRows, _ = rowsByKeyAndID(masterfeatDataset.Rows)
} else {
ctx.masterfeatRows = map[string]map[string]any{}
}
ctx.raceRows = raceRows
ctx.raceIDToKey = raceIDToKey
for _, group := range []map[string]map[string]any{ctx.featRows, ctx.skillRows, ctx.spellRows, ctx.baseitemRows, ctx.classRows, ctx.raceRows, ctx.masterfeatRows} {
for key, row := range group {
ctx.rowsByKey[key] = row
}
}
if ok {
ctx.statuses["feat"] = collectDatasetStatuses(filepath.Join(dataDir, "feat"), "feat")
}
if skillOK {
ctx.statuses["skills"] = collectDatasetStatuses(filepath.Join(dataDir, "skills"), "skills")
}
if spellOK {
ctx.statuses["spells"] = collectDatasetStatuses(filepath.Join(dataDir, "spells"), "spells")
}
if baseitemOK {
ctx.statuses["baseitems"] = collectDatasetStatuses(filepath.Join(dataDir, "baseitems"), "baseitems")
}
if classOK {
ctx.statuses["classes"] = collectDatasetStatuses(filepath.Join(dataDir, "classes", "core"), "classes/core")
}
ctx.statuses["racialtypes"] = collectWikiRacialtypeStatuses(dataDir)
ctx.implementedFeats = ctx.collectImplementedFeatKeys()
return ctx, nil
}
func collectWikiRacialtypesDatasets(dataDir string, datasets []nativeDataset) ([]nativeCollectedDataset, error) {
out := []nativeCollectedDataset{}
for _, dataset := range datasets {
name := filepath.ToSlash(dataset.Name)
if !strings.HasPrefix(name, "racialtypes/") {
continue
}
collected, err := collectNativeDataset(dataset)
if err != nil {
return nil, err
}
if collected.Dataset.OutputName == "racialtypes.2da" || collected.TableKey != "" {
out = append(out, collected)
}
}
if len(out) > 0 {
return out, nil
}
return collectRacialtypesRegistryDatasets(dataDir, false)
}
func collectWikiRacialtypeStatuses(dataDir string) map[string]string {
coreRoot := filepath.Join(dataDir, "racialtypes", "core")
if _, err := os.Stat(coreRoot); err == nil {
return collectDatasetStatuses(coreRoot, "racialtypes/core")
}
return collectRaceStatuses(filepath.Join(dataDir, "racialtypes", "registry"))
}
func rowsByKeyAndID(rows []map[string]any) (map[string]map[string]any, map[int]string) {
byKey := map[string]map[string]any{}
byID := map[int]string{}
for _, row := range rows {
key, _ := row["key"].(string)
if key == "" {
continue
}
byKey[key] = row
if id, ok := row["id"].(int); ok {
byID[id] = key
}
}
return byKey, byID
}
func cloneRows(rows []map[string]any) []map[string]any {
out := make([]map[string]any, 0, len(rows))
for _, row := range rows {
out = append(out, cloneRowMap(row))
}
return out
}
func loadWikiTables(root string) (map[string]wikiTable, error) {
info, err := os.Stat(root)
if err != nil {
if os.IsNotExist(err) {
return map[string]wikiTable{}, nil
}
return nil, err
}
if !info.IsDir() {
return nil, fmt.Errorf("%s must be a directory", root)
}
tables := map[string]wikiTable{}
err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !strings.EqualFold(filepath.Ext(path), ".json") || strings.EqualFold(filepath.Base(path), "lock.json") || strings.EqualFold(filepath.Base(path), "global.json") {
return nil
}
obj, err := loadJSONObject(path)
if err != nil {
return err
}
rawRows, ok := obj["rows"].([]any)
if !ok {
return fmt.Errorf("%s: rows must be an array", path)
}
rows := make([]map[string]any, 0, len(rawRows))
for _, raw := range rawRows {
row, ok := raw.(map[string]any)
if !ok {
return fmt.Errorf("%s: row must be an object", path)
}
rows = append(rows, row)
}
output, _ := obj["output"].(string)
if output == "" {
output = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + ".2da"
}
table := wikiTable{
Key: stringField(obj, "key"),
Output: output,
OutputStem: outputStem(output),
Rows: rows,
}
tables[table.OutputStem] = table
if table.Key != "" {
tables[table.Key] = table
}
return nil
})
if err != nil {
return nil, err
}
return tables, nil
}
func loadWikiTablesFromDirs(roots ...string) (map[string]wikiTable, error) {
merged := map[string]wikiTable{}
for _, root := range roots {
tables, err := loadWikiTables(root)
if err != nil {
return nil, err
}
for key, table := range tables {
merged[key] = table
}
}
return merged, nil
}
func generateEntityPages(outputDir string, ctx *wikiContext, writePage func(pageID, content, title, status string) error) error {
if err := generateCategoryPages("classes", ctx.classRows, ctx, writePage); err != nil {
return err
}
if err := generateCategoryPages("feat", ctx.featRows, ctx, writePage); err != nil {
return err
}
if err := generateCategoryPages("skills", ctx.skillRows, ctx, writePage); err != nil {
return err
}
if err := generateCategoryPages("spells", ctx.spellRows, ctx, writePage); err != nil {
return err
}
if err := generateCategoryPages("baseitems", ctx.baseitemRows, ctx, writePage); err != nil {
return err
}
if err := generateCategoryPages("racialtypes", ctx.raceRows, ctx, writePage); err != nil {
return err
}
_ = outputDir
return nil
}
func generateCategoryPages(category string, rows map[string]map[string]any, ctx *wikiContext, writePage func(pageID, content, title, status string) error) error {
keys := sortedKeys(rows)
for _, key := range keys {
row := rows[key]
if !ctx.shouldGeneratePage(category, key, row) {
continue
}
title := ctx.resolveRowName(category, row)
if title == "" {
continue
}
content, err := ctx.renderPage(category, key, row, title)
if err != nil {
return err
}
if err := writePage(wikiPageIDForKey(key), content, title, ctx.pageStatus(category, key, row)); err != nil {
return err
}
}
return nil
}
func (ctx *wikiContext) shouldGeneratePage(category, key string, row map[string]any) bool {
if row == nil {
return false
}
if ctx.visibilityPolicy != nil {
return ctx.canReferenceWikiKey(key)
}
override := ctx.wikiGenerateOverride(row)
if override == "0" {
return false
}
name := ctx.resolveRowName(category, row)
description := ctx.resolveRowDescription(category, row)
switch category {
case "classes":
if isToolLike(name, stringField(row, "Label"), stringField(row, "Constant")) {
return false
}
return override == "1" || stringValue(row, "PlayerClass") == "1"
case "feat":
if name == "" || description == "" || strings.EqualFold(stringValue(row, "FEAT"), nullValue) {
return false
}
if override == "1" {
return true
}
if stringValue(row, "ALLCLASSESCANUSE") == "1" {
return true
}
_, ok := ctx.implementedFeats[key]
return ok
case "skills":
if name == "" || description == "" {
return false
}
return override == "1" || stringValue(row, "HideFromLevelUp") != "1"
case "racialtypes":
if name == "" || description == "" {
return false
}
return override == "1" || stringValue(row, "PlayerRace") == "1"
case "spells":
if isToolLike(name, stringField(row, "Label"), stringField(row, "Constant")) {
return false
}
return name != "" && description != ""
default:
return name != "" && description != ""
}
}
func (ctx *wikiContext) wikiRowCanRender(category string, row map[string]any) bool {
if row == nil {
return false
}
name := ctx.resolveRowName(category, row)
description := ctx.resolveRowDescription(category, row)
switch category {
case "classes", "spells":
if isToolLike(name, stringField(row, "Label"), stringField(row, "Constant")) {
return false
}
}
return name != "" && description != ""
}
func (ctx *wikiContext) renderPage(category, key string, row map[string]any, title string) (string, error) {
manualSections := ctx.manualSections
if len(manualSections) == 0 {
manualSections = defaultWikiManualSections()
}
page := wikiTemplatePage{
PageID: wikiPageIDForKey(key),
Category: category,
Key: key,
Title: title,
Status: ctx.pageStatus(category, key, row),
Row: row,
ManualSections: manualSections,
}
return ctx.renderWikiPageTemplate(category, page)
}
func (ctx *wikiContext) renderFacts(category, key string, row map[string]any) string {
lines := []string{}
switch category {
case "feat":
lines = append(lines, ctx.renderFeatFacts(row)...)
case "skills":
lines = append(lines, formatFact("Key Ability", stringValue(row, "KeyAbility")))
lines = append(lines, formatFact("Untrained", yesNoValue(stringValue(row, "Untrained"))))
lines = append(lines, formatFact("Armor Check Penalty", yesNoValue(stringValue(row, "ArmorCheckPenalty"))))
case "spells":
lines = append(lines, formatFact("Constant", stringValue(row, "Constant")))
case "baseitems":
lines = append(lines, formatFact("Item Class", stringValue(row, "ItemClass")))
lines = append(lines, formatFact("Weapon Type", stringValue(row, "WeaponType")))
lines = append(lines, formatFact("Required Feats", ctx.renderReferenceList(ctx.wikiReqFeats(row))))
lines = append(lines, formatFact("Base Item Stats", ctx.resolveTextValue(fieldValue(row, "BaseItemStatRef"), nil)))
case "classes":
lines = append(lines, formatFact("Hit Die", "d"+stringValue(row, "HitDie")))
lines = append(lines, formatFact("Skill Points", stringValue(row, "SkillPointBase")))
lines = append(lines, formatFact("Primary Ability", stringValue(row, "PrimaryAbil")))
case "racialtypes":
lines = append(lines, formatFact("Ability Adjustments", formatAbilityAdjustments(row)))
lines = append(lines, formatFact("Favored Class", ctx.renderReference(fieldValue(row, "Favored"), ctx.classIDToKey, ctx.resolveClassName)))
lines = append(lines, formatFact("Favored Enemy", ctx.renderReference(fieldValue(row, "FavoredEnemyFeat"), ctx.featIDToKey, ctx.resolveFeatName)))
lines = append(lines, formatFact("Racial Feats", ctx.renderRaceFeatList(row)))
}
out := make([]string, 0, len(lines))
for _, line := range lines {
if strings.TrimSpace(line) != "" {
out = append(out, line)
}
}
return strings.Join(out, "\n")
}
func (ctx *wikiContext) renderFeatFacts(row map[string]any) []string {
lines := []string{}
lines = append(lines, formatFact("Prerequisites", ctx.renderFeatPrerequisites(row)))
lines = append(lines, formatFact("Minimum Attack Bonus", stringValue(row, "MINATTACKBONUS")))
lines = append(lines, formatFact("Minimum Spell Level", stringValue(row, "MINSPELLLVL")))
lines = append(lines, formatFact("Minimum Fortitude Save", stringValue(row, "MinFortSave")))
return lines
}
func (ctx *wikiContext) renderFeatPrerequisites(row map[string]any) string {
parts := []string{}
for _, field := range []struct {
Key string
Label string
}{
{"MINSTR", "Str"}, {"MINDEX", "Dex"}, {"MINCON", "Con"}, {"MININT", "Int"}, {"MINWIS", "Wis"}, {"MINCHA", "Cha"},
} {
if value := stringValue(row, field.Key); value != "" {
parts = append(parts, field.Label+" "+value)
}
}
for _, field := range []string{"PREREQFEAT1", "PREREQFEAT2"} {
if rendered := ctx.renderReference(fieldValue(row, field), ctx.featIDToKey, ctx.resolveFeatName); rendered != "" {
parts = append(parts, rendered)
}
}
orReqs := []string{}
for _, field := range []string{"OrReqFeat0", "OrReqFeat1", "OrReqFeat2", "OrReqFeat3", "OrReqFeat4"} {
if rendered := ctx.renderReference(fieldValue(row, field), ctx.featIDToKey, ctx.resolveFeatName); rendered != "" {
orReqs = append(orReqs, rendered)
}
}
if len(orReqs) > 0 {
parts = append(parts, strings.Join(orReqs, " or "))
}
if rendered := ctx.renderReference(fieldValue(row, "MinLevelClass"), ctx.classIDToKey, ctx.resolveClassName); rendered != "" {
if value := stringValue(row, "MinLevel"); value != "" {
parts = append(parts, "Level "+value+" of "+rendered)
}
}
for _, spec := range []struct {
SkillField string
RankField string
}{
{"REQSKILL", "ReqSkillMinRanks"},
{"REQSKILL2", "ReqSkillMinRanks2"},
} {
if skill := ctx.renderReference(fieldValue(row, spec.SkillField), ctx.skillIDToKey, ctx.resolveSkillName); skill != "" {
ranks := stringValue(row, spec.RankField)
if ranks != "" {
parts = append(parts, skill+" "+ranks+" ranks")
} else {
parts = append(parts, skill)
}
}
}
return strings.Join(parts, ", ")
}
func (ctx *wikiContext) renderProgression(category string, row map[string]any) string {
if category != "classes" {
return ""
}
lines := []string{}
if table := ctx.tableForValue(fieldValue(row, "SkillsTable"), ctx.classSkillTables); table != nil {
skills := []string{}
for _, skillRow := range table.Rows {
if stringValue(skillRow, "ClassSkill") != "1" {
continue
}
name := ctx.renderReference(fieldValue(skillRow, "SkillIndex"), ctx.skillIDToKey, ctx.resolveSkillName)
if name != "" {
skills = append(skills, name)
}
}
if len(skills) > 0 {
lines = append(lines, "==== Class Skills ====")
lines = append(lines, "")
lines = append(lines, " * "+strings.Join(skills, ", "))
lines = append(lines, "")
}
}
if table := ctx.tableForValue(fieldValue(row, "FeatsTable"), ctx.classFeatTables); table != nil {
byLevel := map[string][]string{}
selectable := []string{}
for _, featRow := range table.Rows {
name := ctx.renderReference(fieldValue(featRow, "FeatIndex"), ctx.featIDToKey, ctx.resolveFeatName)
if name == "" {
name = ctx.renderReference(fieldValue(featRow, "FeatIndex"), nil, func(string) string { return "" })
}
if name == "" {
continue
}
level := stringValue(featRow, "GrantedOnLevel")
if level == "" || strings.HasPrefix(level, "-") {
selectable = append(selectable, name)
continue
}
byLevel[level] = append(byLevel[level], name)
}
if len(byLevel) > 0 {
lines = append(lines, "==== Granted Feats ====")
lines = append(lines, "")
levels := sortedKeys(byLevel)
for _, level := range levels {
lines = append(lines, " * Level "+level+": "+strings.Join(byLevel[level], ", "))
}
lines = append(lines, "")
}
if len(selectable) > 0 {
lines = append(lines, "==== Selectable Feats ====")
lines = append(lines, "")
lines = append(lines, " * "+strings.Join(selectable, ", "))
lines = append(lines, "")
}
}
if table := ctx.tableForValue(fieldValue(row, "BonusFeatsTable"), ctx.classBonusTables); table != nil && len(table.Rows) > 0 {
lines = append(lines, "==== Bonus Feats ====")
lines = append(lines, "")
lines = append(lines, " * Bonus feat table: "+table.OutputStem)
lines = append(lines, "")
}
if len(lines) == 0 {
return "No class progression data."
}
return strings.TrimSpace(strings.Join(lines, "\n"))
}
func (ctx *wikiContext) renderRaceNameForms(row map[string]any) string {
parts := []string{}
if plural := ctx.resolveTextValue(fieldValue(row, "NamePlural"), nil); plural != "" {
parts = append(parts, "**Plural:** "+plural+`\\`)
}
if converted := ctx.resolveTextValue(fieldValue(row, "ConverName"), nil); converted != "" {
parts = append(parts, "**Converted Name:** "+converted+`\\`)
}
if lower := ctx.resolveTextValue(fieldValue(row, "ConverNameLower"), nil); lower != "" {
parts = append(parts, "**Lower Name:** "+lower+`\\`)
}
return strings.Join(parts, "\n")
}
func (ctx *wikiContext) renderRaceFeatList(row map[string]any) string {
table := ctx.tableForValue(fieldValue(row, "FeatsTable"), ctx.raceFeatTables)
if table == nil {
return ""
}
parts := []string{}
for _, featRow := range table.Rows {
if rendered := ctx.renderReference(fieldValue(featRow, "FeatIndex"), ctx.featIDToKey, ctx.resolveFeatName); rendered != "" {
parts = append(parts, rendered)
}
}
return strings.Join(parts, ", ")
}
func (ctx *wikiContext) renderReference(value any, idToKey map[int]string, nameResolver func(string) string) string {
key := resolveReferenceKey(value, idToKey)
if key == "" || !ctx.canReferenceWikiKey(key) {
return ""
}
name := nameResolver(key)
if name == "" {
name = key
}
if target := ctx.publicWikiTargetForKey(key, name); target != "" {
return "[[" + target + "|" + name + "]]"
}
return name
}
func (ctx *wikiContext) renderReferenceList(values []any) string {
parts := []string{}
for _, value := range values {
if rendered := ctx.renderReference(value, ctx.featIDToKey, ctx.resolveFeatName); rendered != "" {
parts = append(parts, rendered)
}
}
return strings.Join(parts, ", ")
}
func (ctx *wikiContext) resolveRowName(category string, row map[string]any) string {
spec := specForDataset(category)
nameFields := spec.NameFields
if category == "classes" {
nameFields = []string{"Name", "Plural", "Lower", "CLASS", "Label", "Short"}
}
for _, field := range nameFields {
if value, ok := lookupField(row, field); ok {
if text := ctx.resolveTextValue(value, nil); text != "" {
return text
}
}
}
return ""
}
func (ctx *wikiContext) resolveRowDescription(category string, row map[string]any) string {
spec := specForDataset(category)
for _, field := range spec.DescriptionFields {
if value, ok := lookupField(row, field); ok {
if text := ctx.resolveTextValue(value, nil); text != "" {
return text
}
}
}
return ""
}
func (ctx *wikiContext) resolveTextValue(value any, stack map[string]struct{}) string {
switch typed := value.(type) {
case nil:
return ""
case string:
trimmed := strings.TrimSpace(typed)
if trimmed == "" || trimmed == nullValue {
return ""
}
if entry, ok := ctx.dialog[trimmed]; ok {
return entry
}
return trimmed
case int:
if entry, ok := ctx.dialog[strconv.Itoa(typed)]; ok {
return entry
}
return strconv.Itoa(typed)
case float64:
text := strconv.Itoa(int(typed))
if entry, ok := ctx.dialog[text]; ok {
return entry
}
return text
case map[string]any:
payload, ok, err := parseTLKPayload(typed, true)
if err == nil && ok {
switch {
case payload.Text != "":
return payload.Text
case payload.Ref != "":
if stack == nil {
stack = map[string]struct{}{}
}
token := payload.Ref + "." + payload.RefField
if _, seen := stack[token]; seen {
return ""
}
stack[token] = struct{}{}
defer delete(stack, token)
target := ctx.rowsByKey[payload.Ref]
if target == nil {
return ""
}
field, ok := lookupField(target, payload.RefField)
if !ok {
return ""
}
return ctx.resolveTextValue(field, stack)
case payload.Key != "":
if entry, ok := ctx.dialog[payload.Key]; ok {
return entry
}
return ""
}
}
}
return ""
}
func (ctx *wikiContext) resolveFeatName(key string) string {
if row := ctx.featRows[key]; row != nil {
return ctx.resolveRowName("feat", row)
}
if row := ctx.masterfeatRows[key]; row != nil {
return ctx.resolveRowName("masterfeats", row)
}
return ""
}
func (ctx *wikiContext) resolveSkillName(key string) string {
if row := ctx.skillRows[key]; row != nil {
return ctx.resolveRowName("skills", row)
}
return ""
}
func (ctx *wikiContext) resolveSpellName(key string) string {
if row := ctx.spellRows[key]; row != nil {
return ctx.resolveRowName("spells", row)
}
return ""
}
func (ctx *wikiContext) resolveClassName(key string) string {
if row := ctx.classRows[key]; row != nil {
return ctx.resolveRowName("classes", row)
}
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 {
if value, ok := wiki["generate"]; ok {
return normalizeWikiScalar(value)
}
}
}
if value, ok := lookupField(row, "WIKIGENERATE"); ok {
return normalizeWikiScalar(value)
}
return ""
}
func (ctx *wikiContext) wikiReqFeats(row map[string]any) []any {
if meta, err := parseExistingMetadata(row); err == nil && meta != nil {
if wiki, ok := meta["wiki"].(map[string]any); ok {
if value, ok := wiki["reqfeats"]; ok {
if typed, ok := value.([]any); ok {
return typed
}
}
}
}
return nil
}
func (ctx *wikiContext) pageStatus(category, key string, row map[string]any) string {
if meta, err := parseExistingMetadata(row); err == nil && meta != nil {
if wiki, ok := meta["wiki"].(map[string]any); ok {
if value, ok := wiki["status"]; ok {
if normalized := normalizeWikiScalar(value); normalized != "" {
return normalized
}
}
}
}
if categoryStatuses, ok := ctx.statuses[category]; ok {
if status, ok := categoryStatuses[key]; ok && status != "" {
return status
}
}
return wikiStatusVanilla
}
func (ctx *wikiContext) collectImplementedFeatKeys() map[string]struct{} {
implemented := map[string]struct{}{}
masterfeatGroups := map[string][]string{}
for key, row := range ctx.featRows {
if group := masterfeatGroupKey(fieldValue(row, "MASTERFEAT"), ctx.masterfeatRows); group != "" {
masterfeatGroups[group] = append(masterfeatGroups[group], key)
}
}
classCounts := func(row map[string]any) bool {
override := ctx.wikiGenerateOverride(row)
if override == "1" {
return true
}
if override == "0" {
return false
}
return stringValue(row, "PlayerClass") == "1"
}
for _, row := range ctx.classRows {
if !classCounts(row) {
continue
}
for _, tableMap := range []map[string]wikiTable{ctx.classFeatTables, ctx.classBonusTables} {
if table := ctx.tableForValue(fieldValue(row, "FeatsTable"), tableMap); table != nil {
ctx.collectImplementedFromTable(implemented, masterfeatGroups, table.Rows)
}
if table := ctx.tableForValue(fieldValue(row, "BonusFeatsTable"), tableMap); table != nil {
ctx.collectImplementedFromTable(implemented, masterfeatGroups, table.Rows)
}
}
}
for _, row := range ctx.raceRows {
override := ctx.wikiGenerateOverride(row)
if override == "0" || (override != "1" && stringValue(row, "PlayerRace") != "1") {
continue
}
if table := ctx.tableForValue(fieldValue(row, "FeatsTable"), ctx.raceFeatTables); table != nil {
ctx.collectImplementedFromTable(implemented, masterfeatGroups, table.Rows)
}
}
return implemented
}
func (ctx *wikiContext) collectImplementedFromTable(target map[string]struct{}, masterfeatGroups map[string][]string, rows []map[string]any) {
for _, row := range rows {
key := resolveReferenceKey(fieldValue(row, "FeatIndex"), ctx.featIDToKey)
if key == "" {
if key = resolveReferenceKey(fieldValue(row, "FeatIndex"), nil); key == "" {
continue
}
}
if strings.HasPrefix(key, "masterfeats:") {
for _, featKey := range masterfeatGroups[key] {
target[featKey] = struct{}{}
}
continue
}
target[key] = struct{}{}
}
}
func (ctx *wikiContext) tableForValue(value any, tables map[string]wikiTable) *wikiTable {
switch typed := value.(type) {
case string:
if table, ok := tables[typed]; ok {
return &table
}
case map[string]any:
if tableKey, ok := typed["table"].(string); ok {
tableKey = outputStem(tableKey)
if table, ok := tables[tableKey]; ok {
return &table
}
}
}
return nil
}
func collectDatasetStatuses(root, datasetName string) map[string]string {
statuses := map[string]string{}
basePath := filepath.Join(root, "base.json")
baseObj, err := loadJSONObject(basePath)
if err == nil {
if rows, ok := baseObj["rows"].([]any); ok {
for _, raw := range rows {
row, ok := raw.(map[string]any)
if !ok {
continue
}
if key := stringField(row, "key"); key != "" {
statuses[key] = wikiStatusVanilla
}
}
}
}
mergeExplicitWikiStatuses(basePath, statuses)
for _, dirName := range []string{"modules", "generated"} {
dir := filepath.Join(root, dirName)
paths, err := collectModulePaths(dir)
if err != nil {
continue
}
for _, path := range paths {
obj, err := loadJSONObject(path)
if err != nil {
continue
}
if entries, ok := obj["entries"].(map[string]any); ok {
for key, raw := range entries {
row, ok := raw.(map[string]any)
if !ok {
continue
}
if _, exists := statuses[key]; exists {
statuses[key] = wikiStatusModified
} else {
statuses[key] = wikiStatusNew
}
mergeRowWikiStatus(key, row, statuses)
}
}
if overrides, ok := obj["overrides"].([]any); ok {
for _, raw := range overrides {
row, ok := raw.(map[string]any)
if !ok {
continue
}
key := stringField(row, "key")
if key == "" {
continue
}
if _, exists := statuses[key]; exists {
statuses[key] = wikiStatusModified
} else {
statuses[key] = wikiStatusNew
}
mergeRowWikiStatus(key, row, statuses)
}
}
}
}
_ = datasetName
return statuses
}
func collectRaceStatuses(root string) map[string]string {
statuses := map[string]string{}
baseObj, err := loadJSONObject(filepath.Join(root, "base.json"))
if err == nil {
if rows, ok := baseObj["rows"].([]any); ok {
for _, raw := range rows {
row, ok := raw.(map[string]any)
if !ok {
continue
}
if key := stringField(row, "key"); key != "" {
statuses[key] = wikiStatusVanilla
}
}
}
}
paths, err := collectModulePaths(filepath.Join(root, "races"))
if err != nil {
return statuses
}
for _, path := range paths {
row, err := loadJSONObject(path)
if err != nil {
continue
}
key := stringField(row, "key")
if key == "" {
continue
}
if _, exists := statuses[key]; exists {
statuses[key] = wikiStatusModified
} else {
statuses[key] = wikiStatusNew
}
mergeRowWikiStatus(key, row, statuses)
if core, ok := row["core"].(map[string]any); ok {
mergeRowWikiStatus(key, core, statuses)
}
}
return statuses
}
func mergeExplicitWikiStatuses(path string, statuses map[string]string) {
obj, err := loadJSONObject(path)
if err != nil {
return
}
if rows, ok := obj["rows"].([]any); ok {
for _, raw := range rows {
row, ok := raw.(map[string]any)
if !ok {
continue
}
if key := stringField(row, "key"); key != "" {
mergeRowWikiStatus(key, row, statuses)
}
}
}
}
func mergeRowWikiStatus(key string, row map[string]any, statuses map[string]string) {
if meta, err := parseExistingMetadata(row); err == nil && meta != nil {
if wiki, ok := meta["wiki"].(map[string]any); ok {
if value, ok := wiki["status"]; ok {
if normalized := normalizeWikiScalar(value); normalized != "" {
statuses[key] = normalized
}
}
}
}
}
func buildWikiStatusBlock(status, category string) string {
message := "This " + wikiStatusNoun(category) + " is unchanged from vanilla."
switch status {
case wikiStatusNew:
message = "This is a new " + wikiStatusNoun(category) + "!"
case wikiStatusModified:
message = "This " + wikiStatusNoun(category) + " has been altered from vanilla."
}
return "<p class=\"wiki-callout wiki-callout--status\">" + html.EscapeString(message) + "</p>"
}
func wikiStatusNoun(category string) string {
switch category {
case "feat":
return "feat"
case "skills":
return "skill"
case "spells":
return "spell"
case "classes":
return "class"
case "racialtypes":
return "race"
case "baseitems":
return "item type"
default:
return "page"
}
}
func generateStatusPages(outputDir string, pageStatuses map[string]string, pageTitles map[string]string, namespaces []string, listingScope string, manualSections []wikiManualSection, ctx *wikiContext) error {
summaries := buildWikiNamespaceSummaries(pageStatuses, namespaces)
if err := writeWikiFile(filepath.Join(outputDir, "meta", "wikistatus.html"), renderWikiStatusReportPage(summaries, namespaces, ctx), manualSections); err != nil {
return err
}
for _, namespace := range namespaces {
summary := summaries[namespace]
if err := writeWikiFile(filepath.Join(outputDir, "meta", "wikistatus", namespace+".html"), renderWikiNamespaceFragment(namespace, summary, ctx), manualSections); err != nil {
return err
}
for _, status := range []string{wikiStatusNew, wikiStatusModified, wikiStatusVanilla} {
pageIDs := filterWikiPageIDs(pageStatuses, namespace, status)
if err := writeWikiFile(filepath.Join(outputDir, "meta", "wikistatus", namespace, status+".html"), renderWikiStatusListingPage(wikiNamespacePublicTitle(ctx, namespace)+" "+wikiStatusLabels[status]+" Pages", pageIDs, pageTitles, ctx), manualSections); err != nil {
return err
}
}
}
if listingScope == "" {
listingScope = project.DefaultTopDataWikiStatusListingScope
}
if listingScope == "all" {
for _, status := range []string{wikiStatusNew, wikiStatusModified, wikiStatusVanilla} {
pageIDs := filterWikiPageIDs(pageStatuses, "", status)
if err := writeWikiFile(filepath.Join(outputDir, "meta", "wikistatus", status+".html"), renderWikiStatusListingPage(wikiStatusLabels[status]+" Pages", pageIDs, pageTitles, ctx), manualSections); err != nil {
return err
}
}
}
return nil
}
func writeWikiFile(path, content string, manualSections []wikiManualSection) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
pageID := wikiRelPathToPageID(path)
content = renderNodeBBManagedHTML(pageID, content, manualSections)
return os.WriteFile(path, []byte(ensureTrailingNewline(content)), 0o644)
}
func buildWikiNamespaceSummaries(pageStatuses map[string]string, namespaces []string) map[string]map[string]any {
summaries := map[string]map[string]any{}
for _, namespace := range namespaces {
statuses := []string{}
for pageID, status := range pageStatuses {
if strings.SplitN(pageID, ":", 2)[0] == namespace {
statuses = append(statuses, status)
}
}
summaries[namespace] = map[string]any{
"status": rollupWikiStatuses(statuses),
"new": countWikiStatus(statuses, wikiStatusNew),
"modified": countWikiStatus(statuses, wikiStatusModified),
"vanilla": countWikiStatus(statuses, wikiStatusVanilla),
"total": len(statuses),
}
}
return summaries
}
func countWikiStatus(statuses []string, target string) int {
count := 0
for _, status := range statuses {
if status == target {
count++
}
}
return count
}
func rollupWikiStatuses(statuses []string) string {
best := wikiStatusVanilla
for _, status := range statuses {
if wikiStatusPriority[status] > wikiStatusPriority[best] {
best = status
}
}
return best
}
func wikiNamespacePublicTitle(ctx *wikiContext, namespace string) string {
if ctx != nil && ctx.namespaceTitles != nil {
if declared := strings.TrimSpace(ctx.namespaceTitles[namespace]); declared != "" {
return declared
}
}
return namespaceTitle(namespace)
}
func renderWikiNamespaceFragment(namespace string, summary map[string]any, ctx *wikiContext) string {
return renderStatusTable(wikiNamespacePublicTitle(ctx, namespace)+" Namespace", summary, "")
}
func renderWikiStatusReportPage(summaries map[string]map[string]any, namespaces []string, ctx *wikiContext) string {
if ctx == nil {
ctx = &wikiContext{}
}
lines := []string{"<h2>Wiki Status</h2>", "<p>This page is auto-generated from builder source provenance.</p>"}
for _, namespace := range namespaces {
summary := summaries[namespace]
title := wikiNamespacePublicTitle(ctx, namespace)
lines = append(lines, renderStatusTable(title, summary, fmt.Sprintf("[[%s|%s]]", ctx.publicWikiPath(namespace, ""), title)))
}
return strings.Join(lines, "\n")
}
func renderWikiStatusListingPage(title string, pageIDs []string, pageTitles map[string]string, ctx *wikiContext) string {
if ctx == nil {
ctx = &wikiContext{}
}
lines := []string{"<h2>" + html.EscapeString(title) + "</h2>"}
if len(pageIDs) == 0 {
lines = append(lines, "<p>No matching generated pages.</p>")
} else {
lines = append(lines, `<ul class="wiki-list wiki-status-list">`)
for _, pageID := range pageIDs {
title := pageTitles[pageID]
if title == "" {
title = pageID
}
namespace := strings.SplitN(pageID, ":", 2)[0]
lines = append(lines, "<li>"+renderInlineHTML("[["+ctx.publicWikiPath(namespace, title)+"|"+title+"]]")+"</li>")
}
lines = append(lines, "</ul>")
}
return strings.Join(lines, "\n")
}
func renderStatusTable(title string, summary map[string]any, namespaceLink string) string {
rows := []string{
"<h2>" + html.EscapeString(title) + "</h2>",
`<table class="wiki-facts wiki-status-summary"><tbody>`,
"<tr><th>Namespace Status</th><td>" + html.EscapeString(wikiStatusLabels[summary["status"].(string)]) + "</td></tr>",
fmt.Sprintf("<tr><th>Generated Pages</th><td>%d</td></tr>", summary["total"].(int)),
fmt.Sprintf("<tr><th>New</th><td>%d</td></tr>", summary["new"].(int)),
fmt.Sprintf("<tr><th>Modified</th><td>%d</td></tr>", summary["modified"].(int)),
fmt.Sprintf("<tr><th>Vanilla</th><td>%d</td></tr>", summary["vanilla"].(int)),
}
if namespaceLink != "" {
rows = append(rows, "<tr><th>Namespace</th><td>"+renderInlineHTML(namespaceLink)+"</td></tr>")
}
rows = append(rows, "</tbody></table>")
return strings.Join(rows, "\n")
}
func filterWikiPageIDs(pageStatuses map[string]string, namespace, status string) []string {
pageIDs := []string{}
for pageID, candidate := range pageStatuses {
if candidate != status {
continue
}
if namespace != "" && strings.SplitN(pageID, ":", 2)[0] != namespace {
continue
}
pageIDs = append(pageIDs, pageID)
}
slices.Sort(pageIDs)
return pageIDs
}
func wikiNamespaceTitles(declarations []wikiNamespaceDeclaration) map[string]string {
titles := map[string]string{}
for _, declaration := range declarations {
id := strings.TrimSpace(declaration.ID)
title := strings.TrimSpace(declaration.Title)
if id != "" && title != "" {
titles[id] = title
}
}
return titles
}
func wikiNamespacePaths(declarations []wikiNamespaceDeclaration) map[string]string {
paths := map[string]string{}
for _, declaration := range declarations {
id := strings.TrimSpace(declaration.ID)
path := strings.TrimSpace(declaration.CanonicalPath)
if id != "" && path != "" {
paths[id] = path
}
}
return paths
}
func wikiPageIDForKey(key string) string {
if key == "" {
return ""
}
category, value, ok := strings.Cut(key, ":")
if !ok {
return ""
}
namespace := map[string]string{
"baseitems": "itemtypes",
"racialtypes": "races",
"skills": "skills",
"spells": "spells",
"classes": "classes",
"feat": "feat",
}[category]
if namespace == "" {
return ""
}
value = strings.NewReplacer("/", "_", "\\", "_").Replace(value)
return namespace + ":" + value
}
func (ctx *wikiContext) publicWikiTargetForKey(key, fallbackTitle string) string {
pageID := wikiPageIDForKey(key)
if pageID == "" {
return ""
}
title := ""
if row, ok := ctx.rowsByKey[key]; ok {
category, _, _ := strings.Cut(key, ":")
title = ctx.resolveRowName(category, row)
}
if strings.TrimSpace(title) == "" {
title = fallbackTitle
}
if strings.TrimSpace(title) == "" {
title = key
}
namespace := strings.SplitN(pageID, ":", 2)[0]
return ctx.publicWikiPath(namespace, title)
}
func (ctx *wikiContext) publicWikiPath(namespace, title string) string {
namespacePath := canonicalWikiSegment(namespaceTitle(namespace))
if ctx != nil && ctx.namespacePaths != nil {
if declared := strings.TrimSpace(ctx.namespacePaths[namespace]); declared != "" {
namespacePath = declared
}
}
if ctx != nil && ctx.namespaceTitles != nil && (ctx.namespacePaths == nil || strings.TrimSpace(ctx.namespacePaths[namespace]) == "") {
if declared := strings.TrimSpace(ctx.namespaceTitles[namespace]); declared != "" {
namespacePath = canonicalWikiSegment(declared)
}
}
titlePath := canonicalWikiPath(wikiTitlePathParts(title)...)
switch {
case namespacePath == "":
return titlePath
case titlePath == "":
return namespacePath
default:
return namespacePath + "/" + titlePath
}
}
func wikiTitlePathParts(title string) []string {
parts := []string{}
for _, part := range strings.Split(title, "::") {
part = strings.TrimSpace(part)
if part != "" {
parts = append(parts, part)
}
}
return parts
}
func wikiSlugifyTitle(value string) string {
return slugifyWikiText(value, "topic")
}
func wikiPageOutputRelPath(pageID, title string) string {
namespace := strings.TrimSpace(strings.SplitN(pageID, ":", 2)[0])
if namespace == "" {
namespace = "page"
}
parts := []string{namespace}
parts = append(parts, wikiTitlePathParts(title)...)
path := canonicalWikiPath(parts...)
if path == "" {
path = wikiPageIDToRelPath(pageID)
return path
}
return filepath.FromSlash(path + ".html")
}
func wikiPageIDToRelPath(pageID string) string {
return filepath.FromSlash(strings.ReplaceAll(pageID, ":", "/") + ".html")
}
func wikiRelPathToPageID(path string) string {
path = filepath.ToSlash(path)
path = strings.TrimSuffix(path, ".md")
path = strings.TrimSuffix(path, ".html")
if index := strings.LastIndex(path, "/pages/"); index >= 0 {
path = path[index+len("/pages/"):]
}
return strings.ReplaceAll(path, "/", ":")
}
func namespaceTitle(namespace string) string {
switch namespace {
case "itemtypes":
return "Item Types"
case "feat":
return "Feats"
default:
return strings.Title(namespace)
}
}
func categoryFromWikiNamespace(namespace string) string {
switch namespace {
case "itemtypes":
return "baseitems"
case "races":
return "racialtypes"
default:
return namespace
}
}
func wikiEditPolicy(namespace string) string {
if namespace == "meta" {
return "generated_only"
}
return "preserve_manual_sections"
}
func normalizeWikiScalar(value any) string {
switch typed := value.(type) {
case string:
trimmed := strings.TrimSpace(strings.ToLower(typed))
if _, ok := wikiStatusPriority[trimmed]; ok {
return trimmed
}
if trimmed == "0" || trimmed == "1" {
return trimmed
}
}
return ""
}
func resolveReferenceKey(value any, idToKey map[int]string) string {
switch typed := value.(type) {
case map[string]any:
if key, ok := typed["key"].(string); ok && strings.Contains(key, ":") {
return key
}
if id, ok := typed["id"].(string); ok && strings.Contains(id, ":") {
return id
}
if idToKey != nil {
if rawID, ok := typed["id"]; ok {
if id, err := asInt(rawID); err == nil {
return idToKey[id]
}
}
}
case string:
if strings.Contains(typed, ":") {
return typed
}
if idToKey != nil {
if id, err := strconv.Atoi(typed); err == nil {
return idToKey[id]
}
}
case int:
if idToKey != nil {
return idToKey[typed]
}
case float64:
if idToKey != nil {
return idToKey[int(typed)]
}
}
return ""
}
func masterfeatGroupKey(value any, masterfeatRows map[string]map[string]any) string {
key := resolveReferenceKey(value, nil)
if strings.HasPrefix(key, "masterfeats:") {
return key
}
id, err := asInt(value)
if err != nil {
return ""
}
for key, row := range masterfeatRows {
if rowID, ok := row["id"].(int); ok && rowID == id {
return key
}
}
return ""
}
func fieldValue(row map[string]any, field string) any {
value, _ := lookupField(row, field)
return value
}
func stringValue(row map[string]any, field string) string {
value, ok := lookupField(row, field)
if !ok || value == nil {
return ""
}
switch typed := value.(type) {
case string:
if typed == nullValue {
return ""
}
return typed
case int:
return strconv.Itoa(typed)
case float64:
return strconv.Itoa(int(typed))
default:
return ""
}
}
func formatAbilityAdjustments(row map[string]any) string {
parts := []string{}
for _, spec := range []struct {
Field string
Label string
}{
{"StrAdjust", "Strength"},
{"DexAdjust", "Dexterity"},
{"ConAdjust", "Constitution"},
{"IntAdjust", "Intelligence"},
{"WisAdjust", "Wisdom"},
{"ChaAdjust", "Charisma"},
} {
value := stringValue(row, spec.Field)
if value == "" || value == "0" {
continue
}
if !strings.HasPrefix(value, "-") {
value = "+" + value
}
parts = append(parts, value+" "+spec.Label)
}
return strings.Join(parts, ", ")
}
func yesNoValue(value string) string {
switch value {
case "1":
return "Yes"
case "0":
return "No"
default:
return ""
}
}
func formatFact(label, value string) string {
if strings.TrimSpace(value) == "" {
return ""
}
return "**" + label + ":** " + value + `\\`
}
func renderNodeBBManagedHTML(pageID, sourceText string, manualSections []wikiManualSection) string {
body := strings.TrimSpace(sourceText)
hash := computeManagedHash(body)
parts := []string{
"<!-- sow-topdata-wiki:page=" + pageID + " -->",
"<!-- sow-topdata-wiki:managed:start hash=\"sha256:" + hash + "\" -->",
body,
"<!-- sow-topdata-wiki:managed:end -->",
}
renderedSections := extractManualRegions(body)
for _, section := range manualSections {
if _, ok := renderedSections[section.ID]; ok {
continue
}
parts = append(parts,
renderWikiManualSection(section),
)
}
return ensureTrailingNewline(strings.Join(parts, "\n"))
}
func renderInlineHTML(text string) string {
var out strings.Builder
for {
start := strings.Index(text, "[[")
if start < 0 {
out.WriteString(html.EscapeString(text))
break
}
end := strings.Index(text[start+2:], "]]")
if end < 0 {
out.WriteString(html.EscapeString(text))
break
}
end += start + 2
out.WriteString(html.EscapeString(text[:start]))
out.WriteString(text[start : end+2])
text = text[end+2:]
}
return out.String()
}
func renderWikiLinks(text string) string {
var out strings.Builder
for {
start := strings.Index(text, "[[")
if start < 0 {
out.WriteString(text)
break
}
end := strings.Index(text[start+2:], "]]")
if end < 0 {
out.WriteString(text)
break
}
end += start + 2
out.WriteString(text[:start])
marker := text[start : end+2]
out.WriteString(marker)
text = text[end+2:]
}
return out.String()
}
func ensureTrailingNewline(text string) string {
if strings.HasSuffix(text, "\n") {
return text
}
return text + "\n"
}
func isToolLike(values ...string) bool {
haystack := strings.ToLower(strings.Join(values, " "))
return strings.Contains(haystack, "dm tool") || strings.Contains(haystack, "player tool") || strings.Contains(haystack, "activate item") || strings.Contains(haystack, "dm_tool") || strings.Contains(haystack, "player_tool") || strings.Contains(haystack, "activate_item")
}