//! Implementations for a [`DocsTree`] which represents the docs directory.
use std::collections::BTreeMap;
use std::collections::HashSet;
use std::io::Error as IoError;
use std::io::ErrorKind;
use std::path::Path;
use std::path::PathBuf;
use std::path::absolute;
use std::rc::Rc;
use ammonia::Url;
use maud::Markup;
use maud::html;
use path_clean::PathClean;
use pathdiff::diff_paths;
use serde::Serialize;
use crate::AdditionalScript;
use crate::DocError;
use crate::Markdown;
use crate::Render;
use crate::config::ExternalUrls;
use crate::document::Document;
use crate::r#enum::Enum;
use crate::error::DocResult;
use crate::error::ResultContextExt;
use crate::full_page;
use crate::get_assets;
use crate::r#struct::Struct;
use crate::task::Task;
use crate::workflow::Workflow;
/// Filename for the dark theme logo SVG expected to be in the "assets"
/// directory.
const LOGO_FILE_NAME: &str = "logo.svg";
/// Filename for the light theme logo SVG expected to be in the "assets"
/// directory.
const LIGHT_LOGO_FILE_NAME: &str = "logo.light.svg";
/// The type of a page.
#[derive(Debug)]
pub(crate) enum PageType {
/// An index page.
Index(Document),
/// A struct page.
Struct(Struct),
/// An enum page.
Enum(Enum),
/// A task page.
Task(Task),
/// A workflow page.
Workflow(Workflow),
}
/// An HTML page in the docs directory.
#[derive(Debug)]
pub(crate) struct HTMLPage {
/// The display name of the page.
name: String,
/// The type of the page.
page_type: PageType,
}
impl HTMLPage {
/// Create a new HTML page.
pub(crate) fn new(name: String, page_type: PageType) -> Self {
Self { name, page_type }
}
/// Get the name of the page.
pub(crate) fn name(&self) -> &str {
&self.name
}
/// Get the type of the page.
pub(crate) fn page_type(&self) -> &PageType {
&self.page_type
}
}
/// A page header or page sub header.
///
/// This is used to represent the headers in the right sidebar of the
/// documentation pages. Each header has a name (first `String`) and an ID
/// (second `String`), which is used to link to the header in the page.
#[derive(Debug)]
pub(crate) enum Header {
/// A header in the page.
Header(String, String),
/// A sub header in the page.
SubHeader(String, String),
}
/// A collection of page headers representing the sections of a page.
///
/// This is used to render the right sidebar of documentation pages.
/// Each section added to this collection will be rendered in the
/// order it was added.
#[derive(Debug, Default)]
pub(crate) struct PageSections {
/// The headers in the page.
pub headers: Vec<Header>,
}
impl PageSections {
/// Push a header to the page sections.
pub fn push(&mut self, header: Header) {
self.headers.push(header);
}
/// Extend the page headers with another collection of headers.
pub fn extend(&mut self, headers: Self) {
self.headers.extend(headers.headers);
}
/// Render the page sections as HTML for the right sidebar.
pub fn render(&self) -> Markup {
html!(
@for header in &self.headers {
@match header {
Header::Header(name, id) => {
a href=(format!("#{}", id)) class="right-sidebar__section-header" { (name) }
}
Header::SubHeader(name, id) => {
div class="right-sidebar__section-items" {
a href=(format!("#{}", id)) class="right-sidebar__section-item" { (name) }
}
}
}
}
)
}
}
/// A node in the docs directory tree.
#[derive(Debug)]
struct Node {
/// The name of the node.
name: String,
/// The path from the root to the node.
path: PathBuf,
/// The page associated with the node.
page: Option<Rc<HTMLPage>>,
/// The children of the node.
children: BTreeMap<String, Node>,
}
impl Node {
/// Create a new node.
pub fn new<P: Into<PathBuf>>(name: String, path: P) -> Self {
Self {
name,
path: path.into(),
page: None,
children: BTreeMap::new(),
}
}
/// Get the name of the node.
pub fn name(&self) -> &str {
&self.name
}
/// Get the path from the root to the node.
pub fn path(&self) -> &PathBuf {
&self.path
}
/// Determine if the node is part of a path.
///
/// Path should be relative to the root or false positives may occur.
pub fn part_of_path<P: AsRef<Path>>(&self, path: P) -> bool {
let other_path = path.as_ref();
let self_path = if self.path().ends_with("index.html") {
self.path().parent().expect("index should have parent")
} else {
self.path()
};
self_path
.components()
.all(|c| other_path.components().any(|p| p == c))
}
/// Get the page associated with the node.
pub fn page(&self) -> Option<&Rc<HTMLPage>> {
self.page.as_ref()
}
/// Get the children of the node.
pub fn children(&self) -> &BTreeMap<String, Node> {
&self.children
}
/// Gather the node and its children in a Depth First Traversal order.
///
/// Traversal order among children is alphabetical by node name, with the
/// exception of any "external" node, which is always last.
pub fn depth_first_traversal(&self) -> Vec<&Node> {
fn recurse_depth_first<'a>(node: &'a Node, nodes: &mut Vec<&'a Node>) {
nodes.push(node);
for child in node.children().values() {
recurse_depth_first(child, nodes);
}
}
let mut nodes = Vec::new();
nodes.push(self);
for child in self.children().values().filter(|c| c.name() != "external") {
recurse_depth_first(child, &mut nodes);
}
if let Some(external) = self.children().get("external") {
recurse_depth_first(external, &mut nodes);
}
nodes
}
}
/// A builder for a [`DocsTree`] which represents the docs directory.
#[derive(Debug)]
pub struct DocsTreeBuilder {
/// The root directory for the docs.
root: PathBuf,
/// The path to a Markdown file to embed in the `<root>/index.html` page.
homepage: Option<PathBuf>,
/// An optional path to a custom theme to use for the docs.
custom_theme: Option<PathBuf>,
/// The path to a custom dark theme logo to embed at the top of the left
/// sidebar.
///
/// If this is `Some(_)` and no `alt_logo` is supplied, this will be used
/// for both dark and light themes.
logo: Option<PathBuf>,
/// External URLs related to the project, rendered as buttons in the header.
external_urls: ExternalUrls,
/// The path to an alternate light theme custom logo to embed at the top of
/// the left sidebar.
alt_logo: Option<PathBuf>,
/// Optional JavaScript to embed in each HTML page.
additional_javascript: AdditionalScript,
/// Start on the "Full Directory" left sidebar view instead of the
/// "Workflows" view.
///
/// Users can toggle the view. This only impacts the initialized value.
init_on_full_directory: bool,
/// Start in light mode instead of the default dark mode.
init_light_mode: bool,
}
impl DocsTreeBuilder {
/// Create a new docs tree builder.
pub fn new(root: impl AsRef<Path>) -> Self {
let root = absolute(root.as_ref())
.expect("should get absolute path")
.clean();
Self {
root,
homepage: None,
custom_theme: None,
logo: None,
external_urls: ExternalUrls::default(),
alt_logo: None,
additional_javascript: AdditionalScript::None,
init_on_full_directory: crate::PREFER_FULL_DIRECTORY,
init_light_mode: false,
}
}
/// Set the homepage for the docs with an option.
pub fn maybe_homepage(mut self, homepage: Option<impl Into<PathBuf>>) -> Self {
self.homepage = homepage.map(|hp| hp.into());
self
}
/// Set the homepage for the docs.
pub fn homepage(self, homepage: impl Into<PathBuf>) -> Self {
self.maybe_homepage(Some(homepage))
}
/// Set the custom theme for the docs with an option.
pub fn maybe_custom_theme(mut self, theme: Option<impl AsRef<Path>>) -> DocResult<Self> {
self.custom_theme = if let Some(t) = theme {
Some(
absolute(t.as_ref())
.map_err(Into::<DocError>::into)
.with_context(|| {
format!(
"failed to resolve absolute path for custom theme: `{}`",
t.as_ref().display()
)
})?
.clean(),
)
} else {
None
};
Ok(self)
}
/// Set the custom theme for the docs.
pub fn custom_theme(self, theme: impl AsRef<Path>) -> DocResult<Self> {
self.maybe_custom_theme(Some(theme))
}
/// Set the custom logo for the left sidebar with an option.
pub fn maybe_logo(mut self, logo: Option<impl Into<PathBuf>>) -> Self {
self.logo = logo.map(|l| l.into());
self
}
/// Set the custom logo for the left sidebar.
pub fn logo(self, logo: impl Into<PathBuf>) -> Self {
self.maybe_logo(Some(logo))
}
/// Set the external URLs for the header.
pub fn external_urls(mut self, external_urls: ExternalUrls) -> Self {
self.external_urls = external_urls;
self
}
/// Set the alt (i.e. light mode) custom logo for the left sidebar with an
/// option.
pub fn maybe_alt_logo(mut self, logo: Option<impl Into<PathBuf>>) -> Self {
self.alt_logo = logo.map(|l| l.into());
self
}
/// Set the alt (i.e. light mode) custom logo for the left sidebar.
pub fn alt_logo(self, logo: impl Into<PathBuf>) -> Self {
self.maybe_alt_logo(Some(logo))
}
/// Set the additional javascript for each page.
pub fn additional_javascript(mut self, js: AdditionalScript) -> Self {
self.additional_javascript = js;
self
}
/// Set whether the "Full Directory" view should be initialized instead of
/// the "Workflows" view of the left sidebar.
pub fn prefer_full_directory(mut self, prefer_full_directory: bool) -> Self {
self.init_on_full_directory = prefer_full_directory;
self
}
/// Set whether light mode should be the initial view instead of dark mode.
pub fn init_light_mode(mut self, init_light_mode: bool) -> Self {
self.init_light_mode = init_light_mode;
self
}
/// Build the docs tree.
pub fn build(self) -> DocResult<DocsTree> {
self.write_assets()?;
let node = Node::new(
self.root
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or("docs".to_string()),
PathBuf::from(""),
);
Ok(DocsTree {
root: node,
path: self.root,
homepage: self.homepage,
external_urls: self.external_urls,
additional_javascript: self.additional_javascript,
init_on_full_directory: self.init_on_full_directory,
init_light_mode: self.init_light_mode,
})
}
/// Write assets to the root docs directory.
///
/// This will create an `assets` directory in the root and write all
/// necessary assets to it. It will also write the default `style.css` and
/// `index.js` files to the root unless a custom theme is
/// provided, in which case it will copy the `style.css` and `index.js`
/// files from the custom theme's `dist` directory.
fn write_assets(&self) -> DocResult<()> {
let dir = &self.root;
let custom_theme = self.custom_theme.as_ref();
let assets_dir = dir.join("assets");
std::fs::create_dir_all(&assets_dir)
.map_err(Into::<DocError>::into)
.with_context(|| {
format!(
"failed to create assets directory: `{}`",
assets_dir.display()
)
})?;
if let Some(custom_theme) = custom_theme {
if !custom_theme.exists() {
return Err(IoError::new(
ErrorKind::NotFound,
format!(
"custom theme does not exist at `{}`",
custom_theme.display()
),
)
.into());
}
std::fs::copy(
custom_theme.join("dist").join("style.css"),
dir.join("style.css"),
)
.map_err(Into::<DocError>::into)
.with_context(|| {
format!(
"failed to copy stylesheet from `{}` to `{}`",
custom_theme.join("dist").join("style.css").display(),
dir.join("style.css").display()
)
})?;
std::fs::copy(
custom_theme.join("dist").join("index.js"),
dir.join("index.js"),
)
.map_err(Into::<DocError>::into)
.with_context(|| {
format!(
"failed to copy web components from `{}` to `{}`",
custom_theme.join("dist").join("index.js").display(),
dir.join("index.js").display()
)
})?;
} else {
std::fs::write(
dir.join("style.css"),
include_str!("../theme/dist/style.css"),
)
.map_err(Into::<DocError>::into)
.with_context(|| {
format!(
"failed to write default stylesheet to `{}`",
dir.join("style.css").display()
)
})?;
std::fs::write(dir.join("index.js"), include_str!("../theme/dist/index.js"))
.map_err(Into::<DocError>::into)
.with_context(|| {
format!(
"failed to write default web components to `{}`",
dir.join("index.js").display()
)
})?;
}
for (file_name, bytes) in get_assets() {
let path = assets_dir.join(file_name);
std::fs::write(&path, bytes)
.map_err(Into::<DocError>::into)
.with_context(|| format!("failed to write asset to `{}`", path.display()))?;
}
// The above `get_assets()` call will write the default logos; then the
// following logic may overwrite those files with user supplied logos.
match (&self.logo, &self.alt_logo) {
(Some(dark_logo), Some(light_logo)) => {
let logo_path = assets_dir.join(LOGO_FILE_NAME);
std::fs::copy(dark_logo, &logo_path)
.map_err(Into::<DocError>::into)
.with_context(|| {
format!(
"failed to copy dark theme custom logo from `{}` to `{}`",
dark_logo.display(),
logo_path.display()
)
})?;
let logo_path = assets_dir.join(LIGHT_LOGO_FILE_NAME);
std::fs::copy(light_logo, &logo_path)
.map_err(Into::<DocError>::into)
.with_context(|| {
format!(
"failed to copy light theme custom logo from `{}` to `{}`",
light_logo.display(),
logo_path.display()
)
})?;
}
(Some(logo), None) => {
let logo_path = assets_dir.join(LOGO_FILE_NAME);
std::fs::copy(logo, &logo_path)
.map_err(Into::<DocError>::into)
.with_context(|| {
format!(
"failed to copy custom logo from `{}` to `{}`",
logo.display(),
logo_path.display()
)
})?;
let logo_path = assets_dir.join(LIGHT_LOGO_FILE_NAME);
std::fs::copy(logo, &logo_path)
.map_err(Into::<DocError>::into)
.with_context(|| {
format!(
"failed to copy custom logo from `{}` to `{}`",
logo.display(),
logo_path.display()
)
})?;
}
(None, Some(logo)) => {
let logo_path = assets_dir.join(LOGO_FILE_NAME);
std::fs::copy(logo, &logo_path)
.map_err(Into::<DocError>::into)
.with_context(|| {
format!(
"failed to copy custom logo from `{}` to `{}`",
logo.display(),
logo_path.display()
)
})?;
let logo_path = assets_dir.join(LIGHT_LOGO_FILE_NAME);
std::fs::copy(logo, &logo_path)
.map_err(Into::<DocError>::into)
.with_context(|| {
format!(
"failed to copy custom logo from `{}` to `{}`",
logo.display(),
logo_path.display()
)
})?;
}
(None, None) => {}
}
Ok(())
}
}
/// A tree representing the docs directory.
///
/// For construction, see [`DocsTreeBuilder`].
#[derive(Debug)]
pub struct DocsTree {
/// The root of the tree.
root: Node,
/// The absolute path to the root directory.
path: PathBuf,
/// An optional path to a Markdown file which will be embedded in the
/// `<root>/index.html` page.
homepage: Option<PathBuf>,
/// External URLs related to the project, rendered as buttons in the header.
external_urls: ExternalUrls,
/// Optional JavaScript to embed in each HTML page.
additional_javascript: AdditionalScript,
/// Initialize pages on the "Full Directory" view instead of the "Workflows"
/// view of the left sidebar.
init_on_full_directory: bool,
/// Initialize in light mode instead of the default dark mode.
init_light_mode: bool,
}
impl DocsTree {
/// Get the root of the tree.
fn root(&self) -> &Node {
&self.root
}
/// Get the root of the tree as mutable.
fn root_mut(&mut self) -> &mut Node {
&mut self.root
}
/// Get the absolute path to the root directory.
fn root_abs_path(&self) -> &PathBuf {
&self.path
}
/// Get the path to the root directory relative to a given path.
fn root_relative_to<P: AsRef<Path>>(&self, path: P) -> PathBuf {
let path = path.as_ref();
diff_paths(self.root_abs_path(), path).expect("should diff paths")
}
/// Get the absolute path to the assets directory.
fn assets(&self) -> PathBuf {
self.root_abs_path().join("assets")
}
/// Get a relative path to the assets directory.
fn assets_relative_to<P: AsRef<Path>>(&self, path: P) -> PathBuf {
let path = path.as_ref();
diff_paths(self.assets(), path).expect("should diff paths")
}
/// Get a relative path to an asset in the assets directory (converted to a
/// string).
fn get_asset<P: AsRef<Path>>(&self, path: P, asset: &str) -> String {
self.assets_relative_to(path)
.join(asset)
.to_string_lossy()
.to_string()
}
/// Get a relative path to the root index page.
fn root_index_relative_to<P: AsRef<Path>>(&self, path: P) -> PathBuf {
let path = path.as_ref();
diff_paths(self.root_abs_path().join("index.html"), path).expect("should diff paths")
}
/// Add a page to the tree.
///
/// Path can be an absolute path or a path relative to the root.
pub(crate) fn add_page<P: Into<PathBuf>>(&mut self, path: P, page: Rc<HTMLPage>) {
let path = path.into();
let rel_path = path.strip_prefix(self.root_abs_path()).unwrap_or(&path);
let root = self.root_mut();
let mut current_node = root;
let mut components = rel_path.components().peekable();
while let Some(component) = components.next() {
let cur_name = component.as_os_str().to_string_lossy();
if current_node.children.contains_key(cur_name.as_ref()) {
current_node = current_node
.children
.get_mut(cur_name.as_ref())
.expect("node should exist");
} else {
let new_path = current_node.path().join(component);
let new_node = Node::new(cur_name.to_string(), new_path);
current_node.children.insert(cur_name.to_string(), new_node);
current_node = current_node
.children
.get_mut(cur_name.as_ref())
.expect("node should exist");
}
if let Some(next_component) = components.peek()
&& next_component.as_os_str().to_string_lossy() == "index.html"
{
current_node.path = current_node.path().join("index.html");
break;
}
}
current_node.page = Some(page);
}
/// Get the [`Node`] associated with a path.
///
/// Path can be an absolute path or a path relative to the root.
fn get_node<P: AsRef<Path>>(&self, path: P) -> Option<&Node> {
let root = self.root();
let path = path.as_ref();
let rel_path = path.strip_prefix(self.root_abs_path()).unwrap_or(path);
let mut current_node = root;
for component in rel_path
.components()
.map(|c| c.as_os_str().to_string_lossy())
{
if component == "index.html" {
return Some(current_node);
}
if current_node.children.contains_key(component.as_ref()) {
current_node = current_node
.children
.get(component.as_ref())
.expect("node should exist");
} else {
return None;
}
}
Some(current_node)
}
/// Get the [`HTMLPage`] associated with a path.
///
/// Can be an abolute path or a path relative to the root.
fn get_page<P: AsRef<Path>>(&self, path: P) -> Option<&Rc<HTMLPage>> {
self.get_node(path).and_then(|node| node.page())
}
/// Get workflows by category.
fn get_workflows_by_category(&self) -> Vec<(String, Vec<&Node>)> {
let mut workflows_by_category = Vec::new();
let mut categories = HashSet::new();
let mut nodes = Vec::new();
for node in self.root().depth_first_traversal() {
if let Some(page) = node.page()
&& let PageType::Workflow(workflow) = page.page_type()
{
if node
.path()
.iter()
.next()
.expect("path should have a next component")
.to_string_lossy()
== "external"
{
categories.insert("External".to_string());
} else if let Some(category) = workflow.category() {
categories.insert(category);
} else {
categories.insert("Other".to_string());
}
nodes.push(node);
}
}
let sorted_categories = sort_workflow_categories(categories);
for category in sorted_categories {
let workflows = nodes
.iter()
.filter(|node| {
let page = node
.page()
.map(|p| p.page_type())
.expect("node should have a page");
if let PageType::Workflow(workflow) = page {
if node
.path()
.iter()
.next()
.expect("path should have a next component")
.to_string_lossy()
== "external"
{
return category == "External";
} else if let Some(cat) = workflow.category() {
return cat == category;
} else {
return category == "Other";
}
}
unreachable!("expected a workflow page");
})
.cloned()
.collect::<Vec<_>>();
workflows_by_category.push((category, workflows));
}
workflows_by_category
}
/// Render a left sidebar component in the "workflows view" mode given a
/// path.
///
/// Destination is expected to be an absolute path.
fn sidebar_workflows_view(&self, destination: &Path) -> Markup {
let base = destination
.parent()
.expect("destination should have a parent");
let workflows_by_category = self.get_workflows_by_category();
html! {
@for (category, workflows) in workflows_by_category {
li class="" {
div class="left-sidebar__row left-sidebar__row--unclickable" {
img src=(self.get_asset(base, "category-selected.svg")) class="left-sidebar__icon block light:hidden" alt="Category icon";
img src=(self.get_asset(base, "category-selected.light.svg")) class="left-sidebar__icon hidden light:block" alt="Category icon";
p class="text-slate-50" { (category) }
}
ul class="" {
@for node in workflows {
a href=(diff_paths(self.root_abs_path().join(node.path()), base).expect("should diff paths").to_string_lossy()) x-data=(format!(r#"{{
node: {{
current: {},
icon: '{}',
}}
}}"#,
self.root_abs_path().join(node.path()) == destination,
self.get_asset(base, if self.root_abs_path().join(node.path()) == destination {
"workflow-selected.svg"
} else {
"workflow-unselected.svg"
},
))) class="left-sidebar__row" x-bind:class="node.current ? 'bg-slate-600/50 is-scrolled-to' : 'hover:bg-slate-700/40'" {
@if let Some(page) = node.page() {
@match page.page_type() {
PageType::Workflow(wf) => {
div class="left-sidebar__indent -1" {}
div class="left-sidebar__content-item-container crop-ellipsis"{
img x-bind:src="node.icon" class="left-sidebar__icon light:hidden" alt="Workflow icon";
img x-bind:src="node.icon?.replace('.svg', '.light.svg')" class="left-sidebar__icon hidden light:block" alt="Workflow icon";
sprocket-tooltip content=(wf.render_name()) class="crop-ellipsis" x-bind:class="node.current ? 'text-slate-50' : 'group-hover:text-slate-50'" {
span {
(wf.render_name())
}
}
}
}
_ => {
p { "ERROR: Not a workflow page" }
}
}
}
}
}
}
}
}
}
}
/// Render a left sidebar component given a path.
///
/// Path is expected to be an absolute path.
// TODO: lots here can be improved
// e.g. it could be broken into smaller functions, the JS could be
// generated in a more structured way, etc.
fn render_left_sidebar<P: AsRef<Path>>(&self, path: P) -> Markup {
let root = self.root();
let path = path.as_ref();
let rel_path = path
.strip_prefix(self.root_abs_path())
.expect("path should be in root");
let base = path.parent().expect("path should have a parent");
let make_key = |path: &Path| -> String {
let path = if path.file_name().expect("path should have a file name") == "index.html" {
// Remove unnecessary index.html from the path.
// Not needed for the key.
path.parent().expect("path should have a parent")
} else {
path
};
path.to_string_lossy()
.replace("-", "_")
.replace(".", "_")
.replace(std::path::MAIN_SEPARATOR_STR, "_")
};
#[derive(Serialize)]
struct JsNode {
/// The key of the node.
key: String,
/// The display name of the node.
display_name: String,
/// The parent directory of the node.
///
/// This is used for displaying the path to the node in the sidebar.
parent: String,
/// The search name of the node.
search_name: String,
/// The icon for the node.
icon: Option<String>,
/// The href for the node.
href: Option<String>,
/// Whether the node is ancestor.
ancestor: bool,
/// Whether the node is the current page.
current: bool,
/// The nest level of the node.
nest_level: usize,
/// The children of the node.
children: Vec<String>,
}
let all_nodes = root
.depth_first_traversal()
.iter()
.skip(1) // Skip the root node
.map(|node| {
let key = make_key(node.path());
let display_name = match node.page() {
Some(page) => page.name().to_string(),
None => node.name().to_string(),
};
let parent = node
.path()
.parent()
.expect("path should have a parent")
.to_string_lossy()
.to_string();
let search_name = if node.page().is_none() {
// Page-less nodes should not be searchable
"".to_string()
} else {
node.path().to_string_lossy().to_string()
};
let href = if node.page().is_some() {
Some(
diff_paths(self.root_abs_path().join(node.path()), base)
.expect("should diff paths")
.to_string_lossy()
.to_string(),
)
} else {
None
};
let ancestor = node.part_of_path(rel_path);
let current = path == self.root_abs_path().join(node.path());
let icon = match node.page() {
Some(page) => match page.page_type() {
PageType::Task(_) => Some(self.get_asset(
base,
if ancestor {
"task-selected.svg"
} else {
"task-unselected.svg"
},
)),
PageType::Struct(_) => Some(self.get_asset(
base,
if ancestor {
"struct-selected.svg"
} else {
"struct-unselected.svg"
},
)),
PageType::Enum(_) => Some(self.get_asset(
base,
if ancestor {
"enum-selected.svg"
} else {
"enum-unselected.svg"
},
)),
PageType::Workflow(_) => Some(self.get_asset(
base,
if ancestor {
"workflow-selected.svg"
} else {
"workflow-unselected.svg"
},
)),
PageType::Index(_) => Some(self.get_asset(
base,
if ancestor {
"wdl-dir-selected.svg"
} else {
"wdl-dir-unselected.svg"
},
)),
},
None => None,
};
let nest_level = node
.path()
.components()
.filter(|c| c.as_os_str().to_string_lossy() != "index.html")
.count();
let children = node
.children()
.values()
.map(|child| make_key(child.path()))
.collect::<Vec<String>>();
JsNode {
key,
display_name,
parent,
search_name: search_name.clone(),
icon,
href,
ancestor,
current,
nest_level,
children,
}
})
.collect::<Vec<JsNode>>();
let js_dag = all_nodes
.iter()
.map(|node| {
let children = node
.children
.iter()
.map(|child| format!("'{child}'"))
.collect::<Vec<String>>()
.join(", ");
format!("'{}': [{}]", node.key, children)
})
.collect::<Vec<String>>()
.join(", ");
let all_nodes_true = all_nodes
.iter()
.map(|node| format!("'{}': true", node.key))
.collect::<Vec<String>>()
.join(", ");
let data = format!(
r#"{{
showWorkflows: $persist({}).using(sessionStorage),
dirOpen: '{}',
dirClosed: '{}',
nodes: [{}],
get shownNodes() {{
return this.nodes.filter(node => this.showSelfCache[node.key]);
}},
dag: {{{}}},
showSelfCache: $persist({{{}}}).using(sessionStorage),
showChildrenCache: $persist({{{}}}).using(sessionStorage),
children(key) {{
return this.dag[key];
}},
toggleChildren(key) {{
this.nodes.forEach(n => {{
if (n.key === key) {{
this.showChildrenCache[key] = !this.showChildrenCache[key];
this.children(key).forEach(child => {{
this.setShow(child, this.showChildrenCache[key]);
}});
}}
}});
}},
setShow(key, value) {{
this.nodes.forEach(n => {{
if (n.key === key) {{
this.showSelfCache[key] = value;
this.showChildrenCache[key] = value;
this.children(key).forEach(child => {{
this.setShow(child, value);
}});
}}
}});
}},
reset() {{
this.nodes.forEach(n => {{
this.showSelfCache[n.key] = true;
this.showChildrenCache[n.key] = true;
}});
}}
}}"#,
!self.init_on_full_directory,
self.get_asset(base, "chevron-up.svg"),
self.get_asset(base, "chevron-down.svg"),
all_nodes
.iter()
.map(|node| serde_json::to_string(node).expect("should serialize node"))
.collect::<Vec<String>>()
.join(", "),
js_dag,
all_nodes_true,
all_nodes_true,
);
html! {
div x-data=(data) x-cloak x-init="$nextTick(() => { document.querySelector('.is-scrolled-to')?.scrollIntoView({ block: 'center', behavior: 'instant' }); })" class="left-sidebar__container" {
// top navbar
div class="sticky px-4" {
div class="left-sidebar__tabs-container mt-4" {
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' : ''" {
img src=(self.get_asset(base, "list-bullet-selected.svg")) class="left-sidebar__icon block light:hidden" alt="List icon";
img src=(self.get_asset(base, "list-bullet-selected.light.svg")) class="left-sidebar__icon hidden light:block" alt="List icon";
p { "Workflows" }
}
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' : ''" {
img src=(self.get_asset(base, "folder-selected.svg")) class="left-sidebar__icon block light:hidden" alt="List icon";
img src=(self.get_asset(base, "folder-selected.light.svg")) class="left-sidebar__icon hidden light:block" alt="List icon";
p { "Full Directory" }
}
}
}
// Main content
div x-cloak class="left-sidebar__content-container pt-4" {
// Full directory view
ul x-show="! showWorkflows" class="left-sidebar__content" {
// Root node for the directory tree
sprocket-tooltip content=(root.name()) class="block" {
a href=(self.root_index_relative_to(base).to_string_lossy()) aria-label=(root.name()) class="left-sidebar__row hover:bg-slate-700/40" {
div class="left-sidebar__content-item-container crop-ellipsis" {
div class="relative shrink-0" {
img src=(self.get_asset(base, "dir-open.svg")) class="left-sidebar__icon block light:hidden" alt="Directory icon";
img src=(self.get_asset(base, "dir-open.light.svg")) class="left-sidebar__icon hidden light:block" alt="Directory icon";
}
div class="text-slate-50" { (root.name()) }
}
}
}
// Nodes in the directory tree
template x-for="node in shownNodes" {
sprocket-tooltip x-bind:content="node.display_name" class="block isolate" {
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' : ''}`" {
template x-for="i in Array.from({ length: node.nest_level })" {
div class="left-sidebar__indent -z-1" {}
}
div class="left-sidebar__content-item-container crop-ellipsis" {
div class="relative left-sidebar__icon shrink-0" {
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' : ''}`";
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' : ''}`";
}
div class="crop-ellipsis" x-text="node.display_name" {
}
}
}
}
}
}
// Workflows view
ul x-show="showWorkflows" class="left-sidebar__content" {
(self.sidebar_workflows_view(path))
}
}
}
}
}
/// Render a right sidebar component.
fn render_right_sidebar(&self, headers: PageSections) -> Markup {
html! {
div class="right-sidebar__container" {
div class="right-sidebar__header" {
"ON THIS PAGE"
}
(headers.render())
div class="right-sidebar__back-to-top-container" {
// TODO: this should be a link to the top of the page, not just a link to the title
a href="#title" class="right-sidebar__back-to-top" {
span class="right-sidebar__back-to-top-icon" {
"↑"
}
span class="right-sidebar__back-to-top-text" {
"Back to top"
}
}
}
}
}
}
/// Renders a page "breadcrumb" navigation component.
///
/// Path is expected to be an absolute path.
fn render_breadcrumbs<P: AsRef<Path>>(&self, path: P) -> Markup {
let path = path.as_ref();
let base = path.parent().expect("path should have a parent");
let mut current_path = path
.strip_prefix(self.root_abs_path())
.expect("path should be in the docs directory");
let mut breadcrumbs = vec![];
let cur_page = self.get_page(path).expect("path should have a page");
match cur_page.page_type() {
PageType::Index(_) => {
// Index pages are handled by the below while loop
}
_ => {
// Last crumb, i.e. the current page, should not be clickable
breadcrumbs.push((cur_page.name(), None));
}
}
while let Some(parent) = current_path.parent() {
let cur_node = self.get_node(parent).expect("path should have a node");
if let Some(page) = cur_node.page() {
breadcrumbs.push((
page.name(),
if self.root_abs_path().join(cur_node.path()) == path {
// Don't insert a link to the current page.
// This happens on index pages.
None
} else {
Some(
diff_paths(self.root_abs_path().join(cur_node.path()), base)
.expect("should diff paths"),
)
},
));
} else if cur_node.name() == self.root().name() {
breadcrumbs.push((cur_node.name(), Some(self.root_index_relative_to(base))))
} else {
breadcrumbs.push((cur_node.name(), None));
}
current_path = parent;
}
breadcrumbs.reverse();
let mut breadcrumbs = breadcrumbs.into_iter();
let root_crumb = breadcrumbs
.next()
.expect("should have at least one breadcrumb");
let root_crumb = html! {
a class="layout__breadcrumb-clickable" href=(root_crumb.1.expect("root crumb should have path").to_string_lossy()) { (root_crumb.0) }
};
html! {
div class="layout__breadcrumb-container" data-pagefind-ignore="all" {
(root_crumb)
@for crumb in breadcrumbs {
span { " / " }
@if let Some(path) = crumb.1 {
a href=(path.to_string_lossy()) class="layout__breadcrumb-clickable" { (crumb.0) }
} @else {
span class="layout__breadcrumb-inactive" { (crumb.0) }
}
}
}
}
}
/// Render every page in the tree.
pub fn render_all(&self) -> DocResult<()> {
let root = self.root();
for node in root.depth_first_traversal() {
if let Some(page) = node.page() {
self.write_page(page.as_ref(), self.root_abs_path().join(node.path()))
.with_context(|| {
format!("failed to write page at `{}`", node.path().display())
})?;
}
}
self.write_homepage()?;
Ok(())
}
/// Write the homepage to disk.
fn write_homepage(&self) -> DocResult<()> {
let index_path = self.root_abs_path().join("index.html");
let left_sidebar = self.render_left_sidebar(&index_path);
let content = html! {
@if let Some(homepage) = &self.homepage {
div class="main__section" {
div
class="markdown-body"
data-pagefind-body
meta-img-dark="home.svg"
meta-img-light="home.light.svg"
data-pagefind-meta="image_dark[meta-img-dark], image_light[meta-img-light]"
{
(Markdown(std::fs::read_to_string(homepage).map_err(Into::<DocError>::into).with_context(|| {
format!("failed to read provided homepage file: `{}`", homepage.display())
})?).render())
}
}
} @else {
div class="main__section--empty" {
img src=(self.get_asset(self.root_abs_path(), "missing-home.svg")) class="size-12 block light:hidden" alt="Missing home icon";
img src=(self.get_asset(self.root_abs_path(), "missing-home.light.svg")) class="size-12 hidden light:block" alt="Missing home icon";
h2 class="main__section-header" { "There's nothing to see on this page" }
p { "The markdown file for this page wasn't supplied." }
}
}
};
let homepage_content = html! {
h5 class="main__homepage-header" {
"Home"
}
(content)
};
let html = full_page(
"Home",
self.render_layout(
left_sidebar,
homepage_content,
self.render_right_sidebar(PageSections::default()),
None,
&self.assets_relative_to(self.root_abs_path()),
&index_path,
),
self.root().path(),
&self.additional_javascript,
self.init_light_mode,
);
std::fs::write(&index_path, html.into_string())
.map_err(Into::<DocError>::into)
.with_context(|| format!("failed to write homepage to `{}`", index_path.display()))?;
Ok(())
}
/// Render reusable sidebar control buttons
fn render_sidebar_control_buttons(&self, assets: &Path) -> Markup {
html! {
button
x-on:click="collapseSidebar()"
x-bind:disabled="sidebarState === 'hidden'"
x-bind:class="getSidebarButtonClass('hidden')" {
img src=(assets.join("sidebar-icon-hide.svg").to_string_lossy()) alt="" class="block light:hidden" {}
img src=(assets.join("sidebar-icon-hide.light.svg").to_string_lossy()) alt="" class="hidden light:block" {}
}
button
x-on:click="restoreSidebar()"
x-bind:disabled="sidebarState === 'normal'"
x-bind:class="getSidebarButtonClass('normal')" {
img src=(assets.join("sidebar-icon-default.svg").to_string_lossy()) alt="" class="block light:hidden" {}
img src=(assets.join("sidebar-icon-default.light.svg").to_string_lossy()) alt="" class="hidden light:block" {}
}
button
x-on:click="expandSidebar()"
x-bind:disabled="sidebarState === 'xl'"
x-bind:class="getSidebarButtonClass('xl')" {
img src=(assets.join("sidebar-icon-expand.svg").to_string_lossy()) alt="" class="block light:hidden" {}
img src=(assets.join("sidebar-icon-expand.light.svg").to_string_lossy()) alt="" class="hidden light:block" {}
}
}
}
/// Render the header nav.
fn render_header(&self, assets: &Path, path: &Path) -> Markup {
fn external_urls(assets: &Path, external_urls: &ExternalUrls) -> Markup {
fn button(assets: &Path, url: &Url, icon_name: &str) -> Markup {
html! {
a class="header__button" target="_blank" rel="noopener noreferrer" href=(url) {
img src=(assets.join(format!("{icon_name}.svg")).to_string_lossy()) class="size-6 block light:hidden" alt=(format!("{icon_name} icon"));
img src=(assets.join(format!("{icon_name}.light.svg")).to_string_lossy()) class="size-6 hidden light:block" alt=(format!("{icon_name} icon"));
}
}
}
let ExternalUrls { github, homepage } = external_urls;
if github.is_none() && homepage.is_none() {
return html! {};
}
html! {
div class="flex flex-row pr-6" {
div class="w-px h-[80%] border-l border-slate-800 self-center pr-6" {}
div class="flex flex-row gap-4 items-center" {
@if let Some(github) = github {
(button(assets, github, "github"))
}
@if let Some(homepage) = homepage {
(button(assets, homepage, "link-chain"))
}
}
}
}
}
let base = path.parent().expect("path should have a parent");
html! {
div
class="layout__header"
"@keydown.window.slash"="
if (!['INPUT', 'TEXTAREA'].includes($event.target.tagName)) {
$event.preventDefault();
$refs.searchBox.focus();
}"
{
div class="w-full grid grid-cols-3 items-center h-12 px-6" {
a href=(self.root_index_relative_to(base).to_string_lossy()) {
img src=(self.get_asset(base, LOGO_FILE_NAME)) class="w-[120px] flex-none block light:hidden" alt="Logo";
img src=(self.get_asset(base, LIGHT_LOGO_FILE_NAME)) class="w-[120px] flex-none hidden light:block" alt="Logo";
}
div id="search" class="w-sm h-10 absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" {
input id="searchbox" "x-ref"="searchBox" "x-model.debounce"="$store.search.query" type="text" placeholder="Search...";
img src=(assets.join("search.svg").to_string_lossy()) class="absolute left-2 top-1/2 -translate-y-1/2 size-6 pointer-events-none block light:hidden" alt="Search icon";
img src=(assets.join("search.light.svg").to_string_lossy()) class="absolute left-2 top-1/2 -translate-y-1/2 size-6 pointer-events-none hidden light:block" alt="Search icon";
img src=(assets.join("x-mark.svg").to_string_lossy()) class="absolute right-2 top-1/2 -translate-y-1/2 size-6 hover:cursor-pointer block light:hidden" alt="Clear icon" x-show="$store.search.query !== ''" x-on:click="$store.search.query = ''";
img src=(assets.join("x-mark.light.svg").to_string_lossy()) class="absolute right-2 top-1/2 -translate-y-1/2 size-6 hover:cursor-pointer hidden light:block" alt="Clear icon" x-show="$store.search.query !== ''" x-on:click="$store.search.query = ''";
div id="search-shortcut-hint" x-show="$store.search.query === ''" { "/" }
}
div class="flex flex-row-reverse items-start justify-between col-start-3 justify-self-end" x-data="{ showTooltip: false }" {
div class="relative" {
button
x-on:click="
document.documentElement.classList.toggle('light')
theme = document.documentElement.classList.contains('light') ? 'light' : 'dark'
"
"@mouseenter"="showTooltip = true"
"@mouseleave"="showTooltip = false"
"@focusin"="showTooltip = true"
"@focusout"="showTooltip = false"
id="theme-toggle"
class="header__button" {
img src=(assets.join("moon.light.svg").to_string_lossy()) class="size-6 hidden light:block";
img src=(assets.join("sun.svg").to_string_lossy()) class="size-6 block light:hidden";
}
div class="absolute top-full flex flex-col items-center left-1/2 -translate-x-1/2 mt-2" x-show="showTooltip" {
div class="w-3 h-3 -mb-2 rotate-45 bg-slate-800" {}
div class="relative z-10 px-3 py-2 text-sm text-slate-200 bg-slate-800 rounded-md shadow-lg whitespace-nowrap" {
"Switch theme"
}
}
}
}
}
(external_urls(assets, &self.external_urls))
}
}
}
/// Render the main layout template with left sidebar, content, and right
/// sidebar.
fn render_layout(
&self,
left_sidebar: Markup,
content: Markup,
right_sidebar: Markup,
breadcrumbs: Option<Markup>,
assets: &Path,
path: &Path,
) -> Markup {
html! {
div class="layout__container layout__container--alt-layout" x-transition x-data="{
sidebarState: $persist(window.innerWidth < 768 ? 'hidden' : 'normal').using(sessionStorage),
get showSidebarButtons() { return this.sidebarState !== 'hidden'; },
get showCenterButtons() { return this.sidebarState === 'hidden'; },
get containerClasses() {
const base = 'layout__container layout__container--alt-layout';
switch(this.sidebarState) {
case 'hidden': return base + ' layout__container--left-hidden';
case 'xl': return base + ' layout__container--left-xl';
default: return base;
}
},
getSidebarButtonClass(state) {
return 'left-sidebar__size-button ' + (this.sidebarState === state ? 'left-sidebar__size-button--active' : '');
},
collapseSidebar() { this.sidebarState = 'hidden'; },
restoreSidebar() { this.sidebarState = 'normal'; },
expandSidebar() { this.sidebarState = 'xl'; }
}" x-bind:class="containerClasses" {
(self.render_header(assets, path))
div class="layout__sidebar-left" x-transition {
(left_sidebar)
}
div class="layout__main-center" {
div class="layout__main-center-content" {
@if let Some(breadcrumbs) = breadcrumbs {
div class="layout__breadcrumbs" {
(breadcrumbs)
}
}
div class="flex flex-col gap-5" x-show="$store.search.query !== ''" x-data {
h2 class="text-base leading-6 font-medium" x-text="`${$store.search.results.length} results for '${$store.search.query}'`" {}
div x-show="$store.search.loading" { "Loading..." }
ul class="flex flex-col gap-5" {
template x-for="result in $store.search.results" ":key"="result.url" {
li class="search-result" {
div class="flex flex-row gap-2 items-center" {
@let assets_str = assets.to_string_lossy().replace('\\', "/");
img
class="size-6"
x-bind:src=(format!("theme === 'dark' ? `{assets_str}/${{result.meta.image_dark}}` : `{assets_str}/${{result.meta.image_light}}`"))
x-bind:alt="result.meta.image_alt || result.meta.title";
a
":href"="result.url"
class="text-2xl leading-8 text-slate-50 font-medium"
x-text="result.meta.title"
{}
}
p class="search-result-excerpt" x-html="result.excerpt" {}
}
}
div x-show="!$store.search.loading && $store.search.results.length === 0" {
// No results found icon
li class="flex place-content-center" {
img src=(assets.join("search.svg").to_string_lossy()) class="size-8 block light:hidden" alt="Search icon";
img src=(assets.join("search.light.svg").to_string_lossy()) class="size-8 hidden light:block" alt="Search icon";
}
// No results found message
li class="flex gap-1 place-content-center text-center break-words whitespace-normal text-sm text-slate-500" {
span x-text="'No results found for'" {}
span x-text="`\"${$store.search.query}\"`" class="text-slate-50" {}
}
}
}
}
div {
div class="flex gap-1 mb-3" x-show="showCenterButtons" {
(self.render_sidebar_control_buttons(assets))
}
}
div x-show="$store.search.query === ''" {
(content)
}
}
}
div class="layout__sidebar-right" {
(right_sidebar)
}
}
}
}
/// Write a page to disk at the designated path.
///
/// Path is expected to be an absolute path.
fn write_page<P: Into<PathBuf>>(&self, page: &HTMLPage, path: P) -> DocResult<()> {
let path = path.into();
let base = path.parent().expect("path should have a parent");
let (content, headers) = match page.page_type() {
PageType::Index(doc) => doc.render(),
PageType::Struct(s) => s.render(&self.assets_relative_to(base)),
PageType::Enum(e) => e.render(&self.assets_relative_to(base)),
PageType::Task(t) => t.render(&self.assets_relative_to(base)),
PageType::Workflow(w) => w.render(&self.assets_relative_to(base)),
};
let breadcrumbs = self.render_breadcrumbs(&path);
let left_sidebar = self.render_left_sidebar(&path);
let html = full_page(
page.name(),
self.render_layout(
left_sidebar,
content,
self.render_right_sidebar(headers),
Some(breadcrumbs),
&self.assets_relative_to(base),
&path,
),
self.root_relative_to(base),
&self.additional_javascript,
self.init_light_mode,
);
std::fs::write(&path, html.into_string())
.map_err(Into::<DocError>::into)
.with_context(|| format!("failed to write page at `{}`", path.display()))?;
Ok(())
}
}
/// Sort workflow categories in a specific order.
fn sort_workflow_categories(categories: HashSet<String>) -> Vec<String> {
let mut sorted_categories: Vec<String> = categories.into_iter().collect();
sorted_categories.sort_by(|a, b| {
if a == b {
std::cmp::Ordering::Equal
} else if a == "External" {
std::cmp::Ordering::Greater
} else if b == "External" {
std::cmp::Ordering::Less
} else if a == "Other" {
std::cmp::Ordering::Greater
} else if b == "Other" {
std::cmp::Ordering::Less
} else {
a.cmp(b)
}
});
sorted_categories
}