1use 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
29const LOGO_FILE_NAME: &str = "logo.svg";
32const LIGHT_LOGO_FILE_NAME: &str = "logo.light.svg";
35
36#[derive(Debug)]
38pub(crate) enum PageType {
39 Index(Document),
41 Struct(Struct),
43 Task(Task),
45 Workflow(Workflow),
47}
48
49#[derive(Debug)]
51pub(crate) struct HTMLPage {
52 name: String,
54 page_type: PageType,
56}
57
58impl HTMLPage {
59 pub(crate) fn new(name: String, page_type: PageType) -> Self {
61 Self { name, page_type }
62 }
63
64 pub(crate) fn name(&self) -> &str {
66 &self.name
67 }
68
69 pub(crate) fn page_type(&self) -> &PageType {
71 &self.page_type
72 }
73}
74
75#[derive(Debug)]
81pub(crate) enum Header {
82 Header(String, String),
84 SubHeader(String, String),
86}
87
88#[derive(Debug, Default)]
94pub(crate) struct PageSections {
95 pub headers: Vec<Header>,
97}
98
99impl PageSections {
100 pub fn push(&mut self, header: Header) {
102 self.headers.push(header);
103 }
104
105 pub fn extend(&mut self, headers: Self) {
107 self.headers.extend(headers.headers);
108 }
109
110 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#[derive(Debug)]
131struct Node {
132 name: String,
134 path: PathBuf,
136 page: Option<Rc<HTMLPage>>,
138 children: BTreeMap<String, Node>,
140}
141
142impl Node {
143 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 pub fn name(&self) -> &str {
155 &self.name
156 }
157
158 pub fn path(&self) -> &PathBuf {
160 &self.path
161 }
162
163 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 pub fn page(&self) -> Option<&Rc<HTMLPage>> {
180 self.page.as_ref()
181 }
182
183 pub fn children(&self) -> &BTreeMap<String, Node> {
185 &self.children
186 }
187
188 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#[derive(Debug)]
216pub struct DocsTreeBuilder {
217 root: PathBuf,
219 homepage: Option<PathBuf>,
221 custom_theme: Option<PathBuf>,
223 logo: Option<PathBuf>,
229 alt_logo: Option<PathBuf>,
232 additional_javascript: AdditionalScript,
234 init_on_full_directory: bool,
239 init_light_mode: bool,
241}
242
243impl DocsTreeBuilder {
244 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 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 pub fn homepage(self, homepage: impl Into<PathBuf>) -> Self {
269 self.maybe_homepage(Some(homepage))
270 }
271
272 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 pub fn custom_theme(self, theme: impl AsRef<Path>) -> Result<Self> {
293 self.maybe_custom_theme(Some(theme))
294 }
295
296 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 pub fn logo(self, logo: impl Into<PathBuf>) -> Self {
304 self.maybe_logo(Some(logo))
305 }
306
307 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 pub fn alt_logo(self, logo: impl Into<PathBuf>) -> Self {
316 self.maybe_alt_logo(Some(logo))
317 }
318
319 pub fn additional_javascript(mut self, js: AdditionalScript) -> Self {
321 self.additional_javascript = js;
322 self
323 }
324
325 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 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 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 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 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#[derive(Debug)]
504pub struct DocsTree {
505 root: Node,
507 path: PathBuf,
509 homepage: Option<PathBuf>,
512 additional_javascript: AdditionalScript,
514 init_on_full_directory: bool,
517 init_light_mode: bool,
519}
520
521impl DocsTree {
522 fn root(&self) -> &Node {
524 &self.root
525 }
526
527 fn root_mut(&mut self) -> &mut Node {
529 &mut self.root
530 }
531
532 fn root_abs_path(&self) -> &PathBuf {
534 &self.path
535 }
536
537 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 fn assets(&self) -> PathBuf {
545 self.root_abs_path().join("assets")
546 }
547
548 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 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 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 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 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 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 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 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 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 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 key: String,
798 display_name: String,
800 parent: String,
804 search_name: String,
806 icon: Option<String>,
808 href: Option<String>,
810 ancestor: bool,
812 current: bool,
814 nest_level: usize,
816 children: Vec<String>,
818 }
819
820 let all_nodes = root
821 .depth_first_traversal()
822 .iter()
823 .skip(1) .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 "".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 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 div x-cloak class="left-sidebar__content-container pt-4" {
1033 ul x-show="! showWorkflows || search != ''" class="left-sidebar__content" {
1035 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 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 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 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 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 ul x-show="showWorkflows && search === ''" class="left-sidebar__content" {
1090 (self.sidebar_workflows_view(path))
1091 }
1092 }
1093 }
1094 }
1095 }
1096
1097 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 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 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 }
1138 _ => {
1139 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 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 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 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 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 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 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
1390fn 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}