Skip to content

Commit eb83878

Browse files
committed
base library
0 parents  commit eb83878

File tree

11 files changed

+682
-0
lines changed

11 files changed

+682
-0
lines changed

README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Slugger
2+
3+
An extensible and configurable library with language specific support for generating slugs.
4+
5+
## Motivation
6+
7+
> TL;DR This is yet another slug generation library.
8+
9+
Creating a slug is common for applications and services and
10+
althought there are several existing libraries the maintenance or purpose
11+
doesn't clearly align with our needs.
12+
13+
## Usage
14+
15+
To generate slugs you will need to define a configuration, the library ships several
16+
options to have a fluent approach for this task.
17+
18+
Here is a basic example for an slugger using English and no suffixes.
19+
20+
```go
21+
package main
22+
23+
import "github.com/avocatl/slugger"
24+
25+
func main() {
26+
cfg := slugger.NewConfig(
27+
slugger.WithLanguage(slugger.English),
28+
slugger.WithNoSuffix(),
29+
)
30+
31+
s := slugger.New(cfg)
32+
33+
slug := s.Slugify("Hello, World!")
34+
println(slug) // Output: hello-world
35+
}
36+
```

config.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
package slugger
2+
3+
// Language defines the language used for slugification.
4+
type Language int
5+
6+
// Supported languages for slugification.
7+
const (
8+
English Language = iota
9+
German
10+
Spanish
11+
)
12+
13+
// Suffix defines the type of suffix to append to slugs.
14+
type Suffix int
15+
16+
// Different suffix strategies.
17+
const (
18+
None Suffix = iota
19+
Numbered
20+
TimestampBased
21+
HashBased
22+
)
23+
24+
// Config holds configuration options for the slugger.
25+
type Config struct {
26+
Lowercase bool
27+
Separator string
28+
MaxLength int
29+
Language Language
30+
SuffixStrategy Suffix
31+
SuffixLength int
32+
TimestampTimezone string
33+
CounterProvider CounterProviderFunction
34+
}
35+
36+
// ConfigOption defines a function type for configuring the Slugger.
37+
type ConfigOption func(*Config)
38+
39+
// WithoutLowercase sets whether slugs should be lowercase.
40+
func WithoutLowercase() ConfigOption {
41+
return func(c *Config) {
42+
c.Lowercase = false
43+
}
44+
}
45+
46+
// WithSeparator sets the separator character for slugs.
47+
func WithSeparator(separator string) ConfigOption {
48+
return func(c *Config) {
49+
c.Separator = separator
50+
}
51+
}
52+
53+
// WithMaxLength sets the maximum length for slugs.
54+
func WithMaxLength(maxLength int) ConfigOption {
55+
return func(c *Config) {
56+
c.MaxLength = maxLength
57+
}
58+
}
59+
60+
61+
// WithLanguage sets the language for slugification.
62+
func WithLanguage(language Language) ConfigOption {
63+
return func(c *Config) {
64+
c.Language = language
65+
}
66+
}
67+
68+
// WithSuffixStrategy sets the suffix strategy for slugs.
69+
func WithSuffixStrategy(suffix Suffix) ConfigOption {
70+
return func(c *Config) {
71+
c.SuffixStrategy = suffix
72+
}
73+
}
74+
75+
// WithHashSuffixLength sets the length of the hash-based suffix.
76+
func WithHashSuffixLength(length int) ConfigOption {
77+
return func(c *Config) {
78+
c.SuffixStrategy = HashBased
79+
c.SuffixLength = length
80+
}
81+
}
82+
83+
// WithTimestampTimezone sets the timezone for timestamp-based suffixes.
84+
func WithTimestampTimezone(timezone string) ConfigOption {
85+
return func(c *Config) {
86+
c.SuffixStrategy = TimestampBased
87+
c.TimestampTimezone = timezone
88+
}
89+
}
90+
91+
// WithNumberedCounterProvider sets the counter provider function for numbered suffixes.
92+
func WithNumberedCounterProvider(counterProvider CounterProviderFunction) ConfigOption {
93+
return func(c *Config) {
94+
c.SuffixStrategy = Numbered
95+
c.CounterProvider = counterProvider
96+
}
97+
}
98+
99+
func WithNoSuffix() ConfigOption {
100+
return func(c *Config) {
101+
c.SuffixStrategy = None
102+
}
103+
}
104+
105+
// NewConfig creates a new Config with the provided options.
106+
func NewConfig(options ...ConfigOption) *Config {
107+
config := &Config{
108+
Lowercase: true,
109+
Separator: "-",
110+
MaxLength: 240,
111+
Language: English,
112+
SuffixStrategy: None,
113+
}
114+
115+
for _, option := range options {
116+
option(config)
117+
}
118+
119+
return config
120+
}

