mdxbook/renderer/html_handlebars/helpers/
toc.rs

1use std::path::Path;
2use std::{cmp::Ordering, collections::BTreeMap};
3
4use crate::utils;
5use crate::utils::bracket_escape;
6
7use handlebars::{Context, Handlebars, Helper, HelperDef, Output, RenderContext, RenderError};
8
9// Handlebars helper to construct TOC
10#[derive(Clone, Copy)]
11pub struct RenderToc {
12    pub no_section_label: bool,
13}
14
15impl HelperDef for RenderToc {
16    fn call<'reg: 'rc, 'rc>(
17        &self,
18        _h: &Helper<'reg, 'rc>,
19        _r: &'reg Handlebars<'_>,
20        ctx: &'rc Context,
21        rc: &mut RenderContext<'reg, 'rc>,
22        out: &mut dyn Output,
23    ) -> Result<(), RenderError> {
24        // get value from context data
25        // rc.get_path() is current json parent path, you should always use it like this
26        // param is the key of value you want to display
27        let chapters = rc.evaluate(ctx, "@root/chapters").and_then(|c| {
28            serde_json::value::from_value::<Vec<BTreeMap<String, String>>>(c.as_json().clone())
29                .map_err(|_| RenderError::new("Could not decode the JSON data"))
30        })?;
31        let current_path = rc
32            .evaluate(ctx, "@root/path")?
33            .as_json()
34            .as_str()
35            .ok_or_else(|| RenderError::new("Type error for `path`, string expected"))?
36            .replace('\"', "");
37
38        let current_section = rc
39            .evaluate(ctx, "@root/section")?
40            .as_json()
41            .as_str()
42            .map(str::to_owned)
43            .unwrap_or_default();
44
45        let fold_enable = rc
46            .evaluate(ctx, "@root/fold_enable")?
47            .as_json()
48            .as_bool()
49            .ok_or_else(|| RenderError::new("Type error for `fold_enable`, bool expected"))?;
50
51        let fold_level = rc
52            .evaluate(ctx, "@root/fold_level")?
53            .as_json()
54            .as_u64()
55            .ok_or_else(|| RenderError::new("Type error for `fold_level`, u64 expected"))?;
56
57        out.write("<ol class=\"chapter\">")?;
58
59        let mut current_level = 1;
60        // The "index" page, which has this attribute set, is supposed to alias the first chapter in
61        // the book, i.e. the first link. There seems to be no easy way to determine which chapter
62        // the "index" is aliasing from within the renderer, so this is used instead to force the
63        // first link to be active. See further below.
64        let mut is_first_chapter = ctx.data().get("is_index").is_some();
65
66        for item in chapters {
67            // Spacer
68            if item.get("spacer").is_some() {
69                out.write("<li class=\"spacer\"></li>")?;
70                continue;
71            }
72
73            let (section, level) = if let Some(s) = item.get("section") {
74                (s.as_str(), s.matches('.').count())
75            } else {
76                ("", 1)
77            };
78
79            let is_expanded =
80                if !fold_enable || (!section.is_empty() && current_section.starts_with(section)) {
81                    // Expand if folding is disabled, or if the section is an
82                    // ancestor or the current section itself.
83                    true
84                } else {
85                    // Levels that are larger than this would be folded.
86                    level - 1 < fold_level as usize
87                };
88
89            match level.cmp(&current_level) {
90                Ordering::Greater => {
91                    while level > current_level {
92                        out.write("<li>")?;
93                        out.write("<ol class=\"section\">")?;
94                        current_level += 1;
95                    }
96                    write_li_open_tag(out, is_expanded, false)?;
97                }
98                Ordering::Less => {
99                    while level < current_level {
100                        out.write("</ol>")?;
101                        out.write("</li>")?;
102                        current_level -= 1;
103                    }
104                    write_li_open_tag(out, is_expanded, false)?;
105                }
106                Ordering::Equal => {
107                    write_li_open_tag(out, is_expanded, item.get("section").is_none())?;
108                }
109            }
110
111            // Part title
112            if let Some(title) = item.get("part") {
113                out.write("<li class=\"part-title\">")?;
114                out.write(&bracket_escape(title))?;
115                out.write("</li>")?;
116                continue;
117            }
118
119            // Link
120            let path_exists: bool;
121            match item.get("path") {
122                Some(path) if !path.is_empty() => {
123                    out.write("<a href=\"")?;
124                    let tmp = Path::new(path)
125                        .with_extension("html")
126                        .to_str()
127                        .unwrap()
128                        // Hack for windows who tends to use `\` as separator instead of `/`
129                        .replace('\\', "/");
130
131                    // Add link
132                    out.write(&utils::fs::path_to_root(&current_path))?;
133                    out.write(&tmp)?;
134                    out.write("\"")?;
135
136                    if path == &current_path || is_first_chapter {
137                        is_first_chapter = false;
138                        out.write(" class=\"active\"")?;
139                    }
140
141                    out.write(">")?;
142                    path_exists = true;
143                }
144                _ => {
145                    out.write("<div>")?;
146                    path_exists = false;
147                }
148            }
149
150            if !self.no_section_label {
151                // Section does not necessarily exist
152                if let Some(section) = item.get("section") {
153                    out.write("<strong aria-hidden=\"true\">")?;
154                    out.write(section)?;
155                    out.write("</strong> ")?;
156                }
157            }
158
159            if let Some(name) = item.get("name") {
160                out.write(&bracket_escape(name))?
161            }
162
163            if path_exists {
164                out.write("</a>")?;
165            } else {
166                out.write("</div>")?;
167            }
168
169            // Render expand/collapse toggle
170            if let Some(flag) = item.get("has_sub_items") {
171                let has_sub_items = flag.parse::<bool>().unwrap_or_default();
172                if fold_enable && has_sub_items {
173                    out.write("<a class=\"toggle\"><div>❱</div></a>")?;
174                }
175            }
176            out.write("</li>")?;
177        }
178        while current_level > 1 {
179            out.write("</ol>")?;
180            out.write("</li>")?;
181            current_level -= 1;
182        }
183
184        out.write("</ol>")?;
185        Ok(())
186    }
187}
188
189fn write_li_open_tag(
190    out: &mut dyn Output,
191    is_expanded: bool,
192    is_affix: bool,
193) -> Result<(), std::io::Error> {
194    let mut li = String::from("<li class=\"chapter-item ");
195    if is_expanded {
196        li.push_str("expanded ");
197    }
198    if is_affix {
199        li.push_str("affix ");
200    }
201    li.push_str("\">");
202    out.write(&li)
203}