pylon_plugin/builtin/
slugify.rs1use std::collections::HashMap;
2
3use crate::{Plugin, PluginError};
4use pylon_auth::AuthContext;
5use serde_json::Value;
6
7pub struct SlugConfig {
9 pub source: String,
11 pub target: String,
13}
14
15pub struct SlugifyPlugin {
17 configs: HashMap<String, SlugConfig>,
18}
19
20impl SlugifyPlugin {
21 pub fn new() -> Self {
22 Self {
23 configs: HashMap::new(),
24 }
25 }
26
27 pub fn add(&mut self, entity: &str, source: &str, target: &str) {
29 self.configs.insert(
30 entity.to_string(),
31 SlugConfig {
32 source: source.to_string(),
33 target: target.to_string(),
34 },
35 );
36 }
37}
38
39pub fn slugify(input: &str) -> String {
41 let mut slug = String::new();
42 let mut last_was_dash = false;
43
44 for ch in input.chars() {
45 if ch.is_alphanumeric() {
46 slug.push(ch.to_lowercase().next().unwrap_or(ch));
47 last_was_dash = false;
48 } else if ch == ' ' || ch == '-' || ch == '_' {
49 if !last_was_dash && !slug.is_empty() {
50 slug.push('-');
51 last_was_dash = true;
52 }
53 }
54 }
56
57 if slug.ends_with('-') {
59 slug.pop();
60 }
61
62 slug
63}
64
65impl Plugin for SlugifyPlugin {
66 fn name(&self) -> &str {
67 "slugify"
68 }
69
70 fn before_insert(
71 &self,
72 entity: &str,
73 data: &mut Value,
74 _auth: &AuthContext,
75 ) -> Result<(), PluginError> {
76 if let Some(config) = self.configs.get(entity) {
77 if let Some(obj) = data.as_object_mut() {
78 let target_exists = obj
80 .get(&config.target)
81 .map(|v| v.as_str().map(|s| !s.is_empty()).unwrap_or(false))
82 .unwrap_or(false);
83
84 if !target_exists {
85 if let Some(source_val) = obj.get(&config.source).and_then(|v| v.as_str()) {
86 let slug = slugify(source_val);
87 obj.insert(config.target.clone(), Value::String(slug));
88 }
89 }
90 }
91 }
92 Ok(())
93 }
94}
95
96#[cfg(test)]
97mod tests {
98 use super::*;
99
100 #[test]
101 fn basic_slugify() {
102 assert_eq!(slugify("Hello World"), "hello-world");
103 assert_eq!(slugify("My Blog Post!"), "my-blog-post");
104 assert_eq!(slugify(" Spaces Everywhere "), "spaces-everywhere");
105 assert_eq!(slugify("UPPER CASE"), "upper-case");
106 assert_eq!(slugify("special@#$chars"), "specialchars");
107 assert_eq!(slugify("already-slugged"), "already-slugged");
108 assert_eq!(slugify(""), "");
109 }
110
111 #[test]
112 fn unicode_slugify() {
113 assert_eq!(slugify("Café Résumé"), "café-résumé");
114 assert_eq!(slugify("日本語"), "日本語");
115 }
116
117 #[test]
118 fn plugin_generates_slug_on_insert() {
119 let mut plugin = SlugifyPlugin::new();
120 plugin.add("Post", "title", "slug");
121
122 let mut data = serde_json::json!({"title": "My First Blog Post"});
123 plugin
124 .before_insert("Post", &mut data, &AuthContext::anonymous())
125 .unwrap();
126 assert_eq!(data["slug"], "my-first-blog-post");
127 }
128
129 #[test]
130 fn does_not_overwrite_existing_slug() {
131 let mut plugin = SlugifyPlugin::new();
132 plugin.add("Post", "title", "slug");
133
134 let mut data = serde_json::json!({"title": "My Post", "slug": "custom-slug"});
135 plugin
136 .before_insert("Post", &mut data, &AuthContext::anonymous())
137 .unwrap();
138 assert_eq!(data["slug"], "custom-slug");
139 }
140
141 #[test]
142 fn no_config_for_entity_passes() {
143 let plugin = SlugifyPlugin::new();
144 let mut data = serde_json::json!({"title": "Test"});
145 plugin
146 .before_insert("Unknown", &mut data, &AuthContext::anonymous())
147 .unwrap();
148 assert!(data.get("slug").is_none());
149 }
150
151 #[test]
152 fn missing_source_field_no_error() {
153 let mut plugin = SlugifyPlugin::new();
154 plugin.add("Post", "title", "slug");
155
156 let mut data = serde_json::json!({"body": "no title here"});
157 plugin
158 .before_insert("Post", &mut data, &AuthContext::anonymous())
159 .unwrap();
160 assert!(data.get("slug").is_none());
161 }
162}