diff --git a/internal/topdata/wiki_deploy.go b/internal/topdata/wiki_deploy.go index 616ea90..ce591d1 100644 --- a/internal/topdata/wiki_deploy.go +++ b/internal/topdata/wiki_deploy.go @@ -702,6 +702,7 @@ func computeManagedHash(content string) string { if !ok { managed = content } + managed = normalizePreservedSectionBodies(managed) h := sha256.Sum256([]byte(strings.TrimSpace(managed))) return fmt.Sprintf("%x", h[:]) } diff --git a/internal/topdata/wiki_deploy_test.go b/internal/topdata/wiki_deploy_test.go index b9d5686..b437dc5 100644 --- a/internal/topdata/wiki_deploy_test.go +++ b/internal/topdata/wiki_deploy_test.go @@ -147,6 +147,49 @@ func TestDeployWikiCreatesNodeBBTopicAndWritesManifest(t *testing.T) { } } +func TestMergeManagedContentPreservesUserTopAndBottomSections(t *testing.T) { + existing := strings.Join([]string{ + ``, + ``, + `
User-written overview.
`, + ``, + `Old generated body.
`, + ``, + `User note.
`, + ``, + ``, + }, "\n") + generated := strings.Join([]string{ + ``, + ``, + `New generated body.
`, + ``, + `User-written overview.
", + "User note.
", + "New generated body.
", + } { + if !strings.Contains(merged, expected) { + t.Fatalf("expected merged content to contain %q:\n%s", expected, merged) + } + } + if strings.Contains(merged, "Old generated body") { + t.Fatalf("expected old generated body to be replaced:\n%s", merged) + } +} + func TestDeployWikiCreatesNodeBBTopicWithoutFallbackForDefaultThreeCharacterTitle(t *testing.T) { root := t.TempDir() sourceDir := filepath.Join(root, "pages") diff --git a/internal/topdata/wiki_formatters.go b/internal/topdata/wiki_formatters.go new file mode 100644 index 0000000..35913d4 --- /dev/null +++ b/internal/topdata/wiki_formatters.go @@ -0,0 +1,113 @@ +package topdata + +import ( + "fmt" + "strings" +) + +func (ctx *wikiContext) renderWikiFormatter(spec string, page wikiTemplatePage) (string, error) { + name := strings.Fields(spec) + if len(name) == 0 { + return "", fmt.Errorf("render wiki template for %s: empty formatter", page.PageID) + } + switch name[0] { + case "Description": + return dokuWikiToNodeBBHTML(toDokuWiki(ctx.resolveRowDescription(page.Category, page.Row))), nil + case "FactsTable": + return dokuWikiToNodeBBHTML(ctx.renderFacts(page.Category, page.Key, page.Row)), nil + case "Prerequisites": + return dokuWikiToNodeBBHTML(formatFact("Prerequisites", ctx.renderFeatPrerequisites(page.Row))), nil + case "SkillList": + if page.Category != "classes" { + return "", nil + } + return dokuWikiToNodeBBHTML(ctx.renderClassSkillList(page.Row)), nil + case "ClassFeatureTable": + if page.Category != "classes" { + return "", nil + } + return dokuWikiToNodeBBHTML(ctx.renderClassFeatureTable(page.Row)), nil + case "FeatProgression": + if page.Category != "classes" { + return "", nil + } + return dokuWikiToNodeBBHTML(ctx.renderClassFeatProgression(page.Row)), nil + case "SpellTable", "SavesProgression", "BABProgression": + return "", nil + case "RaceFeatList": + return dokuWikiToNodeBBHTML(formatFact("Racial Feats", ctx.renderRaceFeatList(page.Row))), nil + case "StatusCallout": + return buildWikiStatusBlock(page.Status, page.Category), nil + case "RaceNameForms": + return dokuWikiToNodeBBHTML(ctx.renderRaceNameForms(page.Row)), nil + case "UserBottomFallback": + return "", nil + default: + return "", fmt.Errorf("render wiki template for %s: unknown formatter %q", page.PageID, name[0]) + } +} + +func (ctx *wikiContext) renderClassSkillList(row map[string]any) string { + table := ctx.tableForValue(fieldValue(row, "SkillsTable"), ctx.classSkillTables) + if table == nil { + return "" + } + 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 { + return "" + } + return "==== Class Skills ====\n\n * " + strings.Join(skills, ", ") +} + +func (ctx *wikiContext) renderClassFeatProgression(row map[string]any) string { + table := ctx.tableForValue(fieldValue(row, "FeatsTable"), ctx.classFeatTables) + if table == nil { + return "" + } + lines := []string{} + 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 ====", "") + for _, level := range sortedKeys(byLevel) { + lines = append(lines, " * Level "+level+": "+strings.Join(byLevel[level], ", ")) + } + lines = append(lines, "") + } + if len(selectable) > 0 { + lines = append(lines, "==== Selectable Feats ====", "", " * "+strings.Join(selectable, ", "), "") + } + return strings.TrimSpace(strings.Join(lines, "\n")) +} + +func (ctx *wikiContext) renderClassFeatureTable(row map[string]any) string { + table := ctx.tableForValue(fieldValue(row, "BonusFeatsTable"), ctx.classBonusTables) + if table == nil || len(table.Rows) == 0 { + return "" + } + return "==== Bonus Feats ====\n\n * Bonus feat table: " + table.OutputStem +} diff --git a/internal/topdata/wiki_native.go b/internal/topdata/wiki_native.go index f10dec3..6dd3e83 100644 --- a/internal/topdata/wiki_native.go +++ b/internal/topdata/wiki_native.go @@ -26,7 +26,7 @@ const ( legacyWikiRootDirName = ".wiki" wikiPagesDirName = "pages" wikiStateFileName = "state.json" - wikiGeneratorVersion = "nodebb-tiptap-html-v1" + wikiGeneratorVersion = "nodebb-tiptap-html-v2" wikiGeneratedStatus = "generated" wikiSkippedStatus = "skipped" ) @@ -68,6 +68,7 @@ type wikiManualSectionsDocument struct { 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"` @@ -107,6 +108,8 @@ type wikiContext struct { raceFeatTables map[string]wikiTable statuses map[string]map[string]string implementedFeats map[string]struct{} + manualSections []wikiManualSection + templateDir string } func buildWiki(p *project.Project, nativeResult BuildResult, force bool, progress func(string)) (wikiResult, error) { @@ -137,6 +140,9 @@ func buildWiki(p *project.Project, nativeResult BuildResult, force bool, progres if err != nil { return wikiResult{}, err } + manualSections := loadWikiManualSections(p) + ctx.manualSections = manualSections + ctx.templateDir = filepath.Join(p.TopDataWikiSourceDir(), filepath.FromSlash(p.EffectiveConfig().TopData.Wiki.TemplatesDir), "pages") if err := os.RemoveAll(outputDir); err != nil { return wikiResult{}, fmt.Errorf("clean wiki output: %w", err) @@ -154,7 +160,7 @@ func buildWiki(p *project.Project, nativeResult BuildResult, force bool, progres if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } - content = renderNodeBBManagedHTML(pageID, content, loadWikiManualSections(p)) + content = renderNodeBBManagedHTML(pageID, content, manualSections) if err := os.WriteFile(path, []byte(content), 0o644); err != nil { return err } @@ -182,7 +188,7 @@ func buildWiki(p *project.Project, nativeResult BuildResult, force bool, progres if err := generateEntityPages(outputDir, ctx, writePage); err != nil { return wikiResult{}, err } - if err := generateStatusPages(outputDir, pageStatuses, pageTitles, p.EffectiveConfig().TopData.Wiki.ManagedNamespaces, loadWikiManualSections(p)); err != nil { + if err := generateStatusPages(outputDir, pageStatuses, pageTitles, p.EffectiveConfig().TopData.Wiki.ManagedNamespaces, manualSections); err != nil { return wikiResult{}, err } if err := indexUnregisteredWikiPages(rootDir, outputDir, p.EffectiveConfig().TopData.Wiki.StalePages.Default, &pageIndex); err != nil { @@ -209,10 +215,7 @@ func buildWiki(p *project.Project, nativeResult BuildResult, force bool, progres } func loadWikiManualSections(p *project.Project) []wikiManualSection { - defaults := []wikiManualSection{ - {ID: "notes", Heading: "Notes", InitialHTML: "Hit Die: {{HitDie}}
+Skill Points: {{SkillPointBase}}
+{{format:StatusCallout}} +{{format:Description}} +{{UserBottomSection}}`, page) + if err != nil { + t.Fatalf("render template: %v", err) + } + + if !strings.Contains(got, "Hit Die: 6
", + "Skill Points: 4
", + "This is a new class!", + "Acolyte description.
", + ``, + } { + if !strings.Contains(got, expected) { + t.Fatalf("expected %q in rendered template:\n%s", expected, got) + } + } +} + +func TestWikiTemplateMissingFieldFailsUnlessDefaultProvided(t *testing.T) { + ctx := &wikiContext{} + page := wikiTemplatePage{PageID: "classes:acolyte", Category: "classes", Title: "Acolyte", Row: map[string]any{}} + + if _, err := ctx.renderWikiTemplateString("classes.html", `{{HitDie}}
`, page); err == nil || !strings.Contains(err.Error(), `missing field "HitDie"`) { + t.Fatalf("expected missing field error, got %v", err) + } + + got, err := ctx.renderWikiTemplateString("classes.html", `{{field:HitDie|default=unknown}}
`, page) + if err != nil { + t.Fatalf("expected defaulted field to render, got %v", err) + } + if got != "unknown
" { + t.Fatalf("expected default value, got %q", got) + } +} + +func TestWikiManagedHashIgnoresPreservedSectionBody(t *testing.T) { + first := strings.Join([]string{ + ``, + ``, + `Original user edit.
`, + ``, + `Generated.
`, + ``, + }, "\n") + second := strings.Replace(first, "Original user edit.
", "Changed by a user.
", 1) + + if computeManagedHash(first) != computeManagedHash(second) { + t.Fatalf("expected preserved section body not to affect managed hash") + } +} + +func TestWikiSourceDigestIncludesTemplates(t *testing.T) { + root := t.TempDir() + mkdirAll(t, filepath.Join(root, "data", "skills")) + mkdirAll(t, filepath.Join(root, "wiki", "templates", "pages")) + writeFile(t, filepath.Join(root, "data", "skills", "base.json"), `{"rows":[]}`+"\n") + templatePath := filepath.Join(root, "wiki", "templates", "pages", "skills.html") + writeFile(t, templatePath, `Changed
`+"\n") + after, err := computeWikiSourceDigest(root) + if err != nil { + t.Fatalf("compute changed digest: %v", err) + } + if before == after { + t.Fatalf("expected template edit to change wiki source digest") + } +} + func TestWikiPageIDForKeyNormalizesSlashAndUnderscoreSeparators(t *testing.T) { got := wikiPageIDForKey("feat:special/attacks_bull_rush") if want := "feat:special:attacks:bull:rush"; got != want { diff --git a/internal/topdata/wiki_preserve.go b/internal/topdata/wiki_preserve.go new file mode 100644 index 0000000..5359bab --- /dev/null +++ b/internal/topdata/wiki_preserve.go @@ -0,0 +1,55 @@ +package topdata + +import "strings" + +func normalizePreservedSectionBodies(content string) string { + var out strings.Builder + offset := 0 + for { + startRel := strings.Index(content[offset:], manualStartMarkerPrefix) + if startRel < 0 { + out.WriteString(content[offset:]) + break + } + start := offset + startRel + startEndRel := strings.Index(content[start:], "-->") + if startEndRel < 0 { + out.WriteString(content[offset:]) + break + } + startMarker := content[start : start+startEndRel+len("-->")] + id := markerID(startMarker) + if id == "" { + out.WriteString(content[offset : start+len(startMarker)]) + offset = start + len(startMarker) + continue + } + endPrefix := manualEndMarkerPrefix + " id=\"" + id + "\"" + endRel := strings.Index(content[start+len(startMarker):], endPrefix) + if endRel < 0 { + out.WriteString(content[offset:]) + break + } + end := start + len(startMarker) + endRel + endEndRel := strings.Index(content[end:], "-->") + if endEndRel < 0 { + out.WriteString(content[offset:]) + break + } + endMarker := content[end : end+endEndRel+len("-->")] + out.WriteString(content[offset:start]) + out.WriteString(startMarker) + out.WriteString("\n\n") + out.WriteString(endMarker) + offset = end + len(endMarker) + } + return out.String() +} + +func defaultWikiManualSections() []wikiManualSection { + return []wikiManualSection{ + {ID: "user_top", Alias: "UserTopSection", Heading: "", InitialHTML: ""}, + {ID: "notes", Alias: "UserBottomSection", Heading: "Notes", InitialHTML: "