1#![warn(missing_docs)]
4#![warn(rust_2018_idioms)]
5#![warn(rust_2021_compatibility)]
6#![warn(missing_debug_implementations)]
7#![warn(clippy::missing_docs_in_private_items)]
8#![warn(rustdoc::broken_intra_doc_links)]
9
10include!(concat!(env!("OUT_DIR"), "/assets.rs"));
11
12mod command_section;
13mod docs_tree;
14mod document;
15mod meta;
16mod parameter;
17mod runnable;
18mod r#struct;
19
20use std::path::Component;
21use std::path::Path;
22use std::path::PathBuf;
23use std::path::absolute;
24use std::rc::Rc;
25
26use anyhow::Context;
27use anyhow::Result;
28use anyhow::anyhow;
29use anyhow::bail;
30pub use command_section::CommandSectionExt;
31pub use docs_tree::DocsTree;
32pub use docs_tree::DocsTreeBuilder;
33use docs_tree::HTMLPage;
34use docs_tree::PageType;
35use document::Document;
36pub use document::parse_preamble_comments;
37use maud::DOCTYPE;
38use maud::Markup;
39use maud::PreEscaped;
40use maud::Render;
41use maud::html;
42use path_clean::PathClean;
43use pathdiff::diff_paths;
44use pulldown_cmark::Options;
45use pulldown_cmark::Parser;
46use runnable::task;
47use runnable::workflow;
48use wdl_analysis::Analyzer;
49use wdl_analysis::Config as AnalysisConfig;
50use wdl_ast::AstToken;
51use wdl_ast::SupportedVersion;
52use wdl_ast::v1::DocumentItem;
53use wdl_ast::version::V1;
54
55const PREFER_FULL_DIRECTORY: bool = true;
58
59pub fn install_theme(theme_dir: &Path) -> Result<()> {
61 let theme_dir = absolute(theme_dir)?;
62 if !theme_dir.exists() {
63 bail!("theme directory does not exist: {}", theme_dir.display());
64 }
65 let output = std::process::Command::new("npm")
66 .arg("install")
67 .current_dir(&theme_dir)
68 .output()
69 .with_context(|| {
70 format!(
71 "failed to run `npm install` in the theme directory: `{}`",
72 theme_dir.display()
73 )
74 })?;
75 if !output.status.success() {
76 bail!(
77 "failed to install theme dependencies using `npm install`: {stderr}",
78 stderr = String::from_utf8_lossy(&output.stderr)
79 );
80 }
81 Ok(())
82}
83
84pub fn build_web_components(theme_dir: &Path) -> Result<()> {
86 let theme_dir = absolute(theme_dir)?;
87 let output = std::process::Command::new("npm")
88 .arg("run")
89 .arg("build")
90 .current_dir(&theme_dir)
91 .output()
92 .with_context(|| {
93 format!(
94 "failed to execute `npm run build` in the theme directory: `{}`",
95 theme_dir.display()
96 )
97 })?;
98 if !output.status.success() {
99 bail!(
100 "failed to build web components using `npm run build`: {stderr}",
101 stderr = String::from_utf8_lossy(&output.stderr)
102 );
103 }
104 Ok(())
105}
106
107pub fn build_stylesheet(theme_dir: &Path) -> Result<()> {
109 let theme_dir = absolute(theme_dir)?;
110 let output = std::process::Command::new("npx")
111 .arg("@tailwindcss/cli")
112 .arg("-i")
113 .arg("src/main.css")
114 .arg("-o")
115 .arg("dist/style.css")
116 .current_dir(&theme_dir)
117 .output()?;
118 if !output.status.success() {
119 bail!(
120 "failed to build stylesheet using `npx @tailwindcss/cli`: {stderr}",
121 stderr = String::from_utf8_lossy(&output.stderr)
122 );
123 }
124 let css_path = theme_dir.join("dist/style.css");
125 if !css_path.exists() {
126 bail!(
127 "failed to build stylesheet using `npx @tailwindcss/cli`: no output file found at `{}`",
128 css_path.display()
129 );
130 }
131
132 Ok(())
133}
134
135struct Css<'a>(&'a str);
137
138impl Render for Css<'_> {
139 fn render(&self) -> Markup {
140 html! {
141 link rel="stylesheet" type="text/css" href=(self.0);
142 }
143 }
144}
145
146pub(crate) fn header<P: AsRef<Path>>(
152 page_title: &str,
153 root: P,
154 script: &AdditionalScript,
155) -> Markup {
156 let root = root.as_ref();
157 html! {
158 head {
159 @match script {
160 AdditionalScript::HeadOpen(s) => script { (PreEscaped(s)) }
161 _ => {}
162 }
163 meta charset="utf-8";
164 meta name="viewport" content="width=device-width, initial-scale=1.0";
165 title { (page_title) }
166 link rel="preconnect" href="https://fonts.googleapis.com";
167 link rel="preconnect" href="https://fonts.gstatic.com" crossorigin;
168 link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap" rel="stylesheet";
169 script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/persist@3.x.x/dist/cdn.min.js" {}
170 script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" {}
171 script defer src=(root.join("index.js").to_string_lossy()) {}
172 (Css(&root.join("style.css").to_string_lossy()))
173 @match script {
174 AdditionalScript::HeadClose(s) => script { (PreEscaped(s)) }
175 _ => {}
176 }
177 }
178 }
179}
180
181pub(crate) fn full_page<P: AsRef<Path>>(
184 page_title: &str,
185 body: Markup,
186 root: P,
187 script: &AdditionalScript,
188 init_light_mode: bool,
189) -> Markup {
190 html! {
191 (DOCTYPE)
192 html x-data=(if init_light_mode { "{ DEFAULT_THEME: 'light' }" } else { "{ DEFAULT_THEME: '' }" }) x-bind:class="(localStorage.getItem('theme') ?? DEFAULT_THEME) === 'light' ? 'light' : ''" x-cloak {
193 (header(page_title, root, script))
194 body class="body--base" {
195 @match script {
196 AdditionalScript::BodyOpen(s) => script { (PreEscaped(s)) }
197 _ => {}
198 }
199 (body)
200 @match script {
201 AdditionalScript::BodyClose(s) => script { (PreEscaped(s)) }
202 _ => {}
203 }
204 }
205 }
206 }
207}
208
209pub(crate) struct Markdown<T>(T);
211
212impl<T: AsRef<str>> Render for Markdown<T> {
213 fn render(&self) -> Markup {
214 let mut unsafe_html = String::new();
216 let mut options = Options::empty();
217 options.insert(Options::ENABLE_TABLES);
218 options.insert(Options::ENABLE_STRIKETHROUGH);
219 options.insert(Options::ENABLE_GFM);
220 options.insert(Options::ENABLE_DEFINITION_LIST);
221 let parser = Parser::new_ext(self.0.as_ref(), options);
222 pulldown_cmark::html::push_html(&mut unsafe_html, parser);
223 let safe_html = ammonia::clean(&unsafe_html);
225
226 let safe_html = if safe_html.starts_with("<p>") && safe_html.ends_with("</p>\n") {
228 let trimmed = &safe_html[3..safe_html.len() - 5];
229 if trimmed.contains("<p>") {
230 safe_html
234 } else {
235 trimmed.to_string()
236 }
237 } else {
238 safe_html
239 };
240 PreEscaped(safe_html)
241 }
242}
243
244#[derive(Debug, Clone)]
247pub(crate) struct VersionBadge {
248 version: SupportedVersion,
250}
251
252impl VersionBadge {
253 fn new(version: SupportedVersion) -> Self {
255 Self { version }
256 }
257
258 fn render(&self) -> Markup {
260 let latest = match &self.version {
261 SupportedVersion::V1(v) => matches!(v, V1::Two),
262 _ => unreachable!("only V1 is supported"),
263 };
264 let text = self.version.to_string();
265 html! {
266 div class="main__badge" {
267 span class="main__badge-text" {
268 "WDL Version"
269 }
270 div class="main__badge-inner" {
271 span class="main__badge-inner-text" {
272 (text)
273 }
274 }
275 @if latest {
276 div class="main__badge-inner main__badge-inner-latest" {
277 span class="main__badge-inner-text" {
278 "Latest"
279 }
280 }
281 }
282 }
283 }
284 }
285}
286
287async fn analyze_workspace(
292 workspace_root: impl AsRef<Path>,
293 config: AnalysisConfig,
294) -> Result<Vec<wdl_analysis::AnalysisResult>> {
295 let workspace = workspace_root.as_ref();
296 let analyzer = Analyzer::new(config, async |_, _, _, _| ());
297 analyzer
298 .add_directory(workspace)
299 .await
300 .with_context(|| "failed to add directory to analyzer".to_string())?;
301 let results = analyzer
302 .analyze(())
303 .await
304 .with_context(|| "failed to analyze workspace".to_string())?;
305
306 if results.is_empty() {
307 return Err(anyhow!("no WDL documents found in analysis",));
308 }
309 let mut workspace_in_results = false;
310 for r in &results {
311 if let Some(e) = r.error() {
312 return Err(anyhow!(
313 "failed to analyze WDL document `{}`: {}",
314 r.document().uri(),
315 e,
316 ));
317 }
318 if r.document().version().is_none() {
319 return Err(anyhow!(
320 "WDL document `{}` does not have a supported version",
321 r.document().uri()
322 ));
323 }
324 if r.document()
325 .parse_diagnostics()
326 .iter()
327 .any(|d| d.severity() == wdl_ast::Severity::Error)
328 {
329 return Err(anyhow!(
330 "WDL document `{}` has parse errors",
331 r.document().uri(),
332 ));
333 }
334
335 if r.document()
336 .uri()
337 .to_file_path()
338 .is_ok_and(|f| f.starts_with(workspace))
339 {
340 workspace_in_results = true;
341 }
342 }
343
344 if !workspace_in_results {
345 return Err(anyhow!(
346 "workspace root `{root}` not found in analysis results",
347 root = workspace.display(),
348 ));
349 }
350
351 Ok(results)
352}
353
354#[derive(Debug)]
357pub enum AdditionalScript {
358 HeadOpen(String),
360 HeadClose(String),
362 BodyOpen(String),
364 BodyClose(String),
366 None,
368}
369
370#[derive(Debug)]
372pub struct Config {
373 analysis_config: AnalysisConfig,
375 workspace: PathBuf,
377 output_dir: PathBuf,
379 homepage: Option<PathBuf>,
381 init_light_mode: bool,
383 custom_theme: Option<PathBuf>,
385 custom_logo: Option<PathBuf>,
387 alt_logo: Option<PathBuf>,
390 additional_javascript: AdditionalScript,
392 init_on_full_directory: bool,
395}
396
397impl Config {
398 pub fn new(
400 analysis_config: AnalysisConfig,
401 workspace: impl Into<PathBuf>,
402 output_dir: impl Into<PathBuf>,
403 ) -> Self {
404 Self {
405 analysis_config,
406 workspace: workspace.into(),
407 output_dir: output_dir.into(),
408 homepage: None,
409 init_light_mode: false,
410 custom_theme: None,
411 custom_logo: None,
412 alt_logo: None,
413 additional_javascript: AdditionalScript::None,
414 init_on_full_directory: PREFER_FULL_DIRECTORY,
415 }
416 }
417
418 pub fn homepage(mut self, homepage: Option<PathBuf>) -> Self {
420 self.homepage = homepage;
421 self
422 }
423
424 pub fn init_light_mode(mut self, init_light_mode: bool) -> Self {
426 self.init_light_mode = init_light_mode;
427 self
428 }
429
430 pub fn custom_theme(mut self, custom_theme: Option<PathBuf>) -> Self {
432 self.custom_theme = custom_theme;
433 self
434 }
435
436 pub fn custom_logo(mut self, custom_logo: Option<PathBuf>) -> Self {
438 self.custom_logo = custom_logo;
439 self
440 }
441
442 pub fn alt_logo(mut self, alt_logo: Option<PathBuf>) -> Self {
444 self.alt_logo = alt_logo;
445 self
446 }
447
448 pub fn additional_javascript(mut self, additional_javascript: AdditionalScript) -> Self {
450 self.additional_javascript = additional_javascript;
451 self
452 }
453
454 pub fn prefer_full_directory(mut self, prefer_full_directory: bool) -> Self {
456 self.init_on_full_directory = prefer_full_directory;
457 self
458 }
459}
460
461pub async fn document_workspace(config: Config) -> Result<()> {
468 let workspace_abs_path = absolute(&config.workspace)
469 .with_context(|| {
470 format!(
471 "failed to resolve absolute path for workspace: `{}`",
472 config.workspace.display()
473 )
474 })?
475 .clean();
476 let homepage = config.homepage.and_then(|p| absolute(p).ok());
477
478 if !workspace_abs_path.is_dir() {
479 bail!(
480 "workspace path `{}` is not a directory",
481 workspace_abs_path.display()
482 );
483 }
484
485 let docs_dir = absolute(&config.output_dir)
486 .with_context(|| {
487 format!(
488 "failed to resolve absolute path for output directory: `{}`",
489 config.output_dir.display()
490 )
491 })?
492 .clean();
493 if !docs_dir.exists() {
494 std::fs::create_dir(&docs_dir).with_context(|| {
495 format!(
496 "failed to create output directory: `{}`",
497 docs_dir.display()
498 )
499 })?;
500 }
501
502 let results = analyze_workspace(&workspace_abs_path, config.analysis_config)
503 .await
504 .with_context(|| {
505 format!(
506 "workspace `{}` has errors and cannot be documented",
507 workspace_abs_path.display()
508 )
509 })?;
510
511 let mut docs_tree = DocsTreeBuilder::new(docs_dir.clone())
512 .maybe_homepage(homepage)
513 .init_light_mode(config.init_light_mode)
514 .maybe_custom_theme(config.custom_theme)?
515 .maybe_logo(config.custom_logo)
516 .maybe_alt_logo(config.alt_logo)
517 .additional_javascript(config.additional_javascript)
518 .prefer_full_directory(config.init_on_full_directory)
519 .build()
520 .with_context(|| "failed to build documentation tree with provided paths".to_string())?;
521
522 for result in results {
523 let uri = result.document().uri();
524 let (root_to_wdl, external_wdl) = match uri.to_file_path() {
525 Ok(path) => match path.strip_prefix(&workspace_abs_path) {
526 Ok(path) => {
527 (path.to_path_buf(), false)
529 }
530 Err(_) => {
531 let external = PathBuf::from("external").join(
535 path.components()
536 .skip_while(|c| !matches!(c, Component::Normal(_)))
537 .collect::<PathBuf>(),
538 );
539 (external, true)
540 }
541 },
542 Err(_) => (
543 PathBuf::from("external").join(
546 uri.path()
547 .strip_prefix("/")
548 .expect("URI path should start with /"),
549 ),
550 true,
551 ),
552 };
553 let cur_dir = docs_dir.join(root_to_wdl.with_extension(""));
554 if !cur_dir.exists() {
555 std::fs::create_dir_all(&cur_dir)
556 .with_context(|| format!("failed to create directory: `{}`", cur_dir.display()))?;
557 }
558 let version = result
559 .document()
560 .version()
561 .expect("document should have a supported version");
562 let ast = result.document().root();
563 let version_statement = ast
564 .version_statement()
565 .expect("document should have a version statement");
566 let ast = ast.ast().unwrap_v1();
567
568 let mut local_pages = Vec::new();
569
570 for item in ast.items() {
571 match item {
572 DocumentItem::Struct(s) => {
573 let name = s.name().text().to_owned();
574 let path = cur_dir.join(format!("{name}-struct.html"));
575
576 let r#struct = r#struct::Struct::new(s.clone(), version);
578
579 let page = Rc::new(HTMLPage::new(name.clone(), PageType::Struct(r#struct)));
580 docs_tree.add_page(path.clone(), page.clone());
581 local_pages
582 .push((diff_paths(path, &cur_dir).expect("should diff paths"), page));
583 }
584 DocumentItem::Task(t) => {
585 let name = t.name().text().to_owned();
586 let path = cur_dir.join(format!("{name}-task.html"));
587
588 let task = task::Task::new(
589 name.clone(),
590 version,
591 t,
592 if external_wdl {
593 None
594 } else {
595 Some(root_to_wdl.clone())
596 },
597 );
598
599 let page = Rc::new(HTMLPage::new(name, PageType::Task(task)));
600 docs_tree.add_page(path.clone(), page.clone());
601 local_pages
602 .push((diff_paths(path, &cur_dir).expect("should diff paths"), page));
603 }
604 DocumentItem::Workflow(w) => {
605 let name = w.name().text().to_owned();
606 let path = cur_dir.join(format!("{name}-workflow.html"));
607
608 let workflow = workflow::Workflow::new(
609 name.clone(),
610 version,
611 w,
612 if external_wdl {
613 None
614 } else {
615 Some(root_to_wdl.clone())
616 },
617 );
618
619 let page = Rc::new(HTMLPage::new(
620 workflow.name_override().unwrap_or(name),
621 PageType::Workflow(workflow),
622 ));
623 docs_tree.add_page(path.clone(), page.clone());
624 local_pages
625 .push((diff_paths(path, &cur_dir).expect("should diff paths"), page));
626 }
627 DocumentItem::Import(_) => {}
628 }
629 }
630 let document_name = root_to_wdl
631 .file_stem()
632 .ok_or(anyhow!(
633 "failed to get file stem for WDL file: `{}`",
634 root_to_wdl.display()
635 ))?
636 .to_string_lossy();
637 let document = Document::new(
638 document_name.to_string(),
639 version,
640 version_statement,
641 local_pages,
642 );
643
644 let index_path = cur_dir.join("index.html");
645
646 docs_tree.add_page(
647 index_path,
648 Rc::new(HTMLPage::new(
649 document_name.to_string(),
650 PageType::Index(document),
651 )),
652 );
653 }
654
655 docs_tree.render_all().with_context(|| {
656 format!(
657 "failed to write documentation to output directory: `{}`",
658 docs_dir.display()
659 )
660 })?;
661
662 Ok(())
663}
664
665#[cfg(test)]
666mod tests {
667 use wdl_ast::Document as AstDocument;
668
669 use super::*;
670 use crate::runnable::Runnable;
671
672 #[test]
673 fn test_parse_preamble_comments() {
674 let source = r#"
675 ## This is a comment
676 ## This is also a comment
677 version 1.0
678 workflow test {
679 input {
680 String name
681 }
682 output {
683 String greeting = "Hello, ${name}!"
684 }
685 call say_hello as say_hello {
686 input:
687 name = name
688 }
689 }
690 "#;
691 let (document, _) = AstDocument::parse(source);
692 let preamble = parse_preamble_comments(&document.version_statement().unwrap());
693 assert_eq!(preamble, "This is a comment\nThis is also a comment");
694 }
695
696 #[test]
697 fn test_markdown_render() {
698 let source = r#"
699 ## This is a paragraph.
700 ##
701 ## This is the start of a new paragraph.
702 ## And this is the same paragraph continued.
703 version 1.0
704 workflow test {
705 meta {
706 description: "A simple description should not render with p tags"
707 }
708 }
709 "#;
710 let (document, _) = AstDocument::parse(source);
711 let preamble = parse_preamble_comments(&document.version_statement().unwrap());
712 let markdown = Markdown(&preamble).render();
713 assert_eq!(
714 markdown.into_string(),
715 "<p>This is a paragraph.</p>\n<p>This is the start of a new paragraph.\nAnd this is \
716 the same paragraph continued.</p>\n"
717 );
718
719 let doc_item = document.ast().into_v1().unwrap().items().next().unwrap();
720 let ast_workflow = doc_item.into_workflow_definition().unwrap();
721 let workflow = workflow::Workflow::new(
722 ast_workflow.name().text().to_string(),
723 SupportedVersion::V1(V1::Zero),
724 ast_workflow,
725 None,
726 );
727
728 let description = workflow.render_description(false);
729 assert_eq!(
730 description.into_string(),
731 "A simple description should not render with p tags"
732 );
733 }
734}