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:
@@ -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
|
||||||
|
|||||||
+189
-22
@@ -106,6 +106,7 @@ 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"`
|
||||||
|
CanonicalPath string `json:"canonical_path" yaml:"canonical_path"`
|
||||||
CategoryEnv string `json:"category_env" yaml:"category_env"`
|
CategoryEnv string `json:"category_env" yaml:"category_env"`
|
||||||
PageTitle string `json:"page_title" yaml:"page_title"`
|
PageTitle string `json:"page_title" yaml:"page_title"`
|
||||||
Tags []string `json:"tags" yaml:"tags"`
|
Tags []string `json:"tags" yaml:"tags"`
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1093,6 +1258,7 @@ type nodeBBWikiPage struct {
|
|||||||
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 {
|
||||||
@@ -1335,6 +1501,7 @@ func parseNodeBBWikiPageList(doc any) ([]nodeBBWikiPage, string, bool) {
|
|||||||
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"])
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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.namespacePaths[namespace]); declared != "" {
|
||||||
|
namespacePath = declared
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ctx != nil && ctx.namespaceTitles != nil && (ctx.namespacePaths == nil || strings.TrimSpace(ctx.namespacePaths[namespace]) == "") {
|
||||||
if declared := strings.TrimSpace(ctx.namespaceTitles[namespace]); declared != "" {
|
if declared := strings.TrimSpace(ctx.namespaceTitles[namespace]); declared != "" {
|
||||||
namespaceTitle = declared
|
namespacePath = canonicalWikiSegment(declared)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
segments := []string{namespaceTitle}
|
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, "::") {
|
for _, part := range strings.Split(title, "::") {
|
||||||
segments = append(segments, part)
|
part = strings.TrimSpace(part)
|
||||||
|
if part != "" {
|
||||||
|
parts = append(parts, part)
|
||||||
}
|
}
|
||||||
return canonicalWikiPath(segments...)
|
}
|
||||||
|
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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user