Skip to main content

pylon_plugin/builtin/
slugify.rs

1use std::collections::HashMap;
2
3use crate::{Plugin, PluginError};
4use pylon_auth::AuthContext;
5use serde_json::Value;
6
7/// Slugify configuration for one entity.
8pub struct SlugConfig {
9    /// Source field to generate slug from.
10    pub source: String,
11    /// Target field to write the slug to.
12    pub target: String,
13}
14
15/// Slugify plugin. Auto-generates URL-safe slugs from a source field.
16pub 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    /// Add slug generation for an entity.
28    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
39/// Convert a string to a URL-safe slug.
40pub 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        // Skip other characters.
55    }
56
57    // Trim trailing dash.
58    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                // Only generate if target field is not already set.
79                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}