Skip to main content

yggdrasil/components/
nav.rs

1//! 前台导航项配置
2//!
3//! 根据当前路径生成前台 Header 所需的导航项列表。
4
5use crate::components::header::NavItemConfig;
6use crate::routes::{is_showcase_path, Route};
7
8/// 生成前台导航项列表,当前访问的路径会被标记为激活。
9///
10/// 纯数据转换函数,可在任意响应式闭包里调用。
11///
12/// 参数:
13/// - `path`:当前显示页面的路径(不含查询串;尾斜杠会被忽略)
14///
15/// 返回:包含首页、笔记、归档(含标签)、友链、关于的导航配置数组。
16/// 搜索以图标形式置于 Header 右侧(主题切换左边),不在此文本导航中。
17pub fn build_nav_items(path: &str) -> Vec<NavItemConfig> {
18    // 去掉尾斜杠,根路径保持 "/"。
19    let path = match path.trim_end_matches('/') {
20        "" => "/",
21        trimmed => trimmed,
22    };
23    vec![
24        NavItemConfig {
25            route: Route::Home,
26            label: "首页",
27            is_active: path == "/" || path.starts_with("/page/"),
28        },
29        NavItemConfig {
30            route: Route::Notes,
31            label: "笔记",
32            is_active: path == "/notes" || path.starts_with("/notes/"),
33        },
34        NavItemConfig {
35            route: Route::Archives,
36            label: "归档",
37            is_active: path == "/archives" || path.starts_with("/tags/"),
38        },
39        NavItemConfig {
40            route: Route::Friends,
41            label: "友链",
42            is_active: path == "/friends",
43        },
44        NavItemConfig {
45            route: Route::About,
46            label: "关于",
47            is_active: is_about_family(path),
48        },
49    ]
50}
51
52/// 「关于」家族(关于 / 写作指南 / 组件图鉴)的高亮判定。
53///
54/// 图鉴路径(`/about/components` 及其子路径)经 [`is_showcase_path`] 判定。
55fn is_about_family(path: &str) -> bool {
56    matches!(path, "/about" | "/about/writing") || is_showcase_path(path)
57}
58
59#[cfg(test)]
60mod tests {
61    use super::build_nav_items;
62    use crate::routes::Route;
63
64    fn active(path: &str) -> Vec<&'static str> {
65        build_nav_items(path)
66            .into_iter()
67            .filter(|item| item.is_active)
68            .map(|item| item.label)
69            .collect()
70    }
71
72    #[test]
73    fn highlights_by_path() {
74        assert_eq!(active("/"), ["首页"]);
75        assert_eq!(active("/page/2"), ["首页"]);
76        assert_eq!(active("/notes"), ["笔记"]);
77        assert_eq!(active("/notes/book/3"), ["笔记"]);
78        assert_eq!(active("/notes/some-note"), ["笔记"]);
79        assert_eq!(active("/archives"), ["归档"]);
80        assert_eq!(active("/tags/rust"), ["归档"]);
81        assert_eq!(active("/friends"), ["友链"]);
82        assert_eq!(active("/about"), ["关于"]);
83        assert_eq!(active("/about/writing/"), ["关于"]);
84        assert!(active("/post/hello").is_empty());
85        assert!(active("/search").is_empty());
86        assert!(active("/changelog").is_empty());
87    }
88
89    #[test]
90    fn showcase_belongs_to_about_family() {
91        assert_eq!(active("/about/components"), ["关于"]);
92        assert_eq!(active("/about/components/"), ["关于"]);
93        assert_eq!(active("/about/components/button"), ["关于"]);
94        // 前缀相同但不是图鉴路径。
95        assert!(active("/about/componentsx").is_empty());
96    }
97
98    #[test]
99    fn tags_index_without_slug_is_not_highlighted() {
100        // `/tags` 由服务端永久重定向到 `/archives`,不属于任何栏目。
101        assert!(active("/tags").is_empty());
102    }
103
104    #[test]
105    fn items_keep_fixed_order_and_targets() {
106        let items = build_nav_items("/");
107        let seen: Vec<_> = items
108            .iter()
109            .map(|item| (item.label, item.route.to_string()))
110            .collect();
111        assert_eq!(
112            seen,
113            [
114                ("首页", "/".to_string()),
115                ("笔记", "/notes".to_string()),
116                ("归档", "/archives".to_string()),
117                ("友链", "/friends".to_string()),
118                ("关于", "/about".to_string()),
119            ]
120        );
121        assert_eq!(items[0].route, Route::Home);
122    }
123}