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:
@@ -56,6 +56,8 @@ type wikiPageIndexEntry struct {
|
|||||||
SourceDataset string `json:"source_dataset"`
|
SourceDataset string `json:"source_dataset"`
|
||||||
SourceKey string `json:"source_key"`
|
SourceKey string `json:"source_key"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
|
PublicSlug string `json:"public_slug"`
|
||||||
|
PublicTarget string `json:"public_target"`
|
||||||
Hash string `json:"hash"`
|
Hash string `json:"hash"`
|
||||||
OutputPath string `json:"output_path"`
|
OutputPath string `json:"output_path"`
|
||||||
EditPolicy string `json:"edit_policy"`
|
EditPolicy string `json:"edit_policy"`
|
||||||
@@ -115,6 +117,7 @@ type wikiContext struct {
|
|||||||
wikiTableDefinitions map[string]wikiTableDefinition
|
wikiTableDefinitions map[string]wikiTableDefinition
|
||||||
visibilityPolicy *wikiVisibilityDefinitions
|
visibilityPolicy *wikiVisibilityDefinitions
|
||||||
visibleWikiKeys map[string]bool
|
visibleWikiKeys map[string]bool
|
||||||
|
pageSlugOverrides 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) {
|
||||||
@@ -158,6 +161,11 @@ func buildWiki(p *project.Project, nativeResult BuildResult, force bool, progres
|
|||||||
if err := ctx.loadWikiVisibility(visibilityPath); err != nil {
|
if err := ctx.loadWikiVisibility(visibilityPath); err != nil {
|
||||||
return wikiResult{}, err
|
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 {
|
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)
|
||||||
@@ -175,13 +183,14 @@ func buildWiki(p *project.Project, nativeResult BuildResult, force bool, progres
|
|||||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
return err
|
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 {
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
pageTitles[pageID] = title
|
pageTitles[pageID] = title
|
||||||
pageStatuses[pageID] = status
|
pageStatuses[pageID] = status
|
||||||
namespace := strings.SplitN(pageID, ":", 2)[0]
|
|
||||||
rel, err := filepath.Rel(rootDir, path)
|
rel, err := filepath.Rel(rootDir, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
rel = filepath.Base(path)
|
rel = filepath.Base(path)
|
||||||
@@ -192,6 +201,8 @@ func buildWiki(p *project.Project, nativeResult BuildResult, force bool, progres
|
|||||||
SourceDataset: categoryFromWikiNamespace(namespace),
|
SourceDataset: categoryFromWikiNamespace(namespace),
|
||||||
SourceKey: pageID,
|
SourceKey: pageID,
|
||||||
Title: title,
|
Title: title,
|
||||||
|
PublicSlug: publicSlug,
|
||||||
|
PublicTarget: wikiPublicTarget(namespace, publicSlug),
|
||||||
Hash: computeManagedHash(content),
|
Hash: computeManagedHash(content),
|
||||||
OutputPath: filepath.ToSlash(rel),
|
OutputPath: filepath.ToSlash(rel),
|
||||||
EditPolicy: wikiEditPolicy(namespace),
|
EditPolicy: wikiEditPolicy(namespace),
|
||||||
@@ -291,6 +302,7 @@ func saveWikiPageIndex(path string, index wikiPageIndex) error {
|
|||||||
func validateWikiPageIndex(index wikiPageIndex) error {
|
func validateWikiPageIndex(index wikiPageIndex) error {
|
||||||
pageIDs := map[string]string{}
|
pageIDs := map[string]string{}
|
||||||
outputPaths := map[string]string{}
|
outputPaths := map[string]string{}
|
||||||
|
publicTargets := map[string]string{}
|
||||||
for _, entry := range index.Pages {
|
for _, entry := range index.Pages {
|
||||||
if previous, ok := pageIDs[entry.PageID]; ok {
|
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)
|
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 {
|
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)
|
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
|
pageIDs[entry.PageID] = entry.OutputPath
|
||||||
outputPaths[entry.OutputPath] = entry.PageID
|
outputPaths[entry.OutputPath] = entry.PageID
|
||||||
}
|
}
|
||||||
@@ -340,6 +358,8 @@ func indexUnregisteredWikiPages(rootDir, outputDir, stalePolicy string, index *w
|
|||||||
SourceDataset: categoryFromWikiNamespace(namespace),
|
SourceDataset: categoryFromWikiNamespace(namespace),
|
||||||
SourceKey: pageID,
|
SourceKey: pageID,
|
||||||
Title: extractHTMLTitle(string(raw), pageID),
|
Title: extractHTMLTitle(string(raw), pageID),
|
||||||
|
PublicSlug: wikiSlugifyTitle(extractHTMLTitle(string(raw), pageID)),
|
||||||
|
PublicTarget: wikiPublicTarget(namespace, wikiSlugifyTitle(extractHTMLTitle(string(raw), pageID))),
|
||||||
Hash: computeManagedHash(string(raw)),
|
Hash: computeManagedHash(string(raw)),
|
||||||
OutputPath: filepath.ToSlash(relToRoot),
|
OutputPath: filepath.ToSlash(relToRoot),
|
||||||
EditPolicy: wikiEditPolicy(namespace),
|
EditPolicy: wikiEditPolicy(namespace),
|
||||||
@@ -1031,8 +1051,8 @@ func (ctx *wikiContext) renderReference(value any, idToKey map[int]string, nameR
|
|||||||
if name == "" {
|
if name == "" {
|
||||||
name = key
|
name = key
|
||||||
}
|
}
|
||||||
if pageID := wikiPageIDForKey(key); pageID != "" {
|
if target := ctx.publicWikiTargetForKey(key, name); target != "" {
|
||||||
return "[[" + pageID + "|" + name + "]]"
|
return "[[" + target + "|" + name + "]]"
|
||||||
}
|
}
|
||||||
return 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 {
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
return err
|
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)
|
return os.WriteFile(path, []byte(ensureTrailingNewline(content)), 0o644)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1613,6 +1634,67 @@ func wikiPageIDForKey(key string) string {
|
|||||||
return namespace + ":" + value
|
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 {
|
func wikiPageIDToRelPath(pageID string) string {
|
||||||
return filepath.FromSlash(strings.ReplaceAll(pageID, ":", "/") + ".html")
|
return filepath.FromSlash(strings.ReplaceAll(pageID, ":", "/") + ".html")
|
||||||
}
|
}
|
||||||
@@ -1835,14 +1917,14 @@ func renderNodeBBManagedMarkdown(pageID, dokuText string) string {
|
|||||||
return ensureTrailingNewline(strings.Join(parts, "\n"))
|
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
|
body := sourceText
|
||||||
if !strings.Contains(sourceText, "<h1") {
|
if !strings.Contains(sourceText, "<h1") {
|
||||||
body = dokuWikiToNodeBBHTML(sourceText)
|
body = dokuWikiToNodeBBHTML(sourceText)
|
||||||
}
|
}
|
||||||
hash := computeManagedHash(body)
|
hash := computeManagedHash(body)
|
||||||
parts := []string{
|
parts := []string{
|
||||||
"<!-- sow-topdata-wiki:page=" + pageID + " -->",
|
"<!-- sow-topdata-wiki:page=" + pageID + " wiki_slug=" + publicSlug + " -->",
|
||||||
"<!-- sow-topdata-wiki:managed:start hash=\"sha256:" + hash + "\" -->",
|
"<!-- sow-topdata-wiki:managed:start hash=\"sha256:" + hash + "\" -->",
|
||||||
strings.TrimSpace(body),
|
strings.TrimSpace(body),
|
||||||
"<!-- sow-topdata-wiki:managed:end -->",
|
"<!-- sow-topdata-wiki:managed:end -->",
|
||||||
|
|||||||
@@ -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.") {
|
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)
|
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)
|
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>") {
|
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)
|
t.Fatalf("expected page-index entries to match generated HTML files, got index=%d files=%d", len(pageIndex.Pages), generatedHTMLCount)
|
||||||
}
|
}
|
||||||
indexText := string(indexRaw)
|
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)
|
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"`) {
|
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>1st</td>`,
|
||||||
`<td>+1</td>`,
|
`<td>+1</td>`,
|
||||||
`<td>2</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>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) {
|
if !strings.Contains(got, expected) {
|
||||||
t.Fatalf("expected %q in rendered table:\n%s", expected, got)
|
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)
|
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) {
|
if !strings.Contains(classText, visible) {
|
||||||
t.Fatalf("expected visible %q reference in class page:\n%s", visible, classText)
|
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) {
|
func TestBuildAndPackageBuildsWikiOnlyWhenRequested(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"))
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package topdata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type wikiPagePathDocument struct {
|
||||||
|
PagePaths wikiPagePaths `json:"page_paths" yaml:"page_paths"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type wikiPagePaths struct {
|
||||||
|
SlugOverrides []wikiPageSlugOverride `json:"slug_overrides" yaml:"slug_overrides"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type wikiPageSlugOverride struct {
|
||||||
|
PageID string `json:"page_id" yaml:"page_id"`
|
||||||
|
Slug string `json:"slug" yaml:"slug"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadWikiPagePathDeclarations(path string) (map[string]string, error) {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return map[string]string{}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return parseWikiPagePathDeclarations(raw, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseWikiPagePathDeclarations(raw []byte, path string) (map[string]string, error) {
|
||||||
|
var doc wikiPagePathDocument
|
||||||
|
if err := yaml.Unmarshal(raw, &doc); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse wiki page paths %s: %w", path, err)
|
||||||
|
}
|
||||||
|
overrides := map[string]string{}
|
||||||
|
for _, override := range doc.PagePaths.SlugOverrides {
|
||||||
|
pageID := strings.TrimSpace(override.PageID)
|
||||||
|
slug := strings.TrimSpace(override.Slug)
|
||||||
|
if pageID == "" {
|
||||||
|
return nil, fmt.Errorf("parse wiki page paths %s: slug override has empty page_id", path)
|
||||||
|
}
|
||||||
|
if !isCanonicalWikiSlug(slug) {
|
||||||
|
return nil, fmt.Errorf("parse wiki page paths %s: page_id %q has invalid slug %q", path, pageID, override.Slug)
|
||||||
|
}
|
||||||
|
if _, ok := overrides[pageID]; ok {
|
||||||
|
return nil, fmt.Errorf("parse wiki page paths %s: duplicate page_id %q", path, pageID)
|
||||||
|
}
|
||||||
|
overrides[pageID] = slug
|
||||||
|
}
|
||||||
|
return overrides, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isCanonicalWikiSlug(value string) bool {
|
||||||
|
if value == "" || value != strings.ToLower(value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
parts := strings.Split(value, "-")
|
||||||
|
for _, part := range parts {
|
||||||
|
if part == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range part {
|
||||||
|
if !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -813,8 +813,10 @@ func (p *wikiExprParser) callHelper(name string, args []any) (any, error) {
|
|||||||
if p.ctx != nil && !p.ctx.canReferenceWikiKey(key) {
|
if p.ctx != nil && !p.ctx.canReferenceWikiKey(key) {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
if pageID := wikiPageIDForKey(key); pageID != "" {
|
if p.ctx != nil {
|
||||||
key = pageID
|
if target := p.ctx.publicWikiTargetForKey(key, ""); target != "" {
|
||||||
|
key = target
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return "[[" + key + "]]", nil
|
return "[[" + key + "]]", nil
|
||||||
}
|
}
|
||||||
@@ -827,8 +829,10 @@ func (p *wikiExprParser) callHelper(name string, args []any) (any, error) {
|
|||||||
if p.ctx != nil && !p.ctx.canReferenceWikiKey(key) {
|
if p.ctx != nil && !p.ctx.canReferenceWikiKey(key) {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
if pageID := wikiPageIDForKey(key); pageID != "" {
|
if p.ctx != nil {
|
||||||
key = pageID
|
if target := p.ctx.publicWikiTargetForKey(key, label); target != "" {
|
||||||
|
key = target
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return "[[" + key + "|" + label + "]]", nil
|
return "[[" + key + "|" + label + "]]", nil
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user