diff --git a/README.md b/README.md
index 19a706b..7407d05 100644
--- a/README.md
+++ b/README.md
@@ -403,7 +403,10 @@ configuration.
## Wiki And Changelog Utilities
`build-topdata --wiki` or `build-wiki` renders wiki pages from the current
-native topdata state. `deploy-wiki` publishes those pages to NodeBB. It reads
+native topdata state as Tiptap-compatible NodeBB HTML. Page templates are HTML
+fragments; they do not need to include a visible title heading because deploy
+uses generated `page-index.json` metadata for page titles and public paths.
+`deploy-wiki` publishes those pages to NodeBB. It reads
`NODEBB_API_ENDPOINT`, `NODEBB_API_TOKEN`, `GITHUB_REF_NAME`, and
`NODEBB_WIKI_CATEGORIES` when equivalent flags are omitted. Category mappings
use `namespace=cid`, for example:
diff --git a/internal/topdata/wiki_deploy.go b/internal/topdata/wiki_deploy.go
index 42ec4a6..b686c4a 100644
--- a/internal/topdata/wiki_deploy.go
+++ b/internal/topdata/wiki_deploy.go
@@ -421,13 +421,18 @@ func collectLocalPages(sourceDir string, namespaces []string) (map[string]wikiDe
return nil
}
}
+ indexEntry := pageIndex[pageID]
publicPath := extractWikiPagePublicPath(content)
if publicPath == "" {
- publicPath = pageIndex[pageID].PublicPath
+ publicPath = indexEntry.PublicPath
+ }
+ title := strings.TrimSpace(indexEntry.Title)
+ if title == "" {
+ title = extractPageTitle(content, pageID)
}
pages[pageID] = wikiDeployPage{
PageID: pageID,
- Title: extractPageTitle(content, pageID),
+ Title: title,
PublicPath: publicPath,
Namespace: namespace,
Content: content,
@@ -1278,13 +1283,17 @@ func extractMarkdownTitle(content string) string {
func extractHTMLTitle(content, fallback string) string {
lower := strings.ToLower(content)
- start := strings.Index(lower, "
")
- end := strings.Index(lower, "
")
- if start >= 0 && end > start {
- title := strings.TrimSpace(content[start+len("") : end])
- title = strings.ReplaceAll(title, "\n", " ")
- if title != "" {
- return html.UnescapeString(stripSimpleTags(title))
+ for _, tag := range []string{"h1", "h2"} {
+ open := "<" + tag + ">"
+ close := "" + tag + ">"
+ start := strings.Index(lower, open)
+ end := strings.Index(lower, close)
+ if start >= 0 && end > start {
+ title := strings.TrimSpace(content[start+len(open) : end])
+ title = strings.ReplaceAll(title, "\n", " ")
+ if title != "" {
+ return html.UnescapeString(stripSimpleTags(title))
+ }
}
}
parts := strings.Split(fallback, ":")
diff --git a/internal/topdata/wiki_deploy_test.go b/internal/topdata/wiki_deploy_test.go
index fd6386a..f9a3850 100644
--- a/internal/topdata/wiki_deploy_test.go
+++ b/internal/topdata/wiki_deploy_test.go
@@ -327,6 +327,48 @@ func TestCollectLocalPagesReadsCanonicalPublicPathFromPageIndex(t *testing.T) {
}
}
+func TestCollectLocalPagesReadsTitleFromPageIndexForHeadinglessHTML(t *testing.T) {
+ root := t.TempDir()
+ sourceDir := filepath.Join(root, "pages")
+ if err := os.MkdirAll(filepath.Join(sourceDir, "spells"), 0755); err != nil {
+ t.Fatalf("create source dir: %v", err)
+ }
+ generated := `
+
+
Generated acid fog page.
+
+
+`
+ if err := os.WriteFile(filepath.Join(sourceDir, "spells", "Acid_Fog.html"), []byte(generated), 0644); err != nil {
+ t.Fatalf("write source page: %v", err)
+ }
+ if err := saveWikiPageIndex(filepath.Join(root, "page-index.json"), wikiPageIndex{
+ Version: wikiGeneratorVersion,
+ Pages: []wikiPageIndexEntry{
+ {
+ PageID: "spells:acid_fog",
+ Title: "Acid Fog",
+ PublicPath: "Spells/Acid_Fog",
+ OutputPath: "pages/spells/Acid_Fog.html",
+ },
+ },
+ }); err != nil {
+ t.Fatalf("write page index: %v", err)
+ }
+
+ pages, err := collectLocalPages(sourceDir, []string{"spells"})
+ if err != nil {
+ t.Fatalf("collectLocalPages failed: %v", err)
+ }
+ page := pages["spells:acid_fog"]
+ if page.Title != "Acid Fog" {
+ t.Fatalf("expected page-index title for headingless HTML, got %#v", page)
+ }
+ if page.PublicPath != "Spells/Acid_Fog" {
+ t.Fatalf("expected page-index public path for headingless HTML, got %#v", page)
+ }
+}
+
func TestExtractWikiPagePublicPathAcceptsCanonicalSlashPath(t *testing.T) {
content := `
@@ -997,6 +1039,154 @@ func TestDeployWikiRenamesManagedTopicWhenGeneratedTitleChanges(t *testing.T) {
}
}
+func TestDeployWikiDoesNotRenameHeadinglessPageToPageIDFallback(t *testing.T) {
+ root := t.TempDir()
+ sourceDir := filepath.Join(root, "pages")
+ if err := os.MkdirAll(filepath.Join(sourceDir, "spells"), 0755); err != nil {
+ t.Fatalf("create source dir: %v", err)
+ }
+ generated := `
+
+Generated acid fog page.
+
+
+`
+ if err := os.WriteFile(filepath.Join(sourceDir, "spells", "Acid_Fog.html"), []byte(generated), 0644); err != nil {
+ t.Fatalf("write source page: %v", err)
+ }
+ if err := saveWikiPageIndex(filepath.Join(root, "page-index.json"), wikiPageIndex{
+ Version: wikiGeneratorVersion,
+ Pages: []wikiPageIndexEntry{
+ {PageID: "spells:acid_fog", Title: "Acid Fog", PublicPath: "Spells/Acid_Fog", OutputPath: "pages/spells/Acid_Fog.html"},
+ },
+ }); err != nil {
+ t.Fatalf("write page index: %v", err)
+ }
+ manifestPath := filepath.Join(root, "deploy-manifest.json")
+ if err := saveDeployManifest(manifestPath, wikiDeployManifest{
+ Version: "nodebb-v1",
+ Pages: map[string]wikiDeployManifestPage{
+ "spells:acid_fog": {
+ Hash: computeManagedHash(generated),
+ Title: "Acid Fog",
+ TopicTitle: "Acid Fog",
+ Namespace: "spells",
+ TID: 7,
+ PID: 42,
+ CID: 5,
+ },
+ },
+ }); err != nil {
+ t.Fatalf("write deploy manifest: %v", err)
+ }
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ t.Fatalf("headingless unchanged page must not call NodeBB, got %s %s", r.Method, r.URL.String())
+ }))
+ defer server.Close()
+
+ result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
+ SourceDir: sourceDir,
+ Endpoint: server.URL,
+ Token: "nodebb-token",
+ ManifestPath: manifestPath,
+ }, nil)
+ if err != nil {
+ t.Fatalf("DeployWikiWithOptions headingless skip failed: %v", err)
+ }
+ if result.Renamed != 0 || result.Updated != 0 || result.Skipped != 1 {
+ t.Fatalf("expected headingless page to skip without page-id fallback rename, got %#v", result)
+ }
+}
+
+func TestDeployWikiRenamesBrokenHeadinglessFallbackTitleBackToPageIndexTitle(t *testing.T) {
+ root := t.TempDir()
+ sourceDir := filepath.Join(root, "pages")
+ if err := os.MkdirAll(filepath.Join(sourceDir, "spells"), 0755); err != nil {
+ t.Fatalf("create source dir: %v", err)
+ }
+ generated := `
+
+Generated acid fog page.
+
+
+`
+ if err := os.WriteFile(filepath.Join(sourceDir, "spells", "Acid_Fog.html"), []byte(generated), 0644); err != nil {
+ t.Fatalf("write source page: %v", err)
+ }
+ if err := saveWikiPageIndex(filepath.Join(root, "page-index.json"), wikiPageIndex{
+ Version: wikiGeneratorVersion,
+ Pages: []wikiPageIndexEntry{
+ {PageID: "spells:acid_fog", Title: "Acid Fog", PublicPath: "Spells/Acid_Fog", OutputPath: "pages/spells/Acid_Fog.html"},
+ },
+ }); err != nil {
+ t.Fatalf("write page index: %v", err)
+ }
+ manifestPath := filepath.Join(root, "deploy-manifest.json")
+ if err := saveDeployManifest(manifestPath, wikiDeployManifest{
+ Version: "nodebb-v1",
+ Pages: map[string]wikiDeployManifestPage{
+ "spells:acid_fog": {
+ Hash: computeManagedHash(generated),
+ Title: "Acid_fog",
+ TopicTitle: "Acid_fog",
+ Namespace: "spells",
+ TID: 7,
+ PID: 42,
+ CID: 5,
+ },
+ },
+ }); err != nil {
+ t.Fatalf("write deploy manifest: %v", err)
+ }
+
+ renameCalls := 0
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodGet && r.URL.Path == "/api/v3/topics/7":
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "response": map[string]any{"tid": 7, "title": "Acid_fog", "mainPid": 42},
+ })
+ case r.Method == http.MethodPut && r.URL.Path == "/api/v3/plugins/westgate-wiki/page/move":
+ renameCalls++
+ var req struct {
+ TID int `json:"tid"`
+ CID int `json:"cid"`
+ Title string `json:"title"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ t.Fatalf("decode rename request: %v", err)
+ }
+ if req.TID != 7 || req.CID != 5 || req.Title != "Acid Fog" {
+ t.Fatalf("unexpected rename request: %#v", req)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"tid": 7}})
+ default:
+ t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
+ }
+ }))
+ defer server.Close()
+
+ result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
+ SourceDir: sourceDir,
+ Endpoint: server.URL,
+ Token: "nodebb-token",
+ ManifestPath: manifestPath,
+ }, nil)
+ if err != nil {
+ t.Fatalf("DeployWikiWithOptions headingless title repair failed: %v", err)
+ }
+ if result.Renamed != 1 || result.Updated != 0 || result.Skipped != 1 || renameCalls != 1 {
+ t.Fatalf("expected one title repair rename and skipped body update, result=%#v renameCalls=%d", result, renameCalls)
+ }
+ entry := loadDeployManifest(manifestPath).Pages["spells:acid_fog"]
+ if entry.Title != "Acid Fog" || entry.TopicTitle != "Acid Fog" {
+ t.Fatalf("expected repaired manifest title from page-index, got %#v", entry)
+ }
+}
+
func TestDeployWikiAdoptsExistingNodeBBPageWhenManifestIsMissingWithoutCreate(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
index 35913d4..0a9dfed 100644
--- a/internal/topdata/wiki_formatters.go
+++ b/internal/topdata/wiki_formatters.go
@@ -2,6 +2,7 @@ package topdata
import (
"fmt"
+ "html"
"strings"
)
@@ -12,34 +13,34 @@ func (ctx *wikiContext) renderWikiFormatter(spec string, page wikiTemplatePage)
}
switch name[0] {
case "Description":
- return dokuWikiToNodeBBHTML(toDokuWiki(ctx.resolveRowDescription(page.Category, page.Row))), nil
+ return renderWikiTemplatePlainHTML(ctx.resolveRowDescription(page.Category, page.Row)), nil
case "FactsTable":
- return dokuWikiToNodeBBHTML(ctx.renderFacts(page.Category, page.Key, page.Row)), nil
+ return ctx.renderFactsHTML(page.Category, page.Key, page.Row), nil
case "Prerequisites":
- return dokuWikiToNodeBBHTML(formatFact("Prerequisites", ctx.renderFeatPrerequisites(page.Row))), nil
+ return renderWikiFactTableHTML([]wikiHTMLFact{{Label: "Prerequisites", Value: ctx.renderFeatPrerequisites(page.Row)}}), nil
case "SkillList":
if page.Category != "classes" {
return "", nil
}
- return dokuWikiToNodeBBHTML(ctx.renderClassSkillList(page.Row)), nil
+ return ctx.renderClassSkillListHTML(page.Row), nil
case "ClassFeatureTable":
if page.Category != "classes" {
return "", nil
}
- return dokuWikiToNodeBBHTML(ctx.renderClassFeatureTable(page.Row)), nil
+ return ctx.renderClassFeatureTableHTML(page.Row), nil
case "FeatProgression":
if page.Category != "classes" {
return "", nil
}
- return dokuWikiToNodeBBHTML(ctx.renderClassFeatProgression(page.Row)), nil
+ return ctx.renderClassFeatProgressionHTML(page.Row), nil
case "SpellTable", "SavesProgression", "BABProgression":
return "", nil
case "RaceFeatList":
- return dokuWikiToNodeBBHTML(formatFact("Racial Feats", ctx.renderRaceFeatList(page.Row))), nil
+ return renderWikiFactTableHTML([]wikiHTMLFact{{Label: "Racial Feats", Value: ctx.renderRaceFeatList(page.Row)}}), nil
case "StatusCallout":
return buildWikiStatusBlock(page.Status, page.Category), nil
case "RaceNameForms":
- return dokuWikiToNodeBBHTML(ctx.renderRaceNameForms(page.Row)), nil
+ return ctx.renderRaceNameFormsHTML(page.Row), nil
case "UserBottomFallback":
return "", nil
default:
@@ -47,6 +48,99 @@ func (ctx *wikiContext) renderWikiFormatter(spec string, page wikiTemplatePage)
}
}
+type wikiHTMLFact struct {
+ Label string
+ Value string
+}
+
+func (ctx *wikiContext) renderFactsHTML(category, key string, row map[string]any) string {
+ facts := []wikiHTMLFact{}
+ add := func(label, value string) {
+ if strings.TrimSpace(value) != "" {
+ facts = append(facts, wikiHTMLFact{Label: label, Value: value})
+ }
+ }
+ switch category {
+ case "feat":
+ for _, fact := range ctx.renderFeatFactsHTML(row) {
+ add(fact.Label, fact.Value)
+ }
+ case "skills":
+ add("Key Ability", stringValue(row, "KeyAbility"))
+ add("Untrained", yesNoValue(stringValue(row, "Untrained")))
+ add("Armor Check Penalty", yesNoValue(stringValue(row, "ArmorCheckPenalty")))
+ case "spells":
+ add("Constant", stringValue(row, "Constant"))
+ case "baseitems":
+ add("Item Class", stringValue(row, "ItemClass"))
+ add("Weapon Type", stringValue(row, "WeaponType"))
+ add("Required Feats", ctx.renderReferenceList(ctx.wikiReqFeats(row)))
+ add("Base Item Stats", ctx.resolveTextValue(fieldValue(row, "BaseItemStatRef"), nil))
+ case "classes":
+ add("Hit Die", "d"+stringValue(row, "HitDie"))
+ add("Skill Points", stringValue(row, "SkillPointBase"))
+ add("Primary Ability", stringValue(row, "PrimaryAbil"))
+ case "racialtypes":
+ add("Ability Adjustments", formatAbilityAdjustments(row))
+ add("Favored Class", ctx.renderReference(fieldValue(row, "Favored"), ctx.classIDToKey, ctx.resolveClassName))
+ add("Favored Enemy", ctx.renderReference(fieldValue(row, "FavoredEnemyFeat"), ctx.featIDToKey, ctx.resolveFeatName))
+ add("Racial Feats", ctx.renderRaceFeatList(row))
+ }
+ _ = key
+ return renderWikiFactTableHTML(facts)
+}
+
+func (ctx *wikiContext) renderFeatFactsHTML(row map[string]any) []wikiHTMLFact {
+ return []wikiHTMLFact{
+ {Label: "Prerequisites", Value: ctx.renderFeatPrerequisites(row)},
+ {Label: "Minimum Attack Bonus", Value: stringValue(row, "MINATTACKBONUS")},
+ {Label: "Minimum Spell Level", Value: stringValue(row, "MINSPELLLVL")},
+ {Label: "Minimum Fortitude Save", Value: stringValue(row, "MinFortSave")},
+ }
+}
+
+func renderWikiFactTableHTML(facts []wikiHTMLFact) string {
+ rows := []string{}
+ for _, fact := range facts {
+ if strings.TrimSpace(fact.Value) == "" {
+ continue
+ }
+ rows = append(rows, `| `+html.EscapeString(fact.Label)+` | `+renderMultilineInlineHTML(fact.Value)+` |
`)
+ }
+ if len(rows) == 0 {
+ return ""
+ }
+ return `` + strings.Join(rows, "\n") + `
`
+}
+
+func renderMultilineInlineHTML(text string) string {
+ lines := []string{}
+ for _, line := range strings.Split(strings.ReplaceAll(strings.TrimSpace(text), "\r\n", "\n"), "\n") {
+ line = strings.TrimSpace(line)
+ if line != "" {
+ lines = append(lines, renderInlineHTML(line))
+ }
+ }
+ return strings.Join(lines, "
")
+}
+
+func renderWikiListHTML(class string, items []string) string {
+ rendered := []string{}
+ for _, item := range items {
+ if strings.TrimSpace(item) != "" {
+ rendered = append(rendered, ""+renderInlineHTML(item)+"")
+ }
+ }
+ if len(rendered) == 0 {
+ return ""
+ }
+ classAttr := ""
+ if class != "" {
+ classAttr = ` class="` + html.EscapeString(class) + `"`
+ }
+ return "" + strings.Join(rendered, "\n") + "
"
+}
+
func (ctx *wikiContext) renderClassSkillList(row map[string]any) string {
table := ctx.tableForValue(fieldValue(row, "SkillsTable"), ctx.classSkillTables)
if table == nil {
@@ -68,6 +162,27 @@ func (ctx *wikiContext) renderClassSkillList(row map[string]any) string {
return "==== Class Skills ====\n\n * " + strings.Join(skills, ", ")
}
+func (ctx *wikiContext) renderClassSkillListHTML(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
` + renderWikiListHTML("wiki-list wiki-class-skills", skills)
+}
+
func (ctx *wikiContext) renderClassFeatProgression(row map[string]any) string {
table := ctx.tableForValue(fieldValue(row, "FeatsTable"), ctx.classFeatTables)
if table == nil {
@@ -104,6 +219,42 @@ func (ctx *wikiContext) renderClassFeatProgression(row map[string]any) string {
return strings.TrimSpace(strings.Join(lines, "\n"))
}
+func (ctx *wikiContext) renderClassFeatProgressionHTML(row map[string]any) string {
+ table := ctx.tableForValue(fieldValue(row, "FeatsTable"), ctx.classFeatTables)
+ if table == nil {
+ return ""
+ }
+ 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)
+ }
+ parts := []string{}
+ if len(byLevel) > 0 {
+ items := []string{}
+ for _, level := range sortedKeys(byLevel) {
+ items = append(items, "Level "+level+": "+strings.Join(byLevel[level], ", "))
+ }
+ parts = append(parts, `Granted Feats
`+renderWikiListHTML("wiki-list wiki-class-granted-feats", items))
+ }
+ if len(selectable) > 0 {
+ parts = append(parts, `Selectable Feats
`+renderWikiListHTML("wiki-list wiki-class-selectable-feats", []string{strings.Join(selectable, ", ")}))
+ }
+ return strings.Join(parts, "\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 {
@@ -111,3 +262,25 @@ func (ctx *wikiContext) renderClassFeatureTable(row map[string]any) string {
}
return "==== Bonus Feats ====\n\n * Bonus feat table: " + table.OutputStem
}
+
+func (ctx *wikiContext) renderClassFeatureTableHTML(row map[string]any) string {
+ table := ctx.tableForValue(fieldValue(row, "BonusFeatsTable"), ctx.classBonusTables)
+ if table == nil || len(table.Rows) == 0 {
+ return ""
+ }
+ return `Bonus Feats
` + renderWikiListHTML("wiki-list wiki-class-bonus-feats", []string{"Bonus feat table: " + table.OutputStem})
+}
+
+func (ctx *wikiContext) renderRaceNameFormsHTML(row map[string]any) string {
+ facts := []wikiHTMLFact{}
+ if plural := ctx.resolveTextValue(fieldValue(row, "NamePlural"), nil); plural != "" {
+ facts = append(facts, wikiHTMLFact{Label: "Plural", Value: plural})
+ }
+ if converted := ctx.resolveTextValue(fieldValue(row, "ConverName"), nil); converted != "" {
+ facts = append(facts, wikiHTMLFact{Label: "Converted Name", Value: converted})
+ }
+ if lower := ctx.resolveTextValue(fieldValue(row, "ConverNameLower"), nil); lower != "" {
+ facts = append(facts, wikiHTMLFact{Label: "Lower Name", Value: lower})
+ }
+ return renderWikiFactTableHTML(facts)
+}
diff --git a/internal/topdata/wiki_native.go b/internal/topdata/wiki_native.go
index 7da4aa1..398f7de 100644
--- a/internal/topdata/wiki_native.go
+++ b/internal/topdata/wiki_native.go
@@ -912,7 +912,7 @@ func (ctx *wikiContext) renderFacts(category, key string, row map[string]any) st
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", toInlineDokuWiki(ctx.resolveTextValue(fieldValue(row, "BaseItemStatRef"), nil))))
+ 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")))
@@ -1607,14 +1607,14 @@ func wikiNamespacePublicTitle(ctx *wikiContext, namespace string) string {
}
func renderWikiNamespaceFragment(namespace string, summary map[string]any, ctx *wikiContext) string {
- return renderStatusTable(wikiNamespacePublicTitle(ctx, namespace), summary, "")
+ 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{"====== Wiki Status ======", "", "This page is auto-generated from builder source provenance.", ""}
+ lines := []string{"Wiki Status
", "This page is auto-generated from builder source provenance.
"}
for _, namespace := range namespaces {
summary := summaries[namespace]
title := wikiNamespacePublicTitle(ctx, namespace)
@@ -1627,35 +1627,38 @@ func renderWikiStatusListingPage(title string, pageIDs []string, pageTitles map[
if ctx == nil {
ctx = &wikiContext{}
}
- lines := []string{"====== " + title + " ======", ""}
+ lines := []string{"" + html.EscapeString(title) + "
"}
if len(pageIDs) == 0 {
- lines = append(lines, "No matching generated pages.")
+ lines = append(lines, "No matching generated pages.
")
} else {
+ lines = append(lines, ``)
for _, pageID := range pageIDs {
title := pageTitles[pageID]
if title == "" {
title = pageID
}
namespace := strings.SplitN(pageID, ":", 2)[0]
- lines = append(lines, " * [["+ctx.publicWikiPath(namespace, title)+"|"+title+"]]")
+ lines = append(lines, "- "+renderInlineHTML("[["+ctx.publicWikiPath(namespace, title)+"|"+title+"]]")+"
")
}
+ lines = append(lines, "
")
}
return strings.Join(lines, "\n")
}
func renderStatusTable(title string, summary map[string]any, namespaceLink string) string {
rows := []string{
- "===== " + title + " =====",
- "",
- fmt.Sprintf("**Namespace Status:** %s\\\\", wikiStatusLabels[summary["status"].(string)]),
- fmt.Sprintf("**Generated Pages:** %d\\\\", summary["total"].(int)),
- fmt.Sprintf("**New:** %d\\\\", summary["new"].(int)),
- fmt.Sprintf("**Modified:** %d\\\\", summary["modified"].(int)),
- fmt.Sprintf("**Vanilla:** %d\\\\", summary["vanilla"].(int)),
+ "" + html.EscapeString(title) + "
",
+ ``,
+ "| Namespace Status | " + html.EscapeString(wikiStatusLabels[summary["status"].(string)]) + " |
",
+ fmt.Sprintf("| Generated Pages | %d |
", summary["total"].(int)),
+ fmt.Sprintf("| New | %d |
", summary["new"].(int)),
+ fmt.Sprintf("| Modified | %d |
", summary["modified"].(int)),
+ fmt.Sprintf("| Vanilla | %d |
", summary["vanilla"].(int)),
}
if namespaceLink != "" {
- rows = append(rows, fmt.Sprintf("**Namespace:** %s", namespaceLink))
+ rows = append(rows, "| Namespace | "+renderInlineHTML(namespaceLink)+" |
")
}
+ rows = append(rows, "
")
return strings.Join(rows, "\n")
}
@@ -1973,61 +1976,13 @@ func formatFact(label, value string) string {
return "**" + label + ":** " + value + `\\`
}
-func toInlineDokuWiki(text string) string {
- lines := strings.Split(strings.TrimSpace(text), "\n")
- for index, line := range lines {
- lines[index] = strings.TrimSpace(line)
- }
- return strings.Join(lines, `\\`)
-}
-
-func toDokuWiki(text string) string {
- text = strings.ReplaceAll(strings.TrimSpace(text), "\r\n", "\n")
- if text == "" {
- return ""
- }
- lines := strings.Split(text, "\n")
- out := make([]string, 0, len(lines))
- for _, line := range lines {
- trimmed := strings.TrimSpace(line)
- switch {
- case trimmed == "":
- out = append(out, "")
- case strings.HasPrefix(trimmed, "- "):
- out = append(out, " * "+strings.TrimSpace(strings.TrimPrefix(trimmed, "- ")))
- default:
- out = append(out, line)
- }
- }
- return strings.Join(out, "\n")
-}
-
-func renderNodeBBManagedMarkdown(pageID, dokuText string) string {
- generated, notes := splitDokuWikiNotes(dokuText)
- body := dokuWikiToNodeBBMarkdown(generated)
- parts := []string{
- "[//]: # (sow-topdata-wiki:page=" + pageID + ")",
- "[//]: # (sow-topdata-wiki:managed:start)",
- body,
- "[//]: # (sow-topdata-wiki:managed:end)",
- }
- notesMarkdown := dokuWikiToNodeBBMarkdown("===== Notes =====\n" + notes)
- if strings.TrimSpace(notesMarkdown) != "" {
- parts = append(parts, notesMarkdown)
- }
- return ensureTrailingNewline(strings.Join(parts, "\n"))
-}
-
func renderNodeBBManagedHTML(pageID, sourceText string, manualSections []wikiManualSection) string {
- body := sourceText
- if !strings.Contains(sourceText, "",
"",
- strings.TrimSpace(body),
+ body,
"",
}
renderedSections := extractManualRegions(body)
@@ -2042,104 +1997,6 @@ func renderNodeBBManagedHTML(pageID, sourceText string, manualSections []wikiMan
return ensureTrailingNewline(strings.Join(parts, "\n"))
}
-func dokuWikiToNodeBBHTML(text string) string {
- text = strings.ReplaceAll(strings.TrimSpace(text), "\r\n", "\n")
- if text == "" {
- return ""
- }
- lines := strings.Split(text, "\n")
- var out []string
- var paragraph []string
- var list []string
- var tableRows []string
-
- flushParagraph := func() {
- if len(paragraph) == 0 {
- return
- }
- out = append(out, "
"+renderInlineHTML(strings.Join(paragraph, " "))+"
")
- paragraph = nil
- }
- flushList := func() {
- if len(list) == 0 {
- return
- }
- out = append(out, "")
- for _, item := range list {
- out = append(out, "- "+renderInlineHTML(item)+"
")
- }
- out = append(out, "
")
- list = nil
- }
- flushTable := func() {
- if len(tableRows) == 0 {
- return
- }
- out = append(out, "")
- out = append(out, tableRows...)
- out = append(out, "
")
- tableRows = nil
- }
- flushBlocks := func() {
- flushParagraph()
- flushList()
- flushTable()
- }
-
- for _, line := range lines {
- trimmed := strings.TrimSpace(line)
- if trimmed == "" {
- flushBlocks()
- continue
- }
- if strings.HasPrefix(trimmed, "%s", level, renderInlineHTML(heading), level))
- continue
- }
- if strings.HasPrefix(trimmed, " * ") || strings.HasPrefix(trimmed, "* ") || strings.HasPrefix(trimmed, "- ") {
- flushParagraph()
- flushTable()
- item := strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(strings.TrimPrefix(trimmed, " * "), "* "), "- "))
- list = append(list, item)
- continue
- }
- if label, value, ok := parseFactLine(trimmed); ok {
- flushParagraph()
- flushList()
- tableRows = append(tableRows, "
| "+html.EscapeString(label)+" | "+renderInlineHTML(value)+" |
")
- continue
- }
- flushList()
- flushTable()
- for _, part := range strings.Split(trimmed, `\\`) {
- part = strings.TrimSpace(part)
- if part != "" {
- paragraph = append(paragraph, part)
- }
- }
- }
- flushBlocks()
- return strings.TrimSpace(strings.Join(out, "\n"))
-}
-
-func parseFactLine(line string) (string, string, bool) {
- if !strings.HasPrefix(line, "**") {
- return "", "", false
- }
- rest := strings.TrimPrefix(line, "**")
- label, value, ok := strings.Cut(rest, ":**")
- if !ok {
- return "", "", false
- }
- return strings.TrimSpace(label), strings.Trim(strings.TrimSpace(value), `\`), true
-}
-
func renderInlineHTML(text string) string {
var out strings.Builder
for {
@@ -2161,114 +2018,6 @@ func renderInlineHTML(text string) string {
return out.String()
}
-func splitDokuWikiNotes(text string) (string, string) {
- const delimiter = "===== Notes ====="
- before, after, ok := strings.Cut(text, delimiter)
- if !ok {
- return text, ""
- }
- return before, after
-}
-
-func dokuWikiToNodeBBMarkdown(text string) string {
- text = strings.ReplaceAll(strings.TrimSpace(text), "\r\n", "\n")
- if text == "" {
- return ""
- }
-
- var out []string
- inNotice := false
-
- closeNotice := func() {
- if inNotice {
- inNotice = false
- if len(out) > 0 && strings.TrimSpace(out[len(out)-1]) != "" {
- out = append(out, "")
- }
- }
- }
- appendBlank := func() {
- if len(out) == 0 || strings.TrimSpace(out[len(out)-1]) == "" {
- return
- }
- out = append(out, "")
- }
-
- for _, line := range strings.Split(text, "\n") {
- trimmed := strings.TrimSpace(line)
- if trimmed == "" {
- appendBlank()
- continue
- }
- if strings.HasPrefix(trimmed, "" {
- closeNotice()
- continue
- }
- if heading, level, ok := parseDokuHeading(trimmed); ok {
- closeNotice()
- appendBlank()
- out = append(out, strings.Repeat("#", level)+" "+renderInlineNodeBBMarkdown(heading))
- out = append(out, "")
- continue
- }
- if strings.HasPrefix(trimmed, " * ") || strings.HasPrefix(trimmed, "* ") || strings.HasPrefix(trimmed, "- ") {
- item := strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(strings.TrimPrefix(trimmed, " * "), "* "), "- "))
- prefix := "- "
- if inNotice {
- prefix = "> - "
- }
- out = append(out, prefix+renderInlineNodeBBMarkdown(item))
- continue
- }
-
- parts := strings.Split(trimmed, `\\`)
- for _, part := range parts {
- part = strings.TrimSpace(part)
- if part != "" {
- prefix := ""
- if inNotice {
- prefix = "> "
- }
- out = append(out, prefix+renderInlineNodeBBMarkdown(part))
- }
- }
- }
- closeNotice()
- return strings.TrimSpace(strings.Join(out, "\n"))
-}
-
-func parseDokuHeading(line string) (string, int, bool) {
- if !strings.HasPrefix(line, "=") || !strings.HasSuffix(line, "=") {
- return "", 0, false
- }
- left := len(line) - len(strings.TrimLeft(line, "="))
- right := len(line) - len(strings.TrimRight(line, "="))
- if left == 0 || left != right {
- return "", 0, false
- }
- title := strings.TrimSpace(line[left : len(line)-right])
- if title == "" {
- return "", 0, false
- }
- level := 7 - left
- if level < 1 {
- level = 1
- }
- if level > 6 {
- level = 6
- }
- return title, level, true
-}
-
-func renderInlineNodeBBMarkdown(text string) string {
- return renderWikiLinks(text)
-}
-
func renderWikiLinks(text string) string {
var out strings.Builder
for {
diff --git a/internal/topdata/wiki_native_test.go b/internal/topdata/wiki_native_test.go
index 61acf81..ad1c8ee 100644
--- a/internal/topdata/wiki_native_test.go
+++ b/internal/topdata/wiki_native_test.go
@@ -53,7 +53,7 @@ func TestBuildNativeGeneratesAndSkipsWikiPages(t *testing.T) {
if !strings.Contains(text, "Athletics
") || !strings.Contains(text, `class="wiki-callout wiki-callout--status"`) || !strings.Contains(text, "This skill is unchanged from vanilla.") {
t.Fatalf("unexpected generated page:\n%s", text)
}
- if !strings.Contains(text, ``) || strings.Contains(text, `wiki_slug=`) || !strings.Contains(text, ``) || strings.Contains(text, `wiki_slug=`) || !strings.Contains(text, `
+
+
+Generated description.
+
+Class Skills
+
+`)
+
+ got := renderNodeBBManagedHTML("classes:adept", source, []wikiManualSection{
+ {ID: "user_top", Alias: "UserTopSection", InitialHTML: ""},
+ {ID: "user_bottom", Alias: "UserBottomSection", InitialHTML: ""},
+ })
+
+ for _, want := range []string{
+ `