diff --git a/doc.go b/doc.go index f6f764e..9807fea 100644 --- a/doc.go +++ b/doc.go @@ -38,6 +38,23 @@ Example: } textSub := slug.Make("water is hot") fmt.Println(textSub) // Will print: "sand-is-hot" + + // as above but goroutine safe, without race hazard + + slugger := slug.New() // captures current global defaults + + enText := slugger.MakeLang("This & that", "en") + fmt.Println(enText) // Will print: "this-and-that" + + slugger.Lowercase = false // Keep uppercase characters + deUppercaseText := slugger.MakeLang("Diese & Dass", "de") + fmt.Println(deUppercaseText) // Will print: "Diese-und-Dass" + + slugger.CustomSub = map[string]string{ + "water": "sand", + } + textSub := slugger.Make("water is hot") + fmt.Println(textSub) // Will print: "sand-is-hot" } Requests or bugs? diff --git a/go.mod b/go.mod index f528a7a..b13c9a3 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,5 @@ module github.com/gosimple/slug -go 1.13 - require github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be + +go 1.13 diff --git a/slug.go b/slug.go index 272cc3f..8d4f6d6 100644 --- a/slug.go +++ b/slug.go @@ -36,6 +36,26 @@ var ( regexpMultipleDashes = regexp.MustCompile("-+") ) +// Slugger configures slug generation +type Slugger struct { + // CustomSub stores custom substitution map + CustomSub map[string]string + // CustomRuneSub stores custom rune substitution map + CustomRuneSub map[rune]string + + // MaxLength stores maximum slug length. + // It's smart so it will cat slug after full word. + // By default slugs aren't shortened. + // If MaxLength is smaller than length of the first word, then returned + // slug will contain only substring from the first word truncated + // after MaxLength. + MaxLength int + + // Lowercase defines if the resulting slug is transformed to lowercase. + // Default is true. + Lowercase bool +} + //============================================================================= // Make returns slug generated from provided string. Will use "en" as language @@ -47,12 +67,28 @@ func Make(s string) (slug string) { // MakeLang returns slug generated from provided string and will use provided // language for chars substitution. func MakeLang(s string, lang string) (slug string) { + return New().MakeLang(s, lang) +} + +// New returns a Slugger initialized with the current global defaults +func New() Slugger { + return Slugger{ + CustomSub: CustomSub, + CustomRuneSub: CustomRuneSub, + MaxLength: MaxLength, + Lowercase: Lowercase, + } +} + +// MakeLang returns slug generated from provided string and will use provided +// language for chars substitution. +func (sl Slugger) MakeLang(s string, lang string) (slug string) { slug = strings.TrimSpace(s) // Custom substitutions // Always substitute runes first - slug = SubstituteRune(slug, CustomRuneSub) - slug = Substitute(slug, CustomSub) + slug = SubstituteRune(slug, sl.CustomRuneSub) + slug = Substitute(slug, sl.CustomSub) // Process string with selected substitution language. // Catch ISO 3166-1, ISO 639-1:2002 and ISO 639-3:2007. @@ -84,7 +120,7 @@ func MakeLang(s string, lang string) (slug string) { // Process all non ASCII symbols slug = unidecode.Unidecode(slug) - if Lowercase { + if sl.Lowercase { slug = strings.ToLower(slug) } @@ -93,8 +129,8 @@ func MakeLang(s string, lang string) (slug string) { slug = regexpMultipleDashes.ReplaceAllString(slug, "-") slug = strings.Trim(slug, "-_") - if MaxLength > 0 { - slug = smartTruncate(slug) + if sl.MaxLength > 0 { + slug = smartTruncate(slug, sl.MaxLength) } return slug @@ -131,20 +167,20 @@ func SubstituteRune(s string, sub map[rune]string) string { return buf.String() } -func smartTruncate(text string) string { - if len(text) < MaxLength { +func smartTruncate(text string, maxLength int) string { + if len(text) < maxLength { return text } var truncated string words := strings.SplitAfter(text, "-") - // If MaxLength is smaller than length of the first word return word - // truncated after MaxLength. - if len(words[0]) > MaxLength { - return words[0][:MaxLength] + // If maxLength is smaller than length of the first word return word + // truncated after maxLength. + if len(words[0]) > maxLength { + return words[0][:maxLength] } for _, word := range words { - if len(truncated)+len(word)-1 <= MaxLength { + if len(truncated)+len(word)-1 <= maxLength { truncated = truncated + word } else { break @@ -159,8 +195,17 @@ func smartTruncate(text string) string { // It should be in range of the MaxLength var if specified. // All output from slug.Make(text) should pass this test. func IsSlug(text string) bool { + return Slugger{MaxLength: MaxLength}.IsSlug(text) +} + +// IsSlug returns True if provided text does not contain white characters, +// punctuation, all letters are lower case and only from ASCII range. +// It could contain `-` and `_` but not at the beginning or end of the text. +// It should be in range of the MaxLength var if specified. +// All output from slug.Make(text) should pass this test. +func (sl Slugger) IsSlug(text string) bool { if text == "" || - (MaxLength > 0 && len(text) > MaxLength) || + (sl.MaxLength > 0 && len(text) > sl.MaxLength) || text[0] == '-' || text[0] == '_' || text[len(text)-1] == '-' || text[len(text)-1] == '_' { return false diff --git a/slug_test.go b/slug_test.go index e43eeed..10f1101 100644 --- a/slug_test.go +++ b/slug_test.go @@ -6,6 +6,7 @@ package slug import ( + "fmt" "testing" ) @@ -52,12 +53,16 @@ func TestSlugMake(t *testing.T) { } for index, st := range testCases { - got := Make(st.in) - if got != st.want { - t.Errorf( - "%d. Make(%#v) = %#v; want %#v", - index, st.in, got, st.want) - } + t.Run(fmt.Sprintf("%d", index), func(t *testing.T) { + //t.Parallel() + st := st + got := Make(st.in) + if got != st.want { + t.Errorf( + "%d. Make(%#v) = %#v; want %#v", + index, st.in, got, st.want) + } + }) } } @@ -116,13 +121,17 @@ func TestSlugMakeLang(t *testing.T) { } for index, smlt := range testCases { - Lowercase = smlt.lowercase - got := MakeLang(smlt.in, smlt.lang) - if got != smlt.want { - t.Errorf( - "%d. MakeLang(%#v, %#v) = %#v; want %#v", - index, smlt.in, smlt.lang, got, smlt.want) - } + t.Run(fmt.Sprintf("%d", index), func(t *testing.T) { + //t.Parallel() + smlt := smlt // closure + Lowercase = smlt.lowercase + got := MakeLang(smlt.in, smlt.lang) + if got != smlt.want { + t.Errorf( + "%d. MakeLang(%#v, %#v) = %#v; want %#v", + index, smlt.in, smlt.lang, got, smlt.want) + } + }) } } @@ -144,14 +153,18 @@ func TestSlugMakeUserSubstituteLang(t *testing.T) { } for index, smust := range testCases { - CustomSub = smust.cSub - got := MakeLang(smust.in, smust.lang) - if got != smust.want { - t.Errorf( - "%d. %#v; MakeLang(%#v, %#v) = %#v; want %#v", - index, smust.cSub, smust.in, smust.lang, - got, smust.want) - } + t.Run(fmt.Sprintf("%d", index), func(t *testing.T) { + //t.Parallel() + smust := smust + CustomSub = smust.cSub + got := MakeLang(smust.in, smust.lang) + if got != smust.want { + t.Errorf( + "%d. %#v; MakeLang(%#v, %#v) = %#v; want %#v", + index, smust.cSub, smust.in, smust.lang, + got, smust.want) + } + }) } } @@ -170,15 +183,19 @@ func TestSlugMakeSubstituteOrderLang(t *testing.T) { } for index, smsot := range testCases { - CustomRuneSub = smsot.rSub - CustomSub = smsot.sSub - got := Make(smsot.in) - if got != smsot.want { - t.Errorf( - "%d. %#v; %#v; Make(%#v) = %#v; want %#v", - index, smsot.rSub, smsot.sSub, smsot.in, - got, smsot.want) - } + t.Run(fmt.Sprintf("%d", index), func(t *testing.T) { + //t.Parallel() + smsot := smsot + CustomRuneSub = smsot.rSub + CustomSub = smsot.sSub + got := Make(smsot.in) + if got != smsot.want { + t.Errorf( + "%d. %#v; %#v; Make(%#v) = %#v; want %#v", + index, smsot.rSub, smsot.sSub, smsot.in, + got, smsot.want) + } + }) } } @@ -195,12 +212,16 @@ func TestSubstituteLang(t *testing.T) { } for index, sst := range testCases { - got := Substitute(sst.in, sst.cSub) - if got != sst.want { - t.Errorf( - "%d. Substitute(%#v, %#v) = %#v; want %#v", - index, sst.in, sst.cSub, got, sst.want) - } + t.Run(fmt.Sprintf("%d", index), func(t *testing.T) { + //t.Parallel() + sst := sst + got := Substitute(sst.in, sst.cSub) + if got != sst.want { + t.Errorf( + "%d. Substitute(%#v, %#v) = %#v; want %#v", + index, sst.in, sst.cSub, got, sst.want) + } + }) } } @@ -217,12 +238,16 @@ func TestSubstituteRuneLang(t *testing.T) { } for index, ssrt := range testCases { - got := SubstituteRune(ssrt.in, ssrt.cSub) - if got != ssrt.want { - t.Errorf( - "%d. SubstituteRune(%#v, %#v) = %#v; want %#v", - index, ssrt.in, ssrt.cSub, got, ssrt.want) - } + t.Run(fmt.Sprintf("%d", index), func(t *testing.T) { + //t.Parallel() + ssrt := ssrt + got := SubstituteRune(ssrt.in, ssrt.cSub) + if got != ssrt.want { + t.Errorf( + "%d. SubstituteRune(%#v, %#v) = %#v; want %#v", + index, ssrt.in, ssrt.cSub, got, ssrt.want) + } + }) } } @@ -241,13 +266,17 @@ func TestSlugMakeSmartTruncate(t *testing.T) { } for index, smstt := range testCases { - MaxLength = smstt.maxLength - got := Make(smstt.in) - if got != smstt.want { - t.Errorf( - "%d. MaxLength = %v; Make(%#v) = %#v; want %#v", - index, smstt.maxLength, smstt.in, got, smstt.want) - } + t.Run(fmt.Sprintf("%d", index), func(t *testing.T) { + //t.Parallel() + smstt := smstt + MaxLength = smstt.maxLength + got := Make(smstt.in) + if got != smstt.want { + t.Errorf( + "%d. MaxLength = %v; Make(%#v) = %#v; want %#v", + index, smstt.maxLength, smstt.in, got, smstt.want) + } + }) } } @@ -276,8 +305,10 @@ func TestIsSlug(t *testing.T) { {"outside ASCII –", args{"2000–2013"}, false}, {"smile ☺", args{"smile ☺"}, false}, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { + for index, tt := range tests { + t.Run(fmt.Sprintf("%d", index), func(t *testing.T) { + //t.Parallel() + tt := tt if got := IsSlug(tt.args.text); got != tt.want { t.Errorf("IsSlug() = %v, want %v", got, tt.want) } @@ -403,7 +434,7 @@ func BenchmarkSmartTruncateShort(b *testing.B) { b.ReportAllocs() b.ResetTimer() for n := 0; n < b.N; n++ { - smartTruncate(shortStr) + smartTruncate(shortStr, MaxLength) } } @@ -428,7 +459,7 @@ func BenchmarkSmartTruncateLong(b *testing.B) { b.ReportAllocs() b.ResetTimer() for n := 0; n < b.N; n++ { - smartTruncate(longStr) + smartTruncate(longStr, MaxLength) } }