From 835d1153f0659e1f04652b51b1ae01b761bdb514 Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Fri, 22 May 2026 12:37:00 +0200 Subject: [PATCH] Canonical path adherence (#10) Reviewed-on: https://gitea.westgate.pw/ShadowsOverWestgate/sow-tools/pulls/10 Co-authored-by: vickydotbat Co-committed-by: vickydotbat --- internal/topdata/wiki_deploy.go | 44 ++++++--- internal/topdata/wiki_deploy_test.go | 28 +++--- internal/topdata/wiki_native.go | 83 +++++++++------- internal/topdata/wiki_native_test.go | 135 ++++++++++++++------------- internal/topdata/wiki_page_paths.go | 3 + internal/topdata/wiki_slug.go | 53 +++++++++++ 6 files changed, 218 insertions(+), 128 deletions(-) diff --git a/internal/topdata/wiki_deploy.go b/internal/topdata/wiki_deploy.go index dbcef7c..be92ebc 100644 --- a/internal/topdata/wiki_deploy.go +++ b/internal/topdata/wiki_deploy.go @@ -66,7 +66,7 @@ type deployResult struct { type wikiDeployPage struct { PageID string Title string - PublicSlug string + PublicPath string Namespace string Content string Hash string @@ -333,7 +333,7 @@ func collectLocalPages(sourceDir string, namespaces []string) (map[string]wikiDe pages[pageID] = wikiDeployPage{ PageID: pageID, Title: extractPageTitle(content, pageID), - PublicSlug: extractWikiPagePublicSlug(content), + PublicPath: extractWikiPagePublicPath(content), Namespace: namespace, Content: content, Hash: computeManagedHash(content), @@ -636,10 +636,10 @@ func fetchNodeBBWikiPageMapping(page wikiDeployPage, cid int, remotePage nodeBBW } func matchExistingNodeBBPage(page wikiDeployPage, remotePages []nodeBBWikiPage) (nodeBBWikiPage, bool) { - if page.PublicSlug != "" { + if page.PublicPath != "" { for _, candidate := range remotePages { - for _, slug := range []string{nodeBBPathLeaf(candidate.Slug), nodeBBPathLeaf(candidate.WikiPath)} { - if slugifyWikiTitle(slug) == page.PublicSlug { + for _, path := range []string{candidate.WikiPath, candidate.Slug} { + if normalizeWikiPath(path) == normalizeWikiPath(page.PublicPath) { return candidate, true } } @@ -653,12 +653,12 @@ func matchExistingNodeBBPage(page wikiDeployPage, remotePages []nodeBBWikiPage) } desiredSlugs := map[string]struct{}{} for title := range desiredTitles { - if slug := slugifyWikiTitle(title); slug != "" { + if slug := strings.ToLower(canonicalWikiSegment(title)); slug != "" { desiredSlugs[slug] = struct{}{} } } for _, slug := range []string{pageIDLeaf(page.PageID), strings.ReplaceAll(pageIDRemainder(page.PageID), ":", "-")} { - if normalized := slugifyWikiTitle(slug); normalized != "" { + if normalized := strings.ToLower(canonicalWikiSegment(slug)); normalized != "" { desiredSlugs[normalized] = struct{}{} } } @@ -670,7 +670,7 @@ func matchExistingNodeBBPage(page wikiDeployPage, remotePages []nodeBBWikiPage) } } for _, slug := range []string{nodeBBPathLeaf(candidate.Slug), nodeBBPathLeaf(candidate.WikiPath)} { - if _, ok := desiredSlugs[slugifyWikiTitle(slug)]; ok { + if _, ok := desiredSlugs[strings.ToLower(canonicalWikiSegment(slug))]; ok { return candidate, true } } @@ -718,6 +718,20 @@ func slugifyWikiTitle(value string) string { return slugifyWikiText(value, "") } +func normalizeWikiPath(value string) string { + value = strings.TrimSpace(value) + value = strings.TrimPrefix(value, "/wiki/") + value = strings.TrimPrefix(value, "wiki/") + parts := []string{} + for _, part := range strings.Split(value, "/") { + canonical := canonicalWikiSegment(part) + if canonical != "" { + parts = append(parts, strings.ToLower(canonical)) + } + } + return strings.Join(parts, "/") +} + func loadDeployManifest(path string) wikiDeployManifest { doc := wikiDeployManifest{Version: "nodebb-v1", Pages: map[string]wikiDeployManifestPage{}} raw, err := os.ReadFile(path) @@ -910,9 +924,9 @@ func renderArchivedWikiPage(pageID, title string) string { }, "\n")) } -func extractWikiPagePublicSlug(content string) string { +func extractWikiPagePublicPath(content string) string { const pageMarker = "sow-topdata-wiki:page=" - const slugField = "wiki_slug=" + const pathField = "public_path=" start := strings.Index(content, pageMarker) if start < 0 { return "" @@ -922,15 +936,15 @@ func extractWikiPagePublicSlug(content string) string { return "" } marker := content[start : start+end] - fieldStart := strings.Index(marker, slugField) + fieldStart := strings.Index(marker, pathField) if fieldStart < 0 { return "" } - value := marker[fieldStart+len(slugField):] + value := marker[fieldStart+len(pathField):] if fields := strings.Fields(value); len(fields) > 0 { - slug := strings.TrimSpace(fields[0]) - if slug != "" && slug == slugifyWikiTitle(slug) { - return slug + path := strings.Trim(strings.TrimSpace(fields[0]), `"`) + if path != "" && canonicalWikiPath(path) == path { + return path } } return "" diff --git a/internal/topdata/wiki_deploy_test.go b/internal/topdata/wiki_deploy_test.go index c8c18cd..1801cae 100644 --- a/internal/topdata/wiki_deploy_test.go +++ b/internal/topdata/wiki_deploy_test.go @@ -153,34 +153,34 @@ func TestDeployWikiDryRunReadoptsMissingMappedPost(t *testing.T) { } } -func TestMatchExistingNodeBBPagePrefersGeneratedPublicSlug(t *testing.T) { +func TestMatchExistingNodeBBPageIgnoresRetiredGeneratedPublicSlug(t *testing.T) { page := wikiDeployPage{ PageID: "spells:pdk:fear", Title: "Fear", - PublicSlug: "pdk-fear", + PublicPath: "Spells/Fear", } matched, ok := matchExistingNodeBBPage(page, []nodeBBWikiPage{ {TID: 31, Title: "Fear", TitleLeaf: "Fear", WikiPath: "/wiki/spells/fear"}, {TID: 32, Title: "Fear", TitleLeaf: "Fear", WikiPath: "/wiki/spells/pdk-fear"}, }) - if !ok || matched.TID != 32 { - t.Fatalf("expected generated public slug to select tid 32, got %#v ok=%t", matched, ok) + if !ok || matched.TID != 31 { + t.Fatalf("expected canonical title match to select tid 31, got %#v ok=%t", matched, ok) } } -func TestDeploySlugifyWikiTitleMatchesGeneratedSlugPolicy(t *testing.T) { +func TestDeployCanonicalWikiSegmentMatchesGeneratedPathPolicy(t *testing.T) { tests := map[string]string{ - `Grandmaster's Battle Momentum`: "grandmasters-battle-momentum", - `Bigby’s Clenched Fist`: "bigbys-clenched-fist", - `Hardiness vs. Enchantments`: "hardiness-vs-enchantments", - `Clairaudience/Clairvoyance`: "clairaudience-clairvoyance", - `Élite & Noble Houses`: "elite-noble-houses", - `Alpha!@#$%^&*()[]-=_+/?.,<>` + "`" + `~|\Omega`: "alpha-omega", - `Æther Œuvre Øresund Straße Þorn Łódź Đelta`: "aether-oeuvre-oresund-strasse-thorn-lodz-delta", + `Grandmaster's Battle Momentum`: "Grandmasters_Battle_Momentum", + `Bigby’s Clenched Fist`: "Bigbys_Clenched_Fist", + `Hardiness vs. Enchantments`: "Hardiness_vs_Enchantments", + `Clairaudience/Clairvoyance`: "Clairaudience_Clairvoyance", + `Élite & Noble Houses`: "Elite_Noble_Houses", + `Alpha!@#$%^&*()[]-=_+/?.,<>` + "`" + `~|\Omega`: "Alpha_Omega", + `Æther Œuvre Øresund Straße Þorn Łódź Đelta`: "Aether_Oeuvre_Oresund_Strasse_Thorn_Lodz_Delta", } for input, want := range tests { - if got := slugifyWikiTitle(input); got != want { - t.Fatalf("slugifyWikiTitle(%q) = %q, want %q", input, got, want) + if got := canonicalWikiSegment(input); got != want { + t.Fatalf("canonicalWikiSegment(%q) = %q, want %q", input, got, want) } } } diff --git a/internal/topdata/wiki_native.go b/internal/topdata/wiki_native.go index e39bb27..bc82054 100644 --- a/internal/topdata/wiki_native.go +++ b/internal/topdata/wiki_native.go @@ -56,8 +56,7 @@ type wikiPageIndexEntry struct { SourceDataset string `json:"source_dataset"` SourceKey string `json:"source_key"` Title string `json:"title"` - PublicSlug string `json:"public_slug"` - PublicTarget string `json:"public_target"` + PublicPath string `json:"public_path"` Hash string `json:"hash"` OutputPath string `json:"output_path"` EditPolicy string `json:"edit_policy"` @@ -119,7 +118,7 @@ type wikiContext struct { wikiDataProviders map[string]wikiDataProviderDefinition visibilityPolicy *wikiVisibilityDefinitions visibleWikiKeys map[string]bool - pageSlugOverrides map[string]string + namespaceTitles map[string]string } func buildWiki(p *project.Project, nativeResult BuildResult, force bool, progress func(string)) (wikiResult, error) { @@ -173,7 +172,12 @@ func buildWiki(p *project.Project, nativeResult BuildResult, force bool, progres if err != nil { return wikiResult{}, err } - ctx.pageSlugOverrides = pageSlugOverrides + _ = pageSlugOverrides + namespaceDeclarations, err := loadWikiNamespaceDeclarations(p) + if err != nil { + return wikiResult{}, err + } + ctx.namespaceTitles = wikiNamespaceTitles(namespaceDeclarations) if err := os.RemoveAll(outputDir); err != nil { return wikiResult{}, fmt.Errorf("clean wiki output: %w", err) @@ -192,8 +196,8 @@ func buildWiki(p *project.Project, nativeResult BuildResult, force bool, progres return err } namespace := strings.SplitN(pageID, ":", 2)[0] - publicSlug := ctx.publicWikiPageSlug(pageID, title) - content = renderNodeBBManagedHTML(pageID, publicSlug, content, manualSections) + publicPath := ctx.publicWikiPath(namespace, title) + content = renderNodeBBManagedHTML(pageID, content, manualSections) if err := os.WriteFile(path, []byte(content), 0o644); err != nil { return err } @@ -209,8 +213,7 @@ func buildWiki(p *project.Project, nativeResult BuildResult, force bool, progres SourceDataset: categoryFromWikiNamespace(namespace), SourceKey: pageID, Title: title, - PublicSlug: publicSlug, - PublicTarget: wikiPublicTarget(namespace, publicSlug), + PublicPath: publicPath, Hash: computeManagedHash(content), OutputPath: filepath.ToSlash(rel), EditPolicy: wikiEditPolicy(namespace), @@ -327,7 +330,7 @@ func saveWikiPageIndex(path string, index wikiPageIndex) error { func validateWikiPageIndex(index wikiPageIndex) error { pageIDs := map[string]string{} outputPaths := map[string]string{} - publicTargets := 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) @@ -335,11 +338,11 @@ func validateWikiPageIndex(index wikiPageIndex) error { 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.PublicTarget != "" { - if previous, ok := publicTargets[entry.PublicTarget]; ok { - return fmt.Errorf("duplicate wiki page-index public_target %q for %s and %s", entry.PublicTarget, previous, entry.PageID) + if entry.PublicPath != "" { + if previous, ok := publicPaths[entry.PublicPath]; ok { + return fmt.Errorf("duplicate wiki page-index public_path %q for %s and %s", entry.PublicPath, previous, entry.PageID) } - publicTargets[entry.PublicTarget] = entry.PageID + publicPaths[entry.PublicPath] = entry.PageID } pageIDs[entry.PageID] = entry.OutputPath outputPaths[entry.OutputPath] = entry.PageID @@ -383,8 +386,7 @@ func indexUnregisteredWikiPages(rootDir, outputDir, stalePolicy string, index *w SourceDataset: categoryFromWikiNamespace(namespace), SourceKey: pageID, Title: extractHTMLTitle(string(raw), pageID), - PublicSlug: wikiSlugifyTitle(extractHTMLTitle(string(raw), pageID)), - PublicTarget: wikiPublicTarget(namespace, wikiSlugifyTitle(extractHTMLTitle(string(raw), pageID))), + PublicPath: (&wikiContext{}).publicWikiPath(namespace, extractHTMLTitle(string(raw), pageID)), Hash: computeManagedHash(string(raw)), OutputPath: filepath.ToSlash(relToRoot), EditPolicy: wikiEditPolicy(namespace), @@ -1532,7 +1534,7 @@ func writeWikiFile(path, content string, manualSections []wikiManualSection) err return err } pageID := wikiRelPathToPageID(path) - content = renderNodeBBManagedHTML(pageID, wikiSlugifyTitle(extractHTMLTitle(content, pageID)), content, manualSections) + content = renderNodeBBManagedHTML(pageID, content, manualSections) return os.WriteFile(path, []byte(ensureTrailingNewline(content)), 0o644) } @@ -1636,6 +1638,18 @@ func filterWikiPageIDs(pageStatuses map[string]string, namespace, status string) 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 wikiPageIDForKey(key string) string { if key == "" { return "" @@ -1655,17 +1669,10 @@ func wikiPageIDForKey(key string) string { if namespace == "" { return "" } - value = strings.NewReplacer("/", ":", "\\", ":", "_", ":").Replace(value) + value = strings.NewReplacer("/", ":", "\\", ":").Replace(value) return namespace + ":" + value } -func (ctx *wikiContext) publicWikiPageSlug(pageID, title string) string { - if slug := ctx.pageSlugOverrides[pageID]; slug != "" { - return slug - } - return wikiSlugifyTitle(title) -} - func (ctx *wikiContext) publicWikiTargetForKey(key, fallbackTitle string) string { pageID := wikiPageIDForKey(key) if pageID == "" { @@ -1683,19 +1690,21 @@ func (ctx *wikiContext) publicWikiTargetForKey(key, fallbackTitle string) string title = key } namespace := strings.SplitN(pageID, ":", 2)[0] - return wikiPublicTarget(namespace, ctx.publicWikiPageSlug(pageID, title)) + return ctx.publicWikiPath(namespace, title) } -func wikiPublicTarget(namespace, slug string) string { - namespace = strings.TrimSpace(namespace) - slug = strings.TrimSpace(slug) - if namespace == "" { - return slug +func (ctx *wikiContext) publicWikiPath(namespace, title string) string { + namespaceTitle := namespaceTitle(namespace) + if ctx != nil && ctx.namespaceTitles != nil { + if declared := strings.TrimSpace(ctx.namespaceTitles[namespace]); declared != "" { + namespaceTitle = declared + } } - if slug == "" { - return namespace + segments := []string{namespaceTitle} + for _, part := range strings.Split(title, " :: ") { + segments = append(segments, part) } - return namespace + "/" + slug + return canonicalWikiPath(segments...) } func wikiSlugifyTitle(value string) string { @@ -1719,7 +1728,9 @@ func wikiRelPathToPageID(path string) string { func namespaceTitle(namespace string) string { switch namespace { case "itemtypes": - return "Itemtypes" + return "Item Types" + case "feat": + return "Feats" default: return strings.Title(namespace) } @@ -1924,14 +1935,14 @@ func renderNodeBBManagedMarkdown(pageID, dokuText string) string { return ensureTrailingNewline(strings.Join(parts, "\n")) } -func renderNodeBBManagedHTML(pageID, publicSlug, sourceText string, manualSections []wikiManualSection) string { +func renderNodeBBManagedHTML(pageID, sourceText string, manualSections []wikiManualSection) string { body := sourceText if !strings.Contains(sourceText, "", + "", "", strings.TrimSpace(body), "", diff --git a/internal/topdata/wiki_native_test.go b/internal/topdata/wiki_native_test.go index 851f2d4..fbd833f 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, ``) || strings.Contains(text, `wiki_slug=`) || !strings.Contains(text, `