glugify

A slugification library for Gleam that converts text into URL-friendly slugs.

Package VersionHex Docs

gleam add glugify

Try it live

An interactive playground — powered by this exact library compiled to JavaScript — lives in docs/. Open docs/index.html in a browser (or enable GitHub Pages on the docs/ folder) to experiment with the core configuration options and copy the generated Gleam code. Rebuild the bundle after source changes with ./docs/build-playground.sh.

Quick Start

import glugify
// Simple usage - always returns a string
glugify.slugify("Hello, World!")
// -> "hello-world"
// Error-aware usage - returns Result
glugify.try_slugify("My Blog Post Title!")
// -> Ok("my-blog-post-title")

Three-Tier API

Tier 1: Simple API

Zero-configuration slugification that always returns a string:

import glugify
glugify.slugify("My awesome blog post!")
// -> "my-awesome-blog-post"
glugify.slugify("Café & Restaurant")
// -> "cafe-and-restaurant"

Tier 2: Error-Aware API

Returns Result(String, SlugifyError) for explicit error handling:

import glugify
case glugify.try_slugify("") {
Ok(slug) -> "Generated slug: " <> slug
Error(error) -> "Failed to generate slug"
}

Tier 3: Configurable API

Full control with custom configuration:

import glugify
import glugify/config
let custom_config = config.default()
|> config.with_separator("_")
|> config.with_max_length(20)
|> config.with_word_boundary(True)
glugify.slugify_with("A Very Long Title That Needs Truncation", custom_config)
// -> Ok("a_very_long_title")

Configuration Options

import glugify/config
import glugify/locale
config.default()
|> config.with_separator("_") // Default: "-"
|> config.with_lowercase(False) // Default: True
|> config.with_max_length(50) // Default: None
|> config.with_word_boundary(True) // Default: False
|> config.with_transliterate(False) // Default: True
|> config.with_allow_unicode(True) // Default: False
|> config.with_custom_replacements([ // Default: []
#("&", " and "),
#("@", " at ")
])
|> config.with_stop_words(["the", "a"]) // Default: []
|> config.with_preserve_leading_underscore(True) // Default: False
|> config.with_preserve_trailing_dash(True) // Default: False
|> config.with_locale(locale.German) // Default: locale.Default
|> config.with_decamelize(True) // Default: False
|> config.with_decode_html_entities(True) // Default: False
|> config.with_ignore(["#"]) // Default: []

There is also an SEO-tuned preset (60-character limit, word-boundary truncation, per search engine URL guidance):

glugify.slugify_with(long_title, config.seo_preset())

Unique Slugs

When slugifying many titles (tables of contents, CMS imports, static sites), use glugify/slugger to guarantee uniqueness. The state is an immutable value, so it threads naturally through folds and behaves identically on both targets:

import glugify/slugger
let s = slugger.new()
let #(s, a) = slugger.slug(s, "Hello World")
let #(s, b) = slugger.slug(s, "Hello World")
let #(_, c) = slugger.slug(s, "Hello World")
// a -> "hello-world"
// b -> "hello-world-1"
// c -> "hello-world-2"

Suffixed slugs never collide with slugs from genuinely suffixed input: "foo", "foo", "foo-1" yields foo, foo-1, foo-1-1. Use slugger.slug_with to combine uniqueness with a custom Config.

Advanced Examples

Custom Replacements

let config = config.default()
|> config.with_custom_replacements([
#("&", " and "),
#("@", " at "),
#("%", " percent ")
])
glugify.slugify_with("Cats & Dogs @ 100%", config)
// -> Ok("cats-and-dogs-at-100-percent")

Unicode Handling

// With transliteration (default)
glugify.slugify("Café naïve résumé")
// -> "cafe-naive-resume"
// Preserving Unicode
let unicode_config = config.default()
|> config.with_transliterate(False)
|> config.with_allow_unicode(True)
glugify.slugify_with("Café naïve résumé", unicode_config)
// -> Ok("café-naïve-résumé")

