diff --git a/README.md b/README.md
index b6da11f..be3e522 100644
--- a/README.md
+++ b/README.md
@@ -412,16 +412,24 @@ use `namespace=cid`, for example:
sow-toolkit deploy-wiki --dry-run --namespace spells --category spells=42
```
+Namespace declarations in `topdata/wiki/namespaces.yaml` may set
+`canonical_path` when a generated namespace lives below a fuller NodeBB category
+path, for example `canonical_path: Wiki/Feats`. The `title` remains the display
+label, while `canonical_path` drives generated `public_path` metadata, wiki
+links, page-index entries, and deploy adoption matching.
+
When a deploy manifest is missing but matching wiki pages already exist in
NodeBB, `deploy-wiki` checks the Westgate Wiki namespace directory API and
adopts those topic/post mappings before planning updates. It does the same when
-a cached manifest mapping points to a post NodeBB no longer has. Generated
-public slugs are preferred during adoption before title-based compatibility
-matching. `--create` is still required for genuinely new pages that have no
-existing remote topic. If NodeBB rejects a create because the clean wiki URL
-already exists, `deploy-wiki` re-queries the namespace by page leaf, adopts the
-matching topic, and updates it instead. Updates and stale-page archives acquire
-a Westgate Wiki edit lock before saving, then send the returned
+a cached manifest mapping points to a post NodeBB no longer has. Canonical
+`wikiPath`/`canonicalPath` data is preferred during adoption. Nested generated
+paths require that exact canonical path data; leaf/title compatibility matching
+is only used where it cannot cross a canonical parent path. `--create` is still
+required for genuinely new pages that have no existing remote topic. If NodeBB
+rejects a create because the clean wiki URL already exists, `deploy-wiki`
+re-queries the namespace by page leaf, adopts the matching topic, and updates it
+instead. Updates and stale-page archives acquire a Westgate Wiki edit lock
+before saving, then send the returned
`wikiEditLockToken` with the NodeBB post edit request. Stale generated pages are
reported by default.
`--stale-policy archive` rewrites stale generated pages in place as archived
diff --git a/internal/topdata/wiki_deploy.go b/internal/topdata/wiki_deploy.go
index f78aad8..b714c49 100644
--- a/internal/topdata/wiki_deploy.go
+++ b/internal/topdata/wiki_deploy.go
@@ -104,12 +104,13 @@ type wikiNamespacesDocument struct {
}
type wikiNamespaceDeclaration struct {
- ID string `json:"id" yaml:"id"`
- Title string `json:"title" yaml:"title"`
- CategoryEnv string `json:"category_env" yaml:"category_env"`
- PageTitle string `json:"page_title" yaml:"page_title"`
- Tags []string `json:"tags" yaml:"tags"`
- EditPolicy string `json:"edit_policy" yaml:"edit_policy"`
+ ID string `json:"id" yaml:"id"`
+ Title string `json:"title" yaml:"title"`
+ CanonicalPath string `json:"canonical_path" yaml:"canonical_path"`
+ CategoryEnv string `json:"category_env" yaml:"category_env"`
+ PageTitle string `json:"page_title" yaml:"page_title"`
+ Tags []string `json:"tags" yaml:"tags"`
+ EditPolicy string `json:"edit_policy" yaml:"edit_policy"`
}
func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress func(string)) (deployResult, error) {
@@ -267,6 +268,9 @@ func loadWikiNamespaceDeclarations(p *project.Project) ([]wikiNamespaceDeclarati
if strings.TrimSpace(declaration.CategoryEnv) == "" {
return nil, fmt.Errorf("topdata wiki namespace %q category_env is required", id)
}
+ if err := validateWikiNamespaceDeclarationPath(declaration); err != nil {
+ return nil, err
+ }
switch strings.TrimSpace(declaration.EditPolicy) {
case "", "preserve_manual_sections", "generated_only":
default:
@@ -276,6 +280,46 @@ func loadWikiNamespaceDeclarations(p *project.Project) ([]wikiNamespaceDeclarati
return doc.Namespaces, nil
}
+func validateWikiNamespaceDeclarationPath(declaration wikiNamespaceDeclaration) error {
+ id := strings.TrimSpace(declaration.ID)
+ title := strings.TrimSpace(declaration.Title)
+ canonicalTitle := ""
+ if title != "" {
+ canonicalTitle = canonicalWikiSegment(title)
+ if canonicalTitle == "" {
+ return fmt.Errorf("topdata wiki namespace %q title %q does not produce a valid canonical segment", id, title)
+ }
+ }
+
+ path := strings.TrimSpace(declaration.CanonicalPath)
+ if path == "" {
+ if canonicalTitle != "" {
+ path = canonicalTitle
+ } else {
+ path = canonicalWikiSegment(namespaceTitle(id))
+ }
+ if path == "" {
+ return fmt.Errorf("topdata wiki namespace %q does not produce a valid canonical namespace path", id)
+ }
+ if isReservedCanonicalWikiFirstSegment(path) {
+ return fmt.Errorf("topdata wiki namespace %q title %q uses reserved first segment %q", id, title, path)
+ }
+ return nil
+ }
+
+ canonicalPath := canonicalWikiSlashPath(path)
+ if canonicalPath == "" {
+ return fmt.Errorf("topdata wiki namespace %q canonical_path %q does not produce a valid canonical namespace path", id, path)
+ }
+ if canonicalPath != path {
+ return fmt.Errorf("topdata wiki namespace %q canonical_path %q must already be canonical as %q", id, path, canonicalPath)
+ }
+ if isReservedCanonicalWikiFirstSegment(path) {
+ return fmt.Errorf("topdata wiki namespace %q canonical_path %q uses reserved first segment", id, path)
+ }
+ return nil
+}
+
func categoryIDsFromNamespaceEnv(declarations []wikiNamespaceDeclaration) (map[string]int, error) {
categories := map[string]int{}
for _, declaration := range declarations {
@@ -301,6 +345,10 @@ func collectLocalPages(sourceDir string, namespaces []string) (map[string]wikiDe
for _, namespace := range namespaces {
namespaceSet[namespace] = struct{}{}
}
+ pageIndex, err := loadDeployPageIndex(sourceDir)
+ if err != nil {
+ return nil, err
+ }
pages := map[string]wikiDeployPage{}
for _, ns := range namespaces {
nsDir := filepath.Join(sourceDir, ns)
@@ -337,10 +385,14 @@ func collectLocalPages(sourceDir string, namespaces []string) (map[string]wikiDe
return nil
}
}
+ publicPath := extractWikiPagePublicPath(content)
+ if publicPath == "" {
+ publicPath = pageIndex[pageID].PublicPath
+ }
pages[pageID] = wikiDeployPage{
PageID: pageID,
Title: extractPageTitle(content, pageID),
- PublicPath: extractWikiPagePublicPath(content),
+ PublicPath: publicPath,
Namespace: namespace,
Content: content,
Hash: computeManagedHash(content),
@@ -353,6 +405,28 @@ func collectLocalPages(sourceDir string, namespaces []string) (map[string]wikiDe
return pages, nil
}
+func loadDeployPageIndex(sourceDir string) (map[string]wikiPageIndexEntry, error) {
+ path := filepath.Join(filepath.Dir(sourceDir), "page-index.json")
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return map[string]wikiPageIndexEntry{}, nil
+ }
+ return nil, fmt.Errorf("read wiki page index: %w", err)
+ }
+ var index wikiPageIndex
+ if err := json.Unmarshal(raw, &index); err != nil {
+ return nil, fmt.Errorf("parse wiki page index: %w", err)
+ }
+ byPageID := map[string]wikiPageIndexEntry{}
+ for _, entry := range index.Pages {
+ if strings.TrimSpace(entry.PageID) != "" {
+ byPageID[entry.PageID] = entry
+ }
+ }
+ return byPageID, nil
+}
+
func isDir(path string) bool {
info, err := os.Stat(path)
return err == nil && info.IsDir()
@@ -604,6 +678,9 @@ func adoptExistingNodeBBPage(page wikiDeployPage, cid int, remotePagesByCID map[
}
remotePage, ok := matchExistingNodeBBPage(page, remotePages)
if !ok || remotePage.TID == 0 {
+ if err := nestedCanonicalAdoptionFallbackError(page, remotePages); err != nil {
+ return wikiDeployManifestPage{}, false, err
+ }
return wikiDeployManifestPage{}, false, nil
}
return fetchNodeBBWikiPageMapping(page, cid, remotePage, client)
@@ -624,6 +701,9 @@ func findExistingNodeBBPage(page wikiDeployPage, cid int, client *nodeBBClient)
if ok && remotePage.TID != 0 {
return fetchNodeBBWikiPageMapping(page, cid, remotePage, client)
}
+ if err := nestedCanonicalAdoptionFallbackError(page, remotePages); err != nil {
+ return wikiDeployManifestPage{}, false, err
+ }
}
return wikiDeployManifestPage{}, false, nil
}
@@ -642,49 +722,134 @@ func fetchNodeBBWikiPageMapping(page wikiDeployPage, cid int, remotePage nodeBBW
return wikiDeployManifestPage{TID: topic.TID, PID: topic.PID, CID: cid}, true, nil
}
+func nestedCanonicalAdoptionFallbackError(page wikiDeployPage, remotePages []nodeBBWikiPage) error {
+ if !isNestedCanonicalWikiPath(page.PublicPath) {
+ return nil
+ }
+ targetLeaf := nodeBBPathLeaf(normalizeWikiPath(page.PublicPath))
+ desiredTitle := normalizeWikiTitle(page.Title)
+ for _, candidate := range remotePages {
+ if strings.TrimSpace(candidate.WikiPath) != "" || strings.TrimSpace(candidate.CanonicalPath) != "" {
+ continue
+ }
+ if targetLeaf != "" && !onlyHasRetiredGeneratedWikiPaths(page, candidate) {
+ for _, path := range []string{candidate.Slug} {
+ if strings.ToLower(canonicalWikiSegment(nodeBBPathLeaf(path))) == targetLeaf {
+ return fmt.Errorf("wiki page %q public path %q requires exact canonical wikiPath/canonicalPath for adoption; remote page %d only exposed leaf-compatible slug/title data", page.PageID, page.PublicPath, candidate.TID)
+ }
+ }
+ }
+ if desiredTitle != "" {
+ for _, title := range []string{candidate.TitleLeaf, candidate.Title} {
+ if normalizeWikiTitle(title) == desiredTitle {
+ return fmt.Errorf("wiki page %q public path %q requires exact canonical wikiPath/canonicalPath for adoption; remote page %d only exposed leaf-compatible slug/title data", page.PageID, page.PublicPath, candidate.TID)
+ }
+ }
+ }
+ }
+ return nil
+}
+
func matchExistingNodeBBPage(page wikiDeployPage, remotePages []nodeBBWikiPage) (nodeBBWikiPage, bool) {
+ targetPath := normalizeWikiPath(page.PublicPath)
+ requiresExactPath := isNestedCanonicalWikiPath(page.PublicPath)
if page.PublicPath != "" {
for _, candidate := range remotePages {
- for _, path := range []string{candidate.WikiPath, candidate.Slug} {
- if normalizeWikiPath(path) == normalizeWikiPath(page.PublicPath) {
+ paths := []string{candidate.WikiPath, candidate.CanonicalPath}
+ if !requiresExactPath {
+ paths = append(paths, candidate.Slug)
+ }
+ for _, path := range paths {
+ if normalizeWikiPath(path) == targetPath {
return candidate, true
}
}
}
}
+ if requiresExactPath {
+ return nodeBBWikiPage{}, false
+ }
+
+ targetLeaf := nodeBBPathLeaf(targetPath)
+ if targetLeaf != "" {
+ matches := []nodeBBWikiPage{}
+ for _, candidate := range remotePages {
+ if strings.TrimSpace(candidate.WikiPath) != "" {
+ continue
+ }
+ for _, path := range []string{candidate.WikiPath, candidate.Slug} {
+ if isRetiredGeneratedWikiPath(page, path) {
+ continue
+ }
+ if strings.ToLower(canonicalWikiSegment(nodeBBPathLeaf(path))) == targetLeaf {
+ matches = append(matches, candidate)
+ break
+ }
+ }
+ }
+ if len(matches) == 1 {
+ return matches[0], true
+ }
+ }
+
desiredTitles := map[string]struct{}{}
- for _, title := range []string{page.Title, pageIDLeaf(page.PageID)} {
+ for _, title := range []string{page.Title} {
if normalized := normalizeWikiTitle(title); normalized != "" {
desiredTitles[normalized] = struct{}{}
}
}
- desiredSlugs := map[string]struct{}{}
- for title := range desiredTitles {
- if slug := strings.ToLower(canonicalWikiSegment(title)); slug != "" {
- desiredSlugs[slug] = struct{}{}
- }
- }
- for _, slug := range []string{pageIDLeaf(page.PageID), strings.ReplaceAll(pageIDRemainder(page.PageID), ":", "-")} {
- if normalized := strings.ToLower(canonicalWikiSegment(slug)); normalized != "" {
- desiredSlugs[normalized] = struct{}{}
- }
- }
-
+ matches := []nodeBBWikiPage{}
for _, candidate := range remotePages {
+ if targetPath != "" && strings.TrimSpace(candidate.WikiPath) != "" {
+ continue
+ }
+ if onlyHasRetiredGeneratedWikiPaths(page, candidate) {
+ continue
+ }
for _, title := range []string{candidate.TitleLeaf, candidate.Title} {
if _, ok := desiredTitles[normalizeWikiTitle(title)]; ok {
- return candidate, true
- }
- }
- for _, slug := range []string{nodeBBPathLeaf(candidate.Slug), nodeBBPathLeaf(candidate.WikiPath)} {
- if _, ok := desiredSlugs[strings.ToLower(canonicalWikiSegment(slug))]; ok {
- return candidate, true
+ matches = append(matches, candidate)
+ break
}
}
}
+ if len(matches) == 1 {
+ return matches[0], true
+ }
return nodeBBWikiPage{}, false
}
+func onlyHasRetiredGeneratedWikiPaths(page wikiDeployPage, candidate nodeBBWikiPage) bool {
+ seenPath := false
+ for _, path := range []string{candidate.WikiPath, candidate.Slug} {
+ if strings.TrimSpace(path) == "" {
+ continue
+ }
+ seenPath = true
+ if !isRetiredGeneratedWikiPath(page, path) {
+ return false
+ }
+ }
+ return seenPath
+}
+
+func isRetiredGeneratedWikiPath(page wikiDeployPage, path string) bool {
+ retired := retiredGeneratedWikiPathLeaf(page.PageID)
+ if retired == "" {
+ return false
+ }
+ return strings.ToLower(canonicalWikiSegment(nodeBBPathLeaf(path))) == retired
+}
+
+func retiredGeneratedWikiPathLeaf(pageID string) string {
+ remainder := pageIDRemainder(pageID)
+ leaf := pageIDLeaf(pageID)
+ if remainder == "" || remainder == leaf {
+ return ""
+ }
+ return strings.ToLower(canonicalWikiSegment(strings.ReplaceAll(remainder, ":", "-")))
+}
+
func pageIDRemainder(pageID string) string {
_, rest, ok := strings.Cut(pageID, ":")
if !ok {
@@ -966,7 +1131,7 @@ func extractWikiPagePublicPath(content string) string {
value := marker[fieldStart+len(pathField):]
if fields := strings.Fields(value); len(fields) > 0 {
path := strings.Trim(strings.TrimSpace(fields[0]), `"`)
- if path != "" && canonicalWikiPath(path) == path {
+ if path != "" && canonicalWikiSlashPath(path) == path {
return path
}
}
@@ -1088,11 +1253,12 @@ type nodeBBEditLock struct {
}
type nodeBBWikiPage struct {
- TID int
- Title string
- TitleLeaf string
- Slug string
- WikiPath string
+ TID int
+ Title string
+ TitleLeaf string
+ Slug string
+ WikiPath string
+ CanonicalPath string
}
func newNodeBBClient(endpoint, token string) *nodeBBClient {
@@ -1330,11 +1496,12 @@ func parseNodeBBWikiPageList(doc any) ([]nodeBBWikiPage, string, bool) {
continue
}
pages = append(pages, nodeBBWikiPage{
- TID: firstInt(row, "tid"),
- Title: firstString(row, "title"),
- TitleLeaf: firstString(row, "titleLeaf", "title_leaf"),
- Slug: firstString(row, "slug"),
- WikiPath: firstString(row, "wikiPath", "wiki_path"),
+ TID: firstInt(row, "tid"),
+ Title: firstString(row, "title"),
+ TitleLeaf: firstString(row, "titleLeaf", "title_leaf"),
+ Slug: firstString(row, "slug"),
+ WikiPath: firstString(row, "wikiPath", "wiki_path"),
+ CanonicalPath: firstString(row, "canonicalPath", "canonical_path"),
})
}
return pages, firstString(m, "nextCursor", "next_cursor"), boolFromAny(m["hasMore"])
diff --git a/internal/topdata/wiki_deploy_test.go b/internal/topdata/wiki_deploy_test.go
index bb7c0d9..ab207fd 100644
--- a/internal/topdata/wiki_deploy_test.go
+++ b/internal/topdata/wiki_deploy_test.go
@@ -108,6 +108,54 @@ func TestCollectLocalPagesReadsGeneratedPageIDFromMarkerWhenPathIsTitleBased(t *
}
}
+func TestCollectLocalPagesReadsCanonicalPublicPathFromPageIndex(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 := `
+
+
Fear
+Generated page.
+
+`
+ if err := os.WriteFile(filepath.Join(sourceDir, "spells", "Fear.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:pdk:fear",
+ PublicPath: "Magic/Fear",
+ OutputPath: "pages/spells/Fear.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)
+ }
+ if got := pages["spells:pdk:fear"].PublicPath; got != "Magic/Fear" {
+ t.Fatalf("expected canonical public path from page-index, got %q", got)
+ }
+}
+
+func TestExtractWikiPagePublicPathAcceptsCanonicalSlashPath(t *testing.T) {
+ content := `
+
+Fear
+
+`
+ if got := extractWikiPagePublicPath(content); got != "Magic/Fear" {
+ t.Fatalf("expected slash-separated public path, got %q", got)
+ }
+}
+
func TestDeployWikiDryRunReadoptsMissingMappedPost(t *testing.T) {
root := t.TempDir()
sourceDir := filepath.Join(root, "pages")
@@ -200,6 +248,83 @@ func TestMatchExistingNodeBBPageIgnoresRetiredGeneratedPublicSlug(t *testing.T)
}
}
+func TestMatchExistingNodeBBPageDoesNotPreferRetiredGeneratedSlugWhenRemoteOrderChanges(t *testing.T) {
+ page := wikiDeployPage{
+ PageID: "spells:pdk:fear",
+ Title: "Fear",
+ PublicPath: "Spells/Fear",
+ }
+ matched, ok := matchExistingNodeBBPage(page, []nodeBBWikiPage{
+ {TID: 32, Title: "Fear", TitleLeaf: "Fear", Slug: "32/pdk-fear", WikiPath: "/wiki/spells/pdk-fear"},
+ {TID: 31, Title: "Fear", TitleLeaf: "Fear", Slug: "31/fear"},
+ })
+ if !ok || matched.TID != 31 {
+ t.Fatalf("expected canonical leaf match to select tid 31 over retired generated slug, got %#v ok=%t", matched, ok)
+ }
+}
+
+func TestMatchExistingNodeBBPageAdoptsCanonicalWikiPathWhenSlugIsRetired(t *testing.T) {
+ page := wikiDeployPage{
+ PageID: "spells:pdk:fear",
+ Title: "Fear",
+ }
+ matched, ok := matchExistingNodeBBPage(page, []nodeBBWikiPage{
+ {TID: 32, Title: "Fear", TitleLeaf: "Fear", Slug: "32/pdk-fear", WikiPath: "/wiki/Spells/Fear"},
+ })
+ if !ok || matched.TID != 32 {
+ t.Fatalf("expected title fallback to adopt canonical wiki path despite retired NodeBB topic slug, got %#v ok=%t", matched, ok)
+ }
+}
+
+func TestMatchExistingNodeBBPageRequiresFullPathBeforeLeafFallback(t *testing.T) {
+ page := wikiDeployPage{
+ PageID: "spells:pdk:fear",
+ Title: "Fear",
+ PublicPath: "Spells/Necromancy/Fear",
+ }
+ matched, ok := matchExistingNodeBBPage(page, []nodeBBWikiPage{
+ {TID: 31, Title: "Fear", TitleLeaf: "Fear", WikiPath: "/wiki/Spells/Illusion/Fear"},
+ })
+ if ok {
+ t.Fatalf("expected no cross-namespace leaf fallback match, got %#v", matched)
+ }
+}
+
+func TestMatchExistingNodeBBPageRequiresCanonicalPathForNestedPublicPath(t *testing.T) {
+ page := wikiDeployPage{
+ PageID: "spells:pdk:fear",
+ Title: "Fear",
+ PublicPath: "Spells/Necromancy/Fear",
+ }
+ matched, ok := matchExistingNodeBBPage(page, []nodeBBWikiPage{
+ {TID: 31, Title: "Fear", TitleLeaf: "Fear", Slug: "31/fear"},
+ })
+ if ok {
+ t.Fatalf("expected no nested-path title or leaf fallback match without canonical path, got %#v", matched)
+ }
+
+ matched, ok = matchExistingNodeBBPage(page, []nodeBBWikiPage{
+ {TID: 31, Title: "Fear", TitleLeaf: "Fear", CanonicalPath: "Spells/Necromancy/Fear"},
+ })
+ if !ok || matched.TID != 31 {
+ t.Fatalf("expected canonical path match for nested public path, got %#v ok=%t", matched, ok)
+ }
+}
+
+func TestNestedCanonicalAdoptionFallbackError(t *testing.T) {
+ page := wikiDeployPage{
+ PageID: "spells:pdk:fear",
+ Title: "Fear",
+ PublicPath: "Spells/Necromancy/Fear",
+ }
+ err := nestedCanonicalAdoptionFallbackError(page, []nodeBBWikiPage{
+ {TID: 31, Title: "Fear", TitleLeaf: "Fear", Slug: "31/fear"},
+ })
+ if err == nil || !strings.Contains(err.Error(), "requires exact canonical wikiPath/canonicalPath") {
+ t.Fatalf("expected exact canonical remapping error, got %v", err)
+ }
+}
+
func TestDeployCanonicalWikiSegmentMatchesGeneratedPathPolicy(t *testing.T) {
tests := map[string]string{
`Grandmaster's Battle Momentum`: "Grandmasters_Battle_Momentum",
@@ -217,6 +342,90 @@ func TestDeployCanonicalWikiSegmentMatchesGeneratedPathPolicy(t *testing.T) {
}
}
+func TestLoadWikiNamespaceDeclarationsValidatesCanonicalPaths(t *testing.T) {
+ root := testProjectRoot(t)
+ wikiDir := filepath.Join(root, "topdata", "wiki")
+ if err := os.MkdirAll(wikiDir, 0755); err != nil {
+ t.Fatalf("create wiki dir: %v", err)
+ }
+ write := func(text string) {
+ t.Helper()
+ if err := os.WriteFile(filepath.Join(wikiDir, "namespaces.yaml"), []byte(text), 0644); err != nil {
+ t.Fatalf("write namespaces: %v", err)
+ }
+ }
+
+ write(`
+namespaces:
+ - id: feat
+ title: Feats
+ canonical_path: Wiki/Feats
+ category_env: SOW_WIKI_FEATS_CID
+ edit_policy: preserve_manual_sections
+`)
+ declarations, err := loadWikiNamespaceDeclarations(testProject(root))
+ if err != nil {
+ t.Fatalf("expected canonical namespace path declaration to load: %v", err)
+ }
+ if got := declarations[0].CanonicalPath; got != "Wiki/Feats" {
+ t.Fatalf("expected canonical namespace path to be preserved, got %q", got)
+ }
+
+ write(`
+namespaces:
+ - id: search
+ title: Search
+ canonical_path: Wiki/Search
+ category_env: SOW_WIKI_SEARCH_CID
+ edit_policy: preserve_manual_sections
+`)
+ declarations, err = loadWikiNamespaceDeclarations(testProject(root))
+ if err != nil {
+ t.Fatalf("expected reserved display title with safe canonical path to load: %v", err)
+ }
+ if got := declarations[0].CanonicalPath; got != "Wiki/Search" {
+ t.Fatalf("expected safe canonical namespace path to be preserved, got %q", got)
+ }
+
+ write(`
+namespaces:
+ - id: bad
+ title: "!!!"
+ category_env: SOW_WIKI_BAD_CID
+ edit_policy: preserve_manual_sections
+`)
+ _, err = loadWikiNamespaceDeclarations(testProject(root))
+ if err == nil || !strings.Contains(err.Error(), `topdata wiki namespace "bad" title`) {
+ t.Fatalf("expected invalid namespace title error, got %v", err)
+ }
+
+ write(`
+namespaces:
+ - id: search
+ title: Search
+ canonical_path: search
+ category_env: SOW_WIKI_SEARCH_CID
+ edit_policy: preserve_manual_sections
+`)
+ _, err = loadWikiNamespaceDeclarations(testProject(root))
+ if err == nil || !strings.Contains(err.Error(), `reserved first segment`) {
+ t.Fatalf("expected reserved namespace path error, got %v", err)
+ }
+
+ write(`
+namespaces:
+ - id: numeric
+ title: Feats
+ canonical_path: 123/Feats
+ category_env: SOW_WIKI_NUMERIC_CID
+ edit_policy: preserve_manual_sections
+`)
+ _, err = loadWikiNamespaceDeclarations(testProject(root))
+ if err == nil || !strings.Contains(err.Error(), `reserved first segment`) {
+ t.Fatalf("expected numeric namespace path error, got %v", err)
+ }
+}
+
func TestDeployWikiCreatesNodeBBTopicAndWritesManifest(t *testing.T) {
root := t.TempDir()
sourceDir := filepath.Join(root, "pages")
diff --git a/internal/topdata/wiki_native.go b/internal/topdata/wiki_native.go
index 1eaca57..7da4aa1 100644
--- a/internal/topdata/wiki_native.go
+++ b/internal/topdata/wiki_native.go
@@ -119,6 +119,7 @@ type wikiContext struct {
visibilityPolicy *wikiVisibilityDefinitions
visibleWikiKeys map[string]bool
namespaceTitles map[string]string
+ namespacePaths map[string]string
}
func buildWiki(p *project.Project, nativeResult BuildResult, force bool, progress func(string)) (wikiResult, error) {
@@ -178,6 +179,7 @@ func buildWiki(p *project.Project, nativeResult BuildResult, force bool, progres
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)
@@ -231,10 +233,10 @@ 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, p.EffectiveConfig().TopData.Wiki.StatusListingScope, manualSections); err != nil {
+ 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, &pageIndex); err != nil {
+ if err := indexUnregisteredWikiPages(rootDir, outputDir, p.EffectiveConfig().TopData.Wiki.StalePages.Default, ctx, &pageIndex); err != nil {
return wikiResult{}, err
}
if err := saveWikiPageIndex(filepath.Join(rootDir, "page-index.json"), pageIndex); err != nil {
@@ -345,10 +347,14 @@ func validateWikiPageIndex(index wikiPageIndex) error {
return fmt.Errorf("duplicate wiki page-index output_path %q for %s and %s", entry.OutputPath, previous, entry.PageID)
}
if entry.PublicPath != "" {
- if previous, ok := publicPaths[entry.PublicPath]; ok {
+ 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[entry.PublicPath] = entry.PageID
+ publicPaths[publicPathKey] = entry.PageID
}
pageIDs[entry.PageID] = entry.OutputPath
outputPaths[entry.OutputPath] = entry.PageID
@@ -356,11 +362,14 @@ func validateWikiPageIndex(index wikiPageIndex) error {
return nil
}
-func indexUnregisteredWikiPages(rootDir, outputDir, stalePolicy string, index *wikiPageIndex) error {
+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
@@ -389,13 +398,14 @@ func indexUnregisteredWikiPages(rootDir, outputDir, stalePolicy string, index *w
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: extractHTMLTitle(string(raw), pageID),
- PublicPath: (&wikiContext{}).publicWikiPath(namespace, extractHTMLTitle(string(raw), pageID)),
+ Title: title,
+ PublicPath: ctx.publicWikiPath(namespace, title),
Hash: computeManagedHash(string(raw)),
OutputPath: filepath.ToSlash(relToRoot),
EditPolicy: wikiEditPolicy(namespace),
@@ -1507,19 +1517,19 @@ func wikiStatusNoun(category string) string {
}
}
-func generateStatusPages(outputDir string, pageStatuses map[string]string, pageTitles map[string]string, namespaces []string, listingScope string, manualSections []wikiManualSection) error {
+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), manualSections); err != nil {
+ 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), manualSections); err != nil {
+ 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(namespaceTitle(namespace)+" "+wikiStatusLabels[status]+" Pages", pageIDs, pageTitles), manualSections); err != nil {
+ 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
}
}
@@ -1530,7 +1540,7 @@ func generateStatusPages(outputDir string, pageStatuses map[string]string, pageT
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), manualSections); err != nil {
+ if err := writeWikiFile(filepath.Join(outputDir, "meta", "wikistatus", status+".html"), renderWikiStatusListingPage(wikiStatusLabels[status]+" Pages", pageIDs, pageTitles, ctx), manualSections); err != nil {
return err
}
}
@@ -1587,20 +1597,36 @@ func rollupWikiStatuses(statuses []string) string {
return best
}
-func renderWikiNamespaceFragment(namespace string, summary map[string]any) string {
- return renderStatusTable(namespaceTitle(namespace), summary, "")
+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 renderWikiStatusReportPage(summaries map[string]map[string]any, namespaces []string) string {
+func renderWikiNamespaceFragment(namespace string, summary map[string]any, ctx *wikiContext) string {
+ return renderStatusTable(wikiNamespacePublicTitle(ctx, 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.", ""}
for _, namespace := range namespaces {
summary := summaries[namespace]
- lines = append(lines, renderStatusTable(namespaceTitle(namespace), summary, fmt.Sprintf("[[%s:index|%s]]", namespace, namespaceTitle(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) string {
+func renderWikiStatusListingPage(title string, pageIDs []string, pageTitles map[string]string, ctx *wikiContext) string {
+ if ctx == nil {
+ ctx = &wikiContext{}
+ }
lines := []string{"====== " + title + " ======", ""}
if len(pageIDs) == 0 {
lines = append(lines, "No matching generated pages.")
@@ -1610,7 +1636,8 @@ func renderWikiStatusListingPage(title string, pageIDs []string, pageTitles map[
if title == "" {
title = pageID
}
- lines = append(lines, " * [["+pageID+"|"+title+"]]")
+ namespace := strings.SplitN(pageID, ":", 2)[0]
+ lines = append(lines, " * [["+ctx.publicWikiPath(namespace, title)+"|"+title+"]]")
}
}
return strings.Join(lines, "\n")
@@ -1659,6 +1686,18 @@ func wikiNamespaceTitles(declarations []wikiNamespaceDeclaration) map[string]str
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 ""
@@ -1703,17 +1742,37 @@ func (ctx *wikiContext) publicWikiTargetForKey(key, fallbackTitle string) string
}
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
+ namespacePath := canonicalWikiSegment(namespaceTitle(namespace))
+ if ctx != nil && ctx.namespacePaths != nil {
+ if declared := strings.TrimSpace(ctx.namespacePaths[namespace]); declared != "" {
+ namespacePath = declared
}
}
- segments := []string{namespaceTitle}
- for _, part := range strings.Split(title, " :: ") {
- segments = append(segments, part)
+ 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)
+ }
}
- return canonicalWikiPath(segments...)
+ 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 {
@@ -1726,9 +1785,7 @@ func wikiPageOutputRelPath(pageID, title string) string {
namespace = "page"
}
parts := []string{namespace}
- for _, part := range strings.Split(title, " :: ") {
- parts = append(parts, part)
- }
+ parts = append(parts, wikiTitlePathParts(title)...)
path := canonicalWikiPath(parts...)
if path == "" {
path = wikiPageIDToRelPath(pageID)
diff --git a/internal/topdata/wiki_native_test.go b/internal/topdata/wiki_native_test.go
index cbe4dba..b17e56f 100644
--- a/internal/topdata/wiki_native_test.go
+++ b/internal/topdata/wiki_native_test.go
@@ -180,6 +180,65 @@ func TestBuildWikiRegeneratesMissingCachedPages(t *testing.T) {
}
}
+func TestBuildNativeIndexesUnregisteredStatusPagesWithDeclaredNamespacePath(t *testing.T) {
+ root := testProjectRoot(t)
+ mkdirAll(t, filepath.Join(root, "topdata", "data", "skills"))
+ mkdirAll(t, filepath.Join(root, "topdata", "wiki"))
+ writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n")
+ writeFile(t, filepath.Join(root, "topdata", "wiki", "namespaces.yaml"), `
+namespaces:
+ - id: meta
+ title: Wiki Status
+ canonical_path: Wiki/Status
+ category_env: SOW_WIKI_META_CID
+ edit_policy: generated_only
+`)
+ writeFile(t, filepath.Join(root, "topdata", "data", "skills", "base.json"), `{
+ "output": "skills.2da",
+ "columns": ["Label", "Name", "Description", "HideFromLevelUp", "Untrained", "KeyAbility", "ArmorCheckPenalty", "Constant"],
+ "rows": [
+ {
+ "id": 0,
+ "key": "skills:athletics",
+ "Label": "Athletics",
+ "Name": {"tlk": {"key": "skills:athletics.name", "text": "Athletics"}},
+ "Description": {"tlk": {"key": "skills:athletics.description", "text": "Ability: Strength."}},
+ "HideFromLevelUp": "0",
+ "Untrained": "1",
+ "KeyAbility": "STR",
+ "ArmorCheckPenalty": "1",
+ "Constant": "SKILL_ATHLETICS"
+ }
+ ]
+}`+"\n")
+ writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), `{"skills:athletics":0}`+"\n")
+
+ proj := testProject(root)
+ proj.Config.TopData.ReferenceBuilder = ""
+
+ if _, err := BuildNative(proj, nil); err != nil {
+ t.Fatalf("BuildNative failed: %v", err)
+ }
+ indexRaw, err := os.ReadFile(filepath.Join(root, ".cache", "wiki", "page-index.json"))
+ if err != nil {
+ t.Fatalf("read page index: %v", err)
+ }
+ var pageIndex wikiPageIndex
+ if err := json.Unmarshal(indexRaw, &pageIndex); err != nil {
+ t.Fatalf("parse page index: %v", err)
+ }
+ paths := map[string]string{}
+ for _, page := range pageIndex.Pages {
+ paths[page.PageID] = page.PublicPath
+ }
+ if got, want := paths["meta:wikistatus"], "Wiki/Status/Wiki_Status"; got != want {
+ t.Fatalf("expected declared canonical path for status page, got %q want %q", got, want)
+ }
+ if got := paths["meta:wikistatus:skills:vanilla"]; !strings.HasPrefix(got, "Wiki/Status/") {
+ t.Fatalf("expected declared canonical path for status listing, got %q", got)
+ }
+}
+
func TestBuildNativeCanOmitAggregateWikiStatusListings(t *testing.T) {
root := testProjectRoot(t)
mkdirAll(t, filepath.Join(root, "topdata", "data", "skills"))
@@ -1367,6 +1426,29 @@ func TestSaveWikiPageIndexRejectsDuplicatePublicPaths(t *testing.T) {
}
}
+func TestSaveWikiPageIndexRejectsFoldedDuplicatePublicPaths(t *testing.T) {
+ err := saveWikiPageIndex(filepath.Join(t.TempDir(), "page-index.json"), wikiPageIndex{
+ Version: wikiGeneratorVersion,
+ Pages: []wikiPageIndexEntry{
+ {PageID: "spells:fear", PublicPath: "Magic/Fear", OutputPath: "pages/spells/fear.html"},
+ {PageID: "spells:accented_fear", PublicPath: "magic/fear", OutputPath: "pages/spells/accented_fear.html"},
+ },
+ })
+ if err == nil || !strings.Contains(err.Error(), "duplicate wiki page-index public_path") {
+ t.Fatalf("expected folded duplicate public_path validation error, got %v", err)
+ }
+}
+
+func TestPublicWikiPathSplitsTitlePathWithoutWhitespaceAroundSeparator(t *testing.T) {
+ ctx := &wikiContext{namespaceTitles: map[string]string{"spells": "Magic"}}
+ if got := ctx.publicWikiPath("spells", "Parent::Child"); got != "Magic/Parent/Child" {
+ t.Fatalf("expected NodeBB-style title path split, got %q", got)
+ }
+ if got := wikiPageOutputRelPath("spells:parent_child", "Parent::Child"); got != filepath.FromSlash("spells/Parent/Child.html") {
+ t.Fatalf("expected title-based output path split, got %q", got)
+ }
+}
+
func TestParseWikiPagePathDeclarationsRejectsLegacySlugOverrides(t *testing.T) {
_, err := parseWikiPagePathDeclarations([]byte(`
page_paths:
@@ -1401,16 +1483,95 @@ func TestCanonicalWikiSegmentPreservesCaseAndUnderscoreSpaces(t *testing.T) {
}
}
+func TestCanonicalWikiSegmentFoldedKeyMatchesPluginFixture(t *testing.T) {
+ tests := []struct {
+ source string
+ canonical string
+ folded string
+ }{
+ {source: "Inspire Competence", canonical: "Inspire_Competence", folded: "inspire competence"},
+ {source: "Grandmaster's Battle Momentum", canonical: "Grandmasters_Battle_Momentum", folded: "grandmasters battle momentum"},
+ {source: "Æther Œuvre Øresund Straße Þorn Łódź Đelta", canonical: "Aether_Oeuvre_Oresund_Strasse_Thorn_Lodz_Delta", folded: "aether oeuvre oresund strasse thorn lodz delta"},
+ }
+ for _, tt := range tests {
+ if got := canonicalWikiSegment(tt.source); got != tt.canonical {
+ t.Fatalf("canonicalWikiSegment(%q) = %q, want %q", tt.source, got, tt.canonical)
+ }
+ if got := canonicalWikiSegmentFoldedKey(tt.source); got != tt.folded {
+ t.Fatalf("canonicalWikiSegmentFoldedKey(%q) = %q, want %q", tt.source, got, tt.folded)
+ }
+ }
+}
+
+func TestWikiStatusPagesEmitCanonicalLinks(t *testing.T) {
+ ctx := &wikiContext{}
+ report := renderWikiStatusReportPage(map[string]map[string]any{
+ "skills": {
+ "status": wikiStatusVanilla,
+ "new": 0,
+ "modified": 0,
+ "vanilla": 1,
+ "total": 1,
+ },
+ }, []string{"skills"}, ctx)
+ if !strings.Contains(report, "[[Skills|Skills]]") || strings.Contains(report, "[[skills:index|Skills]]") {
+ t.Fatalf("expected status report namespace link to use canonical public path, got:\n%s", report)
+ }
+
+ listing := renderWikiStatusListingPage("Skills Vanilla Pages", []string{"skills:inspire_competence", "feat:grandmaster_battle_momentum"}, map[string]string{
+ "skills:inspire_competence": "Inspire Competence",
+ "feat:grandmaster_battle_momentum": "Grandmaster's Battle Momentum",
+ }, ctx)
+ for _, want := range []string{
+ "[[Skills/Inspire_Competence|Inspire Competence]]",
+ "[[Feats/Grandmasters_Battle_Momentum|Grandmaster's Battle Momentum]]",
+ } {
+ if !strings.Contains(listing, want) {
+ t.Fatalf("expected status listing canonical link %q, got:\n%s", want, listing)
+ }
+ }
+ if strings.Contains(listing, "[[skills:inspire_competence|") || strings.Contains(listing, "[[feat:grandmaster_battle_momentum|") {
+ t.Fatalf("expected status listing to omit generated page IDs as public targets, got:\n%s", listing)
+ }
+
+ customCtx := &wikiContext{
+ namespaceTitles: map[string]string{
+ "spells": "Magic",
+ },
+ }
+ customReport := renderWikiStatusReportPage(map[string]map[string]any{
+ "spells": {
+ "status": wikiStatusNew,
+ "new": 1,
+ "modified": 0,
+ "vanilla": 0,
+ "total": 1,
+ },
+ }, []string{"spells"}, customCtx)
+ if !strings.Contains(customReport, "[[Magic|Magic]]") || strings.Contains(customReport, "[[Spells|Spells]]") {
+ t.Fatalf("expected custom namespace status report link to use declared canonical title, got:\n%s", customReport)
+ }
+ customListing := renderWikiStatusListingPage("Magic New Pages", []string{"spells:fear"}, map[string]string{
+ "spells:fear": "Fear",
+ }, customCtx)
+ if !strings.Contains(customListing, "[[Magic/Fear|Fear]]") || strings.Contains(customListing, "[[Spells/Fear|Fear]]") {
+ t.Fatalf("expected custom namespace status listing link to use declared canonical title, got:\n%s", customListing)
+ }
+}
+
func TestPublicWikiTargetUsesCanonicalNamespaceAndTitlePath(t *testing.T) {
ctx := &wikiContext{
namespaceTitles: map[string]string{
"feat": "Feats",
},
+ namespacePaths: map[string]string{
+ "feat": "Wiki/Feats",
+ },
rowsByKey: map[string]map[string]any{
"feat:hardiness_versus_enchantments": {"FEAT": map[string]any{"tlk": map[string]any{"text": "Hardiness vs. Enchantments"}}},
},
}
- if got, want := ctx.publicWikiTargetForKey("feat:hardiness_versus_enchantments", "Display Alias"), "Feats/Hardiness_vs_Enchantments"; got != want {
+ if got, want := ctx.publicWikiTargetForKey("feat:hardiness_versus_enchantments", "Display Alias"), "Wiki/Feats/Hardiness_vs_Enchantments"; got != want {
t.Fatalf("expected canonical title path %q, got %q", want, got)
}
}
diff --git a/internal/topdata/wiki_slug.go b/internal/topdata/wiki_slug.go
index d9557b6..5eb6574 100644
--- a/internal/topdata/wiki_slug.go
+++ b/internal/topdata/wiki_slug.go
@@ -8,6 +8,16 @@ import (
"golang.org/x/text/unicode/norm"
)
+var reservedCanonicalWikiFirstSegments = map[string]struct{}{
+ "admin": {},
+ "api": {},
+ "category": {},
+ "compose": {},
+ "edit": {},
+ "namespace": {},
+ "search": {},
+}
+
func canonicalWikiSegment(value string) string {
value = html.UnescapeString(strings.TrimSpace(value))
var b strings.Builder
@@ -40,6 +50,72 @@ func canonicalWikiSegment(value string) string {
return strings.Trim(b.String(), "_")
}
+func canonicalWikiSegmentFoldedKey(value string) string {
+ return strings.Join(strings.Fields(strings.ToLower(strings.ReplaceAll(canonicalWikiSegment(value), "_", " "))), " ")
+}
+
+func canonicalWikiSlashPath(value string) string {
+ segments := []string{}
+ for _, part := range strings.Split(strings.Trim(strings.TrimSpace(value), "/"), "/") {
+ canonical := canonicalWikiSegment(part)
+ if canonical != "" {
+ segments = append(segments, canonical)
+ }
+ }
+ return strings.Join(segments, "/")
+}
+
+func canonicalWikiSlashPathFoldedKey(value string) string {
+ segments := []string{}
+ for _, part := range strings.Split(strings.Trim(strings.TrimSpace(value), "/"), "/") {
+ folded := canonicalWikiSegmentFoldedKey(part)
+ if folded != "" {
+ segments = append(segments, folded)
+ }
+ }
+ return strings.Join(segments, "/")
+}
+
+func canonicalWikiSlashPathSegments(value string) []string {
+ segments := []string{}
+ for _, part := range strings.Split(strings.Trim(strings.TrimSpace(value), "/"), "/") {
+ canonical := canonicalWikiSegment(part)
+ if canonical != "" {
+ segments = append(segments, canonical)
+ }
+ }
+ return segments
+}
+
+func isReservedCanonicalWikiFirstSegment(path string) bool {
+ segments := canonicalWikiSlashPathSegments(path)
+ if len(segments) == 0 {
+ return false
+ }
+ first := strings.ToLower(segments[0])
+ if isCanonicalWikiNumericSegment(first) {
+ return true
+ }
+ _, reserved := reservedCanonicalWikiFirstSegments[first]
+ return reserved
+}
+
+func isCanonicalWikiNumericSegment(segment string) bool {
+ if segment == "" {
+ return false
+ }
+ for _, r := range segment {
+ if r < '0' || r > '9' {
+ return false
+ }
+ }
+ return true
+}
+
+func isNestedCanonicalWikiPath(path string) bool {
+ return len(canonicalWikiSlashPathSegments(path)) > 2
+}
+
func matchWikiTransliterationCase(r rune, value string) string {
if unicode.IsUpper(r) {
if len(value) == 1 {