///|
pub(all) struct SlugOptions {
separator : Char
lowercase : Bool
trim_separator : Bool
max_length : Int?
ascii_only : Bool
fallback : String?
}
///|
pub struct RuleSet {
raw_word_map : Array[(String, String)]
raw_char_map : Array[(Char, String)]
raw_drop_chars : Array[Char]
raw_separator_chars : Array[Char]
word_map_buckets : Buckets[(Array[Char], String)]
char_map_buckets : Buckets[(Char, String)]
drop_buckets : Buckets[Char]
separator_buckets : Buckets[Char]
}
///|
pub impl Default for SlugOptions with default() {
{
separator: '-',
lowercase: true,
trim_separator: true,
max_length: None,
ascii_only: true,
fallback: None,
}
}
///|
pub impl Default for RuleSet with default() {
default_rule_set
}
///|
pub fn RuleSet::new(
word_map? : Array[(String, String)],
char_map? : Array[(Char, String)],
drop_chars? : Array[Char],
separator_chars? : Array[Char],
) -> RuleSet {
let resolved_word_map = if word_map is Some(value) {
normalize_word_map(value)
} else {
default_word_map
}
let resolved_char_map = if char_map is Some(value) {
dedupe_char_map(value)
} else {
default_char_map()
}
let resolved_drop_chars = if drop_chars is Some(value) {
dedupe_chars(value)
} else {
default_drop_chars
}
let resolved_separator_chars = if separator_chars is Some(value) {
dedupe_chars(value)
} else {
default_separator_chars
}
build_ruleset(
resolved_word_map, resolved_char_map, resolved_drop_chars, resolved_separator_chars,
)
}
///|
pub fn RuleSet::extend(
self : RuleSet,
word_map? : Array[(String, String)] = [],
char_map? : Array[(Char, String)] = [],
drop_chars? : Array[Char] = [],
separator_chars? : Array[Char] = [],
) -> RuleSet {
build_ruleset(
merge_word_map(self.raw_word_map, normalize_word_map(word_map)),
merge_char_map(self.raw_char_map, char_map),
union_chars(self.raw_drop_chars, drop_chars),
union_chars(self.raw_separator_chars, separator_chars),
)
}
///|
pub fn RuleSet::merge(self : RuleSet, other : RuleSet) -> RuleSet {
build_ruleset(
merge_word_map(self.raw_word_map, other.raw_word_map),
merge_char_map(self.raw_char_map, other.raw_char_map),
union_chars(self.raw_drop_chars, other.raw_drop_chars),
union_chars(self.raw_separator_chars, other.raw_separator_chars),
)
}
///|
let default_rule_set : RuleSet = RuleSet::new()
///|
pub fn slugify(
input : String,
options? : SlugOptions = SlugOptions::default(),
rules? : RuleSet = RuleSet::default(),
) -> String {
let normalized_input = normalize_for_slug(input)
let expanded_input = apply_word_map(normalized_input, rules)
let output : Array[Char] = []
let mut last_is_separator = false
for c in expanded_input {
if rules.drop_buckets.contains(c) {
continue
}
if c.is_ascii_alphabetic() || c.is_ascii_digit() {
output.push(normalize_ascii(c, options.lowercase))
last_is_separator = false
continue
}
if rules.char_map_buckets.lookup_pair(c) is Some(mapped) {
append_mapped(
mapped,
output,
options.lowercase,
options.separator,
rules,
last_is_separator,
)
last_is_separator = !output.is_empty() &&
output[output.length() - 1] == options.separator
continue
}
if is_separator_candidate(c, rules) {
if !output.is_empty() && !last_is_separator {
output.push(options.separator)
last_is_separator = true
}
continue
}
if !options.ascii_only {
output.push(c)
last_is_separator = false
}
}
let normalized = if options.trim_separator {
trim_separator_edges(output, options.separator)
} else {
output
}
let limited = apply_max_length(normalized, options.max_length)
let final_chars = if options.trim_separator {
trim_separator_edges(limited, options.separator)
} else {
limited
}
if final_chars.length() == 0 {
if options.fallback is Some(fallback) {
fallback
} else {
""
}
} else {
[..final_chars]
}
}
///|
fn build_ruleset(
raw_word_map : Array[(String, String)],
raw_char_map : Array[(Char, String)],
raw_drop_chars : Array[Char],
raw_separator_chars : Array[Char],
) -> RuleSet {
let word_map_buckets = Buckets::new_word(257)
for pair in raw_word_map {
let (from, to) = pair
let key_chars : Array[Char] = [..from]
if !key_chars.is_empty() {
word_map_buckets[key_chars[0]].push((key_chars, to))
}
}
let char_map_buckets = Buckets::new_pair(257)
for pair in raw_char_map {
char_map_buckets[pair.0].push(pair)
}
let drop_buckets = Buckets::new_char(64)
for c in raw_drop_chars {
drop_buckets[c].push(c)
}
let separator_buckets = Buckets::new_char(64)
for c in raw_separator_chars {
separator_buckets[c].push(c)
}
{
raw_word_map,
raw_char_map,
raw_drop_chars,
raw_separator_chars,
word_map_buckets,
char_map_buckets,
drop_buckets,
separator_buckets,
}
}
///|
fn merge_char_map(
left : Array[(Char, String)],
right : Array[(Char, String)],
) -> Array[(Char, String)] {
let merged : Array[(Char, String)] = [..left]
for pair in right {
let (key, value) = pair
upsert_pair(merged, key, value)
}
merged
}
///|
fn merge_word_map(
left : Array[(String, String)],
right : Array[(String, String)],
) -> Array[(String, String)] {
let merged : Array[(String, String)] = [..left]
for pair in right {
let (key, value) = pair
upsert_word_pair(merged, key, value)
}
merged
}
///|
fn upsert_pair(
entries : Array[(Char, String)],
key : Char,
value : String,
) -> Unit {
for i in 0.. Unit {
for i in 0.. Array[Char] {
let merged : Array[Char] = [..left]
for c in right {
if !merged.contains(c) {
merged.push(c)
}
}
merged
}
///|
fn dedupe_char_map(entries : Array[(Char, String)]) -> Array[(Char, String)] {
let deduped : Array[(Char, String)] = []
for pair in entries {
let (key, value) = pair
upsert_pair(deduped, key, value)
}
deduped
}
///|
fn normalize_word_map(
entries : Array[(String, String)],
) -> Array[(String, String)] {
let deduped : Array[(String, String)] = []
for pair in entries {
let (from, to) = pair
let normalized_key = normalize_for_slug(from)
if normalized_key != "" {
upsert_word_pair(deduped, normalized_key, to)
}
}
deduped
}
///|
fn dedupe_chars(chars : Array[Char]) -> Array[Char] {
let seen = Set::new()
let deduped = []
for c in chars {
if !seen.contains(c) {
seen.add(c)
deduped.push(c)
}
}
deduped
}
///|
fn default_char_map() -> Array[(Char, String)] {
[
('\u{00E0}', "a"),
('\u{00E1}', "a"),
('\u{00E2}', "a"),
('\u{00E3}', "a"),
('\u{00E4}', "a"),
('\u{00E5}', "a"),
('\u{00E7}', "c"),
('\u{00E8}', "e"),
('\u{00E9}', "e"),
('\u{00EA}', "e"),
('\u{00EB}', "e"),
('\u{00EC}', "i"),
('\u{00ED}', "i"),
('\u{00EE}', "i"),
('\u{00EF}', "i"),
('\u{00F1}', "n"),
('\u{00F2}', "o"),
('\u{00F3}', "o"),
('\u{00F4}', "o"),
('\u{00F5}', "o"),
('\u{00F6}', "o"),
('\u{00F9}', "u"),
('\u{00FA}', "u"),
('\u{00FB}', "u"),
('\u{00FC}', "u"),
('\u{00FD}', "y"),
('\u{00FF}', "y"),
('\u{00C0}', "a"),
('\u{00C1}', "a"),
('\u{00C2}', "a"),
('\u{00C3}', "a"),
('\u{00C4}', "a"),
('\u{00C5}', "a"),
('\u{00C7}', "c"),
('\u{00C8}', "e"),
('\u{00C9}', "e"),
('\u{00CA}', "e"),
('\u{00CB}', "e"),
('\u{00CC}', "i"),
('\u{00CD}', "i"),
('\u{00CE}', "i"),
('\u{00CF}', "i"),
('\u{00D1}', "n"),
('\u{00D2}', "o"),
('\u{00D3}', "o"),
('\u{00D4}', "o"),
('\u{00D5}', "o"),
('\u{00D6}', "o"),
('\u{00D9}', "u"),
('\u{00DA}', "u"),
('\u{00DB}', "u"),
('\u{00DC}', "u"),
('\u{00DD}', "y"),
('\u{00DF}', "ss"),
]
}
///|
let default_word_map : Array[(String, String)] = []
///|
let default_drop_chars : Array[Char] = ['\'', '"']
///|
let default_separator_chars : Array[Char] = [
' ', '_', '-', '.', '/', '\\', '+', '&',
]
///|
struct Buckets[T](FixedArray[Array[T]])
///|
fn Buckets::new_pair(size : Int) -> Buckets[(Char, String)] {
FixedArray::makei(size, _ => [])
}
///|
fn Buckets::new_word(size : Int) -> Buckets[(Array[Char], String)] {
FixedArray::makei(size, _ => [])
}
///|
fn Buckets::new_char(size : Int) -> Buckets[Char] {
FixedArray::makei(size, _ => [])
}
///|
fn bucket_index(c : Char, bucket_count : Int) -> Int {
c.to_int() % bucket_count
}
///|
#alias("_[_]")
fn[T] Buckets::get(self : Buckets[T], c : Char) -> Array[T] {
self.0[bucket_index(c, self.0.length())]
}
///|
fn Buckets::contains(self : Buckets[Char], c : Char) -> Bool {
self.get(c).contains(c)
}
///|
fn Buckets::lookup_pair(self : Buckets[(Char, String)], c : Char) -> String? {
for pair in self.get(c) {
let (from, to) = pair
if from == c {
return Some(to)
}
}
None
}
///|
fn normalize_ascii(c : Char, lowercase : Bool) -> Char {
if !lowercase {
return c
}
c.to_ascii_lowercase()
}
///|
fn normalize_for_slug(input : String) -> String {
let normalized = @normalization.nfkd(input)
let filtered : Array[Char] = []
for c in normalized {
if !is_combining_mark(c) {
filtered.push(c)
}
}
[..filtered]
}
///|
fn apply_word_map(input : String, rules : RuleSet) -> String {
if rules.raw_word_map.is_empty() {
return input
}
let input_chars : Array[Char] = [..input]
let output : Array[Char] = []
let mut index = 0
while index < input_chars.length() {
if find_word_match(input_chars, index, rules)
is Some((matched_len, replacement)) {
for c in replacement {
output.push(c)
}
index = index + matched_len
} else {
output.push(input_chars[index])
index = index + 1
}
}
[..output]
}
///|
fn find_word_match(
chars : Array[Char],
index : Int,
rules : RuleSet,
) -> (Int, String)? {
let current = chars[index]
let candidates = rules.word_map_buckets[current]
let mut best_len = 0
let mut best_replacement = ""
for entry in candidates {
let (key_chars, replacement) = entry
let len = key_chars.length()
if len == 0 {
continue
}
if len <= best_len {
continue
}
if !is_word_boundary(chars, index) {
continue
}
if !matches_at(chars, index, key_chars) {
continue
}
if !is_word_boundary(chars, index + len) {
continue
}
best_len = len
best_replacement = replacement
}
if best_len > 0 {
Some((best_len, best_replacement))
} else {
None
}
}
///|
fn matches_at(chars : Array[Char], index : Int, needle : Array[Char]) -> Bool {
if index + needle.length() > chars.length() {
return false
}
for i in 0.. Bool {
if index <= 0 || index >= chars.length() {
return true
}
!is_word_char(chars[index - 1]) || !is_word_char(chars[index])
}
///|
fn is_word_char(c : Char) -> Bool {
c.is_ascii_alphabetic() || c.is_ascii_digit()
}
///|
fn is_combining_mark(c : Char) -> Bool {
match c {
'\u{0300}'..='\u{036F}' => true
'\u{1AB0}'..='\u{1AFF}' => true
'\u{1DC0}'..='\u{1DFF}' => true
'\u{20D0}'..='\u{20FF}' => true
'\u{FE20}'..='\u{FE2F}' => true
_ => false
}
}
///|
fn is_separator_candidate(c : Char, rules : RuleSet) -> Bool {
if rules.separator_buckets.contains(c) {
return true
}
match c {
' '..='/' => true
':'..='@' => true
'['..='`' => true
'{'..='~' => true
_ => false
}
}
///|
fn append_mapped(
mapped : String,
output : Array[Char],
lowercase : Bool,
separator : Char,
rules : RuleSet,
last_is_separator : Bool,
) -> Unit {
let mut local_last_is_separator = last_is_separator
for c in mapped {
if c.is_ascii_alphabetic() || c.is_ascii_digit() {
output.push(normalize_ascii(c, lowercase))
local_last_is_separator = false
continue
}
if is_separator_candidate(c, rules) {
if !output.is_empty() && !local_last_is_separator {
output.push(separator)
local_last_is_separator = true
}
}
}
}
///|
fn trim_separator_edges(chars : Array[Char], separator : Char) -> Array[Char] {
let total = chars.length()
let mut start = 0
while start < total && chars[start] == separator {
start = start + 1
}
let mut end = total
while end > start && chars[end - 1] == separator {
end = end - 1
}
chars[start:end].to_array()
}
///|
fn apply_max_length(chars : Array[Char], max_length : Int?) -> Array[Char] {
if max_length is Some(limit) {
if limit <= 0 {
[]
} else if chars.length() <= limit {
chars
} else {
chars[:limit].to_array()
}
} else {
chars
}
}