Skip to main content

yggdrasil/
routes.rs

1//! 站内地址枚举:只负责生成 href,不参与路由匹配。
2//!
3//! 路由匹配由 `crate::app` 里的 `<Routes>` 表完成;这里的 [`Route`] 让调用方用
4//! 类型安全的变体拼地址(`<a href=Route::PostDetail { slug }.to_string()>`、
5//! `crate::navigation::push(&route.to_string())`),避免散落的字符串拼接。
6//!
7//! 动态路径段与查询值分别使用 [`PATH_ASCII_SET`]、[`QUERY_ASCII_SET`] 做百分号编码,
8//! 查询参数值为空时整个 `?key=` 省略。
9
10use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
11use std::fmt;
12
13/// 查询值需要编码的字符。`%` 与 `&`/`=`/`+` 是 `application/x-www-form-urlencoded`
14/// 的转义符、分隔符和空格写法,不编码时 `C++`、`R&D`、`100%` 会被解析成别的值。
15const 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
26/// 路径段需要编码的字符(在查询集合上追加 `? ^ ` { }`)。
27const PATH_ASCII_SET: &AsciiSet = &QUERY_ASCII_SET
28    .add(b'?')
29    .add(b'^')
30    .add(b'`')
31    .add(b'{')
32    .add(b'}');
33
34/// 全站路由,每个变体对应一个页面路径。
35#[derive(Clone, Debug, PartialEq)]
36pub enum Route {
37    /// 首页
38    Home,
39    /// 首页分页
40    HomePage { page: i32 },
41    /// 笔记索引
42    Notes,
43    /// 笔记本详情
44    NotebookDetail { id: i32 },
45    /// 笔记阅读页;`book` 指明在哪个笔记本里读(空串省略)
46    NoteDetail { slug: String, book: String },
47    /// 文章归档与标签索引
48    Archives,
49    /// 单个标签下的文章列表
50    TagDetail { tag: String },
51    /// 文章详情页,按 slug 匹配
52    PostDetail { slug: String },
53    /// 搜索页;`q` 带入初始查询(空串省略)
54    Search { q: String },
55    /// 关于页面
56    About,
57    /// Agent 写作技能与 MCP 接入指南
58    WritingGuide,
59    /// 项目自写组件的公开图鉴
60    ComponentShowcase,
61    /// 单枚组件的交互示例与用法
62    ComponentDetail { component: String },
63    /// 友链页
64    Friends,
65    /// 更新日志页
66    Changelog,
67    /// 后台仪表盘
68    Admin,
69    /// 写文章页
70    Write,
71    /// 编辑文章页
72    WriteEdit { id: i32 },
73    /// 草稿/文章预览(管理员只读)
74    PostPreview { slug: String },
75    /// 文章管理
76    Posts,
77    /// 笔记管理
78    AdminNotes,
79    /// 新建笔记
80    NewNote,
81    /// 编辑笔记
82    EditNote { id: i32 },
83    /// 笔记本管理
84    AdminNotebooks,
85    /// 文章回收站
86    PostsTrash,
87    /// 素材管理
88    Assets,
89    /// 友链管理
90    FriendsAdmin,
91    /// 评论管理
92    AdminComments,
93    /// 评论管理分页
94    AdminCommentsPage { page: i32 },
95    /// 系统管理
96    System,
97    /// 代码试运行沙箱
98    Runner,
99    /// MCP 令牌管理
100    Mcp,
101    /// 运行日志
102    Logs,
103    /// 站点配置
104    SiteSettingsPage,
105    /// 个人信息
106    Profile,
107    /// 登录页
108    Login,
109    /// 注册页
110    Register,
111}
112
113/// 编码一个动态路径段。
114fn seg(value: &str) -> impl fmt::Display + '_ {
115    utf8_percent_encode(value, PATH_ASCII_SET)
116}
117
118/// 写出可选查询参数:值为空时整个 `?key=` 省略。
119fn 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
179/// 是否为文章详情路径(`/post/` 前缀):文章页主区放宽,分享元信息由页面自己写。
180pub fn is_post_path(path: &str) -> bool {
181    path.starts_with("/post/")
182}
183
184/// 是否为组件图鉴路径(`/about/components` 或其子路径)。
185pub fn is_showcase_path(path: &str) -> bool {
186    path == "/about/components" || path.starts_with("/about/components/")
187}
188
189/// 是否为后台路径(`/admin` 或其子路径)。按路径段判断:同前缀的静态文件 `/admin.css`
190/// 是公开资源(组件图鉴也加载它),不能被后台登录守卫拦截。
191pub 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}