1use leptos_router::location::Url;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum RouteTarget {
14 Home {
16 page: Option<String>,
17 },
18 Notes,
19 Notebook {
20 id: String,
21 },
22 Note {
24 slug: String,
25 book: Option<String>,
26 },
27 Archives,
28 Tag {
29 tag: String,
30 },
31 Post {
32 slug: String,
33 },
34 Search,
35 About,
36 WritingGuide,
37 Showcase,
38 ShowcaseDetail,
39 Friends,
40 Changelog,
41 Login,
42 Register,
43 Admin(AdminPage),
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum AdminPage {
49 Dashboard,
50 Write,
51 WriteEdit,
52 Preview,
53 Posts,
54 PostsTrash,
55 Notes,
56 NewNote,
57 EditNote,
58 Notebooks,
59 Assets,
60 Friends,
61 Comments,
62 System,
63 Runner,
64 Mcp,
65 Logs,
66 Settings,
67 Profile,
68}
69
70fn query_value(search: &str, key: &str) -> Option<String> {
72 search.split('&').find_map(|pair| {
73 let (k, v) = pair.split_once('=')?;
74 (Url::unescape(k) == key).then(|| Url::unescape(v))
75 })
76}
77
78pub fn match_route(route: &str) -> Option<RouteTarget> {
80 let (path, search) = route.split_once('?').unwrap_or((route, ""));
81 let rest = path.strip_prefix('/')?;
82 let segments: Vec<&str> = if rest.is_empty() {
83 Vec::new()
84 } else {
85 rest.split('/').collect()
86 };
87 if segments.iter().any(|s| s.is_empty()) {
89 return None;
90 }
91 let param = |s: &str| Url::unescape(s);
92 Some(match segments.as_slice() {
93 [] => RouteTarget::Home { page: None },
94 ["page", page] => RouteTarget::Home {
95 page: Some(param(page)),
96 },
97 ["notes"] => RouteTarget::Notes,
98 ["notes", "book", id] => RouteTarget::Notebook { id: param(id) },
99 ["notes", slug] => RouteTarget::Note {
100 slug: param(slug),
101 book: query_value(search, "book"),
102 },
103 ["archives"] => RouteTarget::Archives,
104 ["tags", tag] => RouteTarget::Tag { tag: param(tag) },
105 ["post", slug] => RouteTarget::Post { slug: param(slug) },
106 ["search"] => RouteTarget::Search,
107 ["about"] => RouteTarget::About,
108 ["about", "writing"] => RouteTarget::WritingGuide,
109 ["about", "components"] => RouteTarget::Showcase,
110 ["about", "components", _] => RouteTarget::ShowcaseDetail,
111 ["friends"] => RouteTarget::Friends,
112 ["changelog"] => RouteTarget::Changelog,
113 ["login"] => RouteTarget::Login,
114 ["register"] => RouteTarget::Register,
115 ["admin", rest @ ..] => RouteTarget::Admin(match rest {
116 [] => AdminPage::Dashboard,
117 ["write"] => AdminPage::Write,
118 ["write", _] => AdminPage::WriteEdit,
119 ["preview", _] => AdminPage::Preview,
120 ["posts"] => AdminPage::Posts,
121 ["posts", "trash"] => AdminPage::PostsTrash,
122 ["notes"] => AdminPage::Notes,
123 ["notes", "new"] => AdminPage::NewNote,
124 ["notes", "edit", _] => AdminPage::EditNote,
125 ["notebooks"] => AdminPage::Notebooks,
126 ["assets"] => AdminPage::Assets,
127 ["friends"] => AdminPage::Friends,
128 ["comments"] | ["comments", _] => AdminPage::Comments,
129 ["system"] => AdminPage::System,
130 ["runner"] => AdminPage::Runner,
131 ["mcp"] => AdminPage::Mcp,
132 ["logs"] => AdminPage::Logs,
133 ["settings"] => AdminPage::Settings,
134 ["profile"] => AdminPage::Profile,
135 _ => return None,
136 }),
137 _ => return None,
138 })
139}
140
141#[cfg(feature = "server")]
145pub fn route_view_names(target: &RouteTarget) -> &'static [&'static str] {
146 match target {
147 RouteTarget::Home { .. } => &["home"],
148 RouteTarget::Notes => &["notes"],
149 RouteTarget::Notebook { .. } => &["notebook"],
150 RouteTarget::Note { .. } => &["note"],
151 RouteTarget::Archives => &["archives"],
152 RouteTarget::Tag { .. } => &["tag"],
153 RouteTarget::Post { .. } => &["post"],
154 RouteTarget::Search => &["search"],
155 RouteTarget::About => &["about"],
156 RouteTarget::WritingGuide => &["writing_guide"],
157 RouteTarget::Showcase => &["showcase"],
158 RouteTarget::ShowcaseDetail => &["showcase_detail"],
159 RouteTarget::Friends => &["friends"],
160 RouteTarget::Changelog => &["changelog"],
161 RouteTarget::Login => &["login"],
162 RouteTarget::Register => &["register"],
163 RouteTarget::Admin(page) => match page {
164 AdminPage::Dashboard => &["admin_shell", "dashboard"],
165 AdminPage::Write => &["admin_shell", "write"],
166 AdminPage::WriteEdit => &["admin_shell", "write_edit"],
167 AdminPage::Preview => &["admin_shell", "preview"],
168 AdminPage::Posts => &["admin_shell", "posts"],
169 AdminPage::PostsTrash => &["admin_shell", "posts_trash"],
170 AdminPage::Notes => &["admin_shell", "admin_notes"],
171 AdminPage::NewNote => &["admin_shell", "new_note"],
172 AdminPage::EditNote => &["admin_shell", "edit_note"],
173 AdminPage::Notebooks => &["admin_shell", "notebooks"],
174 AdminPage::Assets => &["admin_shell", "assets"],
175 AdminPage::Friends => &["admin_shell", "friends_admin"],
176 AdminPage::Comments => &["admin_shell", "admin_comments"],
177 AdminPage::System => &["admin_shell", "system"],
178 AdminPage::Runner => &["admin_shell", "runner"],
179 AdminPage::Mcp => &["admin_shell", "mcp"],
180 AdminPage::Logs => &["admin_shell", "logs"],
181 AdminPage::Settings => &["admin_shell", "settings"],
182 AdminPage::Profile => &["admin_shell", "profile"],
183 },
184 }
185}
186
187pub fn prefetch(route: &str, with_data: bool, is_admin: bool) {
190 #[cfg(target_arch = "wasm32")]
191 if let Some(target) = match_route(route) {
192 wasm::preload(&target, is_admin);
193 if with_data {
194 wasm::prefetch_data(target);
195 }
196 }
197 #[cfg(not(target_arch = "wasm32"))]
198 let _ = (route, with_data, is_admin);
199}
200
201#[cfg(target_arch = "wasm32")]
202mod wasm {
203 use super::{AdminPage, RouteTarget};
204 use crate::pages::about::AboutRoute;
205 use crate::pages::admin::assets::AssetsRoute;
206 use crate::pages::admin::comments::AdminCommentsRoute;
207 use crate::pages::admin::dashboard::DashboardRoute;
208 use crate::pages::admin::friends::FriendsAdminRoute;
209 use crate::pages::admin::logs::LogsRoute;
210 use crate::pages::admin::mcp::McpRoute;
211 use crate::pages::admin::note_editor::{EditNoteRoute, NewNoteRoute};
212 use crate::pages::admin::notes::notebooks::NotebooksRoute;
213 use crate::pages::admin::notes::AdminNotesRoute;
214 use crate::pages::admin::posts::PostsRoute;
215 use crate::pages::admin::posts_trash::PostsTrashRoute;
216 use crate::pages::admin::preview::PreviewRoute;
217 use crate::pages::admin::profile::ProfileRoute;
218 use crate::pages::admin::runner::RunnerRoute;
219 use crate::pages::admin::settings::SettingsRoute;
220 use crate::pages::admin::system::SystemRoute;
221 use crate::pages::admin::write::{WriteEditRoute, WriteRoute};
222 use crate::pages::admin::AdminShellRoute;
223 use crate::pages::archives::ArchivesRoute;
224 use crate::pages::changelog::ChangelogRoute;
225 use crate::pages::components_showcase::{ShowcaseDetailRoute, ShowcaseRoute};
226 use crate::pages::friends::FriendsRoute;
227 use crate::pages::home::HomeRoute;
228 use crate::pages::login::LoginRoute;
229 use crate::pages::notes::{NoteRoute, NotebookRoute, NotesRoute};
230 use crate::pages::post_detail::PostRoute;
231 use crate::pages::register::RegisterRoute;
232 use crate::pages::search::SearchRoute;
233 use crate::pages::tags::TagRoute;
234 use crate::pages::writing_guide::WritingGuideRoute;
235 use leptos::task::spawn_local;
236 use leptos_router::LazyRoute;
237
238 pub(super) fn preload(target: &RouteTarget, is_admin: bool) {
240 match target {
241 RouteTarget::Home { .. } => spawn_local(HomeRoute::preload()),
242 RouteTarget::Notes => spawn_local(NotesRoute::preload()),
243 RouteTarget::Notebook { .. } => spawn_local(NotebookRoute::preload()),
244 RouteTarget::Note { .. } => spawn_local(NoteRoute::preload()),
245 RouteTarget::Archives => spawn_local(ArchivesRoute::preload()),
246 RouteTarget::Tag { .. } => spawn_local(TagRoute::preload()),
247 RouteTarget::Post { .. } => spawn_local(PostRoute::preload()),
248 RouteTarget::Search => spawn_local(SearchRoute::preload()),
249 RouteTarget::About => spawn_local(AboutRoute::preload()),
250 RouteTarget::WritingGuide => spawn_local(WritingGuideRoute::preload()),
251 RouteTarget::Showcase => spawn_local(ShowcaseRoute::preload()),
252 RouteTarget::ShowcaseDetail => spawn_local(ShowcaseDetailRoute::preload()),
253 RouteTarget::Friends => spawn_local(FriendsRoute::preload()),
254 RouteTarget::Changelog => spawn_local(ChangelogRoute::preload()),
255 RouteTarget::Login => spawn_local(LoginRoute::preload()),
256 RouteTarget::Register => spawn_local(RegisterRoute::preload()),
257 RouteTarget::Admin(page) if is_admin => {
258 spawn_local(AdminShellRoute::preload());
259 match page {
260 AdminPage::Dashboard => spawn_local(DashboardRoute::preload()),
261 AdminPage::Write => spawn_local(WriteRoute::preload()),
262 AdminPage::WriteEdit => spawn_local(WriteEditRoute::preload()),
263 AdminPage::Preview => spawn_local(PreviewRoute::preload()),
264 AdminPage::Posts => spawn_local(PostsRoute::preload()),
265 AdminPage::PostsTrash => spawn_local(PostsTrashRoute::preload()),
266 AdminPage::Notes => spawn_local(AdminNotesRoute::preload()),
267 AdminPage::NewNote => spawn_local(NewNoteRoute::preload()),
268 AdminPage::EditNote => spawn_local(EditNoteRoute::preload()),
269 AdminPage::Notebooks => spawn_local(NotebooksRoute::preload()),
270 AdminPage::Assets => spawn_local(AssetsRoute::preload()),
271 AdminPage::Friends => spawn_local(FriendsAdminRoute::preload()),
272 AdminPage::Comments => spawn_local(AdminCommentsRoute::preload()),
273 AdminPage::System => spawn_local(SystemRoute::preload()),
274 AdminPage::Runner => spawn_local(RunnerRoute::preload()),
275 AdminPage::Mcp => spawn_local(McpRoute::preload()),
276 AdminPage::Logs => spawn_local(LogsRoute::preload()),
277 AdminPage::Settings => spawn_local(SettingsRoute::preload()),
278 AdminPage::Profile => spawn_local(ProfileRoute::preload()),
279 }
280 }
281 RouteTarget::Admin(_) => {}
282 }
283 }
284
285 pub(super) fn prefetch_data(target: RouteTarget) {
288 match target {
289 RouteTarget::Home { page } => HomeRoute::prefetch_data(page.as_deref()),
290 RouteTarget::Notes => NotesRoute::prefetch_data(),
291 RouteTarget::Notebook { id } => NotebookRoute::prefetch_data(&id),
292 RouteTarget::Note { slug, book } => NoteRoute::prefetch_data(slug, book.as_deref()),
293 RouteTarget::Archives => ArchivesRoute::prefetch_data(),
294 RouteTarget::Tag { tag } => TagRoute::prefetch_data(tag),
295 RouteTarget::Post { slug } => PostRoute::prefetch_data(slug),
296 RouteTarget::About => AboutRoute::prefetch_data(),
297 RouteTarget::Friends => FriendsRoute::prefetch_data(),
298 RouteTarget::Changelog => ChangelogRoute::prefetch_data(),
299 RouteTarget::WritingGuide => WritingGuideRoute::prefetch_data(),
300 RouteTarget::Search
301 | RouteTarget::Showcase
302 | RouteTarget::ShowcaseDetail
303 | RouteTarget::Login
304 | RouteTarget::Register
305 | RouteTarget::Admin(_) => {}
306 }
307 }
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313
314 fn post(slug: &str) -> Option<RouteTarget> {
315 Some(RouteTarget::Post { slug: slug.into() })
316 }
317
318 #[test]
319 fn notebook_needs_three_segments() {
320 assert_eq!(
321 match_route("/notes/book/3"),
322 Some(RouteTarget::Notebook { id: "3".into() })
323 );
324 assert_eq!(
325 match_route("/notes/book"),
326 Some(RouteTarget::Note {
327 slug: "book".into(),
328 book: None
329 })
330 );
331 }
332
333 #[test]
334 fn params_are_percent_decoded() {
335 assert_eq!(
336 match_route("/tags/%E4%B8%AD%E6%96%87"),
337 Some(RouteTarget::Tag {
338 tag: "中文".into()
339 })
340 );
341 assert_eq!(match_route("/post/a%20b"), post("a b"));
342 }
343
344 #[test]
345 fn note_takes_the_first_book_query() {
346 assert_eq!(
347 match_route("/notes/x?book=7&book=8"),
348 Some(RouteTarget::Note {
349 slug: "x".into(),
350 book: Some("7".into())
351 })
352 );
353 assert_eq!(
354 match_route("/notes/x?other=1"),
355 Some(RouteTarget::Note {
356 slug: "x".into(),
357 book: None
358 })
359 );
360 }
361
362 #[test]
363 fn home_pages() {
364 assert_eq!(match_route("/"), Some(RouteTarget::Home { page: None }));
365 assert_eq!(
366 match_route("/page/2"),
367 Some(RouteTarget::Home {
368 page: Some("2".into())
369 })
370 );
371 }
372
373 #[test]
374 fn admin_pages() {
375 assert_eq!(
376 match_route("/admin"),
377 Some(RouteTarget::Admin(AdminPage::Dashboard))
378 );
379 assert_eq!(
380 match_route("/admin/comments/2"),
381 Some(RouteTarget::Admin(AdminPage::Comments))
382 );
383 assert_eq!(
384 match_route("/admin/write/5"),
385 Some(RouteTarget::Admin(AdminPage::WriteEdit))
386 );
387 assert_eq!(
388 match_route("/admin/notes/edit/9"),
389 Some(RouteTarget::Admin(AdminPage::EditNote))
390 );
391 assert_eq!(match_route("/admin/unknown"), None);
392 }
393
394 #[test]
395 fn unmatched_routes() {
396 for route in [
397 "/post/",
398 "/post",
399 "/post/a/b",
400 "//",
401 "/unknown",
402 "/api/x",
403 "",
404 "post/a",
405 "/archives/",
406 ] {
407 assert_eq!(match_route(route), None, "{route:?}");
408 }
409 }
410}