wdl_doc/
docs_tree.rs

1//! Implementations for a [`DocsTree`] which represents the docs directory.
2
3use std::collections::BTreeMap;
4use std::collections::HashSet;
5use std::path::Path;
6use std::path::PathBuf;
7use std::path::absolute;
8use std::rc::Rc;
9
10use anyhow::Context;
11use anyhow::Result;
12use anyhow::bail;
13use maud::Markup;
14use maud::html;
15use path_clean::PathClean;
16use pathdiff::diff_paths;
17use serde::Serialize;
18
19use crate::AdditionalScript;
20use crate::Markdown;
21use crate::Render;
22use crate::document::Document;
23use crate::full_page;
24use crate::get_assets;
25use crate::r#struct::Struct;
26use crate::task::Task;
27use crate::workflow::Workflow;
28
29/// Filename for the dark theme logo SVG expected to be in the "assets"
30/// directory.
31const LOGO_FILE_NAME: &str = "logo.svg";
32/// Filename for the light theme logo SVG expected to be in the "assets"
33/// directory.
34const LIGHT_LOGO_FILE_NAME: &str = "logo.light.svg";
35
36/// The type of a page.
37#[derive(Debug)]
38pub(crate) enum PageType {
39    /// An index page.
40    Index(Document),
41    /// A struct page.
42    Struct(Struct),
43    /// A task page.
44    Task(Task),
45    /// A workflow page.
46    Workflow(Workflow),
47}
48
49/// An HTML page in the docs directory.
50#[derive(Debug)]
51pub(crate) struct HTMLPage {
52    /// The display name of the page.
53    name: String,
54    /// The type of the page.
55    page_type: PageType,
56}
57
58impl HTMLPage {
59    /// Create a new HTML page.
60    pub(crate) fn new(name: String, page_type: PageType) -> Self {
61        Self { name, page_type }
62    }
63
64    /// Get the name of the page.
65    pub(crate) fn name(&self) -> &str {
66        &self.name
67    }
68
69    /// Get the type of the page.
70    pub(crate) fn page_type(&self) -> &PageType {
71        &self.page_type
72    }
73}
74
75/// A page header or page sub header.
76///
77/// This is used to represent the headers in the right sidebar of the
78/// documentation pages. Each header has a name (first `String`) and an ID
79/// (second `String`), which is used to link to the header in the page.
80#[derive(Debug)]
81pub(crate) enum Header {
82    /// A header in the page.
83    Header(String, String),
84    /// A sub header in the page.
85    SubHeader(String, String),
86}
87
88/// A collection of page headers representing the sections of a page.
89///
90/// This is used to render the right sidebar of documentation pages.
91/// Each section added to this collection will be rendered in the
92/// order it was added.
93#[derive(Debug, Default)]
94pub(crate) struct PageSections {
95    /// The headers in the page.
96    pub headers: Vec<Header>,
97}
98
99impl PageSections {
100    /// Push a header to the page sections.
101    pub fn push(&mut self, header: Header) {
102        self.headers.push(header);
103    }
104
105    /// Extend the page headers with another collection of headers.
106    pub fn extend(&mut self, headers: Self) {
107        self.headers.extend(headers.headers);
108    }
109
110    /// Render the page sections as HTML for the right sidebar.
111    pub fn render(&self) -> Markup {
112        html!(
113            @for header in &self.headers {
114                @match header {
115                    Header::Header(name, id) => {
116                        a href=(format!("#{}", id)) class="right-sidebar__section-header" { (name) }
117                    }
118                    Header::SubHeader(name, id) => {
119                        div class="right-sidebar__section-items" {
120                            a href=(format!("#{}", id)) class="right-sidebar__section-item" { (name) }
121                        }
122                    }
123                }
124            }
125        )
126    }
127}
128
129/// A node in the docs directory tree.
130#[derive(Debug)]
131struct Node {
132    /// The name of the node.
133    name: String,
134    /// The path from the root to the node.
135    path: PathBuf,
136    /// The page associated with the node.
137    page: Option<Rc<HTMLPage>>,
138    /// The children of the node.
139    children: BTreeMap<String, Node>,
140}
141
142impl Node {
143    /// Create a new node.
144    pub fn new<P: Into<PathBuf>>(name: String, path: P) -> Self {
145        Self {
146            name,
147            path: path.into(),
148            page: None,
149            children: BTreeMap::new(),
150        }
151    }
152
153    /// Get the name of the node.
154    pub fn name(&self) -> &str {
155        &self.name
156    }
157
158    /// Get the path from the root to the node.
159    pub fn path(&self) -> &PathBuf {
160        &self.path
161    }
162
163    /// Determine if the node is part of a path.
164    ///
165    /// Path should be relative to the root or false positives may occur.
166    pub fn part_of_path<P: AsRef<Path>>(&self, path: P) -> bool {
167        let other_path = path.as_ref();
168        let self_path = if self.path().ends_with("index.html") {
169            self.path().parent().expect("index should have parent")
170        } else {
171            self.path()
172        };
173        self_path
174            .components()
175            .all(|c| other_path.components().any(|p| p == c))
176    }
177
178    /// Get the page associated with the node.
179    pub fn page(&self) -> Option<&Rc<HTMLPage>> {
180        self.page.as_ref()
181    }
182
183    /// Get the children of the node.
184    pub fn children(&self) -> &BTreeMap<String, Node> {
185        &self.children
186    }
187
188    /// Gather the node and its children in a Depth First Traversal order.
189    ///
190    /// Traversal order among children is alphabetical by node name, with the
191    /// exception of any "external" node, which is always last.
192    pub fn depth_first_traversal(&self) -> Vec<&Node> {
193        fn recurse_depth_first<'a>(node: &'a Node, nodes: &mut Vec<&'a Node>) {
194            nodes.push(node);
195
196            for child in node.children().values() {
197                recurse_depth_first(child, nodes);
198            }
199        }
200
201        let mut nodes = Vec::new();
202        nodes.push(self);
203        for child in self.children().values().filter(|c| c.name() != "external") {
204            recurse_depth_first(child, &mut nodes);
205        }
206        if let Some(external) = self.children().get("external") {
207            recurse_depth_first(external, &mut nodes);
208        }
209
210        nodes
211    }
212}
213
214/// A builder for a [`DocsTree`] which represents the docs directory.
215#[derive(Debug)]
216pub struct DocsTreeBuilder {
217    /// The root directory for the docs.
218    root: PathBuf,
219    /// The path to a Markdown file to embed in the `<root>/index.html` page.
220    homepage: Option<PathBuf>,
221    /// An optional path to a custom theme to use for the docs.
222    custom_theme: Option<PathBuf>,
223    /// The path to a custom dark theme logo to embed at the top of the left
224    /// sidebar.
225    ///
226    /// If this is `Some(_)` and no `alt_logo` is supplied, this will be used
227    /// for both dark and light themes.
228    logo: Option<PathBuf>,
229    /// The path to an alternate light theme custom logo to embed at the top of
230    /// the left sidebar.
231    alt_logo: Option<PathBuf>,
232    /// Optional JavaScript to embed in each HTML page.
233    additional_javascript: AdditionalScript,
234    /// Start on the "Full Directory" left sidebar view instead of the
235    /// "Workflows" view.
236    ///
237    /// Users can toggle the view. This only impacts the initialized value.
238    init_on_full_directory: bool,
239    /// Start in light mode instead of the default dark mode.
240    init_light_mode: bool,
241}
242
243impl DocsTreeBuilder {
244    /// Create a new docs tree builder.
245    pub fn new(root: impl AsRef<Path>) -> Self {
246        let root = absolute(root.as_ref())
247            .expect("should get absolute path")
248            .clean();
249        Self {
250            root,
251            homepage: None,
252            custom_theme: None,
253            logo: None,
254            alt_logo: None,
255            additional_javascript: AdditionalScript::None,
256            init_on_full_directory: crate::PREFER_FULL_DIRECTORY,
257            init_light_mode: false,
258        }
259    }
260
261    /// Set the homepage for the docs with an option.
262    pub fn maybe_homepage(mut self, homepage: Option<impl Into<PathBuf>>) -> Self {
263        self.homepage = homepage.map(|hp| hp.into());
264        self
265    }
266
267    /// Set the homepage for the docs.
268    pub fn homepage(self, homepage: impl Into<PathBuf>) -> Self {
269        self.maybe_homepage(Some(homepage))
270    }
271
272    /// Set the custom theme for the docs with an option.
273    pub fn maybe_custom_theme(mut self, theme: Option<impl AsRef<Path>>) -> Result<Self> {
274        self.custom_theme = if let Some(t) = theme {
275            Some(
276                absolute(t.as_ref())
277                    .with_context(|| {
278                        format!(
279                            "failed to resolve absolute path for custom theme: `{}`",
280                            t.as_ref().display()
281                        )
282                    })?
283                    .clean(),
284            )
285        } else {
286            None
287        };
288        Ok(self)
289    }
290
291    /// Set the custom theme for the docs.
292    pub fn custom_theme(self, theme: impl AsRef<Path>) -> Result<Self> {
293        self.maybe_custom_theme(Some(theme))
294    }
295
296    /// Set the custom logo for the left sidebar with an option.
297    pub fn maybe_logo(mut self, logo: Option<impl Into<PathBuf>>) -> Self {
298        self.logo = logo.map(|l| l.into());
299        self
300    }
301
302    /// Set the custom logo for the left sidebar.
303    pub fn logo(self, logo: impl Into<PathBuf>) -> Self {
304        self.maybe_logo(Some(logo))
305    }
306
307    /// Set the alt (i.e. light mode) custom logo for the left sidebar with an
308    /// option.
309    pub fn maybe_alt_logo(mut self, logo: Option<impl Into<PathBuf>>) -> Self {
310        self.alt_logo = logo.map(|l| l.into());
311        self
312    }
313
314    /// Set the alt (i.e. light mode) custom logo for the left sidebar.
315    pub fn alt_logo(self, logo: impl Into<PathBuf>) -> Self {
316        self.maybe_alt_logo(Some(logo))
317    }
318
319    /// Set the additional javascript for each page.
320    pub fn additional_javascript(mut self, js: AdditionalScript) -> Self {
321        self.additional_javascript = js;
322        self
323    }
324
325    /// Set whether the "Full Directory" view should be initialized instead of
326    /// the "Workflows" view of the left sidebar.
327    pub fn prefer_full_directory(mut self, prefer_full_directory: bool) -> Self {
328        self.init_on_full_directory = prefer_full_directory;
329        self
330    }
331
332    /// Set whether light mode should be the initial view instead of dark mode.
333    pub fn init_light_mode(mut self, init_light_mode: bool) -> Self {
334        self.init_light_mode = init_light_mode;
335        self
336    }
337
338    /// Build the docs tree.
339    pub fn build(self) -> Result<DocsTree> {
340        self.write_assets().with_context(|| {
341            format!(
342                "failed to write assets to output directory: `{}`",
343                self.root.display()
344            )
345        })?;
346
347        let node = Node::new(
348            self.root
349                .file_name()
350                .map(|n| n.to_string_lossy().to_string())
351                .unwrap_or("docs".to_string()),
352            PathBuf::from(""),
353        );
354        Ok(DocsTree {
355            root: node,
356            path: self.root,
357            homepage: self.homepage,
358            additional_javascript: self.additional_javascript,
359            init_on_full_directory: self.init_on_full_directory,
360            init_light_mode: self.init_light_mode,
361        })
362    }
363
364    /// Write assets to the root docs directory.
365    ///
366    /// This will create an `assets` directory in the root and write all
367    /// necessary assets to it. It will also write the default `style.css` and
368    /// `index.js` files to the root unless a custom theme is
369    /// provided, in which case it will copy the `style.css` and `index.js`
370    /// files from the custom theme's `dist` directory.
371    fn write_assets(&self) -> Result<()> {
372        let dir = &self.root;
373        let custom_theme = self.custom_theme.as_ref();
374        let assets_dir = dir.join("assets");
375        std::fs::create_dir_all(&assets_dir).with_context(|| {
376            format!(
377                "failed to create assets directory: `{}`",
378                assets_dir.display()
379            )
380        })?;
381
382        if let Some(custom_theme) = custom_theme {
383            if !custom_theme.exists() {
384                bail!(
385                    "custom theme directory does not exist: `{}`",
386                    custom_theme.display()
387                );
388            }
389            std::fs::copy(
390                custom_theme.join("dist").join("style.css"),
391                dir.join("style.css"),
392            )
393            .with_context(|| {
394                format!(
395                    "failed to copy stylesheet from `{}` to `{}`",
396                    custom_theme.join("dist").join("style.css").display(),
397                    dir.join("style.css").display()
398                )
399            })?;
400            std::fs::copy(
401                custom_theme.join("dist").join("index.js"),
402                dir.join("index.js"),
403            )
404            .with_context(|| {
405                format!(
406                    "failed to copy web components from `{}` to `{}`",
407                    custom_theme.join("dist").join("index.js").display(),
408                    dir.join("index.js").display()
409                )
410            })?;
411        } else {
412            std::fs::write(
413                dir.join("style.css"),
414                include_str!("../theme/dist/style.css"),
415            )
416            .with_context(|| {
417                format!(
418                    "failed to write default stylesheet to `{}`",
419                    dir.join("style.css").display()
420                )
421            })?;
422            std::fs::write(dir.join("index.js"), include_str!("../theme/dist/index.js"))
423                .with_context(|| {
424                    format!(
425                        "failed to write default web components to `{}`",
426                        dir.join("index.js").display()
427                    )
428                })?;
429        }
430
431        for (file_name, bytes) in get_assets() {
432            let path = assets_dir.join(file_name);
433            std::fs::write(&path, bytes)
434                .with_context(|| format!("failed to write asset to `{}`", path.display()))?;
435        }
436        // The above `get_assets()` call will write the default logos; then the
437        // following logic may overwrite those files with user supplied logos.
438        match (&self.logo, &self.alt_logo) {
439            (Some(dark_logo), Some(light_logo)) => {
440                let logo_path = assets_dir.join(LOGO_FILE_NAME);
441                std::fs::copy(dark_logo, &logo_path).with_context(|| {
442                    format!(
443                        "failed to copy dark theme custom logo from `{}` to `{}`",
444                        dark_logo.display(),
445                        logo_path.display()
446                    )
447                })?;
448                let logo_path = assets_dir.join(LIGHT_LOGO_FILE_NAME);
449                std::fs::copy(light_logo, &logo_path).with_context(|| {
450                    format!(
451                        "failed to copy light theme custom logo from `{}` to `{}`",
452                        light_logo.display(),
453                        logo_path.display()
454                    )
455                })?;
456            }
457            (Some(logo), None) => {
458                let logo_path = assets_dir.join(LOGO_FILE_NAME);
459                std::fs::copy(logo, &logo_path).with_context(|| {
460                    format!(
461                        "failed to copy custom logo from `{}` to `{}`",
462                        logo.display(),
463                        logo_path.display()
464                    )
465                })?;
466                let logo_path = assets_dir.join(LIGHT_LOGO_FILE_NAME);
467                std::fs::copy(logo, &logo_path).with_context(|| {
468                    format!(
469                        "failed to copy custom logo from `{}` to `{}`",
470                        logo.display(),
471                        logo_path.display()
472                    )
473                })?;
474            }
475            (None, Some(logo)) => {
476                let logo_path = assets_dir.join(LOGO_FILE_NAME);
477                std::fs::copy(logo, &logo_path).with_context(|| {
478                    format!(
479                        "failed to copy custom logo from `{}` to `{}`",
480                        logo.display(),
481                        logo_path.display()
482                    )
483                })?;
484                let logo_path = assets_dir.join(LIGHT_LOGO_FILE_NAME);
485                std::fs::copy(logo, &logo_path).with_context(|| {
486                    format!(
487                        "failed to copy custom logo from `{}` to `{}`",
488                        logo.display(),
489                        logo_path.display()
490                    )
491                })?;
492            }
493            (None, None) => {}
494        }
495
496        Ok(())
497    }
498}
499
500/// A tree representing the docs directory.
501///
502/// For construction, see [`DocsTreeBuilder`].
503#[derive(Debug)]
504pub struct DocsTree {
505    /// The root of the tree.
506    root: Node,
507    /// The absolute path to the root directory.
508    path: PathBuf,
509    /// An optional path to a Markdown file which will be embedded in the
510    /// `<root>/index.html` page.
511    homepage: Option<PathBuf>,
512    /// Optional JavaScript to embed in each HTML page.
513    additional_javascript: AdditionalScript,
514    /// Initialize pages on the "Full Directory" view instead of the "Workflows"
515    /// view of the left sidebar.
516    init_on_full_directory: bool,
517    /// Initialize in light mode instead of the default dark mode.
518    init_light_mode: bool,
519}
520
521impl DocsTree {
522    /// Get the root of the tree.
523    fn root(&self) -> &Node {
524        &self.root
525    }
526
527    /// Get the root of the tree as mutable.
528    fn root_mut(&mut self) -> &mut Node {
529        &mut self.root
530    }
531
532    /// Get the absolute path to the root directory.
533    fn root_abs_path(&self) -> &PathBuf {
534        &self.path
535    }
536
537    /// Get the path to the root directory relative to a given path.
538    fn root_relative_to<P: AsRef<Path>>(&self, path: P) -> PathBuf {
539        let path = path.as_ref();
540        diff_paths(self.root_abs_path(), path).expect("should diff paths")
541    }
542
543    /// Get the absolute path to the assets directory.
544    fn assets(&self) -> PathBuf {
545        self.root_abs_path().join("assets")
546    }
547
548    /// Get a relative path to the assets directory.
549    fn assets_relative_to<P: AsRef<Path>>(&self, path: P) -> PathBuf {
550        let path = path.as_ref();
551        diff_paths(self.assets(), path).expect("should diff paths")
552    }
553
554    /// Get a relative path to an asset in the assets directory (converted to a
555    /// string).
556    fn get_asset<P: AsRef<Path>>(&self, path: P, asset: &str) -> String {
557        self.assets_relative_to(path)
558            .join(asset)
559            .to_string_lossy()
560            .to_string()
561    }
562
563    /// Get a relative path to the root index page.
564    fn root_index_relative_to<P: AsRef<Path>>(&self, path: P) -> PathBuf {
565        let path = path.as_ref();
566        diff_paths(self.root_abs_path().join("index.html"), path).expect("should diff paths")
567    }
568
569    /// Add a page to the tree.
570    ///
571    /// Path can be an absolute path or a path relative to the root.
572    pub(crate) fn add_page<P: Into<PathBuf>>(&mut self, path: P, page: Rc<HTMLPage>) {
573        let path = path.into();
574        let rel_path = path.strip_prefix(self.root_abs_path()).unwrap_or(&path);
575
576        let root = self.root_mut();
577        let mut current_node = root;
578
579        let mut components = rel_path.components().peekable();
580        while let Some(component) = components.next() {
581            let cur_name = component.as_os_str().to_string_lossy();
582            if current_node.children.contains_key(cur_name.as_ref()) {
583                current_node = current_node
584                    .children
585                    .get_mut(cur_name.as_ref())
586                    .expect("node should exist");
587            } else {
588                let new_path = current_node.path().join(component);
589                let new_node = Node::new(cur_name.to_string(), new_path);
590                current_node.children.insert(cur_name.to_string(), new_node);
591                current_node = current_node
592                    .children
593                    .get_mut(cur_name.as_ref())
594                    .expect("node should exist");
595            }
596            if let Some(next_component) = components.peek()
597                && next_component.as_os_str().to_string_lossy() == "index.html"
598            {
599                current_node.path = current_node.path().join("index.html");
600                break;
601            }
602        }
603
604        current_node.page = Some(page);
605    }
606
607    /// Get the [`Node`] associated with a path.
608    ///
609    /// Path can be an absolute path or a path relative to the root.
610    fn get_node<P: AsRef<Path>>(&self, path: P) -> Option<&Node> {
611        let root = self.root();
612        let path = path.as_ref();
613        let rel_path = path.strip_prefix(self.root_abs_path()).unwrap_or(path);
614
615        let mut current_node = root;
616
617        for component in rel_path
618            .components()
619            .map(|c| c.as_os_str().to_string_lossy())
620        {
621            if component == "index.html" {
622                return Some(current_node);
623            }
624            if current_node.children.contains_key(component.as_ref()) {
625                current_node = current_node
626                    .children
627                    .get(component.as_ref())
628                    .expect("node should exist");
629            } else {
630                return None;
631            }
632        }
633
634        Some(current_node)
635    }
636
637    /// Get the [`HTMLPage`] associated with a path.
638    ///
639    /// Can be an abolute path or a path relative to the root.
640    fn get_page<P: AsRef<Path>>(&self, path: P) -> Option<&Rc<HTMLPage>> {
641        self.get_node(path).and_then(|node| node.page())
642    }
643
644    /// Get workflows by category.
645    fn get_workflows_by_category(&self) -> Vec<(String, Vec<&Node>)> {
646        let mut workflows_by_category = Vec::new();
647        let mut categories = HashSet::new();
648        let mut nodes = Vec::new();
649
650        for node in self.root().depth_first_traversal() {
651            if let Some(page) = node.page()
652                && let PageType::Workflow(workflow) = page.page_type()
653            {
654                if node
655                    .path()
656                    .iter()
657                    .next()
658                    .expect("path should have a next component")
659                    .to_string_lossy()
660                    == "external"
661                {
662                    categories.insert("External".to_string());
663                } else if let Some(category) = workflow.category() {
664                    categories.insert(category);
665                } else {
666                    categories.insert("Other".to_string());
667                }
668                nodes.push(node);
669            }
670        }
671        let sorted_categories = sort_workflow_categories(categories);
672
673        for category in sorted_categories {
674            let workflows = nodes
675                .iter()
676                .filter(|node| {
677                    let page = node
678                        .page()
679                        .map(|p| p.page_type())
680                        .expect("node should have a page");
681                    if let PageType::Workflow(workflow) = page {
682                        if node
683                            .path()
684                            .iter()
685                            .next()
686                            .expect("path should have a next component")
687                            .to_string_lossy()
688                            == "external"
689                        {
690                            return category == "External";
691                        } else if let Some(cat) = workflow.category() {
692                            return cat == category;
693                        } else {
694                            return category == "Other";
695                        }
696                    }
697                    unreachable!("expected a workflow page");
698                })
699                .cloned()
700                .collect::<Vec<_>>();
701            workflows_by_category.push((category, workflows));
702        }
703
704        workflows_by_category
705    }
706
707    /// Render a left sidebar component in the "workflows view" mode given a
708    /// path.
709    ///
710    /// Destination is expected to be an absolute path.
711    fn sidebar_workflows_view(&self, destination: &Path) -> Markup {
712        let base = destination
713            .parent()
714            .expect("destination should have a parent");
715        let workflows_by_category = self.get_workflows_by_category();
716        html! {
717            @for (category, workflows) in workflows_by_category {
718                li class="" {
719                    div class="left-sidebar__row left-sidebar__row--unclickable" {
720                        img src=(self.get_asset(base, "category-selected.svg")) class="left-sidebar__icon block light:hidden" alt="Category icon";
721                        img src=(self.get_asset(base, "category-selected.light.svg")) class="left-sidebar__icon hidden light:block" alt="Category icon";
722                        p class="text-slate-50" { (category) }
723                    }
724                    ul class="" {
725                        @for node in workflows {
726                            a href=(diff_paths(self.root_abs_path().join(node.path()), base).expect("should diff paths").to_string_lossy()) x-data=(format!(r#"{{
727                                    node: {{
728                                        current: {},
729                                        icon: '{}',
730                                    }}
731                                }}"#,
732                                self.root_abs_path().join(node.path()) == destination,
733                                self.get_asset(base, if self.root_abs_path().join(node.path()) == destination {
734                                        "workflow-selected.svg"
735                                    } else {
736                                        "workflow-unselected.svg"
737                                    },
738                            ))) class="left-sidebar__row" x-bind:class="node.current ? 'bg-slate-600/50 is-scrolled-to' : 'hover:bg-slate-700/40'" {
739                                @if let Some(page) = node.page() {
740                                    @match page.page_type() {
741                                        PageType::Workflow(wf) => {
742                                            div class="left-sidebar__indent -1" {}
743                                            div class="left-sidebar__content-item-container crop-ellipsis"{
744                                                img x-bind:src="node.icon" class="left-sidebar__icon light:hidden" alt="Workflow icon";
745                                                img x-bind:src="node.icon?.replace('.svg', '.light.svg')" class="left-sidebar__icon hidden light:block" alt="Workflow icon";
746                                                sprocket-tooltip content=(wf.render_name()) class="crop-ellipsis" x-bind:class="node.current ? 'text-slate-50' : 'group-hover:text-slate-50'" {
747                                                    span {
748                                                        (wf.render_name())
749                                                    }
750                                                }
751                                            }
752                                        }
753                                        _ => {
754                                            p { "ERROR: Not a workflow page" }
755                                        }
756                                    }
757                                }
758                            }
759                        }
760                    }
761                }
762            }
763        }
764    }
765
766    /// Render a left sidebar component given a path.
767    ///
768    /// Path is expected to be an absolute path.
769    // TODO: lots here can be improved
770    // e.g. it could be broken into smaller functions, the JS could be
771    // generated in a more structured way, etc.
772    fn render_left_sidebar<P: AsRef<Path>>(&self, path: P) -> Markup {
773        let root = self.root();
774        let path = path.as_ref();
775        let rel_path = path
776            .strip_prefix(self.root_abs_path())
777            .expect("path should be in root");
778        let base = path.parent().expect("path should have a parent");
779
780        let make_key = |path: &Path| -> String {
781            let path = if path.file_name().expect("path should have a file name") == "index.html" {
782                // Remove unnecessary index.html from the path.
783                // Not needed for the key.
784                path.parent().expect("path should have a parent")
785            } else {
786                path
787            };
788            path.to_string_lossy()
789                .replace("-", "_")
790                .replace(".", "_")
791                .replace(std::path::MAIN_SEPARATOR_STR, "_")
792        };
793
794        #[derive(Serialize)]
795        struct JsNode {
796            /// The key of the node.
797            key: String,
798            /// The display name of the node.
799            display_name: String,
800            /// The parent directory of the node.
801            ///
802            /// This is used for displaying the path to the node in the sidebar.
803            parent: String,
804            /// The search name of the node.
805            search_name: String,
806            /// The icon for the node.
807            icon: Option<String>,
808            /// The href for the node.
809            href: Option<String>,
810            /// Whether the node is ancestor.
811            ancestor: bool,
812            /// Whether the node is the current page.
813            current: bool,
814            /// The nest level of the node.
815            nest_level: usize,
816            /// The children of the node.
817            children: Vec<String>,
818        }
819
820        let all_nodes = root
821            .depth_first_traversal()
822            .iter()
823            .skip(1) // Skip the root node
824            .map(|node| {
825                let key = make_key(node.path());
826                let display_name = match node.page() {
827                    Some(page) => page.name().to_string(),
828                    None => node.name().to_string(),
829                };
830                let parent = node
831                    .path()
832                    .parent()
833                    .expect("path should have a parent")
834                    .to_string_lossy()
835                    .to_string();
836                let search_name = if node.page().is_none() {
837                    // Page-less nodes should not be searchable
838                    "".to_string()
839                } else {
840                    node.path().to_string_lossy().to_string()
841                };
842                let href = if node.page().is_some() {
843                    Some(
844                        diff_paths(self.root_abs_path().join(node.path()), base)
845                            .expect("should diff paths")
846                            .to_string_lossy()
847                            .to_string(),
848                    )
849                } else {
850                    None
851                };
852                let ancestor = node.part_of_path(rel_path);
853                let current = path == self.root_abs_path().join(node.path());
854                let icon = match node.page() {
855                    Some(page) => match page.page_type() {
856                        PageType::Task(_) => Some(self.get_asset(
857                            base,
858                            if ancestor {
859                                "task-selected.svg"
860                            } else {
861                                "task-unselected.svg"
862                            },
863                        )),
864                        PageType::Struct(_) => Some(self.get_asset(
865                            base,
866                            if ancestor {
867                                "struct-selected.svg"
868                            } else {
869                                "struct-unselected.svg"
870                            },
871                        )),
872                        PageType::Workflow(_) => Some(self.get_asset(
873                            base,
874                            if ancestor {
875                                "workflow-selected.svg"
876                            } else {
877                                "workflow-unselected.svg"
878                            },
879                        )),
880                        PageType::Index(_) => Some(self.get_asset(
881                            base,
882                            if ancestor {
883                                "wdl-dir-selected.svg"
884                            } else {
885                                "wdl-dir-unselected.svg"
886                            },
887                        )),
888                    },
889                    None => None,
890                };
891                let nest_level = node
892                    .path()
893                    .components()
894                    .filter(|c| c.as_os_str().to_string_lossy() != "index.html")
895                    .count();
896                let children = node
897                    .children()
898                    .values()
899                    .map(|child| make_key(child.path()))
900                    .collect::<Vec<String>>();
901                JsNode {
902                    key,
903                    display_name,
904                    parent,
905                    search_name: search_name.clone(),
906                    icon,
907                    href,
908                    ancestor,
909                    current,
910                    nest_level,
911                    children,
912                }
913            })
914            .collect::<Vec<JsNode>>();
915
916        let js_dag = all_nodes
917            .iter()
918            .map(|node| {
919                let children = node
920                    .children
921                    .iter()
922                    .map(|child| format!("'{child}'"))
923                    .collect::<Vec<String>>()
924                    .join(", ");
925                format!("'{}': [{}]", node.key, children)
926            })
927            .collect::<Vec<String>>()
928            .join(", ");
929
930        let all_nodes_true = all_nodes
931            .iter()
932            .map(|node| format!("'{}': true", node.key))
933            .collect::<Vec<String>>()
934            .join(", ");
935
936        let data = format!(
937            r#"{{
938                showWorkflows: $persist({}).using(sessionStorage),
939                search: $persist('').using(sessionStorage),
940                dirOpen: '{}',
941                dirClosed: '{}',
942                nodes: [{}],
943                get searchedNodes() {{
944                    if (this.search === '') {{
945                        return [];
946                    }}
947                    this.showWorkflows = false;
948                    return this.nodes.filter(node => node.search_name.toLowerCase().includes(this.search.toLowerCase()));
949                }},
950                get shownNodes() {{
951                    if (this.search !== '') {{
952                        return [];
953                    }}
954                    return this.nodes.filter(node => this.showSelfCache[node.key]);
955                }},
956                dag: {{{}}},
957                showSelfCache: $persist({{{}}}).using(sessionStorage),
958                showChildrenCache: $persist({{{}}}).using(sessionStorage),
959                children(key) {{
960                    return this.dag[key];
961                }},
962                toggleChildren(key) {{
963                    this.nodes.forEach(n => {{
964                        if (n.key === key) {{
965                            this.showChildrenCache[key] = !this.showChildrenCache[key];
966                            this.children(key).forEach(child => {{
967                                this.setShow(child, this.showChildrenCache[key]);
968                            }});
969                        }}
970                    }});
971                }},
972                setShow(key, value) {{
973                    this.nodes.forEach(n => {{
974                        if (n.key === key) {{
975                            this.showSelfCache[key] = value;
976                            this.showChildrenCache[key] = value;
977                            this.children(key).forEach(child => {{
978                                this.setShow(child, value);
979                            }});
980                        }}
981                    }});
982                }},
983                reset() {{
984                    this.nodes.forEach(n => {{
985                        this.showSelfCache[n.key] = true;
986                        this.showChildrenCache[n.key] = true;
987                    }});
988                }}
989            }}"#,
990            !self.init_on_full_directory,
991            self.get_asset(base, "chevron-up.svg"),
992            self.get_asset(base, "chevron-down.svg"),
993            all_nodes
994                .iter()
995                .map(|node| serde_json::to_string(node).expect("should serialize node"))
996                .collect::<Vec<String>>()
997                .join(", "),
998            js_dag,
999            all_nodes_true,
1000            all_nodes_true,
1001        );
1002
1003        html! {
1004            div x-data=(data) x-cloak x-init="$nextTick(() => { document.querySelector('.is-scrolled-to')?.scrollIntoView({ block: 'center', behavior: 'instant' }); })" class="left-sidebar__container" {
1005                // top navbar
1006                div class="sticky px-4" {
1007                    a href=(self.root_index_relative_to(base).to_string_lossy()) {
1008                        img src=(self.get_asset(base, LOGO_FILE_NAME)) class="w-[120px] flex-none mb-8 block light:hidden" alt="Logo";
1009                        img src=(self.get_asset(base, LIGHT_LOGO_FILE_NAME)) class="w-[120px] flex-none mb-8 hidden light:block" alt="Logo";
1010                    }
1011                    div class="relative w-full h-10" {
1012                        input id="searchbox" "x-model.debounce"="search" type="text" placeholder="Search..." class="left-sidebar__searchbox";
1013                        img src=(self.get_asset(base, "search.svg")) class="absolute left-2 top-1/2 -translate-y-1/2 size-6 pointer-events-none block light:hidden" alt="Search icon";
1014                        img src=(self.get_asset(base, "search.light.svg")) class="absolute left-2 top-1/2 -translate-y-1/2 size-6 pointer-events-none hidden light:block" alt="Search icon";
1015                        img src=(self.get_asset(base, "x-mark.svg")) class="absolute right-2 top-1/2 -translate-y-1/2 size-6 hover:cursor-pointer block light:hidden" alt="Clear icon" x-show="search !== ''" x-on:click="search = ''";
1016                        img src=(self.get_asset(base, "x-mark.light.svg")) class="absolute right-2 top-1/2 -translate-y-1/2 size-6 hover:cursor-pointer hidden light:block" alt="Clear icon" x-show="search !== ''" x-on:click="search = ''";
1017                    }
1018                    div class="left-sidebar__tabs-container mt-4" {
1019                        button x-on:click="showWorkflows = true; search = ''; $nextTick(() => { document.querySelector('.is-scrolled-to')?.scrollIntoView({ block: 'center', behavior: 'instant' }); })" class="left-sidebar__tabs text-slate-50 border-b-slate-50" x-bind:class="! showWorkflows ? 'opacity-40 light:opacity-60 hover:opacity-80' : ''" {
1020                            img src=(self.get_asset(base, "list-bullet-selected.svg")) class="left-sidebar__icon block light:hidden" alt="List icon";
1021                            img src=(self.get_asset(base, "list-bullet-selected.light.svg")) class="left-sidebar__icon hidden light:block" alt="List icon";
1022                            p { "Workflows" }
1023                        }
1024                        button x-on:click="showWorkflows = false; $nextTick(() => { document.querySelector('.is-scrolled-to')?.scrollIntoView({ block: 'center', behavior: 'instant' }); })" class="left-sidebar__tabs text-slate-50 border-b-slate-50" x-bind:class="showWorkflows ? 'opacity-50 light:opacity-60 hover:opacity-80' : ''" {
1025                            img src=(self.get_asset(base, "folder-selected.svg")) class="left-sidebar__icon block light:hidden" alt="List icon";
1026                            img src=(self.get_asset(base, "folder-selected.light.svg")) class="left-sidebar__icon hidden light:block" alt="List icon";
1027                            p { "Full Directory" }
1028                        }
1029                    }
1030                }
1031                // Main content
1032                div x-cloak class="left-sidebar__content-container pt-4" {
1033                    // Full directory view
1034                    ul x-show="! showWorkflows || search != ''" class="left-sidebar__content" {
1035                        // Root node for the directory tree
1036                        sprocket-tooltip content=(root.name()) class="block" {
1037                            a href=(self.root_index_relative_to(base).to_string_lossy()) x-show="search === ''" aria-label=(root.name()) class="left-sidebar__row hover:bg-slate-700/40" {
1038                                div class="left-sidebar__content-item-container crop-ellipsis" {
1039                                    div class="relative shrink-0" {
1040                                        img src=(self.get_asset(base, "dir-open.svg")) class="left-sidebar__icon block light:hidden" alt="Directory icon";
1041                                        img src=(self.get_asset(base, "dir-open.light.svg")) class="left-sidebar__icon hidden light:block" alt="Directory icon";
1042                                    }
1043                                    div class="text-slate-50" { (root.name()) }
1044                                }
1045                            }
1046                        }
1047                        // Nodes in the directory tree
1048                        template x-for="node in shownNodes" {
1049                            sprocket-tooltip x-bind:content="node.display_name" class="block isolate" {
1050                                a x-bind:href="node.href" x-show="showSelfCache[node.key]" x-on:click="if (node.href === null) toggleChildren(node.key)" x-bind:aria-label="node.display_name" class="left-sidebar__row" x-bind:class="`${node.current ? 'is-scrolled-to left-sidebar__row--active' : (node.href === null) ? showChildrenCache[node.key] ? 'left-sidebar__row-folder left-sidebar__row-folder--open' : 'left-sidebar__row-folder left-sidebar__row-folder--closed' : 'left-sidebar__row-page'} ${node.ancestor ? 'left-sidebar__content-item-container--ancestor' : ''}`" {
1051                                    template x-for="i in Array.from({ length: node.nest_level })" {
1052                                        div class="left-sidebar__indent -z-1" {}
1053                                    }
1054                                    div class="left-sidebar__content-item-container crop-ellipsis" {
1055                                        div class="relative left-sidebar__icon shrink-0" {
1056                                            img x-bind:src="node.icon || dirOpen" class="left-sidebar__icon block light:hidden" alt="Node icon" x-bind:class="`${(node.icon === null) && !showChildrenCache[node.key] ? 'rotate-180' : ''}`";
1057                                            img x-bind:src="(node.icon || dirOpen).replace('.svg', '.light.svg')" class="left-sidebar__icon hidden light:block" alt="Node icon" x-bind:class="`${(node.icon === null) && !showChildrenCache[node.key] ? 'rotate-180' : ''}`";
1058                                        }
1059                                        div class="crop-ellipsis" x-text="node.display_name" {
1060                                        }
1061                                    }
1062                                }
1063                            }
1064                        }
1065                        // Search results
1066                        template x-for="node in searchedNodes" {
1067                            li class="left-sidebar__search-result-item" {
1068                                p class="text-xs text-slate-500 crop-ellipsis" x-text="node.parent" {}
1069                                div class="left-sidebar__search-result-item-container" {
1070                                    img x-bind:src="node.icon" class="left-sidebar__icon" alt="Node icon";
1071                                    sprocket-tooltip class="crop-ellipsis" x-bind:content="node.display_name" {
1072                                        a x-bind:href="node.href" x-text="node.display_name" {}
1073                                    }
1074                                }
1075                            }
1076                        }
1077                        // No results found icon
1078                        li x-show="search !== '' && searchedNodes.length === 0" class="flex place-content-center" {
1079                            img src=(self.get_asset(base, "search.svg")) class="size-8 block light:hidden" alt="Search icon";
1080                            img src=(self.get_asset(base, "search.light.svg")) class="size-8 hidden light:block" alt="Search icon";
1081                        }
1082                        // No results found message
1083                        li x-show="search !== '' && searchedNodes.length === 0" class="flex gap-1 place-content-center text-center break-words whitespace-normal text-sm text-slate-500" {
1084                            span x-text="'No results found for'" {}
1085                            span x-text="`\"${search}\"`" class="text-slate-50" {}
1086                        }
1087                    }
1088                    // Workflows view
1089                    ul x-show="showWorkflows && search === ''" class="left-sidebar__content" {
1090                        (self.sidebar_workflows_view(path))
1091                    }
1092                }
1093            }
1094        }
1095    }
1096
1097    /// Render a right sidebar component.
1098    fn render_right_sidebar(&self, headers: PageSections) -> Markup {
1099        html! {
1100            div class="right-sidebar__container" {
1101                div class="right-sidebar__header" {
1102                    "ON THIS PAGE"
1103                }
1104                (headers.render())
1105                div class="right-sidebar__back-to-top-container" {
1106                    // TODO: this should be a link to the top of the page, not just a link to the title
1107                    a href="#title" class="right-sidebar__back-to-top" {
1108                        span class="right-sidebar__back-to-top-icon" {
1109                            "↑"
1110                        }
1111                        span class="right-sidebar__back-to-top-text" {
1112                            "Back to top"
1113                        }
1114                    }
1115                }
1116            }
1117        }
1118    }
1119
1120    /// Renders a page "breadcrumb" navigation component.
1121    ///
1122    /// Path is expected to be an absolute path.
1123    fn render_breadcrumbs<P: AsRef<Path>>(&self, path: P) -> Markup {
1124        let path = path.as_ref();
1125        let base = path.parent().expect("path should have a parent");
1126
1127        let mut current_path = path
1128            .strip_prefix(self.root_abs_path())
1129            .expect("path should be in the docs directory");
1130
1131        let mut breadcrumbs = vec![];
1132
1133        let cur_page = self.get_page(path).expect("path should have a page");
1134        match cur_page.page_type() {
1135            PageType::Index(_) => {
1136                // Index pages are handled by the below while loop
1137            }
1138            _ => {
1139                // Last crumb, i.e. the current page, should not be clickable
1140                breadcrumbs.push((cur_page.name(), None));
1141            }
1142        }
1143
1144        while let Some(parent) = current_path.parent() {
1145            let cur_node = self.get_node(parent).expect("path should have a node");
1146            if let Some(page) = cur_node.page() {
1147                breadcrumbs.push((
1148                    page.name(),
1149                    if self.root_abs_path().join(cur_node.path()) == path {
1150                        // Don't insert a link to the current page.
1151                        // This happens on index pages.
1152                        None
1153                    } else {
1154                        Some(
1155                            diff_paths(self.root_abs_path().join(cur_node.path()), base)
1156                                .expect("should diff paths"),
1157                        )
1158                    },
1159                ));
1160            } else if cur_node.name() == self.root().name() {
1161                breadcrumbs.push((cur_node.name(), Some(self.root_index_relative_to(base))))
1162            } else {
1163                breadcrumbs.push((cur_node.name(), None));
1164            }
1165            current_path = parent;
1166        }
1167        breadcrumbs.reverse();
1168        let mut breadcrumbs = breadcrumbs.into_iter();
1169        let root_crumb = breadcrumbs
1170            .next()
1171            .expect("should have at least one breadcrumb");
1172        let root_crumb = html! {
1173            a class="layout__breadcrumb-clickable" href=(root_crumb.1.expect("root crumb should have path").to_string_lossy()) { (root_crumb.0) }
1174        };
1175
1176        html! {
1177            div class="layout__breadcrumb-container" {
1178                (root_crumb)
1179                @for crumb in breadcrumbs {
1180                    span { " / " }
1181                    @if let Some(path) = crumb.1 {
1182                        a href=(path.to_string_lossy()) class="layout__breadcrumb-clickable" { (crumb.0) }
1183                    } @else {
1184                        span class="layout__breadcrumb-inactive" { (crumb.0) }
1185                    }
1186                }
1187            }
1188        }
1189    }
1190
1191    /// Render every page in the tree.
1192    pub fn render_all(&self) -> Result<()> {
1193        let root = self.root();
1194
1195        for node in root.depth_first_traversal() {
1196            if let Some(page) = node.page() {
1197                self.write_page(page.as_ref(), self.root_abs_path().join(node.path()))
1198                    .with_context(|| {
1199                        format!("failed to write page at `{}`", node.path().display())
1200                    })?;
1201            }
1202        }
1203
1204        self.write_homepage()
1205            .with_context(|| "failed to write homepage".to_string())?;
1206        Ok(())
1207    }
1208
1209    /// Write the homepage to disk.
1210    fn write_homepage(&self) -> Result<()> {
1211        let index_path = self.root_abs_path().join("index.html");
1212
1213        let left_sidebar = self.render_left_sidebar(&index_path);
1214        let content = html! {
1215            @if let Some(homepage) = &self.homepage {
1216                div class="main__section" {
1217                    div class="markdown-body" {
1218                        (Markdown(std::fs::read_to_string(homepage).with_context(|| {
1219                            format!("failed to read provided homepage file: `{}`", homepage.display())
1220                        })?).render())
1221                    }
1222                }
1223            } @else {
1224                div class="main__section--empty" {
1225                    img src=(self.get_asset(self.root_abs_path(), "missing-home.svg")) class="size-12 block light:hidden" alt="Missing home icon";
1226                    img src=(self.get_asset(self.root_abs_path(), "missing-home.light.svg")) class="size-12 hidden light:block" alt="Missing home icon";
1227                    h2 class="main__section-header" { "There's nothing to see on this page" }
1228                    p { "The markdown file for this page wasn't supplied." }
1229                }
1230            }
1231        };
1232
1233        let homepage_content = html! {
1234            h5 class="main__homepage-header" {
1235                "Home"
1236            }
1237            (content)
1238        };
1239
1240        let html = full_page(
1241            "Home",
1242            self.render_layout(
1243                left_sidebar,
1244                homepage_content,
1245                self.render_right_sidebar(PageSections::default()),
1246                None,
1247                &self.assets_relative_to(self.root_abs_path()),
1248            ),
1249            self.root().path(),
1250            &self.additional_javascript,
1251            self.init_light_mode,
1252        );
1253        std::fs::write(&index_path, html.into_string())
1254            .with_context(|| format!("failed to write homepage to `{}`", index_path.display()))?;
1255        Ok(())
1256    }
1257
1258    /// Render reusable sidebar control buttons
1259    fn render_sidebar_control_buttons(&self, assets: &Path) -> Markup {
1260        html! {
1261            button
1262                x-on:click="collapseSidebar()"
1263                x-bind:disabled="sidebarState === 'hidden'"
1264                x-bind:class="getSidebarButtonClass('hidden')" {
1265                img src=(assets.join("sidebar-icon-hide.svg").to_string_lossy()) alt="" class="block light:hidden" {}
1266                img src=(assets.join("sidebar-icon-hide.light.svg").to_string_lossy()) alt="" class="hidden light:block" {}
1267            }
1268            button
1269                x-on:click="restoreSidebar()"
1270                x-bind:disabled="sidebarState === 'normal'"
1271                x-bind:class="getSidebarButtonClass('normal')" {
1272                img src=(assets.join("sidebar-icon-default.svg").to_string_lossy()) alt="" class="block light:hidden" {}
1273                img src=(assets.join("sidebar-icon-default.light.svg").to_string_lossy()) alt="" class="hidden light:block" {}
1274            }
1275            button
1276                x-on:click="expandSidebar()"
1277                x-bind:disabled="sidebarState === 'xl'"
1278                x-bind:class="getSidebarButtonClass('xl')" {
1279                    img src=(assets.join("sidebar-icon-expand.svg").to_string_lossy()) alt="" class="block light:hidden" {}
1280                    img src=(assets.join("sidebar-icon-expand.light.svg").to_string_lossy()) alt="" class="hidden light:block" {}
1281                }
1282        }
1283    }
1284
1285    /// Render the main layout template with left sidebar, content, and right
1286    /// sidebar.
1287    fn render_layout(
1288        &self,
1289        left_sidebar: Markup,
1290        content: Markup,
1291        right_sidebar: Markup,
1292        breadcrumbs: Option<Markup>,
1293        assets: &Path,
1294    ) -> Markup {
1295        html! {
1296            div class="layout__container layout__container--alt-layout" x-transition x-data="{
1297                sidebarState: $persist(window.innerWidth < 768 ? 'hidden' : 'normal').using(sessionStorage),
1298                get showSidebarButtons() { return this.sidebarState !== 'hidden'; },
1299                get showCenterButtons() { return this.sidebarState === 'hidden'; },
1300                get containerClasses() {
1301                    const base = 'layout__container layout__container--alt-layout';
1302                    switch(this.sidebarState) {
1303                        case 'hidden': return base + ' layout__container--left-hidden';
1304                        case 'xl': return base + ' layout__container--left-xl';
1305                        default: return base;
1306                    }
1307                },
1308                getSidebarButtonClass(state) {
1309                    return 'left-sidebar__size-button ' + (this.sidebarState === state ? 'left-sidebar__size-button--active' : '');
1310                },
1311                collapseSidebar() { this.sidebarState = 'hidden'; },
1312                restoreSidebar() { this.sidebarState = 'normal'; },
1313                expandSidebar() { this.sidebarState = 'xl'; }
1314            }" x-bind:class="containerClasses" {
1315                div class="layout__sidebar-left" x-transition {
1316                    div class="absolute top-5 right-2 flex gap-1 z-10" x-cloak x-show="showSidebarButtons" {
1317                        (self.render_sidebar_control_buttons(assets))
1318                    }
1319                    (left_sidebar)
1320                }
1321                div class="layout__main-center" {
1322                    div class="layout__main-center-content" {
1323                        div {
1324                            div class="flex gap-1 mb-3" x-show="showCenterButtons" {
1325                                (self.render_sidebar_control_buttons(assets))
1326                            }
1327                            div class="flex flex-row-reverse items-start justify-between" {
1328                                button
1329                                x-on:click="
1330                                document.documentElement.classList.toggle('light')
1331                                localStorage.setItem('theme', document.documentElement.classList.contains('light') ? 'light' : 'dark')
1332                                "
1333                                class="border border-slate-700 rounded-md h-8 flex items-center justify-center text-slate-300 text-lg w-8 cursor-pointer hover:border-slate-500" {
1334                                    "☀︎"
1335                                }
1336                                @if let Some(breadcrumbs) = breadcrumbs {
1337                                    div class="layout__breadcrumbs" {
1338                                        (breadcrumbs)
1339                                    }
1340                                }
1341                            }
1342                        }
1343                        (content)
1344                    }
1345                }
1346                div class="layout__sidebar-right" {
1347                    (right_sidebar)
1348                }
1349            }
1350        }
1351    }
1352
1353    /// Write a page to disk at the designated path.
1354    ///
1355    /// Path is expected to be an absolute path.
1356    fn write_page<P: Into<PathBuf>>(&self, page: &HTMLPage, path: P) -> Result<()> {
1357        let path = path.into();
1358        let base = path.parent().expect("path should have a parent");
1359
1360        let (content, headers) = match page.page_type() {
1361            PageType::Index(doc) => doc.render(),
1362            PageType::Struct(s) => s.render(),
1363            PageType::Task(t) => t.render(&self.assets_relative_to(base)),
1364            PageType::Workflow(w) => w.render(&self.assets_relative_to(base)),
1365        };
1366
1367        let breadcrumbs = self.render_breadcrumbs(&path);
1368
1369        let left_sidebar = self.render_left_sidebar(&path);
1370
1371        let html = full_page(
1372            page.name(),
1373            self.render_layout(
1374                left_sidebar,
1375                content,
1376                self.render_right_sidebar(headers),
1377                Some(breadcrumbs),
1378                &self.assets_relative_to(base),
1379            ),
1380            self.root_relative_to(base),
1381            &self.additional_javascript,
1382            self.init_light_mode,
1383        );
1384        std::fs::write(&path, html.into_string())
1385            .with_context(|| format!("failed to write page at `{}`", path.display()))?;
1386        Ok(())
1387    }
1388}
1389
1390/// Sort workflow categories in a specific order.
1391fn sort_workflow_categories(categories: HashSet<String>) -> Vec<String> {
1392    let mut sorted_categories: Vec<String> = categories.into_iter().collect();
1393    sorted_categories.sort_by(|a, b| {
1394        if a == b {
1395            std::cmp::Ordering::Equal
1396        } else if a == "External" {
1397            std::cmp::Ordering::Greater
1398        } else if b == "External" {
1399            std::cmp::Ordering::Less
1400        } else if a == "Other" {
1401            std::cmp::Ordering::Greater
1402        } else if b == "Other" {
1403            std::cmp::Ordering::Less
1404        } else {
1405            a.cmp(b)
1406        }
1407    });
1408    sorted_categories
1409}