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 {