URL slugs that keep the original script.
slugify/2 turns a title into a slug in its own writing system: Persian stays Persian, Hindi
stays Hindi, Japanese stays Japanese. Only Latin letters change: they lose their accents the
way WordPress writes them (Café gives cafe), unless latin: :keep.
iex> MishkaSlug.slugify("قیمتگذاری نرمافزار")
{:ok, "قیمتگذاری-نرمافزار"}
iex> MishkaSlug.slugify("हिन्दी भाषा")
{:ok, "हिन्दी-भाषा"}
iex> MishkaSlug.slugify("😀🚀")
{:error, :empty}What a slug is made of
A slug is lowercase words joined by -. A word is a run of letters and decimal digits, with the
combining marks that complete them (Hindi vowel signs and viramas, Thai tone marks). Everything
else, from spaces and punctuation to emoji and / ? # % \ :, separates words. The result is
NFC, so a stored slug compares equal to a pasted one (Hangul jamo are composed even after a
prepended sign such as Tulu-Tigalari U+113D1, which OTP's normalizer leaves decomposed).
Latin letters lose their accents the way WordPress writes them (Café Crème gives
cafe-creme, see :latin), an apostrophe inside a word is removed (don't gives dont),
_ stays in a word (snake_case gives snake_case, see :keep and :trim_kept), and ., & and ,
separate words (Q&A gives q-a, 1,000 gives 1-000). A capital sigma that ends a Greek word becomes ς
(ΟΔΟΣ gives οδος) in every language but tr and az.
Safety
These are always on:
- Invisible characters are removed, never turned into a break: zero-width spaces and joiners,
bidi controls (
U+202Eand the isolates), soft hyphens, byte-order marks, variation selectors, controls, private-use and unassigned code points. Sopay\u200Bpalgivespaypal. - A word may not mix scripts (see
:mixed_scripts). - The slug can never hold a URL delimiter or be
.or.., with or without:trim_kept. - Arabic harakat, tatweel and Hebrew points are dropped (they are optional spelling); every other mark is kept, because in Indic and Thai scripts the marks spell the word.
Idempotent
slugify(slug, opts) returns {:ok, slug} for every slug it made with the same options. A CMS
can therefore re-slug an incoming URL parameter: when the result differs, redirect to it.
Options
Options are checked by MishkaSlug.Options: an unknown option, or a value outside the accepted
ones, raises ArgumentError.
:language-String.t() | nil, defaultnil. The language of the text, such as a page'slanguage("fa","tr"). It picks the lowercasing rules (trandazturnIintoı), forfa,urandckb, folds the Arabicي ى كinto the Persianی ک, and forde,da,ca,srandbspicks howlatin: :asciispells their letters.:locales-[String.t()], default[]. The site's languages (["fa", "en"]). Only letters and digits of their scripts, and Latin, are kept; seeMishkaSlug.Scripts.[]keeps every script. Without a:language, a Persian-family locale withoutarturns on the Persian fold. A script subtag picks the script ("sr-Latn"keeps Latin only); an unknown language is{:error, {:unknown_locale, code}}unless a:script_resolverknows it.:script_resolver-{module, function, args} | nil, defaultnil. Decides the scripts of each:localescode instead of the built-in table: called asapply(module, function, [code | args]), it returns{:ok, [script]}(atoms such as:arabic,:latin,:cyrillic,:han;MishkaSlug.Scripts.from_iso15924/1turns a CLDR code like"Arab"into them) or:error, which gives{:error, {:unknown_locale, code}}.niluses the:script_resolverof the:mishka_slugapplication config, and without one the built-in table.:mixed_scripts-:split | :reject, default:split. What happens to a word that changes script, likepаypalwith a Cyrillicа::splitcuts it at the change,:rejectreturns{:error, :mixed_scripts}.:zwnj-:remove | :separator, default:remove. The Persian half-space (U+200C)::removeglues the parts (قیمتگذاری),:separatormakes it a word break (قیمت-گذاری).:digits-:keep | :ascii, default:keep.:keepleaves digits as typed (۱۴۰۳);:asciiwrites every decimal digit as0-9(1403). With:keep, a digit of a script outside:localesis dropped like a letter;:asciiwrites it as0-9first, so it stays.:latin-:ascii | :keep, default:ascii. What happens to accented Latin letters.:asciiwrites them as WordPress'sremove_accentsdoes: a letter is decomposed and its marks dropped (égivese, Vietnameseệgivese), and the letters that do not decompose get a plain spelling (ßgivess,æae,øo,þth,łl,ıi). The:languageadds its own spelling:dewritesä ö ü ßasae oe ue ss,dawritesæ ø åasae oe aa,cajoinsl·lintoll, andsrandbswriteđasdj. Only Latin letters change: Greek, Cyrillic, Arabic and every other script keep their marks.:keepleaves Latin letters as typed (Cafégivescafé).:keep- list of".","_"and"~", default["_"]. Characters kept inside a word (["."]keepsv1.2). Only the URL-safe.,_and~are accepted. By default_is kept like WordPress and Django keep it (snake_case_name);keep: []makes it a word break again (snake-case-name). The list replaces the default, sokeep: ["."]breaks on_; write[".", "_"]to keep both. See:trim_keptfor the ends of a word.:trim_kept-boolean(), defaultfalse. Whether kept characters at the start or end of a word are removed.falseleaves them like WordPress does (__init__gives__init__,my_post_givesmy_post_,_ _ _gives_-_-_), so a migrated WordPress slug stays the same.truetrims them (__init__givesinit,my_post_givesmy_post,_ _ _gives{:error, :empty}). A kept character that a prepended sign glues into one grapheme is removed too, and the sign stays (Malayalamൎ.givesൎ). An edge-is always removed, and a slug that is only.or..is refused ({:error, :empty}) in both modes.:max_length-pos_integer() | nil, defaultnil. The longest slug, in code points (what Postgres and Ash count). Whole words are kept while they fit; a first word that is too long is cut between two graphemes.
Summary
Types
@type reason() :: :empty | :no_allowed_script | :invalid_utf8 | :mixed_scripts | {:unknown_locale, String.t()}
Why no slug could be made.
Functions
Makes a slug from text.
Returns {:ok, slug}, or {:error, reason} when nothing is left (:empty), the text had
letters or digits but none is in a script of :locales (:no_allowed_script, e.g. Persian
text with locales: ["en"]), the text is not UTF-8 (:invalid_utf8), a word mixes scripts under mixed_scripts: :reject, or a locale is not
known ({:unknown_locale, code}). Invalid options raise ArgumentError.
iex> MishkaSlug.slugify("كتاب يك ۱۲۳", language: "fa", digits: :ascii)
{:ok, "کتاب-یک-123"}
iex> MishkaSlug.slugify("آموزش React در ۱۰ دقیقه", locales: ["fa"])
{:ok, "آموزش-react-در-۱۰-دقیقه"}
iex> MishkaSlug.slugify("قیمت", locales: ["en"])
{:error, :no_allowed_script}
iex> MishkaSlug.slugify("!!!", locales: ["en"])
{:error, :empty}
Same as slugify/2, but returns the slug or raises ArgumentError.
iex> MishkaSlug.slugify!("Привет, мир!")
"привет-мир"