Canonical Wiki Paths Enforcement (#6)

### Summary
- Change generated topdata wiki links to target public wiki namespace and canonical slug paths.
- Add generated public path metadata to wiki page generation and page-index output.
- Add YAML-driven generated slug overrides and public-target collision validation.

### Details
- Generated relation links now render public targets such as:
  - `[[feat/hardiness-vs-enchantments|Hardiness vs. Enchantments]]`
  - `[[feat/favored-enemy-elves|Favored Enemy: Elves]]`
- Topdata page IDs remain the internal identity for output layout, managed markers, deploy state, and bookkeeping.
- Generated managed markers now include the effective public slug:
  - `<!-- sow-topdata-wiki:page=... wiki_slug=... -->`
- Page-index entries now include:
  - `public_slug`
  - `public_target`
- Added parsing and validation for `page_paths.slug_overrides` from `topdata/wiki/wiki.yaml`.
- Generated public targets are validated for duplicate namespace-local collisions.
- Table link helpers now derive public targets from target page titles when available, so display aliases do not accidentally change link destinations.

### Tests
- Added and updated coverage for:
  - public target rendering
  - marker `wiki_slug` output
  - page-index public metadata
  - duplicate generated public target rejection
  - slug override parsing and validation
  - override-driven target selection
  - target title slugging independent of display labels

### Validation
- `go test ./...`
- `gofmt -w` on changed Go files
- `./build-tool.sh`
- `git diff --check`

Reviewed-on: https://gitea.westgate.pw/ShadowsOverWestgate/sow-tools/pulls/6
Co-authored-by: vickydotbat <vickydotbat@tutamail.com>
Co-committed-by: vickydotbat <vickydotbat@tutamail.com>
This commit is contained in:
2026-05-21 19:21:02 +02:00
committed by archvillainette
parent d0e5e42090
commit 8cebf718b7
4 changed files with 233 additions and 16 deletions
+89 -7
View File
@@ -56,6 +56,8 @@ type wikiPageIndexEntry struct {
SourceDataset string `json:"source_dataset"`
SourceKey string `json:"source_key"`
Title string `json:"title"`
PublicSlug string `json:"public_slug"`
PublicTarget string `json:"public_target"`
Hash string `json:"hash"`
OutputPath string `json:"output_path"`
EditPolicy string `json:"edit_policy"`
@@ -115,6 +117,7 @@ type wikiContext struct {
wikiTableDefinitions map[string]wikiTableDefinition
visibilityPolicy *wikiVisibilityDefinitions
visibleWikiKeys map[string]bool
pageSlugOverrides map[string]string
}
func buildWiki(p *project.Project, nativeResult BuildResult, force bool, progress func(string)) (wikiResult, error) {
@@ -158,6 +161,11 @@ func buildWiki(p *project.Project, nativeResult BuildResult, force bool, progres
if err := ctx.loadWikiVisibility(visibilityPath); err != nil {
return wikiResult{}, err
}
pageSlugOverrides, err := loadWikiPagePathDeclarations(filepath.Join(p.TopDataWikiSourceDir(), "wiki.yaml"))
if err != nil {
return wikiResult{}, err
}
ctx.pageSlugOverrides = pageSlugOverrides
if err := os.RemoveAll(outputDir); err != nil {
return wikiResult{}, fmt.Errorf("clean wiki output: %w", err)
@@ -175,13 +183,14 @@ func buildWiki(p *project.Project, nativeResult BuildResult, force bool, progres
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
content = renderNodeBBManagedHTML(pageID, content, manualSections)
namespace := strings.SplitN(pageID, ":", 2)[0]
publicSlug := ctx.publicWikiPageSlug(pageID, title)
content = renderNodeBBManagedHTML(pageID, publicSlug, content, manualSections)
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
return err
}
pageTitles[pageID] = title
pageStatuses[pageID] = status
namespace := strings.SplitN(pageID, ":", 2)[0]
rel, err := filepath.Rel(rootDir, path)
if err != nil {
rel = filepath.Base(path)
@@ -192,6 +201,8 @@ func buildWiki(p *project.Project, nativeResult BuildResult, force bool, progres
SourceDataset: categoryFromWikiNamespace(namespace),
SourceKey: pageID,
Title: title,
PublicSlug: publicSlug,
PublicTarget: wikiPublicTarget(namespace, publicSlug),
Hash: computeManagedHash(content),
OutputPath: filepath.ToSlash(rel),
EditPolicy: wikiEditPolicy(namespace),
@@ -291,6 +302,7 @@ func saveWikiPageIndex(path string, index wikiPageIndex) error {
func validateWikiPageIndex(index wikiPageIndex) error {
pageIDs := map[string]string{}
outputPaths := map[string]string{}
publicTargets := map[string]string{}
for _, entry := range index.Pages {
if previous, ok := pageIDs[entry.PageID]; ok {
return fmt.Errorf("duplicate wiki page-index page_id %q for %s and %s", entry.PageID, previous, entry.OutputPath)
@@ -298,6 +310,12 @@ func validateWikiPageIndex(index wikiPageIndex) error {
if previous, ok := outputPaths[entry.OutputPath]; ok {
return fmt.Errorf("duplicate wiki page-index output_path %q for %s and %s", entry.OutputPath, previous, entry.PageID)
}
if entry.PublicTarget != "" {
if previous, ok := publicTargets[entry.PublicTarget]; ok {
return fmt.Errorf("duplicate wiki page-index public_target %q for %s and %s", entry.PublicTarget, previous, entry.PageID)
}
publicTargets[entry.PublicTarget] = entry.PageID
}
pageIDs[entry.PageID] = entry.OutputPath
outputPaths[entry.OutputPath] = entry.PageID
}
@@ -340,6 +358,8 @@ func indexUnregisteredWikiPages(rootDir, outputDir, stalePolicy string, index *w
SourceDataset: categoryFromWikiNamespace(namespace),
SourceKey: pageID,
Title: extractHTMLTitle(string(raw), pageID),
PublicSlug: wikiSlugifyTitle(extractHTMLTitle(string(raw), pageID)),
PublicTarget: wikiPublicTarget(namespace, wikiSlugifyTitle(extractHTMLTitle(string(raw), pageID))),
Hash: computeManagedHash(string(raw)),
OutputPath: filepath.ToSlash(relToRoot),
EditPolicy: wikiEditPolicy(namespace),
@@ -1031,8 +1051,8 @@ func (ctx *wikiContext) renderReference(value any, idToKey map[int]string, nameR
if name == "" {
name = key
}
if pageID := wikiPageIDForKey(key); pageID != "" {
return "[[" + pageID + "|" + name + "]]"
if target := ctx.publicWikiTargetForKey(key, name); target != "" {
return "[[" + target + "|" + name + "]]"
}
return name
}
@@ -1486,7 +1506,8 @@ func writeWikiFile(path, content string, manualSections []wikiManualSection) err
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
content = renderNodeBBManagedHTML(wikiRelPathToPageID(path), content, manualSections)
pageID := wikiRelPathToPageID(path)
content = renderNodeBBManagedHTML(pageID, wikiSlugifyTitle(extractHTMLTitle(content, pageID)), content, manualSections)
return os.WriteFile(path, []byte(ensureTrailingNewline(content)), 0o644)
}
@@ -1613,6 +1634,67 @@ func wikiPageIDForKey(key string) string {
return namespace + ":" + value
}
func (ctx *wikiContext) publicWikiPageSlug(pageID, title string) string {
if slug := ctx.pageSlugOverrides[pageID]; slug != "" {
return slug
}
return wikiSlugifyTitle(title)
}
func (ctx *wikiContext) publicWikiTargetForKey(key, fallbackTitle string) string {
pageID := wikiPageIDForKey(key)
if pageID == "" {
return ""
}
title := ""
if row, ok := ctx.rowsByKey[key]; ok {
category, _, _ := strings.Cut(key, ":")
title = ctx.resolveRowName(category, row)
}
if strings.TrimSpace(title) == "" {
title = fallbackTitle
}
if strings.TrimSpace(title) == "" {
title = key
}
namespace := strings.SplitN(pageID, ":", 2)[0]
return wikiPublicTarget(namespace, ctx.publicWikiPageSlug(pageID, title))
}
func wikiPublicTarget(namespace, slug string) string {
namespace = strings.TrimSpace(namespace)
slug = strings.TrimSpace(slug)
if namespace == "" {
return slug
}
if slug == "" {
return namespace
}
return namespace + "/" + slug
}
func wikiSlugifyTitle(value string) string {
value = html.UnescapeString(strings.TrimSpace(value))
var b strings.Builder
lastDash := false
for _, r := range strings.ToLower(value) {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
lastDash = false
continue
}
if b.Len() > 0 && !lastDash {
b.WriteByte('-')
lastDash = true
}
}
slug := strings.Trim(b.String(), "-")
if slug == "" {
return "topic"
}
return slug
}
func wikiPageIDToRelPath(pageID string) string {
return filepath.FromSlash(strings.ReplaceAll(pageID, ":", "/") + ".html")
}
@@ -1835,14 +1917,14 @@ func renderNodeBBManagedMarkdown(pageID, dokuText string) string {
return ensureTrailingNewline(strings.Join(parts, "\n"))
}
func renderNodeBBManagedHTML(pageID, sourceText string, manualSections []wikiManualSection) string {
func renderNodeBBManagedHTML(pageID, publicSlug, sourceText string, manualSections []wikiManualSection) string {
body := sourceText
if !strings.Contains(sourceText, "<h1") {
body = dokuWikiToNodeBBHTML(sourceText)
}
hash := computeManagedHash(body)
parts := []string{
"<!-- sow-topdata-wiki:page=" + pageID + " -->",
"<!-- sow-topdata-wiki:page=" + pageID + " wiki_slug=" + publicSlug + " -->",
"<!-- sow-topdata-wiki:managed:start hash=\"sha256:" + hash + "\" -->",
strings.TrimSpace(body),
"<!-- sow-topdata-wiki:managed:end -->",