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
+62 -5
View File
@@ -53,7 +53,7 @@ func TestBuildNativeGeneratesAndSkipsWikiPages(t *testing.T) {
if !strings.Contains(text, "<h1>Athletics</h1>") || !strings.Contains(text, `class="wiki-callout wiki-callout--status"`) || !strings.Contains(text, "This skill is unchanged from vanilla.") {
t.Fatalf("unexpected generated page:\n%s", text)
}
if !strings.Contains(text, `<!-- sow-topdata-wiki:page=skills:athletics -->`) || !strings.Contains(text, `<!-- sow-topdata-wiki:managed:start hash="sha256:`) || !strings.Contains(text, "Ability: Strength.") || !strings.Contains(text, "<li>Climb</li>") {
if !strings.Contains(text, `<!-- sow-topdata-wiki:page=skills:athletics wiki_slug=athletics -->`) || !strings.Contains(text, `<!-- sow-topdata-wiki:managed:start hash="sha256:`) || !strings.Contains(text, "Ability: Strength.") || !strings.Contains(text, "<li>Climb</li>") {
t.Fatalf("expected HTML managed markers and description to be rendered into wiki page:\n%s", text)
}
if strings.Contains(text, "[//]: #") || strings.Contains(text, "<WRAP>") {
@@ -85,7 +85,7 @@ func TestBuildNativeGeneratesAndSkipsWikiPages(t *testing.T) {
t.Fatalf("expected page-index entries to match generated HTML files, got index=%d files=%d", len(pageIndex.Pages), generatedHTMLCount)
}
indexText := string(indexRaw)
if !strings.Contains(indexText, `"page_id": "skills:athletics"`) || !strings.Contains(indexText, `"output_path": "pages/skills/athletics.html"`) || !strings.Contains(indexText, `"edit_policy": "preserve_manual_sections"`) {
if !strings.Contains(indexText, `"page_id": "skills:athletics"`) || !strings.Contains(indexText, `"public_slug": "athletics"`) || !strings.Contains(indexText, `"public_target": "skills/athletics"`) || !strings.Contains(indexText, `"output_path": "pages/skills/athletics.html"`) || !strings.Contains(indexText, `"edit_policy": "preserve_manual_sections"`) {
t.Fatalf("expected deterministic page-index metadata, got:\n%s", indexText)
}
if !strings.Contains(indexText, `"page_id": "meta:wikistatus"`) || !strings.Contains(indexText, `"output_path": "pages/meta/wikistatus.html"`) || !strings.Contains(indexText, `"edit_policy": "generated_only"`) {
@@ -336,9 +336,9 @@ tables:
`<td>1st</td>`,
`<td>+1</td>`,
`<td>2</td>`,
`<td>[[feat:literate|Literate]], [[feat:weapon:proficiency:simple|Simple Weapon Proficiency]]</td>`,
`<td>[[feat/literate|Literate]], [[feat/simple-weapon-proficiency|Simple Weapon Proficiency]]</td>`,
`<td>1-10</td>`,
`<td>[[feat:weapon:specialization|Weapon Specialization]] first available.</td>`,
`<td>[[feat/weapon-specialization|Weapon Specialization]] first available.</td>`,
} {
if !strings.Contains(got, expected) {
t.Fatalf("expected %q in rendered table:\n%s", expected, got)
@@ -541,7 +541,7 @@ datasets:
t.Fatalf("expected hidden %q reference to be omitted from class page:\n%s", hidden, classText)
}
}
for _, visible := range []string{"[[skills:athletics|Athletics]]", "[[feat:class:only|Class Feat]]"} {
for _, visible := range []string{"[[skills/athletics|Athletics]]", "[[feat/class-feat|Class Feat]]"} {
if !strings.Contains(classText, visible) {
t.Fatalf("expected visible %q reference in class page:\n%s", visible, classText)
}
@@ -929,6 +929,63 @@ func TestSaveWikiPageIndexRejectsDuplicateOutputPaths(t *testing.T) {
}
}
func TestSaveWikiPageIndexRejectsDuplicatePublicTargets(t *testing.T) {
err := saveWikiPageIndex(filepath.Join(t.TempDir(), "page-index.json"), wikiPageIndex{
Version: wikiGeneratorVersion,
Pages: []wikiPageIndexEntry{
{PageID: "spells:darkness", PublicTarget: "spells/darkness", OutputPath: "pages/spells/darkness.html"},
{PageID: "spells:gwildshape:driderdarkness", PublicTarget: "spells/darkness", OutputPath: "pages/spells/gwildshape/driderdarkness.html"},
},
})
if err == nil || !strings.Contains(err.Error(), "duplicate wiki page-index public_target") {
t.Fatalf("expected duplicate public_target validation error, got %v", err)
}
}
func TestParseWikiPagePathOverridesRejectsInvalidAndDuplicateSlugs(t *testing.T) {
_, err := parseWikiPagePathDeclarations([]byte(`
page_paths:
slug_overrides:
- page_id: "spells:pdk:fear"
slug: "Pdk Fear"
`), "wiki.yaml")
if err == nil || !strings.Contains(err.Error(), `invalid slug "Pdk Fear"`) {
t.Fatalf("expected invalid wiki slug error, got %v", err)
}
_, err = parseWikiPagePathDeclarations([]byte(`
page_paths:
slug_overrides:
- page_id: "spells:pdk:fear"
slug: "pdk-fear"
- page_id: "spells:pdk:fear"
slug: "fear-pdk"
`), "wiki.yaml")
if err == nil || !strings.Contains(err.Error(), `duplicate page_id "spells:pdk:fear"`) {
t.Fatalf("expected duplicate page ID error, got %v", err)
}
}
func TestWikiPageSlugOverridesChangeTargets(t *testing.T) {
ctx := &wikiContext{pageSlugOverrides: map[string]string{
"spells:pdk:fear": "pdk-fear",
}}
if got, want := ctx.publicWikiPageSlug("spells:pdk:fear", "Fear"), "pdk-fear"; got != want {
t.Fatalf("expected slug override %q, got %q", want, got)
}
}
func TestPublicWikiTargetUsesTargetTitleBeforeDisplayLabel(t *testing.T) {
ctx := &wikiContext{
rowsByKey: map[string]map[string]any{
"feat:hardiness:versus:enchantments": {"FEAT": "Hardiness vs. Enchantments"},
},
}
if got, want := ctx.publicWikiTargetForKey("feat:hardiness:versus:enchantments", "Display Alias"), "feat/hardiness-vs-enchantments"; got != want {
t.Fatalf("expected target title slug %q, got %q", want, got)
}
}
func TestBuildAndPackageBuildsWikiOnlyWhenRequested(t *testing.T) {
root := testProjectRoot(t)
mkdirAll(t, filepath.Join(root, "topdata", "data", "skills"))