Skip to main content

yggdrasil/
app.rs

1//! HTML 外壳、应用根组件与路由表。
2//!
3//! `shell()` 是服务端渲染的完整 HTML 文档;全局样式与启动脚本的地址由
4//! [`crate::static_assets::url`] 按文件内容生成版本号,改文件不需要手动改地址。
5//!
6//! 路由表里每个页面都是 `Lazy::<T>`:路由数据(`LazyRoute::data`)在主包里同步创建资源,
7//! 视图代码在 `cargo leptos --split` 下拆进独立 WASM 分包。前台布局(页头、页脚、主题)
8//! 留在主包;后台外壳是懒加载的父路由,子页面切换时外壳不重建。
9
10use leptos::prelude::*;
11use leptos_meta::{provide_meta_context, Title};
12use leptos_router::components::{ParentRoute, Route, Router, Routes};
13use leptos_router::{path, Lazy, SsrMode};
14
15use crate::components::frontend_layout::FrontendLayout;
16use crate::components::share_meta::provide_share_context;
17use crate::components::ui::{provide_toaster, ToastRegion};
18use crate::context::{ShowcaseFilters, UserContext};
19use crate::navigation::{NavigationBridge, RouteMarker, RouteProgress};
20use crate::pages::about::AboutRoute;
21use crate::pages::admin::assets::AssetsRoute;
22use crate::pages::admin::comments::AdminCommentsRoute;
23use crate::pages::admin::dashboard::DashboardRoute;
24use crate::pages::admin::friends::FriendsAdminRoute;
25use crate::pages::admin::logs::LogsRoute;
26use crate::pages::admin::mcp::McpRoute;
27use crate::pages::admin::note_editor::{EditNoteRoute, NewNoteRoute};
28use crate::pages::admin::notes::notebooks::NotebooksRoute;
29use crate::pages::admin::notes::AdminNotesRoute;
30use crate::pages::admin::posts::PostsRoute;
31use crate::pages::admin::posts_trash::PostsTrashRoute;
32use crate::pages::admin::preview::PreviewRoute;
33use crate::pages::admin::profile::ProfileRoute;
34use crate::pages::admin::runner::RunnerRoute;
35use crate::pages::admin::settings::SettingsRoute;
36use crate::pages::admin::system::SystemRoute;
37use crate::pages::admin::write::{WriteEditRoute, WriteRoute};
38use crate::pages::admin::AdminShellRoute;
39use crate::pages::archives::ArchivesRoute;
40use crate::pages::changelog::ChangelogRoute;
41use crate::pages::components_showcase::{ShowcaseDetailRoute, ShowcaseRoute};
42use crate::pages::friends::FriendsRoute;
43use crate::pages::home::HomeRoute;
44use crate::pages::login::LoginRoute;
45use crate::pages::not_found::NotFound;
46use crate::pages::notes::{NoteRoute, NotebookRoute, NotesRoute};
47use crate::pages::post_detail::PostRoute;
48use crate::pages::register::RegisterRoute;
49use crate::pages::search::SearchRoute;
50use crate::pages::tags::TagRoute;
51use crate::pages::writing_guide::WritingGuideRoute;
52use crate::theme::provide_theme;
53
54/// 服务端渲染的 HTML 文档外壳。
55#[cfg(feature = "server")]
56pub fn shell(options: LeptosOptions) -> impl IntoView {
57    use crate::static_assets::{
58        self, ADMIN_CSS, CORE_CSS, CORE_JS, HIGHLIGHT_CSS, KATEX_CSS, STYLE_CSS,
59    };
60    use crate::theme::ThemePreload;
61    use leptos_meta::MetaTags;
62
63    // 后台与组件图鉴直开时由 SSR 写入后台样式;客户端导航进入时由路由 data() 补插。
64    let admin_css = crate::request_ctx::request_parts()
65        .is_some_and(|p| static_assets::needs_admin_css(p.uri.path()));
66
67    view! {
68        <!DOCTYPE html>
69        <html lang="zh-CN">
70            <head>
71                <meta charset="utf-8"/>
72                <meta name="viewport" content="width=device-width, initial-scale=1"/>
73                <meta name="theme-color" content="#f5f5f3" media="(prefers-color-scheme: light)"/>
74                <meta name="theme-color" content="#0f0f0e" media="(prefers-color-scheme: dark)"/>
75                <link rel="icon" href="/favicon.svg" type="image/svg+xml"/>
76                <link rel="icon" href="/favicon.ico" sizes="16x16 32x32 48x48"/>
77                <link rel="apple-touch-icon" href="/apple-touch-icon.png"/>
78                <link rel="manifest" href="/manifest.webmanifest"/>
79                <link rel="stylesheet" href=static_assets::url(STYLE_CSS)/>
80                {admin_css
81                    .then(|| {
82                        view! {
83                            <link
84                                rel="stylesheet"
85                                id=static_assets::ADMIN_CSS_ELEMENT_ID
86                                href=static_assets::url(ADMIN_CSS)
87                            />
88                        }
89                    })}
90                <link rel="stylesheet" href=static_assets::url(HIGHLIGHT_CSS)/>
91                <link rel="stylesheet" href=static_assets::url(KATEX_CSS)/>
92                <link rel="stylesheet" href=static_assets::url(CORE_CSS)/>
93                <script
94                    type="application/json"
95                    id=static_assets::VERSIONS_ELEMENT_ID
96                    inner_html=static_assets::versions_json()
97                ></script>
98                <script src=static_assets::url(CORE_JS)></script>
99                <ThemePreload/>
100                <AutoReload options=options.clone()/>
101                <HydrationScriptsWithPreload options/>
102                <MetaTags/>
103            </head>
104            <body>
105                <App/>
106            </body>
107        </html>
108    }
109}
110
111/// 水合脚本,只为当前路由预载 WASM 分包。
112///
113/// `HydrationScripts` 把分包清单放进上下文,`leptos_integration_utils` 据此为 SSR 期间调用过的
114/// 每个 lazy fn 展开清单里的全部分包 `<link rel="preload" as="fetch">`,且不去重(首页 360 余条,
115/// 含水合用不到的取数函数分包)。实测弱网下它们与 CSS 抢带宽,移动端 FCP 慢 0.5–2.5 s(见
116/// `docs/首屏性能实测-2026-10-28.md`)。这里在 `HydrationScripts` 之后立即取走清单,改为按请求
117/// 路径只预载路由视图分包([`crate::route_prefetch::route_view_names`],去重)。不预载时它们要等
118/// 主包实例化、水合开始后才发出,整批多等一轮;EdgeOne 对同一客户端的并发请求大致逐个处理,
119/// 这一轮在线上要 100–200 ms。加载器 `__wasm_split.*.js` 在主入口的静态导入链上,单独
120/// modulepreload。
121#[cfg(feature = "server")]
122#[component]
123fn HydrationScriptsWithPreload(options: LeptosOptions) -> impl IntoView {
124    let scripts = view! { <HydrationScripts options/> };
125    let names = crate::request_ctx::request_parts()
126        .and_then(|parts| crate::route_prefetch::match_route(parts.uri.path()))
127        .map(|target| crate::route_prefetch::route_view_names(&target))
128        .unwrap_or_default();
129    let (loader, modules) = leptos::reactive::owner::take_context::<leptos::WasmSplitManifest>()
130        .and_then(|manifest| {
131            manifest.0.try_read_value().map(|value| {
132                let (pkg, manifest, loader) = &*value;
133                (
134                    format!("{pkg}/{loader}"),
135                    route_modules(manifest, names)
136                        .into_iter()
137                        .map(|module| format!("{pkg}/{module}.wasm"))
138                        .collect::<Vec<_>>(),
139                )
140            })
141        })
142        .unzip();
143    view! {
144        {scripts}
145        {loader.map(|href| view! { <link rel="modulepreload" href=href/> })}
146        {modules
147            .unwrap_or_default()
148            .into_iter()
149            .map(|href| {
150                view! {
151                    <link
152                        rel="preload"
153                        href=href
154                        r#as="fetch"
155                        r#type="application/wasm"
156                        crossorigin="anonymous"
157                        fetchpriority="low"
158                    />
159                }
160            })
161            .collect_view()}
162    }
163}
164
165/// 清单里这些路由视图(键形如 `__<name>_route_view_<哈希>`)依赖的全部模块,去重后按首次出现排序。
166#[cfg(feature = "server")]
167fn route_modules<'a>(
168    manifest: &'a std::collections::HashMap<String, Vec<String>>,
169    names: &[&str],
170) -> Vec<&'a str> {
171    let mut modules: Vec<&str> = Vec::new();
172    for name in names {
173        let prefix = format!("__{name}_route_view_");
174        let Some((_, list)) = manifest.iter().find(|(key, _)| {
175            key.strip_prefix(&prefix)
176                .is_some_and(|hash| hash.bytes().all(|b| b.is_ascii_digit()))
177        }) else {
178            continue;
179        };
180        for module in list {
181            if !modules.contains(&module.as_str()) {
182                modules.push(module);
183            }
184        }
185    }
186    modules
187}
188
189/// 应用根组件:全局上下文、路由器、导航桥接与全站提示。
190#[component]
191pub fn App() -> impl IntoView {
192    provide_meta_context();
193    provide_share_context();
194    provide_theme();
195    provide_context(UserContext::new());
196    provide_context(RwSignal::new(ShowcaseFilters::default()));
197    provide_toaster();
198    crate::hooks::ssr_resource::provide_route_data_registry();
199    // 路由器是否正在切换:RouteMarker 据此避开切换中的无效通知,RouteProgress 据此显示进度条。
200    let (is_routing, set_is_routing) = signal(false);
201
202    // 悬停预取的路由匹配表在 `route_prefetch::match_route`,改路由表时同步修改。
203    view! {
204        <Title text="Yggdrasil Blog"/>
205        <Router set_is_routing>
206            <NavigationBridge/>
207            <RouteMarker is_routing>
208                <Routes fallback=|| {
209                    view! {
210                        <FrontendLayout>
211                            <NotFound/>
212                        </FrontendLayout>
213                    }
214                }>
215                    <ParentRoute path=path!("") view=|| view! { <FrontendLayout/> }>
216                        <Route
217                            path=path!("")
218                            view={Lazy::<HomeRoute>::new()}
219                            ssr=SsrMode::InOrder
220                        />
221                        // 非法页码要回 404:前台页面都在 FrontendLayout 的 Suspense 里,InOrder 下
222                        // 状态码能否赶上首块取决于线程调度,只有 Async 等全部渲染完才写状态。
223                        <Route
224                            path=path!("page/:page")
225                            view={Lazy::<HomeRoute>::new()}
226                            ssr=SsrMode::Async
227                        />
228                        <Route
229                            path=path!("notes")
230                            view={Lazy::<NotesRoute>::new()}
231                            ssr=SsrMode::InOrder
232                        />
233                        <Route
234                            path=path!("notes/book/:id")
235                            view={Lazy::<NotebookRoute>::new()}
236                            ssr=SsrMode::Async
237                        />
238                        <Route
239                            path=path!("notes/:slug")
240                            view={Lazy::<NoteRoute>::new()}
241                            ssr=SsrMode::Async
242                        />
243                        <Route
244                            path=path!("archives")
245                            view={Lazy::<ArchivesRoute>::new()}
246                            ssr=SsrMode::InOrder
247                        />
248                        <Route
249                            path=path!("tags/:tag")
250                            view={Lazy::<TagRoute>::new()}
251                            ssr=SsrMode::Async
252                        />
253                        <Route
254                            path=path!("post/:slug")
255                            view={Lazy::<PostRoute>::new()}
256                            ssr=SsrMode::Async
257                        />
258                        <Route path=path!("search") view={Lazy::<SearchRoute>::new()}/>
259                        <Route
260                            path=path!("about")
261                            view={Lazy::<AboutRoute>::new()}
262                            ssr=SsrMode::InOrder
263                        />
264                        <Route
265                            path=path!("about/writing")
266                            view={Lazy::<WritingGuideRoute>::new()}
267                            ssr=SsrMode::InOrder
268                        />
269                        <Route
270                            path=path!("about/components")
271                            view={Lazy::<ShowcaseRoute>::new()}
272                            ssr=SsrMode::Async
273                        />
274                        <Route
275                            path=path!("about/components/:component")
276                            view={Lazy::<ShowcaseDetailRoute>::new()}
277                            ssr=SsrMode::Async
278                        />
279                        <Route
280                            path=path!("friends")
281                            view={Lazy::<FriendsRoute>::new()}
282                            ssr=SsrMode::InOrder
283                        />
284                        <Route
285                            path=path!("changelog")
286                            view={Lazy::<ChangelogRoute>::new()}
287                            ssr=SsrMode::Async
288                        />
289                    </ParentRoute>
290                    <ParentRoute path=path!("admin") view={Lazy::<AdminShellRoute>::new()}>
291                        <Route path=path!("") view={Lazy::<DashboardRoute>::new()}/>
292                        <Route path=path!("write") view={Lazy::<WriteRoute>::new()}/>
293                        <Route path=path!("write/:id") view={Lazy::<WriteEditRoute>::new()}/>
294                        <Route path=path!("preview/:slug") view={Lazy::<PreviewRoute>::new()}/>
295                        <Route path=path!("posts") view={Lazy::<PostsRoute>::new()}/>
296                        <Route path=path!("posts/trash") view={Lazy::<PostsTrashRoute>::new()}/>
297                        <Route path=path!("notes") view={Lazy::<AdminNotesRoute>::new()}/>
298                        <Route path=path!("notes/new") view={Lazy::<NewNoteRoute>::new()}/>
299                        <Route path=path!("notes/edit/:id") view={Lazy::<EditNoteRoute>::new()}/>
300                        <Route path=path!("notebooks") view={Lazy::<NotebooksRoute>::new()}/>
301                        <Route path=path!("assets") view={Lazy::<AssetsRoute>::new()}/>
302                        <Route path=path!("friends") view={Lazy::<FriendsAdminRoute>::new()}/>
303                        <Route path=path!("comments") view={Lazy::<AdminCommentsRoute>::new()}/>
304                        <Route
305                            path=path!("comments/:page")
306                            view={Lazy::<AdminCommentsRoute>::new()}
307                        />
308                        <Route path=path!("system") view={Lazy::<SystemRoute>::new()}/>
309                        <Route path=path!("runner") view={Lazy::<RunnerRoute>::new()}/>
310                        <Route path=path!("mcp") view={Lazy::<McpRoute>::new()}/>
311                        <Route path=path!("logs") view={Lazy::<LogsRoute>::new()}/>
312                        <Route path=path!("settings") view={Lazy::<SettingsRoute>::new()}/>
313                        <Route path=path!("profile") view={Lazy::<ProfileRoute>::new()}/>
314                    </ParentRoute>
315                    <Route path=path!("login") view={Lazy::<LoginRoute>::new()}/>
316                    <Route path=path!("register") view={Lazy::<RegisterRoute>::new()}/>
317                </Routes>
318            </RouteMarker>
319        </Router>
320        <RouteProgress is_routing/>
321        <ToastRegion/>
322    }
323}
324
325#[cfg(all(test, feature = "server"))]
326mod tests {
327    use super::route_modules;
328
329    #[test]
330    fn route_modules_match_whole_route_name_and_dedupe() {
331        let manifest = [
332            (
333                "__note_route_view_11",
334                vec!["chunk_1.a", "split___note_route_view_11.x"],
335            ),
336            ("__notebook_route_view_12", vec!["chunk_2.a"]),
337            ("__friends_admin_route_view_13", vec!["chunk_3.a"]),
338            (
339                "__admin_shell_route_view_14",
340                vec!["chunk_1.a", "chunk_4.a"],
341            ),
342            ("__dashboard_route_view_15", vec!["chunk_4.a", "chunk_5.a"]),
343            ("fetch_note_16", vec!["chunk_6.a"]),
344        ]
345        .into_iter()
346        .map(|(key, modules)| {
347            (
348                key.to_string(),
349                modules.into_iter().map(String::from).collect(),
350            )
351        })
352        .collect();
353
354        // `note` 不能带出 `notebook` 的分包,取数函数的分包也不预载。
355        assert_eq!(
356            route_modules(&manifest, &["note"]),
357            ["chunk_1.a", "split___note_route_view_11.x"]
358        );
359        // 后台外壳与子页面共用的分包只出现一次。
360        assert_eq!(
361            route_modules(&manifest, &["admin_shell", "dashboard"]),
362            ["chunk_1.a", "chunk_4.a", "chunk_5.a"]
363        );
364        // 清单里没有的路由(`friends` 只有后台同名前缀)不预载任何东西。
365        assert!(route_modules(&manifest, &["friends"]).is_empty());
366    }
367}