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}