diff --git a/internal/topdata/wiki_deploy.go b/internal/topdata/wiki_deploy.go index 8fde49f..dbcef7c 100644 --- a/internal/topdata/wiki_deploy.go +++ b/internal/topdata/wiki_deploy.go @@ -715,20 +715,7 @@ func normalizeWikiTitle(value string) string { } func slugifyWikiTitle(value string) string { - var b strings.Builder - lastDash := false - for _, r := range strings.ToLower(strings.TrimSpace(value)) { - if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { - b.WriteRune(r) - lastDash = false - continue - } - if !lastDash && b.Len() > 0 { - b.WriteByte('-') - lastDash = true - } - } - return strings.Trim(b.String(), "-") + return slugifyWikiText(value, "") } func loadDeployManifest(path string) wikiDeployManifest { diff --git a/internal/topdata/wiki_deploy_test.go b/internal/topdata/wiki_deploy_test.go index 0e80c63..9b96eec 100644 --- a/internal/topdata/wiki_deploy_test.go +++ b/internal/topdata/wiki_deploy_test.go @@ -168,6 +168,21 @@ func TestMatchExistingNodeBBPagePrefersGeneratedPublicSlug(t *testing.T) { } } +func TestDeploySlugifyWikiTitleMatchesGeneratedSlugPolicy(t *testing.T) { + tests := map[string]string{ + `Grandmaster's Battle Momentum`: "grandmasters-battle-momentum", + `Bigby’s Clenched Fist`: "bigbys-clenched-fist", + `Hardiness vs. Enchantments`: "hardiness-vs-enchantments", + `Clairaudience/Clairvoyance`: "clairaudience-clairvoyance", + `Élite & Noble Houses`: "elite-noble-houses", + } + for input, want := range tests { + if got := slugifyWikiTitle(input); got != want { + t.Fatalf("slugifyWikiTitle(%q) = %q, want %q", input, got, want) + } + } +} + func TestDeployWikiCreatesNodeBBTopicAndWritesManifest(t *testing.T) { root := t.TempDir() sourceDir := filepath.Join(root, "pages") diff --git a/internal/topdata/wiki_native.go b/internal/topdata/wiki_native.go index db7ac1e..1ce5b49 100644 --- a/internal/topdata/wiki_native.go +++ b/internal/topdata/wiki_native.go @@ -1682,25 +1682,7 @@ func wikiPublicTarget(namespace, slug string) string { } 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 + return slugifyWikiText(value, "topic") } func wikiPageIDToRelPath(pageID string) string { diff --git a/internal/topdata/wiki_native_test.go b/internal/topdata/wiki_native_test.go index 419bb9a..9c92548 100644 --- a/internal/topdata/wiki_native_test.go +++ b/internal/topdata/wiki_native_test.go @@ -1239,6 +1239,25 @@ func TestWikiPageSlugOverridesChangeTargets(t *testing.T) { } } +func TestWikiSlugifyTitleNormalizesPunctuationAndSeparators(t *testing.T) { + tests := map[string]string{ + `Grandmaster's Battle Momentum`: "grandmasters-battle-momentum", + `Bigby’s Clenched Fist`: "bigbys-clenched-fist", + `"Quoted" ‘Curly’ “Title”`: "quoted-curly-title", + `Hardiness vs. Enchantments`: "hardiness-vs-enchantments", + `Clairaudience/Clairvoyance`: "clairaudience-clairvoyance", + `Moon-on-a-Stick`: "moon-on-a-stick", + `Élite & Noble Houses`: "elite-noble-houses", + `Rock 'n' Roll`: "rock-n-roll", + `Melf's Acid Arrow`: "melfs-acid-arrow", + } + for input, want := range tests { + if got := wikiSlugifyTitle(input); got != want { + t.Fatalf("wikiSlugifyTitle(%q) = %q, want %q", input, got, want) + } + } +} + func TestPublicWikiTargetUsesTargetTitleBeforeDisplayLabel(t *testing.T) { ctx := &wikiContext{ rowsByKey: map[string]map[string]any{ diff --git a/internal/topdata/wiki_slug.go b/internal/topdata/wiki_slug.go new file mode 100644 index 0000000..0e1a1cf --- /dev/null +++ b/internal/topdata/wiki_slug.go @@ -0,0 +1,48 @@ +package topdata + +import ( + "html" + "strings" + "unicode" + + "golang.org/x/text/unicode/norm" +) + +func slugifyWikiText(value string, fallback string) string { + value = html.UnescapeString(strings.TrimSpace(value)) + var b strings.Builder + lastDash := false + for _, r := range norm.NFKD.String(strings.ToLower(value)) { + switch { + case r >= 'a' && r <= 'z': + b.WriteRune(r) + lastDash = false + case r >= '0' && r <= '9': + b.WriteRune(r) + lastDash = false + case unicode.Is(unicode.Mn, r): + continue + case isWikiSlugJoinerPunctuation(r): + continue + default: + if b.Len() > 0 && !lastDash { + b.WriteByte('-') + lastDash = true + } + } + } + slug := strings.Trim(b.String(), "-") + if slug == "" { + return fallback + } + return slug +} + +func isWikiSlugJoinerPunctuation(r rune) bool { + switch r { + case '\'', '"', '`', '´', '‘', '’', '‚', '‛', '“', '”', '„', '‟', '‹', '›', '«', '»', '′', '″', ''', '"': + return true + default: + return false + } +}