1use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
11use std::fmt;
12
13const QUERY_ASCII_SET: &AsciiSet = &CONTROLS
16 .add(b' ')
17 .add(b'"')
18 .add(b'#')
19 .add(b'%')
20 .add(b'&')
21 .add(b'+')
22 .add(b'<')
23 .add(b'=')
24 .add(b'>');
25
26const PATH_ASCII_SET: &AsciiSet = &QUERY_ASCII_SET
28 .add(b'?')
29 .add(b'^')
30 .add(b'`')
31 .add(b'{')
32 .add(b'}');
33
34#[derive(Clone, Debug, PartialEq)]
36pub enum Route {
37 Home,
39 HomePage { page: i32 },
41 Notes,
43 NotebookDetail { id: i32 },
45 NoteDetail { slug: String, book: String },
47 Archives,
49 TagDetail { tag: String },
51 PostDetail { slug: String },
53 Search { q: String },
55 About,
57 WritingGuide,
59 ComponentShowcase,
61 ComponentDetail { component: String },
63 Friends,
65 Changelog,
67 Admin,
69 Write,
71 WriteEdit { id: i32 },
73 PostPreview { slug: String },
75 Posts,
77 AdminNotes,
79 NewNote,
81 EditNote { id: i32 },
83 AdminNotebooks,
85 PostsTrash,
87 Assets,
89 FriendsAdmin,
91 AdminComments,
93 AdminCommentsPage { page: i32 },
95 System,
97 Runner,
99 Mcp,
101 Logs,
103 SiteSettingsPage,
105 Profile,
107 Login,
109 Register,
111}
112
113fn seg(value: &str) -> impl fmt::Display + '_ {
115 utf8_percent_encode(value, PATH_ASCII_SET)
116}
117
118fn query(f: &mut fmt::Formatter<'_>, key: &str, value: &str) -> fmt::Result {
120 if value.is_empty() {
121 Ok(())
122 } else {
123 write!(f, "?{key}={}", utf8_percent_encode(value, QUERY_ASCII_SET))
124 }
125}
126
127impl fmt::Display for Route {
128 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129 match self {
130 Route::Home => f.write_str("/"),
131 Route::HomePage { page } => write!(f, "/page/{page}"),
132 Route::Notes => f.write_str("/notes"),
133 Route::NotebookDetail { id } => write!(f, "/notes/book/{id}"),
134 Route::NoteDetail { slug, book } => {
135 write!(f, "/notes/{}", seg(slug))?;
136 query(f, "book", book)
137 }
138 Route::Archives => f.write_str("/archives"),
139 Route::TagDetail { tag } => write!(f, "/tags/{}", seg(tag)),
140 Route::PostDetail { slug } => write!(f, "/post/{}", seg(slug)),
141 Route::Search { q } => {
142 f.write_str("/search")?;
143 query(f, "q", q)
144 }
145 Route::About => f.write_str("/about"),
146 Route::WritingGuide => f.write_str("/about/writing"),
147 Route::ComponentShowcase => f.write_str("/about/components"),
148 Route::ComponentDetail { component } => {
149 write!(f, "/about/components/{}", seg(component))
150 }
151 Route::Friends => f.write_str("/friends"),
152 Route::Changelog => f.write_str("/changelog"),
153 Route::Admin => f.write_str("/admin"),
154 Route::Write => f.write_str("/admin/write"),
155 Route::WriteEdit { id } => write!(f, "/admin/write/{id}"),
156 Route::PostPreview { slug } => write!(f, "/admin/preview/{}", seg(slug)),
157 Route::Posts => f.write_str("/admin/posts"),
158 Route::AdminNotes => f.write_str("/admin/notes"),
159 Route::NewNote => f.write_str("/admin/notes/new"),
160 Route::EditNote { id } => write!(f, "/admin/notes/edit/{id}"),
161 Route::AdminNotebooks => f.write_str("/admin/notebooks"),
162 Route::PostsTrash => f.write_str("/admin/posts/trash"),
163 Route::Assets => f.write_str("/admin/assets"),
164 Route::FriendsAdmin => f.write_str("/admin/friends"),
165 Route::AdminComments => f.write_str("/admin/comments"),
166 Route::AdminCommentsPage { page } => write!(f, "/admin/comments/{page}"),
167 Route::System => f.write_str("/admin/system"),
168 Route::Runner => f.write_str("/admin/runner"),
169 Route::Mcp => f.write_str("/admin/mcp"),
170 Route::Logs => f.write_str("/admin/logs"),
171 Route::SiteSettingsPage => f.write_str("/admin/settings"),
172 Route::Profile => f.write_str("/admin/profile"),
173 Route::Login => f.write_str("/login"),
174 Route::Register => f.write_str("/register"),
175 }
176 }
177}
178
179pub fn is_post_path(path: &str) -> bool {
181 path.starts_with("/post/")
182}
183
184pub fn is_showcase_path(path: &str) -> bool {
186 path == "/about/components" || path.starts_with("/about/components/")
187}
188
189pub fn is_admin_path(path: &str) -> bool {
192 path == "/admin" || path.starts_with("/admin/")
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198
199 #[test]
200 fn dynamic_segments_are_percent_encoded() {
201 assert_eq!(
202 Route::TagDetail {
203 tag: "Rust 编程".to_owned()
204 }
205 .to_string(),
206 "/tags/Rust%20%E7%BC%96%E7%A8%8B"
207 );
208 assert_eq!(
209 Route::PostDetail {
210 slug: "a?b".to_owned()
211 }
212 .to_string(),
213 "/post/a%3Fb"
214 );
215 }
216
217 #[test]
218 fn query_values_are_encoded_and_empty_values_omitted() {
219 assert_eq!(
220 Route::Search {
221 q: "a b".to_owned()
222 }
223 .to_string(),
224 "/search?q=a%20b"
225 );
226 assert_eq!(Route::Search { q: String::new() }.to_string(), "/search");
227 assert_eq!(
228 Route::NoteDetail {
229 slug: "x".to_owned(),
230 book: "3".to_owned()
231 }
232 .to_string(),
233 "/notes/x?book=3"
234 );
235 assert_eq!(
236 Route::NoteDetail {
237 slug: "x".to_owned(),
238 book: String::new()
239 }
240 .to_string(),
241 "/notes/x"
242 );
243 }
244
245 #[test]
246 fn query_values_survive_router_query_parsing() {
247 for q in ["C++", "R&D", "a=b", "100%", "a b"] {
248 let href = Route::Search { q: q.to_owned() }.to_string();
249 let url = leptos_router::location::RequestUrl::new(&href)
250 .parse()
251 .unwrap();
252 assert_eq!(
253 url.search_params().get_all("q"),
254 Some(vec![q.to_owned()]),
255 "{href}"
256 );
257 }
258 }
259
260 #[test]
261 fn numeric_segments_and_admin_paths() {
262 assert_eq!(
263 Route::AdminCommentsPage { page: 2 }.to_string(),
264 "/admin/comments/2"
265 );
266 assert_eq!(Route::HomePage { page: 3 }.to_string(), "/page/3");
267 assert_eq!(Route::Admin.to_string(), "/admin");
268 assert_eq!(Route::EditNote { id: 7 }.to_string(), "/admin/notes/edit/7");
269 }
270
271 #[test]
272 fn path_classifiers() {
273 assert!(is_post_path("/post/hello"));
274 assert!(!is_post_path("/posts"));
275 assert!(is_admin_path("/admin"));
276 assert!(is_admin_path("/admin/posts"));
277 assert!(!is_admin_path("/admin.css"));
278 assert!(!is_admin_path("/administrator"));
279 assert!(is_showcase_path("/about/components"));
280 assert!(is_showcase_path("/about/components/button"));
281 assert!(!is_showcase_path("/about/componentsx"));
282 assert!(!is_showcase_path("/about"));
283 }
284}