Align topdata wiki canonical paths (#11)

Reviewed-on: https://gitea.westgate.pw/ShadowsOverWestgate/sow-tools/pulls/11
Co-authored-by: Michał Piasecki <mpiasecki720@protonmail.com>
Co-committed-by: Michał Piasecki <mpiasecki720@protonmail.com>
This commit is contained in:
2026-05-23 16:38:02 +02:00
committed by archvillainette
parent 7288d7e2b7
commit 6d6367ad18
6 changed files with 754 additions and 76 deletions
+15 -7
View File
@@ -412,16 +412,24 @@ use `namespace=cid`, for example:
sow-toolkit deploy-wiki --dry-run --namespace spells --category spells=42 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 When a deploy manifest is missing but matching wiki pages already exist in
NodeBB, `deploy-wiki` checks the Westgate Wiki namespace directory API and NodeBB, `deploy-wiki` checks the Westgate Wiki namespace directory API and
adopts those topic/post mappings before planning updates. It does the same when 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 a cached manifest mapping points to a post NodeBB no longer has. Canonical
public slugs are preferred during adoption before title-based compatibility `wikiPath`/`canonicalPath` data is preferred during adoption. Nested generated
matching. `--create` is still required for genuinely new pages that have no paths require that exact canonical path data; leaf/title compatibility matching
existing remote topic. If NodeBB rejects a create because the clean wiki URL is only used where it cannot cross a canonical parent path. `--create` is still
already exists, `deploy-wiki` re-queries the namespace by page leaf, adopts the required for genuinely new pages that have no existing remote topic. If NodeBB
matching topic, and updates it instead. Updates and stale-page archives acquire rejects a create because the clean wiki URL already exists, `deploy-wiki`
a Westgate Wiki edit lock before saving, then send the returned 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 `wikiEditLockToken` with the NodeBB post edit request. Stale generated pages are
reported by default. reported by default.
`--stale-policy archive` rewrites stale generated pages in place as archived `--stale-policy archive` rewrites stale generated pages in place as archived
+206 -39
View File
@@ -104,12 +104,13 @@ type wikiNamespacesDocument struct {
} }
type wikiNamespaceDeclaration struct { type wikiNamespaceDeclaration struct {
ID string `json:"id" yaml:"id"` ID string `json:"id" yaml:"id"`
Title string `json:"title" yaml:"title"` Title string `json:"title" yaml:"title"`
CategoryEnv string `json:"category_env" yaml:"category_env"` CanonicalPath string `json:"canonical_path" yaml:"canonical_path"`
PageTitle string `json:"page_title" yaml:"page_title"` CategoryEnv string `json:"category_env" yaml:"category_env"`
Tags []string `json:"tags" yaml:"tags"` PageTitle string `json:"page_title" yaml:"page_title"`
EditPolicy string `json:"edit_policy" yaml:"edit_policy"` 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) { 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) == "" { if strings.TrimSpace(declaration.CategoryEnv) == "" {
return nil, fmt.Errorf("topdata wiki namespace %q category_env is required", id) 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) { switch strings.TrimSpace(declaration.EditPolicy) {
case "", "preserve_manual_sections", "generated_only": case "", "preserve_manual_sections", "generated_only":
default: default:
@@ -276,6 +280,46 @@ func loadWikiNamespaceDeclarations(p *project.Project) ([]wikiNamespaceDeclarati
return doc.Namespaces, nil 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) { func categoryIDsFromNamespaceEnv(declarations []wikiNamespaceDeclaration) (map[string]int, error) {
categories := map[string]int{} categories := map[string]int{}
for _, declaration := range declarations { for _, declaration := range declarations {
@@ -301,6 +345,10 @@ func collectLocalPages(sourceDir string, namespaces []string) (map[string]wikiDe
for _, namespace := range namespaces { for _, namespace := range namespaces {
namespaceSet[namespace] = struct{}{} namespaceSet[namespace] = struct{}{}
} }
pageIndex, err := loadDeployPageIndex(sourceDir)
if err != nil {
return nil, err
}
pages := map[string]wikiDeployPage{} pages := map[string]wikiDeployPage{}
for _, ns := range namespaces { for _, ns := range namespaces {
nsDir := filepath.Join(sourceDir, ns) nsDir := filepath.Join(sourceDir, ns)
@@ -337,10 +385,14 @@ func collectLocalPages(sourceDir string, namespaces []string) (map[string]wikiDe
return nil return nil
} }
} }
publicPath := extractWikiPagePublicPath(content)
if publicPath == "" {
publicPath = pageIndex[pageID].PublicPath
}
pages[pageID] = wikiDeployPage{ pages[pageID] = wikiDeployPage{
PageID: pageID, PageID: pageID,
Title: extractPageTitle(content, pageID), Title: extractPageTitle(content, pageID),
PublicPath: extractWikiPagePublicPath(content), PublicPath: publicPath,
Namespace: namespace, Namespace: namespace,
Content: content, Content: content,
Hash: computeManagedHash(content), Hash: computeManagedHash(content),
@@ -353,6 +405,28 @@ func collectLocalPages(sourceDir string, namespaces []string) (map[string]wikiDe
return pages, nil 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 { func isDir(path string) bool {
info, err := os.Stat(path) info, err := os.Stat(path)
return err == nil && info.IsDir() return err == nil && info.IsDir()
@@ -604,6 +678,9 @@ func adoptExistingNodeBBPage(page wikiDeployPage, cid int, remotePagesByCID map[
} }
remotePage, ok := matchExistingNodeBBPage(page, remotePages) remotePage, ok := matchExistingNodeBBPage(page, remotePages)
if !ok || remotePage.TID == 0 { if !ok || remotePage.TID == 0 {
if err := nestedCanonicalAdoptionFallbackError(page, remotePages); err != nil {
return wikiDeployManifestPage{}, false, err
}
return wikiDeployManifestPage{}, false, nil return wikiDeployManifestPage{}, false, nil
} }
return fetchNodeBBWikiPageMapping(page, cid, remotePage, client) return fetchNodeBBWikiPageMapping(page, cid, remotePage, client)
@@ -624,6 +701,9 @@ func findExistingNodeBBPage(page wikiDeployPage, cid int, client *nodeBBClient)
if ok && remotePage.TID != 0 { if ok && remotePage.TID != 0 {
return fetchNodeBBWikiPageMapping(page, cid, remotePage, client) return fetchNodeBBWikiPageMapping(page, cid, remotePage, client)
} }
if err := nestedCanonicalAdoptionFallbackError(page, remotePages); err != nil {
return wikiDeployManifestPage{}, false, err
}
} }
return wikiDeployManifestPage{}, false, nil 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 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) { func matchExistingNodeBBPage(page wikiDeployPage, remotePages []nodeBBWikiPage) (nodeBBWikiPage, bool) {
targetPath := normalizeWikiPath(page.PublicPath)
requiresExactPath := isNestedCanonicalWikiPath(page.PublicPath)
if page.PublicPath != "" { if page.PublicPath != "" {
for _, candidate := range remotePages { for _, candidate := range remotePages {
for _, path := range []string{candidate.WikiPath, candidate.Slug} { paths := []string{candidate.WikiPath, candidate.CanonicalPath}
if normalizeWikiPath(path) == normalizeWikiPath(page.PublicPath) { if !requiresExactPath {
paths = append(paths, candidate.Slug)
}
for _, path := range paths {
if normalizeWikiPath(path) == targetPath {
return candidate, true 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{}{} desiredTitles := map[string]struct{}{}
for _, title := range []string{page.Title, pageIDLeaf(page.PageID)} { for _, title := range []string{page.Title} {
if normalized := normalizeWikiTitle(title); normalized != "" { if normalized := normalizeWikiTitle(title); normalized != "" {
desiredTitles[normalized] = struct{}{} desiredTitles[normalized] = struct{}{}
} }
} }
desiredSlugs := map[string]struct{}{} matches := []nodeBBWikiPage{}
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{}{}
}
}
for _, candidate := range remotePages { for _, candidate := range remotePages {
if targetPath != "" && strings.TrimSpace(candidate.WikiPath) != "" {
continue
}
if onlyHasRetiredGeneratedWikiPaths(page, candidate) {
continue
}
for _, title := range []string{candidate.TitleLeaf, candidate.Title} { for _, title := range []string{candidate.TitleLeaf, candidate.Title} {
if _, ok := desiredTitles[normalizeWikiTitle(title)]; ok { if _, ok := desiredTitles[normalizeWikiTitle(title)]; ok {
return candidate, true matches = append(matches, candidate)
} break
}
for _, slug := range []string{nodeBBPathLeaf(candidate.Slug), nodeBBPathLeaf(candidate.WikiPath)} {
if _, ok := desiredSlugs[strings.ToLower(canonicalWikiSegment(slug))]; ok {
return candidate, true
} }
} }
} }
if len(matches) == 1 {
return matches[0], true
}
return nodeBBWikiPage{}, false 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 { func pageIDRemainder(pageID string) string {
_, rest, ok := strings.Cut(pageID, ":") _, rest, ok := strings.Cut(pageID, ":")
if !ok { if !ok {
@@ -966,7 +1131,7 @@ func extractWikiPagePublicPath(content string) string {
value := marker[fieldStart+len(pathField):] value := marker[fieldStart+len(pathField):]
if fields := strings.Fields(value); len(fields) > 0 { if fields := strings.Fields(value); len(fields) > 0 {
path := strings.Trim(strings.TrimSpace(fields[0]), `"`) path := strings.Trim(strings.TrimSpace(fields[0]), `"`)
if path != "" && canonicalWikiPath(path) == path { if path != "" && canonicalWikiSlashPath(path) == path {
return path return path
} }
} }
@@ -1088,11 +1253,12 @@ type nodeBBEditLock struct {
} }
type nodeBBWikiPage struct { type nodeBBWikiPage struct {
TID int TID int
Title string Title string
TitleLeaf string TitleLeaf string
Slug string Slug string
WikiPath string WikiPath string
CanonicalPath string
} }
func newNodeBBClient(endpoint, token string) *nodeBBClient { func newNodeBBClient(endpoint, token string) *nodeBBClient {
@@ -1330,11 +1496,12 @@ func parseNodeBBWikiPageList(doc any) ([]nodeBBWikiPage, string, bool) {
continue continue
} }
pages = append(pages, nodeBBWikiPage{ pages = append(pages, nodeBBWikiPage{
TID: firstInt(row, "tid"), TID: firstInt(row, "tid"),
Title: firstString(row, "title"), Title: firstString(row, "title"),
TitleLeaf: firstString(row, "titleLeaf", "title_leaf"), TitleLeaf: firstString(row, "titleLeaf", "title_leaf"),
Slug: firstString(row, "slug"), Slug: firstString(row, "slug"),
WikiPath: firstString(row, "wikiPath", "wiki_path"), WikiPath: firstString(row, "wikiPath", "wiki_path"),
CanonicalPath: firstString(row, "canonicalPath", "canonical_path"),
}) })
} }
return pages, firstString(m, "nextCursor", "next_cursor"), boolFromAny(m["hasMore"]) return pages, firstString(m, "nextCursor", "next_cursor"), boolFromAny(m["hasMore"])
+209
View File
@@ -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 := `<!-- sow-topdata-wiki:page=spells:pdk:fear -->
<!-- sow-topdata-wiki:managed:start hash="sha256:local" -->
<h1>Fear</h1>
<p>Generated page.</p>
<!-- sow-topdata-wiki:managed:end -->
`
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 := `<!-- sow-topdata-wiki:page=spells:pdk:fear public_path="Magic/Fear" -->
<!-- sow-topdata-wiki:managed:start hash="sha256:local" -->
<h1>Fear</h1>
<!-- sow-topdata-wiki:managed:end -->
`
if got := extractWikiPagePublicPath(content); got != "Magic/Fear" {
t.Fatalf("expected slash-separated public path, got %q", got)
}
}
func TestDeployWikiDryRunReadoptsMissingMappedPost(t *testing.T) { func TestDeployWikiDryRunReadoptsMissingMappedPost(t *testing.T) {
root := t.TempDir() root := t.TempDir()
sourceDir := filepath.Join(root, "pages") 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) { func TestDeployCanonicalWikiSegmentMatchesGeneratedPathPolicy(t *testing.T) {
tests := map[string]string{ tests := map[string]string{
`Grandmaster's Battle Momentum`: "Grandmasters_Battle_Momentum", `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) { func TestDeployWikiCreatesNodeBBTopicAndWritesManifest(t *testing.T) {
root := t.TempDir() root := t.TempDir()
sourceDir := filepath.Join(root, "pages") sourceDir := filepath.Join(root, "pages")
+86 -29
View File
@@ -119,6 +119,7 @@ type wikiContext struct {
visibilityPolicy *wikiVisibilityDefinitions visibilityPolicy *wikiVisibilityDefinitions
visibleWikiKeys map[string]bool visibleWikiKeys map[string]bool
namespaceTitles map[string]string namespaceTitles map[string]string
namespacePaths map[string]string
} }
func buildWiki(p *project.Project, nativeResult BuildResult, force bool, progress func(string)) (wikiResult, error) { 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 return wikiResult{}, err
} }
ctx.namespaceTitles = wikiNamespaceTitles(namespaceDeclarations) ctx.namespaceTitles = wikiNamespaceTitles(namespaceDeclarations)
ctx.namespacePaths = wikiNamespacePaths(namespaceDeclarations)
if err := os.RemoveAll(outputDir); err != nil { if err := os.RemoveAll(outputDir); err != nil {
return wikiResult{}, fmt.Errorf("clean wiki output: %w", err) 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 { if err := generateEntityPages(outputDir, ctx, writePage); err != nil {
return wikiResult{}, err 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 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 return wikiResult{}, err
} }
if err := saveWikiPageIndex(filepath.Join(rootDir, "page-index.json"), pageIndex); err != nil { 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) return fmt.Errorf("duplicate wiki page-index output_path %q for %s and %s", entry.OutputPath, previous, entry.PageID)
} }
if entry.PublicPath != "" { 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) 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 pageIDs[entry.PageID] = entry.OutputPath
outputPaths[entry.OutputPath] = entry.PageID outputPaths[entry.OutputPath] = entry.PageID
@@ -356,11 +362,14 @@ func validateWikiPageIndex(index wikiPageIndex) error {
return nil 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{}{} seen := map[string]struct{}{}
for _, entry := range index.Pages { for _, entry := range index.Pages {
seen[entry.PageID] = struct{}{} seen[entry.PageID] = struct{}{}
} }
if ctx == nil {
ctx = &wikiContext{}
}
return filepath.WalkDir(outputDir, func(path string, d fs.DirEntry, err error) error { return filepath.WalkDir(outputDir, func(path string, d fs.DirEntry, err error) error {
if err != nil { if err != nil {
return err return err
@@ -389,13 +398,14 @@ func indexUnregisteredWikiPages(rootDir, outputDir, stalePolicy string, index *w
if err != nil { if err != nil {
relToRoot = filepath.Base(path) relToRoot = filepath.Base(path)
} }
title := extractHTMLTitle(string(raw), pageID)
index.Pages = append(index.Pages, wikiPageIndexEntry{ index.Pages = append(index.Pages, wikiPageIndexEntry{
PageID: pageID, PageID: pageID,
Namespace: namespace, Namespace: namespace,
SourceDataset: categoryFromWikiNamespace(namespace), SourceDataset: categoryFromWikiNamespace(namespace),
SourceKey: pageID, SourceKey: pageID,
Title: extractHTMLTitle(string(raw), pageID), Title: title,
PublicPath: (&wikiContext{}).publicWikiPath(namespace, extractHTMLTitle(string(raw), pageID)), PublicPath: ctx.publicWikiPath(namespace, title),
Hash: computeManagedHash(string(raw)), Hash: computeManagedHash(string(raw)),
OutputPath: filepath.ToSlash(relToRoot), OutputPath: filepath.ToSlash(relToRoot),
EditPolicy: wikiEditPolicy(namespace), 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) 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 return err
} }
for _, namespace := range namespaces { for _, namespace := range namespaces {
summary := summaries[namespace] 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 return err
} }
for _, status := range []string{wikiStatusNew, wikiStatusModified, wikiStatusVanilla} { for _, status := range []string{wikiStatusNew, wikiStatusModified, wikiStatusVanilla} {
pageIDs := filterWikiPageIDs(pageStatuses, namespace, status) 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 return err
} }
} }
@@ -1530,7 +1540,7 @@ func generateStatusPages(outputDir string, pageStatuses map[string]string, pageT
if listingScope == "all" { if listingScope == "all" {
for _, status := range []string{wikiStatusNew, wikiStatusModified, wikiStatusVanilla} { for _, status := range []string{wikiStatusNew, wikiStatusModified, wikiStatusVanilla} {
pageIDs := filterWikiPageIDs(pageStatuses, "", status) 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 return err
} }
} }
@@ -1587,20 +1597,36 @@ func rollupWikiStatuses(statuses []string) string {
return best return best
} }
func renderWikiNamespaceFragment(namespace string, summary map[string]any) string { func wikiNamespacePublicTitle(ctx *wikiContext, namespace string) string {
return renderStatusTable(namespaceTitle(namespace), summary, "") 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.", ""} lines := []string{"====== Wiki Status ======", "", "This page is auto-generated from builder source provenance.", ""}
for _, namespace := range namespaces { for _, namespace := range namespaces {
summary := summaries[namespace] 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") 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 + " ======", ""} lines := []string{"====== " + title + " ======", ""}
if len(pageIDs) == 0 { if len(pageIDs) == 0 {
lines = append(lines, "No matching generated pages.") lines = append(lines, "No matching generated pages.")
@@ -1610,7 +1636,8 @@ func renderWikiStatusListingPage(title string, pageIDs []string, pageTitles map[
if title == "" { if title == "" {
title = pageID 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") return strings.Join(lines, "\n")
@@ -1659,6 +1686,18 @@ func wikiNamespaceTitles(declarations []wikiNamespaceDeclaration) map[string]str
return titles 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 { func wikiPageIDForKey(key string) string {
if key == "" { if key == "" {
return "" return ""
@@ -1703,17 +1742,37 @@ func (ctx *wikiContext) publicWikiTargetForKey(key, fallbackTitle string) string
} }
func (ctx *wikiContext) publicWikiPath(namespace, title string) string { func (ctx *wikiContext) publicWikiPath(namespace, title string) string {
namespaceTitle := namespaceTitle(namespace) namespacePath := canonicalWikiSegment(namespaceTitle(namespace))
if ctx != nil && ctx.namespaceTitles != nil { if ctx != nil && ctx.namespacePaths != nil {
if declared := strings.TrimSpace(ctx.namespaceTitles[namespace]); declared != "" { if declared := strings.TrimSpace(ctx.namespacePaths[namespace]); declared != "" {
namespaceTitle = declared namespacePath = declared
} }
} }
segments := []string{namespaceTitle} if ctx != nil && ctx.namespaceTitles != nil && (ctx.namespacePaths == nil || strings.TrimSpace(ctx.namespacePaths[namespace]) == "") {
for _, part := range strings.Split(title, " :: ") { if declared := strings.TrimSpace(ctx.namespaceTitles[namespace]); declared != "" {
segments = append(segments, part) 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 { func wikiSlugifyTitle(value string) string {
@@ -1726,9 +1785,7 @@ func wikiPageOutputRelPath(pageID, title string) string {
namespace = "page" namespace = "page"
} }
parts := []string{namespace} parts := []string{namespace}
for _, part := range strings.Split(title, " :: ") { parts = append(parts, wikiTitlePathParts(title)...)
parts = append(parts, part)
}
path := canonicalWikiPath(parts...) path := canonicalWikiPath(parts...)
if path == "" { if path == "" {
path = wikiPageIDToRelPath(pageID) path = wikiPageIDToRelPath(pageID)
+162 -1
View File
@@ -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) { func TestBuildNativeCanOmitAggregateWikiStatusListings(t *testing.T) {
root := testProjectRoot(t) root := testProjectRoot(t)
mkdirAll(t, filepath.Join(root, "topdata", "data", "skills")) 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) { func TestParseWikiPagePathDeclarationsRejectsLegacySlugOverrides(t *testing.T) {
_, err := parseWikiPagePathDeclarations([]byte(` _, err := parseWikiPagePathDeclarations([]byte(`
page_paths: 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) { func TestPublicWikiTargetUsesCanonicalNamespaceAndTitlePath(t *testing.T) {
ctx := &wikiContext{ ctx := &wikiContext{
namespaceTitles: map[string]string{ namespaceTitles: map[string]string{
"feat": "Feats", "feat": "Feats",
}, },
namespacePaths: map[string]string{
"feat": "Wiki/Feats",
},
rowsByKey: map[string]map[string]any{ rowsByKey: map[string]map[string]any{
"feat:hardiness_versus_enchantments": {"FEAT": map[string]any{"tlk": map[string]any{"text": "Hardiness vs. Enchantments"}}}, "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) t.Fatalf("expected canonical title path %q, got %q", want, got)
} }
} }
+76
View File
@@ -8,6 +8,16 @@ import (
"golang.org/x/text/unicode/norm" "golang.org/x/text/unicode/norm"
) )
var reservedCanonicalWikiFirstSegments = map[string]struct{}{
"admin": {},
"api": {},
"category": {},
"compose": {},
"edit": {},
"namespace": {},
"search": {},
}
func canonicalWikiSegment(value string) string { func canonicalWikiSegment(value string) string {
value = html.UnescapeString(strings.TrimSpace(value)) value = html.UnescapeString(strings.TrimSpace(value))
var b strings.Builder var b strings.Builder
@@ -40,6 +50,72 @@ func canonicalWikiSegment(value string) string {
return strings.Trim(b.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 { func matchWikiTransliterationCase(r rune, value string) string {
if unicode.IsUpper(r) { if unicode.IsUpper(r) {
if len(value) == 1 { if len(value) == 1 {