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:
+206
-39
@@ -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"])
|
||||
|
||||
@@ -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) {
|
||||
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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user