Skip to main content

pyohwa_core/site/
graph.rs

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/// The complete site graph containing all pages and navigation structure
12#[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
37/// Build the complete site graph from rendered content and config.
38///
39/// This is a pure function that:
40/// 1. Resolves routes for all pages
41/// 2. Builds the sidebar (auto or manual)
42/// 3. Copies nav items from config
43/// 4. Computes prev/next links based on sidebar order
44pub fn build_graph(rendered: &[RenderedContent], config: &Config) -> SiteGraph {
45    build_graph_with_content_dir(rendered, config, &config.build.content_dir)
46}
47
48/// Build site graph with an explicit content_dir path.
49/// Used by the pipeline to pass the resolved absolute content directory.
50pub 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
83/// Build sidebar groups from pages.
84/// If `config.sidebar.auto` is true, groups pages by directory.
85/// Otherwise, uses manual sidebar config.
86fn 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
159/// Collect all sidebar links in order for prev/next computation
160fn 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
167/// Assign prev/next routes to pages based on sidebar link order.
168///
169/// Uses index-based iteration to avoid simultaneous mutable and immutable borrows.
170fn assign_prev_next(pages: &mut [Page], ordered_paths: &[String]) {
171    // Pre-compute: for each page index, determine its prev/next Route
172    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}