Transliteration covers Latin-extended characters (e.g. ø, Ł, æ, ß, İ), Cyrillic, Greek, Arabic/Persian and Hebrew (basic consonantal romanization), typographic punctuation (smart quotes, dashes, ellipses), and common currency signs and symbols. Decomposed (NFD) input is normalized by mapping base characters and dropping combining marks. Characters with no known mapping — such as emoji or CJK — are stripped rather than causing an error:

glugify.slugify("10 Tips 🚀 for Gleam")
// -> "10-tips-for-gleam"
glugify.slugify("Привет мир")
// -> "privet-mir"
glugify.slugify("Don’t — “Stop”")
// -> "dont-stop"

Locale-Aware Transliteration

import glugify/locale
let config = config.default()
|> config.with_locale(locale.German)
glugify.slugify_with("Über München", config)
// -> Ok("ueber-muenchen") (default locale gives "uber-munchen")
let config = config.default()
|> config.with_locale(locale.Danish)
glugify.slugify_with("København på Ærø", config)
// -> Ok("koebenhavn-paa-aeroe")

Decamelize

let config = config.default()
|> config.with_decamelize(True)
glugify.slugify_with("myAwesomeXMLParser", config)
// -> Ok("my-awesome-xml-parser")

HTML Entities

let config = config.default()
|> config.with_decode_html_entities(True)
glugify.slugify_with("Tom &amp; Jerry &ndash; Classics", config)
// -> Ok("tom-and-jerry-classics")

Ignored Characters

let config = config.default()
|> config.with_ignore(["#"])
glugify.slugify_with("C# and F# compared", config)
// -> Ok("c#-and-f#-compared")

Stop Words

let config = config.default()
|> config.with_stop_words(["the", "a", "an", "and", "or"])
glugify.slugify_with("The Quick Brown Fox and the Lazy Dog", config)
// -> Ok("quick-brown-fox-lazy-dog")

Error Handling

The library provides explicit error types for robust error handling:

import glugify/errors
case glugify.try_slugify("") {
Ok(slug) -> slug
Error(errors.EmptyInput) -> "Please provide some text"
Error(errors.TransliterationFailed(char)) -> "Cannot transliterate: " <> char
Error(errors.ConfigurationError(msg)) -> "Config error: " <> msg
}

Performance

Benchmark Results (using gleamy_bench)

Erlang Target

Test CaseFunctionIPS (ops/sec)Min Time (ms)P99 Time (ms)
Simple text ("Hello World")slugify44,9370.0190.033
Simple text ("Hello World")slugify_with_custom_config46,1450.0190.033
Unicode text with emojisslugify22,2920.0390.081
Unicode text with emojisslugify_with_custom_config22,3280.0410.073
Long text (200+ chars)slugify4,3040.2140.430
Long text (200+ chars)slugify_with_custom_config4,4490.2160.275
Complex text (mixed case, symbols)slugify7,3990.1290.162
Complex text (mixed case, symbols)slugify_with_custom_config7,2780.1310.166

Erlang Summary: Average of ~19,900 operations per second across all test cases.

JavaScript Target

Test CaseFunctionIPS (ops/sec)Min Time (ms)P99 Time (ms)
Simple text ("Hello World")slugify5,3910.1121.614
Simple text ("Hello World")slugify_with_custom_config6,0850.1071.669
Unicode text with emojisslugify2,5590.2651.888
Unicode text with emojisslugify_with_custom_config2,2240.2902.099
Long text (200+ chars)slugify3831.6605.404
Long text (200+ chars)slugify_with_custom_config3941.7175.362
Complex text (mixed case, symbols)slugify6630.9554.523
Complex text (mixed case, symbols)slugify_with_custom_config6610.9614.833

JavaScript Summary: Average of ~2,300 operations per second across all test cases.

Performance Characteristics

The benchmarks were run using gleamy_bench with 100ms duration and 10ms warmup per test (gleam run -m benchmark_runner). Results may vary depending on your specific use case and runtime environment.

Installation

Add glugify to your Gleam project:

gleam add glugify

Development

gleam test # Run the tests
gleam test --target javascript # Run the tests on the JavaScript target
gleam format # Format the code
gleam run -m benchmark_runner # Run the benchmarks

Contributing

Contributions are welcome! Please feel free to submit a Pull Request!

Documentation

Further documentation can be found at https://hexdocs.pm/glugify.