wdl_doc/
lib.rs

1//! Library for generating HTML documentation from WDL files.
2
3#![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
55/// Start on the "Full Directory" left sidebar view instead of the
56/// "Workflows" view.
57const PREFER_FULL_DIRECTORY: bool = true;
58
59/// Install the theme dependencies using npm.
60pub 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
84/// Build the web components for the theme.
85pub 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
107/// Build a stylesheet for the documentation, using Tailwind CSS.
108pub 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
135/// HTML link to a CSS stylesheet at the given path.
136struct 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
146/// An HTML header with a `page_title` and all the link/script dependencies
147/// expected by `wdl-doc`.
148///
149/// Requires a relative path to the root where `style.css` and `index.js` files
150/// are expected.
151pub(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
181/// Returns a full HTML page, including the `DOCTYPE`, `html`, `head`, and
182/// `body` tags,
183pub(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
209/// Renders a block of Markdown using `pulldown-cmark`.
210pub(crate) struct Markdown<T>(T);
211
212impl<T: AsRef<str>> Render for Markdown<T> {
213    fn render(&self) -> Markup {
214        // Generate raw HTML
215        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        // Sanitize it with ammonia
224        let safe_html = ammonia::clean(&unsafe_html);
225
226        // Remove the outer `<p>` tag that `pulldown_cmark` wraps single lines in
227        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                // If the trimmed string contains another `<p>` tag, it means
231                // that the original string was more complicated than a single-line paragraph,
232                // so we should keep the outer `<p>` tag.
233                safe_html
234            } else {
235                trimmed.to_string()
236            }
237        } else {
238            safe_html
239        };
240        PreEscaped(safe_html)
241    }
242}
243
244/// A version badge for a WDL document. This is used to display the WDL
245/// version at the top of each documentation page.
246#[derive(Debug, Clone)]
247pub(crate) struct VersionBadge {
248    /// The WDL version of the document.
249    version: SupportedVersion,
250}
251
252impl VersionBadge {
253    /// Create a new version badge.
254    fn new(version: SupportedVersion) -> Self {
255        Self { version }
256    }
257
258    /// Render the version badge as HTML.
259    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
287/// Analyze a workspace directory, ensure it is error-free, and return the
288/// results.
289///
290/// `workspace_root` should be an absolute path.
291async 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/// The location to embed an arbitrary JaveScript `<script>` tag into each HTML
355/// page.
356#[derive(Debug)]
357pub enum AdditionalScript {
358    /// Embed the contents immediately after the opening `<head>` tag.
359    HeadOpen(String),
360    /// Embed the contents immediately before the closing `</head>` tag.
361    HeadClose(String),
362    /// Embed the contents immediately after the opening `<body>` tag.
363    BodyOpen(String),
364    /// Embed the contents immediately before the closing `</body>` tag.
365    BodyClose(String),
366    /// Don't embed any script.
367    None,
368}
369
370/// Configuration for documentation generation.
371#[derive(Debug)]
372pub struct Config {
373    /// Configuration to use for analysis.
374    analysis_config: AnalysisConfig,
375    /// WDL workspace that should be documented.
376    workspace: PathBuf,
377    /// Output location for the documentation.
378    output_dir: PathBuf,
379    /// An optional markdown file to embed in the homepage.
380    homepage: Option<PathBuf>,
381    /// Initialize pages in light mode instead of the default dark mode.
382    init_light_mode: bool,
383    /// An optional custom theme directory.
384    custom_theme: Option<PathBuf>,
385    /// An optional custom logo to embed in the left sidebar.
386    custom_logo: Option<PathBuf>,
387    /// An optional alternate (light mode) custom logo to embed in the left
388    /// sidebar.
389    alt_logo: Option<PathBuf>,
390    /// Optional JavaScript to embed in each HTML page.
391    additional_javascript: AdditionalScript,
392    /// Initialize pages on the "Full Directory" view instead of the "Workflows"
393    /// view of the left sidebar.
394    init_on_full_directory: bool,
395}
396
397impl Config {
398    /// Create a new documentation configuration.
399    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    /// Overwrite the config's homepage with the new value.
419    pub fn homepage(mut self, homepage: Option<PathBuf>) -> Self {
420        self.homepage = homepage;
421        self
422    }
423
424    /// Overwrite the config's light mode default with the new value.
425    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    /// Overwrite the config's custom theme with the new value.
431    pub fn custom_theme(mut self, custom_theme: Option<PathBuf>) -> Self {
432        self.custom_theme = custom_theme;
433        self
434    }
435
436    /// Overwrite the config's custom logo with the new value.
437    pub fn custom_logo(mut self, custom_logo: Option<PathBuf>) -> Self {
438        self.custom_logo = custom_logo;
439        self
440    }
441
442    /// Overwrite the config's alternate logo with the new value.
443    pub fn alt_logo(mut self, alt_logo: Option<PathBuf>) -> Self {
444        self.alt_logo = alt_logo;
445        self
446    }
447
448    /// Overwrite the config's additional JS with the new value.
449    pub fn additional_javascript(mut self, additional_javascript: AdditionalScript) -> Self {
450        self.additional_javascript = additional_javascript;
451        self
452    }
453
454    /// Overwrite the config's init_on_full_directory with the new value.
455    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
461/// Generate HTML documentation for a workspace.
462///
463/// This function will generate HTML documentation for all WDL files in the
464/// workspace directory. This function will overwrite any existing files which
465/// conflict with the generated files, but will not delete any files that
466/// are already present.
467pub 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                    // The path is relative to the workspace
528                    (path.to_path_buf(), false)
529                }
530                Err(_) => {
531                    // URI was successfully converted to a file path, but it is not in the
532                    // workspace. This must be an imported WDL file and the
533                    // documentation will be generated in the `external/` directory.
534                    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                // The URI could not be converted to a file path, so it must be a remote WDL file.
544                // In this case, we will generate documentation in the `external/` directory.
545                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                    // TODO: handle >=v1.2 structs
577                    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}