From 418cd2fa694a33d0eb9d891472d28ce5a110aa08 Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Sun, 24 May 2026 07:32:56 +0200 Subject: [PATCH] Goodbye legacy DokuWiki translators... --- README.md | 5 +- internal/topdata/wiki_deploy.go | 27 ++- internal/topdata/wiki_deploy_test.go | 190 ++++++++++++++++++ internal/topdata/wiki_formatters.go | 189 +++++++++++++++++- internal/topdata/wiki_native.go | 289 ++------------------------- internal/topdata/wiki_native_test.go | 40 +++- 6 files changed, 451 insertions(+), 289 deletions(-) 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 := "" + 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.

+
SchoolConjuration
+ +` + 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.

+
SchoolConjuration
+ +` + 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.

+
SchoolConjuration
+ +` + 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) + "

    ", + ``, + "", + fmt.Sprintf("", summary["total"].(int)), + fmt.Sprintf("", summary["new"].(int)), + fmt.Sprintf("", summary["modified"].(int)), + fmt.Sprintf("", summary["vanilla"].(int)), } if namespaceLink != "" { - rows = append(rows, fmt.Sprintf("**Namespace:** %s", namespaceLink)) + rows = append(rows, "") } + rows = append(rows, "
    Namespace Status" + html.EscapeString(wikiStatusLabels[summary["status"].(string)]) + "
    Generated Pages%d
    New%d
    Modified%d
    Vanilla%d
    Namespace"+renderInlineHTML(namespaceLink)+"
    ") 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.

    +
    Hit Died6
    +

    Class Skills

    +
    • [[Skills/Heal|Heal]]
    +`) + + got := renderNodeBBManagedHTML("classes:adept", source, []wikiManualSection{ + {ID: "user_top", Alias: "UserTopSection", InitialHTML: "

    "}, + {ID: "user_bottom", Alias: "UserBottomSection", InitialHTML: "

    "}, + }) + + for _, want := range []string{ + ``, + `

    Class Skills

    `, + `
    `, + `
  • [[Skills/Heal|Heal]]
  • `, + } { + if !strings.Contains(got, want) { + t.Fatalf("expected generated HTML to preserve %q:\n%s", want, got) + } + } + if strings.Contains(got, "<table") || strings.Contains(got, "<div") || strings.Contains(got, "

    <") { + t.Fatalf("expected generated HTML not to be escaped into plain text:\n%s", got) + } + if strings.Count(got, `manual:start id="user_top"`) != 1 { + t.Fatalf("expected existing user_top manual section not to be duplicated:\n%s", got) + } + if strings.Count(got, `manual:start id="user_bottom"`) != 1 { + t.Fatalf("expected missing user_bottom manual section to be bootstrapped once:\n%s", got) + } +} + func TestBuildNativeIndexesUnregisteredStatusPagesWithDeclaredNamespacePath(t *testing.T) { root := testProjectRoot(t) mkdirAll(t, filepath.Join(root, "topdata", "data", "skills"))