Skip to main content

yggdrasil/
route_prefetch.rs

1//! 链接悬停预取:把站内地址映射到路由,提前下载目标页面的 WASM 分包。
2//!
3//! JS 协调器(`libs/yggdrasil-core/src/route-transitions.ts`)在鼠标停留、键盘聚焦或触屏
4//! 按下站内链接时,把“路径 + 查询”(无前缀、无哈希)交给 [`prefetch`]。
5//!
6//! [`match_route`] 的匹配表与 `src/app.rs` 的路由表一一对应,**新增路由时两处一起改**;
7//! 漏改只会少一次预取,不影响导航。
8
9use leptos_router::location::Url;
10
11/// 悬停目标。参数是路由器会看到的值:路径段经 `Url::unescape` 解码,查询值同样解码。
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum RouteTarget {
14    /// `/`(`None`)或 `/page/:page`(原始段,由 `HomeRoute` 自己解析)。
15    Home {
16        page: Option<String>,
17    },
18    Notes,
19    Notebook {
20        id: String,
21    },
22    /// `/notes/:slug`,`book` 来自查询串 `?book=`。
23    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/// `/admin/*` 子页面。
47#[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
70/// 查询串里第一个名为 `key` 的参数值(键、值都解码)。
71fn 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
78/// `route` 为“路径 + 可选查询”(无前缀、无哈希)。不匹配任何页面返回 `None`。
79pub 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    // 任何空段(尾斜杠、`//`)都不匹配,与路由器一致。
88    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/// 直开该页面时水合要加载的路由视图分包名:`Lazy::<XxxRoute>` 的视图函数在分包清单里
142/// 叫 `__xxx_route_view_<哈希>`,这里给出其中的 `xxx`(后台页面先是外壳,再是子页面)。
143/// SSR 据此只为当前路由预载分包,见 `app::HydrationScriptsWithPreload`;漏改只会少预载。
144#[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
187/// 预取目标路由:总是预取视图分包;`with_data` 时再预取页面数据。
188/// `is_admin` 为假时跳过 `/admin*`:访客悬停后台链接不下载后台分包。
189pub 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    /// 预取视图分包。`preload()` 失败不缓存,下次悬停或点击仍会重试。
239    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    /// 预取页面数据。不预取:`Search`(无路由数据)、`Showcase*`(客户端已有缓存)、
286    /// `Login`/`Register`、全部 `Admin(..)`(后台数据依赖会话且常被写操作改动)。
287    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}