1use std::collections::BTreeMap;
2use std::path::Path;
3
4use serde::Serialize;
5
6use crate::config::Config;
7use crate::content::frontmatter::Layout;
8use crate::content::page::{Page, RenderedContent};
9use crate::site::route::{resolve_route, Route};
10
11#[derive(Debug, Clone)]
13pub struct SiteGraph {
14 pub pages: Vec<Page>,
15 pub sidebar: Vec<SidebarGroup>,
16 pub nav: Vec<NavItem>,
17}
18
19#[derive(Debug, Clone, Serialize, serde::Deserialize)]
20pub struct SidebarGroup {
21 pub text: String,
22 pub items: Vec<SidebarItem>,
23}
24
25#[derive(Debug, Clone, Serialize, serde::Deserialize)]
26pub struct SidebarItem {
27 pub text: String,
28 pub link: String,
29}
30
31#[derive(Debug, Clone, Serialize, serde::Deserialize)]
32pub struct NavItem {
33 pub text: String,
34 pub link: String,
35}
36
37pub fn build_graph(rendered: &[RenderedContent], config: &Config) -> SiteGraph {
45 build_graph_with_content_dir(rendered, config, &config.build.content_dir)
46}
47
48pub fn build_graph_with_content_dir(
51 rendered: &[RenderedContent],
52 config: &Config,
53 content_dir: &Path,
54) -> SiteGraph {
55 let mut pages: Vec<Page> = rendered
56 .iter()
57 .map(|rc| {
58 let route = resolve_route(content_dir, &rc.path);
59 Page {
60 route,
61 frontmatter: rc.frontmatter.clone(),
62 html: rc.html.clone(),
63 toc: rc.toc.clone(),
64 prev: None,
65 next: None,
66 }
67 })
68 .collect();
69
70 let sidebar = build_sidebar(&pages, config);
71 let nav = build_nav(config);
72
73 let ordered_paths = collect_sidebar_links(&sidebar);
74 assign_prev_next(&mut pages, &ordered_paths);
75
76 SiteGraph {
77 pages,
78 sidebar,
79 nav,
80 }
81}
82
83fn build_sidebar(pages: &[Page], config: &Config) -> Vec<SidebarGroup> {
87 if !config.sidebar.auto {
88 return config.sidebar.groups.clone();
89 }
90
91 auto_generate_sidebar(pages)
92}
93
94fn auto_generate_sidebar(pages: &[Page]) -> Vec<SidebarGroup> {
95 let mut groups: BTreeMap<String, Vec<&Page>> = BTreeMap::new();
96
97 for page in pages.iter().filter(|p| p.frontmatter.layout == Layout::Doc) {
98 let dir = page.route.parent_dir();
99 groups.entry(dir).or_default().push(page);
100 }
101
102 groups
103 .into_iter()
104 .map(|(dir, mut dir_pages)| {
105 dir_pages.sort_by(|a, b| {
106 let order_a = a.frontmatter.order.unwrap_or(i32::MAX);
107 let order_b = b.frontmatter.order.unwrap_or(i32::MAX);
108 order_a
109 .cmp(&order_b)
110 .then_with(|| a.frontmatter.title.cmp(&b.frontmatter.title))
111 });
112
113 let display = dir_display_name(&dir);
114 let items = dir_pages
115 .iter()
116 .map(|p| SidebarItem {
117 text: p.frontmatter.title.clone(),
118 link: p.route.path().to_string(),
119 })
120 .collect();
121
122 SidebarGroup {
123 text: display,
124 items,
125 }
126 })
127 .collect()
128}
129
130fn dir_display_name(dir: &str) -> String {
131 if dir.is_empty() {
132 return "Root".to_string();
133 }
134
135 let last = Path::new(dir)
136 .file_name()
137 .and_then(|s| s.to_str())
138 .unwrap_or(dir);
139
140 last.split('-')
141 .map(|word| {
142 let mut chars = word.chars();
143 match chars.next() {
144 None => String::new(),
145 Some(c) => {
146 let upper: String = c.to_uppercase().collect();
147 format!("{upper}{}", chars.as_str())
148 }
149 }
150 })
151 .collect::<Vec<_>>()
152 .join(" ")
153}
154
155fn build_nav(config: &Config) -> Vec<NavItem> {
156 config.nav.clone()
157}
158
159fn collect_sidebar_links(sidebar: &[SidebarGroup]) -> Vec<String> {
161 sidebar
162 .iter()
163 .flat_map(|group| group.items.iter().map(|item| item.link.clone()))
164 .collect()
165}
166
167fn assign_prev_next(pages: &mut [Page], ordered_paths: &[String]) {
171 let assignments: Vec<(Option<Route>, Option<Route>)> = pages
173 .iter()
174 .map(|page| {
175 if page.frontmatter.prev.is_some() || page.frontmatter.next.is_some() {
176 let prev = page.frontmatter.prev.as_ref().map(|p| Route {
177 path: p.clone(),
178 source: Default::default(),
179 output: Default::default(),
180 });
181 let next = page.frontmatter.next.as_ref().map(|p| Route {
182 path: p.clone(),
183 source: Default::default(),
184 output: Default::default(),
185 });
186 return (prev, next);
187 }
188
189 let current_path = page.route.path();
190 let pos = ordered_paths.iter().position(|p| p == current_path);
191
192 let prev = pos
193 .filter(|&idx| idx > 0)
194 .and_then(|idx| find_route_by_path(pages, &ordered_paths[idx - 1]));
195
196 let next = pos
197 .filter(|&idx| idx + 1 < ordered_paths.len())
198 .and_then(|idx| find_route_by_path(pages, &ordered_paths[idx + 1]));
199
200 (prev, next)
201 })
202 .collect();
203
204 for (page, (prev, next)) in pages.iter_mut().zip(assignments) {
205 page.prev = prev;
206 page.next = next;
207 }
208}
209
210fn find_route_by_path(pages: &[Page], path: &str) -> Option<Route> {
211 pages
212 .iter()
213 .find(|p| p.route.path() == path)
214 .map(|p| p.route.clone())
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220 use crate::content::frontmatter::Frontmatter;
221 use crate::content::page::RenderedContent;
222 use std::path::PathBuf;
223
224 fn make_rendered(path: &str, title: &str, order: Option<i32>) -> RenderedContent {
225 RenderedContent {
226 path: PathBuf::from(path),
227 frontmatter: Frontmatter {
228 title: title.to_string(),
229 order,
230 ..Default::default()
231 },
232 html: format!("<p>{title}</p>"),
233 toc: vec![],
234 }
235 }
236
237 #[test]
238 fn test_build_graph_auto_sidebar() {
239 let rendered = vec![
240 make_rendered("content/guide/intro.md", "Introduction", Some(1)),
241 make_rendered("content/guide/setup.md", "Setup", Some(2)),
242 make_rendered("content/api/overview.md", "API Overview", None),
243 ];
244
245 let config = Config::default();
246 let graph = build_graph(&rendered, &config);
247
248 assert_eq!(graph.sidebar.len(), 2);
249 assert_eq!(graph.sidebar[0].text, "Api");
250 assert_eq!(graph.sidebar[0].items.len(), 1);
251 assert_eq!(graph.sidebar[1].text, "Guide");
252 assert_eq!(graph.sidebar[1].items.len(), 2);
253 assert_eq!(graph.sidebar[1].items[0].text, "Introduction");
254 assert_eq!(graph.sidebar[1].items[1].text, "Setup");
255 }
256
257 #[test]
258 fn test_build_graph_prev_next() {
259 let rendered = vec![
260 make_rendered("content/guide/intro.md", "Introduction", Some(1)),
261 make_rendered("content/guide/setup.md", "Setup", Some(2)),
262 make_rendered("content/guide/config.md", "Configuration", Some(3)),
263 ];
264
265 let config = Config::default();
266 let graph = build_graph(&rendered, &config);
267
268 let intro = graph
269 .pages
270 .iter()
271 .find(|p| p.frontmatter.title == "Introduction");
272 let setup = graph.pages.iter().find(|p| p.frontmatter.title == "Setup");
273 let cfg = graph
274 .pages
275 .iter()
276 .find(|p| p.frontmatter.title == "Configuration");
277
278 assert!(intro.is_some());
279 assert!(intro.as_ref().map_or(true, |p| p.prev.is_none()));
280 assert_eq!(
281 intro
282 .as_ref()
283 .and_then(|p| p.next.as_ref().map(|r| r.path.as_str())),
284 Some("/guide/setup")
285 );
286
287 assert_eq!(
288 setup
289 .as_ref()
290 .and_then(|p| p.prev.as_ref().map(|r| r.path.as_str())),
291 Some("/guide/intro")
292 );
293 assert_eq!(
294 setup
295 .as_ref()
296 .and_then(|p| p.next.as_ref().map(|r| r.path.as_str())),
297 Some("/guide/config")
298 );
299
300 assert_eq!(
301 cfg.as_ref()
302 .and_then(|p| p.prev.as_ref().map(|r| r.path.as_str())),
303 Some("/guide/setup")
304 );
305 assert!(cfg.as_ref().map_or(true, |p| p.next.is_none()));
306 }
307
308 #[test]
309 fn test_manual_sidebar() {
310 let rendered = vec![make_rendered(
311 "content/guide/intro.md",
312 "Introduction",
313 None,
314 )];
315
316 let mut config = Config::default();
317 config.sidebar.auto = false;
318 config.sidebar.groups = vec![SidebarGroup {
319 text: "My Group".to_string(),
320 items: vec![SidebarItem {
321 text: "Custom Item".to_string(),
322 link: "/custom".to_string(),
323 }],
324 }];
325
326 let graph = build_graph(&rendered, &config);
327 assert_eq!(graph.sidebar.len(), 1);
328 assert_eq!(graph.sidebar[0].text, "My Group");
329 }
330
331 #[test]
332 fn test_nav_from_config() {
333 let rendered = vec![];
334 let mut config = Config::default();
335 config.nav = vec![NavItem {
336 text: "Guide".to_string(),
337 link: "/guide/".to_string(),
338 }];
339
340 let graph = build_graph(&rendered, &config);
341 assert_eq!(graph.nav.len(), 1);
342 assert_eq!(graph.nav[0].text, "Guide");
343 }
344
345 #[test]
346 fn test_sidebar_order_by_frontmatter() {
347 let rendered = vec![
348 make_rendered("content/guide/z-last.md", "Z Last", Some(3)),
349 make_rendered("content/guide/a-first.md", "A First", Some(1)),
350 make_rendered("content/guide/m-middle.md", "M Middle", Some(2)),
351 ];
352
353 let config = Config::default();
354 let graph = build_graph(&rendered, &config);
355
356 assert_eq!(graph.sidebar[0].items[0].text, "A First");
357 assert_eq!(graph.sidebar[0].items[1].text, "M Middle");
358 assert_eq!(graph.sidebar[0].items[2].text, "Z Last");
359 }
360}