diff --git a/internal/topdata/wiki_native.go b/internal/topdata/wiki_native.go index 10cd44f..1a7a425 100644 --- a/internal/topdata/wiki_native.go +++ b/internal/topdata/wiki_native.go @@ -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, "