Packages

Locale-aware and script-aware URL slugs. German ö→oe, Estonian ö→o, Cyrillic and Greek romanization from cited standards. Pure Elixir, no NIF.

Current section

Files

Jump to
locale_slug README.md
Raw

README.md

# LocaleSlug
Locale-aware and script-aware URL slugs for Elixir. **Zero dependencies.**
```elixir
LocaleSlug.slugify("Größe Fußball", locale: "de") #=> "groesse-fussball"
LocaleSlug.slugify("Töö õun", locale: "et") #=> "too-oun"
LocaleSlug.slugify("Цветокоррекция") #=> "tsvetokorrektsiya"
LocaleSlug.slugify("Καλημέρα") #=> "kalimera"
LocaleSlug.slugify("日本語 入門", script: :native) #=> "日本語-入門"
```
## Why
Slug generation is locale-dependent, and no other Elixir package treats it that way.
`ö` must become `oe` in German and `o` in Estonian. Measured, by running each package:
| input | `slugify` 1.3.1 | `slugger` 0.3.0 | **LocaleSlug** |
|---|---|---|---|
| `Größe Fußball` | `grosse-fussball` | `groesse-fussball`| `groesse-fussball`*(`locale: "de"`)* |
| `Töö õun` (Estonian) | `too-oun`| `toeoe-oun`| `too-oun`*(`locale: "et"`)* |
| `Łódź` ||| `lodz` |
| `Καλημέρα` ||| `kalimera` |
`slugger` applies German rules to Estonian; `slugify` the reverse. Neither can do better,
because neither accepts a locale — and the two maintained transliterators (`any_ascii`,
`unidecode`) expose arity-1 functions, so they are *structurally* incapable of it. The
only locale-aware machinery in Elixir is an ICU C NIF, which needs a toolchain and ICU
headers on every host that compiles it.
## Install
```elixir
def deps do
[{:locale_slug, "~> 0.1"}]
end
```
## Two axes
**`:locale`** picks locale preferences (German `ö→oe`) and, for languages with their own
official romanization, the schema — `uk` selects Ukraine's KMU 55:2010 rather than
treating Ukrainian as Russian with edits.
**`:script`** picks the output alphabet:
```elixir
LocaleSlug.slugify("Цветокоррекция", script: :latin) #=> "tsvetokorrektsiya"
LocaleSlug.slugify("Цветокоррекция", script: :native) #=> "цветокоррекция"
```
Native-script URLs are legal ([RFC 3987](https://www.rfc-editor.org/rfc/rfc3987)),
render natively in browsers, and are what Russian and Japanese sites routinely use.
`:native` is not "return unchanged" — it still normalises, lowercases locale-correctly,
and strips every RFC 3986 reserved character.
## Options
| option | default | |
|---|---|---|
| `:locale` | `nil` | BCP 47 tag: `"de"`, `"et-EE"`, `:et` |
| `:script` | `:latin` | `:latin` or `:native` |
| `:separator` | `"-"` | |
| `:max_length` | `nil` | in output characters; never severs a mapping |
| `:schema` | per-locale | e.g. `:kmu_2010`, or `%{cyrillic: :kmu_2010}` |
| `:fallback` | `:native` | `:empty` for ASCII-or-nothing |
| `:strict` | `false` | raise on malformed **options** only |
⚠️ **`:latin` is not an unconditional ASCII guarantee.** Under the default
`fallback: :native`, a script with no romanizer survives in its own script rather than
disappearing. Pass `fallback: :empty` when you need ASCII or nothing.
## Contextual rules
The romanization standards this implements are not character maps. ISO 843 Type 2 is a
*transcription*:
```elixir
LocaleSlug.slugify("μπύρα") #=> "byra" # μπ is /b/ at a word edge
LocaleSlug.slugify("λάμπα") #=> "lampa" # ...and /mp/ inside one
LocaleSlug.slugify("Ежик") #=> "yezhik" # BGN/PCGN: е is "ye" word-initially
LocaleSlug.slugify("небо") #=> "nebo" # ...and "e" after a consonant
```
## Locale input
Language tags only, case-insensitive, `_` or `-`:
```elixir
LocaleSlug.slugify(text, locale: "et") # all equivalent
LocaleSlug.slugify(text, locale: "ET")
LocaleSlug.slugify(text, locale: "et_EE")
LocaleSlug.slugify(text, locale: :et)
```
`resolve/1` tells you what happened, so `slugify/2` never has to log:
```elixir
LocaleSlug.resolve("de") #=> {:ok, %LocaleSlug.Resolver{}}
LocaleSlug.resolve("xx") #=> {:missing, "xx", %Resolver{}} — still slugs fine
LocaleSlug.resolve("GB") #=> {:invalid, :country_only}
LocaleSlug.resolve("Estonian") #=> {:invalid, :not_a_language_tag}
```
Only codes that are **not real language subtags** are rejected. `uk` is Ukrainian, `no`
is Norwegian, `ar` is Arabic, `ca` is Catalan — none of them are treated as country
codes, even though each doubles as one.
## Trust, and what `verified` means
Every table declares where it came from and how well it is checked:
```elixir
LocaleSlug.info("de")
#=> %{status: "provisional", source: "Duden / Rat für deutsche Rechtschreibung ...", ...}
```
* `verified`**a human who reads the language signed off.** A machine may not grant it.
* `provisional` — sourced from a published standard, no native-speaker sign-off yet.
* `draft` — proposed, reviewed by nothing.
Prefer `verified` tables for slugs you persist. Everything in 0.1.0 is `provisional`:
the tables cite real standards, but no native speaker has reviewed them yet. **Native
speakers wanted** — see below.
## Contributing a locale
Tables are YAML, not Elixir, so you do not need to write Elixir to fix one:
```yaml
locale: de
status: provisional
source: "Duden / Rat für deutsche Rechtschreibung — umlaut transcription"
mappings:
"ä": "ae"
"ö": "oe"
examples: # these become tests
- in: "Größe Fußball"
out: "groesse-fussball"
```
Add an `in`/`out` pair to `examples:` and the suite proves it. A PR that changes a
mapping must change or add an example.
## Limits
* **Turkish `ı`/`i` collapse.** `ılık` and `ilik` both slug to `ilik`. Six Turkish
letters fold onto bare ASCII, and that fold *is* the Turkish convention — inventing a
spelling to avoid it would produce slugs no Turkish speaker recognises. Use
`script: :native` for fidelity; use your schema for uniqueness.
* **Slugs are not identifiers.** Any lossy romanization merges words, and so does plain
English (`Café`/`Cafe`, `Blue Shirt`/`Blue shirt`). Uniqueness belongs to your schema.
* **Cyrillic with no locale is romanized Russian-style.** Nothing in a string declares
its language and we do not guess from character frequency, so a Ukrainian shop must
pass `locale: "uk"`.
* **Kanji is out of scope for `:latin`.** The reading is a property of the word, not the
character (生物 is *seibutsu* or *namamono*), and it is an open research problem.
`script: :native` handles Japanese correctly today.
## License
MIT. Romanization tables derive in part from Unicode CLDR/ICU data under the Unicode
License v3 — see [NOTICE](NOTICE).