aboutsummaryrefslogtreecommitdiffstats
path: root/internal/slug
diff options
context:
space:
mode:
Diffstat (limited to 'internal/slug')
-rw-r--r--internal/slug/slug.go54
-rw-r--r--internal/slug/slug_test.go34
2 files changed, 88 insertions, 0 deletions
diff --git a/internal/slug/slug.go b/internal/slug/slug.go
new file mode 100644
index 0000000..a3c99a4
--- /dev/null
+++ b/internal/slug/slug.go
@@ -0,0 +1,54 @@
+// Package slug turns titles into URL-safe identifiers.
+package slug
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+ "unicode"
+
+ "golang.org/x/text/runes"
+ "golang.org/x/text/transform"
+ "golang.org/x/text/unicode/norm"
+)
+
+var (
+ nonAlnum = regexp.MustCompile(`[^a-z0-9]+`)
+ validRe = regexp.MustCompile(`^[a-z0-9](?:-?[a-z0-9])*$`)
+ stripMarks = transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)
+)
+
+// greek maps Greek letters to Latin so Greek titles get readable slugs.
+var greek = strings.NewReplacer(
+ "α", "a", "β", "v", "γ", "g", "δ", "d", "ε", "e", "ζ", "z", "η", "i", "θ", "th",
+ "ι", "i", "κ", "k", "λ", "l", "μ", "m", "ν", "n", "ξ", "x", "ο", "o", "π", "p",
+ "ρ", "r", "σ", "s", "ς", "s", "τ", "t", "υ", "y", "φ", "f", "χ", "ch", "ψ", "ps", "ω", "o",
+)
+
+// Make returns a slug for s; falls back to "untitled" when nothing usable remains.
+func Make(s string) string {
+ if t, _, err := transform.String(stripMarks, s); err == nil {
+ s = t
+ }
+ s = greek.Replace(strings.ToLower(s))
+ s = nonAlnum.ReplaceAllString(s, "-")
+ s = strings.Trim(s, "-")
+ if len(s) > 80 {
+ s = strings.Trim(s[:80], "-")
+ }
+ if s == "" {
+ return "untitled"
+ }
+ return s
+}
+
+// WithSuffix returns "base-n" (or base when n < 2), used to resolve collisions.
+func WithSuffix(base string, n int) string {
+ if n < 2 {
+ return base
+ }
+ return fmt.Sprintf("%s-%d", base, n)
+}
+
+// Valid reports whether s is an acceptable slug as typed by a user.
+func Valid(s string) bool { return validRe.MatchString(s) && len(s) <= 80 }
diff --git a/internal/slug/slug_test.go b/internal/slug/slug_test.go
new file mode 100644
index 0000000..9953f34
--- /dev/null
+++ b/internal/slug/slug_test.go
@@ -0,0 +1,34 @@
+package slug
+
+import "testing"
+
+func TestMake(t *testing.T) {
+ cases := map[string]string{
+ "Hello, World!": "hello-world",
+ " Ünïcödé Tïtle ": "unicode-title",
+ "!!!": "untitled",
+ "": "untitled",
+ "already-a-slug": "already-a-slug",
+ "Multiple spaces--": "multiple-spaces",
+ "Καλημέρα κόσμε": "kalimera-kosme",
+ "日本語": "untitled", // unsupported scripts fall back; user can type a slug
+ }
+ for in, want := range cases {
+ if got := Make(in); got != want {
+ t.Errorf("Make(%q) = %q, want %q", in, got, want)
+ }
+ }
+}
+
+func TestValid(t *testing.T) {
+ for _, ok := range []string{"a", "abc-def", "a1-b2"} {
+ if !Valid(ok) {
+ t.Errorf("Valid(%q) = false", ok)
+ }
+ }
+ for _, bad := range []string{"", "-a", "a-", "a--b", "A", "a b", "a/b"} {
+ if Valid(bad) {
+ t.Errorf("Valid(%q) = true", bad)
+ }
+ }
+}