config_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package slugger_test
2+
3+
import (
4+
"log"
5+
"reflect"
6+
"testing"
7+
8+
"github.com/avocatl/slugger"
9+
)
10+
11+
func TestCofigOptions(t *testing.T) {
12+
cases := []struct {
13+
name string
14+
opts []slugger.ConfigOption
15+
expected *slugger.Config
16+
}{
17+
{
18+
name: "Default configuration",
19+
opts: []slugger.ConfigOption{},
20+
expected: &slugger.Config{
21+
Lowercase: true,
22+
Separator: "-",
23+
MaxLength: 240,
24+
},
25+
},
26+
{
27+
name: "Custom separator and max length",
28+
opts: []slugger.ConfigOption{
29+
slugger.WithSeparator("_"),
30+
slugger.WithMaxLength(10),
31+
},
32+
expected: &slugger.Config{
33+
Lowercase: true,
34+
Separator: "_",
35+
MaxLength: 10,
36+
},
37+
},
38+
{
39+
name: "Disable lowercase",
40+
opts: []slugger.ConfigOption{
41+
slugger.WithoutLowercase(),
42+
},
43+
expected: &slugger.Config{
44+
Lowercase: false,
45+
Separator: "-",
46+
MaxLength: 240,
47+
},
48+
},
49+
}
50+
51+
for _, tc := range cases {
52+
t.Run(tc.name, func(t *testing.T) {
53+
cfg := slugger.NewConfig(tc.opts...)
54+
55+
log.Println(cfg == tc.expected)
56+
57+
if !reflect.DeepEqual(cfg, tc.expected) {
58+
t.Errorf("Expected config %+v, got %+v", tc.expected, cfg)
59+
}
60+
})
61+
}
62+
}

go.mod

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
module github.com/avocatl/slugger
2+
3+
go 1.25.6
4+
5+
require golang.org/x/text v0.33.0

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
2+
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=

replacer.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package slugger
2+
3+
import "strings"
4+
5+
// LanguageReplacer defines an interface for replacing special characters based on language.
6+
type LanguageReplacer interface {
7+
Replace(string) string
8+
}
9+
10+
type germanReplacer struct {
11+
rep *strings.Replacer
12+
}
13+
14+
// Replace replaces special German characters in the input string.
15+
func (r *germanReplacer) Replace(input string) string {
16+
return r.rep.Replace(input)
17+
}
18+
19+
type englishReplacer struct {
20+
rep *strings.Replacer
21+
}
22+
23+
// Replace replaces special English characters in the input string.
24+
func (r *englishReplacer) Replace(input string) string {
25+
return r.rep.Replace(input)
26+
}
27+
28+
type spanishReplacer struct {
29+
rep *strings.Replacer
30+
}
31+
32+
// Replace replaces special Spanish characters in the input string.
33+
func (r *spanishReplacer) Replace(input string) string {
34+
return r.rep.Replace(input)
35+
}
36+
37+
// NewReplacer creates a LanguageReplacer based on the specified language.
38+
func NewReplacer(language Language) LanguageReplacer {
39+
switch language {
40+
case German:
41+
var oldnew []string
42+
43+
for old, new := range GermanCharacterMapping {
44+
oldnew = append(oldnew, old, new)
45+
}
46+
47+
rep := strings.NewReplacer(oldnew...)
48+
49+
return &germanReplacer{rep: rep}
50+
51+
case English:
52+
var oldnew []string
53+
54+
for old, new := range EnglishCharacterMapping {
55+
oldnew = append(oldnew, old, new)
56+
}
57+
58+
rep := strings.NewReplacer(oldnew...)
59+
60+
return &englishReplacer{rep: rep}
61+
62+
case Spanish:
63+
var oldnew []string
64+
65+
for old, new := range SpanishCharacterMapping {
66+
oldnew = append(oldnew, old, new)
67+
}
68+
69+
rep := strings.NewReplacer(oldnew...)
70+
71+
return &spanishReplacer{rep: rep}
72+
73+
default:
74+
// For unsupported languages, return a no-op replacer.
75+
return &noopReplacer{}
76+
}
77+
}
78+
79+
type noopReplacer struct{}
80+
81+
// Replace returns the input string unchanged.
82+
func (r *noopReplacer) Replace(input string) string {
83+
return input
84+
}

replacer_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package slugger_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/avocatl/slugger"
7+
)
8+
9+
func TestReplacer(t *testing.T) {
10+
cases := []struct {
11+
name string
12+
language slugger.Language
13+
input string
14+
expected string
15+
}{
16+
{
17+
name: "German Replacer",
18+
language: slugger.German,
19+
input: "Fähigkeit Straße über",
20+
expected: "Faehigkeit Strasse ueber",
21+
},
22+
{
23+
name: "English Replacer",
24+
language: slugger.English,
25+
input: "Café & Drinks",
26+
expected: "Café and Drinks",
27+
},
28+
{
29+
name: "Spanish Replacer",
30+
language: slugger.Spanish,
31+
input: "niño corazón jalapeño",
32+
expected: "nino corazon jalapeno",
33+
},
34+
}
35+
36+
for _, tc := range cases {
37+
t.Run(tc.name, func(t *testing.T) {
38+
replacer := slugger.NewReplacer(tc.language)
39+
result := replacer.Replace(tc.input)
40+
if result != tc.expected {
41+
t.Errorf("expected %q, got %q", tc.expected, result)
42+
}
43+
})
44+
}
45+
}

0 commit comments

Comments
 (0)