Skip to main content

moss_core/
slug.rs

1//! URL-safe slug generation for moss-core.
2//!
3//! The two pure primitives used by `compute_url_path` and re-exported to src-tauri.
4//!
5//! **Disambiguation:** `moss_core::content_graph` has its own internal `generate_slug`
6//! that strips file extensions and handles full relative paths (a "path-to-key"
7//! transform). This module's `generate_slug` is a "text-to-slug" primitive for
8//! titles, folder names, and URL segments. Use the right one for the right job.
9//! UID generation and duplicate-slug deduplication remain in src-tauri pending
10//! the byte-slicing audit under #642.
11
12/// Converts a string to a URL-safe slug.
13///
14/// - Lowercases ASCII
15/// - Replaces spaces and underscores with hyphens
16/// - Replaces `&` → `and`, `@` → `at`, `+` → `plus`, `#` → `hash`, `%` → `percent`
17/// - Preserves CJK and other Unicode letters
18/// - Strips consecutive hyphens; trims leading/trailing hyphens
19/// - Caps at 100 chars
20/// - Falls back to `"untitled"` for empty results
21pub fn generate_slug(text: &str) -> String {
22    let result = text
23        .to_lowercase()
24        .replace([' ', '_'], "-")
25        .replace('&', "and")
26        .replace('@', "at")
27        .replace('+', "plus")
28        .replace('#', "hash")
29        .replace('%', "percent")
30        .chars()
31        .map(|c| if c.is_alphanumeric() || c == '-' || c == '.' { c } else { '-' })
32        .collect::<String>()
33        .split('-')
34        .filter(|s| !s.is_empty())
35        .collect::<Vec<&str>>()
36        .join("-")
37        .trim_matches('-')
38        .chars()
39        .take(100)
40        .collect::<String>()
41        .trim_end_matches('-')
42        .to_string();
43
44    if result.is_empty() { "untitled".to_string() } else { result }
45}
46
47/// Normalize path separators to `/`.
48///
49/// moss treats `\` as a path separator everywhere so content paths behave
50/// identically regardless of the authoring OS. Windows `strip_prefix` yields
51/// backslash-separated relative paths; left un-normalized they collapse nested
52/// page/asset URLs (every segment after the first is lost) and defeat the file
53/// watcher's `/.moss/` gate (causing a runaway rebuild loop). Literal backslashes
54/// in content filenames are therefore not supported — they are read as separators.
55pub fn normalize_separators(s: &str) -> String {
56    s.replace('\\', "/")
57}
58
59/// Apply slug rules to every separator-delimited segment of a path.
60///
61/// `News/Sub Section` → `news/sub-section`. Backslash separators are normalized
62/// first (`News\Sub Section` → the same result). Empty segments are skipped.
63pub fn slugify_path_segments(path: &str) -> String {
64    if path.is_empty() {
65        return String::new();
66    }
67    normalize_separators(path)
68        .split('/')
69        .filter(|s| !s.is_empty())
70        .map(generate_slug)
71        .collect::<Vec<_>>()
72        .join("/")
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    #[test]
80    fn ascii_lowercased_and_hyphenated() {
81        assert_eq!(generate_slug("Hello World"), "hello-world");
82    }
83
84    #[test]
85    fn special_chars_replaced() {
86        assert_eq!(generate_slug("A & B"), "a-and-b");
87        assert_eq!(generate_slug("price@50%"), "priceat50percent");
88    }
89
90    #[test]
91    fn cjk_preserved() {
92        assert_eq!(generate_slug("你好世界"), "你好世界");
93    }
94
95    #[test]
96    fn empty_falls_back_to_untitled() {
97        assert_eq!(generate_slug("---"), "untitled");
98    }
99
100    #[test]
101    fn path_segments_slugified() {
102        assert_eq!(slugify_path_segments("News/Sub Section"), "news/sub-section");
103        assert_eq!(slugify_path_segments(""), "");
104    }
105
106    #[test]
107    fn normalize_separators_converts_backslashes() {
108        // moss treats `\` as a path separator everywhere (Windows-authored
109        // content paths arrive backslash-separated). Forward slashes pass through.
110        assert_eq!(normalize_separators("News\\2025"), "News/2025");
111        assert_eq!(normalize_separators("a/b/c"), "a/b/c");
112        assert_eq!(normalize_separators("Sub Dir\\Winter-Song.mov"), "Sub Dir/Winter-Song.mov");
113        assert_eq!(normalize_separators(""), "");
114    }
115
116    #[test]
117    fn path_segments_handle_backslash_separators() {
118        // The Windows bug: a backslash-separated path must slug into the SAME
119        // nested `/`-form as the slash version, not collapse into one segment.
120        assert_eq!(slugify_path_segments("News\\Sub Section"), "news/sub-section");
121        assert_eq!(
122            slugify_path_segments("News\\Sub Section"),
123            slugify_path_segments("News/Sub Section"),
124        );
125        assert!(!slugify_path_segments("A\\B\\C").contains('\\'));
126    }
127}