Skip to main content

usage/docs/markdown/
renderer.rs

1use crate::docs::markdown::tera::TERA;
2use crate::docs::models::Spec;
3use crate::error::UsageErr;
4use itertools::Itertools;
5use regex::Regex;
6use std::sync::LazyLock;
7
8/// One of the templates used to generate Markdown documentation.
9///
10/// A renderer starts with a complete built-in template set. Replacing one member keeps the
11/// others available, including through Tera's `{% include %}` directive. This lets an adopter
12/// change the document shell or the presentation of one kind of item without copying the whole
13/// theme.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum MarkdownTemplate {
16    /// A single-file document containing the root and every subcommand.
17    Spec,
18    /// The landing page generated in multi-file mode.
19    Index,
20    /// A command and its arguments, flags, outputs, exits, and examples.
21    Command,
22    /// The details beneath one positional argument.
23    Argument,
24    /// The details beneath one flag.
25    Flag,
26    /// The configuration reference appended to a spec or written in multi-file mode.
27    Config,
28}
29
30/// The built-in presentation used for generated Markdown.
31#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
32pub enum MarkdownTheme {
33    /// Dense grouped lists intended for scanning a large command reference.
34    #[default]
35    Compact,
36    /// Give every argument and flag its own addressable heading and detail block.
37    Detailed,
38}
39
40impl MarkdownTemplate {
41    fn name(self) -> &'static str {
42        match self {
43            Self::Spec => "spec_template.md.tera",
44            Self::Index => "index_template.md.tera",
45            Self::Command => "cmd_template.md.tera",
46            Self::Argument => "arg_template.md.tera",
47            Self::Flag => "flag_template.md.tera",
48            Self::Config => "config_template.md.tera",
49        }
50    }
51}
52
53/// A backtick span, or a bare `<` outside one.
54static CODE_SPAN_OR_LT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(`[^`]*`)|(<)").unwrap());
55
56fn escape_md_with_indent(value: &str, html_encode: bool, indent: bool) -> String {
57    let mut fence: Option<(char, usize)> = None;
58    // Help text is allowed to contain terminal styling. clap-era applications commonly build
59    // their examples with `color_print::cstr!`, which embeds SGR sequences even when color is
60    // disabled at runtime. Terminal styling has no meaning in generated Markdown, and leaving
61    // it here publishes literal escape bytes in docs and downstream static sites.
62    let value = crate::docs::strip_ansi(value);
63
64    value
65        .lines()
66        .enumerate()
67        .map(|(index, line)| {
68            let line = if !html_encode {
69                line.to_string()
70            } else {
71                // Preserve indented code, including code nested within list items.
72                if line.starts_with("    ") {
73                    line.to_string()
74                } else if let Some((marker, length)) = fence {
75                    let trimmed = line.trim();
76                    if trimmed.len() >= length && trimmed.chars().all(|c| c == marker) {
77                        fence = None;
78                    }
79                    line.to_string()
80                // Converted blocks may require longer fences to contain literal backticks.
81                } else if line.trim_start().starts_with("```")
82                    || line.trim_start().starts_with("~~~")
83                {
84                    let trimmed = line.trim_start();
85                    let marker = trimmed.chars().next().unwrap();
86                    fence = Some((marker, trimmed.chars().take_while(|c| *c == marker).count()));
87                    line.to_string()
88                } else {
89                    // replace '<' with '&lt;' but not inside code blocks
90                    CODE_SPAN_OR_LT
91                        .replace_all(line, |caps: &regex::Captures| {
92                            if caps.get(1).is_some() {
93                                caps.get(1).unwrap().as_str().to_string()
94                            } else {
95                                "&lt;".to_string()
96                            }
97                        })
98                        .to_string()
99                }
100            };
101            if indent && index > 0 && !line.is_empty() {
102                format!("  {line}")
103            } else {
104                line
105            }
106        })
107        .join("\n")
108}
109
110fn escape_md(value: &str, html_encode: bool) -> String {
111    escape_md_with_indent(value, html_encode, false)
112}
113
114#[derive(Debug, Clone)]
115pub struct MarkdownRenderer {
116    /// The spec as its author wrote it, before any rendering.
117    ///
118    /// Kept because rendering depends on the builder options, and the builders run *after*
119    /// [`Self::new`]. Rendering eagerly in `new` read options nobody had set yet, and because
120    /// rendering marks each item done, the later pass with the real options no-opped — which
121    /// is how `--indented-blocks-to-code-fences` came to do nothing at all on single-file
122    /// output. The rendered model is derived from this on first use instead, so it always sees
123    /// the options the caller actually asked for.
124    raw: crate::Spec,
125    /// The rendered docs model, derived from [`Self::raw`] on first use by [`Self::spec`].
126    ///
127    /// Every builder clears this, so an option set after a render still takes effect.
128    ///
129    /// A `OnceLock` rather than a `OnceCell` so the renderer stays `Sync` and `RefUnwindSafe`
130    /// for callers that hold one across threads — a `OnceCell` here would take those away.
131    spec: std::sync::OnceLock<Spec>,
132    pub(crate) header_level: usize,
133    pub(crate) multi: bool,
134    url_prefix: Option<String>,
135    link_extension: String,
136    html_encode: bool,
137    indented_blocks_to_code_fences: bool,
138    theme: MarkdownTheme,
139    templates: Vec<(MarkdownTemplate, String)>,
140}
141
142impl MarkdownRenderer {
143    pub fn new(spec: crate::Spec) -> Self {
144        Self {
145            raw: spec,
146            spec: std::sync::OnceLock::new(),
147            header_level: 1,
148            multi: false,
149            url_prefix: None,
150            link_extension: ".md".into(),
151            html_encode: true,
152            indented_blocks_to_code_fences: false,
153            theme: MarkdownTheme::default(),
154            templates: Vec::new(),
155        }
156    }
157
158    /// The unrendered spec this renderer was built from.
159    pub(crate) fn raw(&self) -> &crate::Spec {
160        &self.raw
161    }
162
163    /// The rendered docs model, built on first use from the options set by then.
164    pub(crate) fn spec(&self) -> &Spec {
165        self.spec.get_or_init(|| {
166            // By reference: `From<&Spec>` already clones internally, so handing it an owned
167            // spec clones the whole tree twice.
168            let mut spec = Spec::from(&self.raw);
169            spec.render_md(self);
170            spec
171        })
172    }
173
174    /// Apply a builder option and drop any model rendered under the old options.
175    ///
176    /// Every `with_*` goes through this. A builder that set its field directly would leave a
177    /// model rendered without its option behind, which is the bug this type already had once.
178    fn with(mut self, set: impl FnOnce(&mut Self)) -> Self {
179        set(&mut self);
180        self.spec = std::sync::OnceLock::new();
181        self
182    }
183
184    /// The file name the settings page gets in `--multi` mode.
185    ///
186    /// One name, always. `settings.md` was the obvious choice and the wrong one: mise has a
187    /// `settings` command, whose own page lands there, and config is written second — so for
188    /// the very CLI this feature is aimed at, the command's page was silently replaced and the
189    /// index linked both entries to the same file.
190    ///
191    /// Choosing between two names by whether a command is in the way would fix that and
192    /// introduce something worse: the name would move when a command is added or removed
193    /// between runs, leaving the abandoned one behind as a stale page nothing links but the
194    /// docs site still serves. A fixed name cannot do that, and `configuration` is a name CLIs
195    /// give to a *file*, not to a command — the command is called `config`.
196    pub fn config_page(&self) -> &'static str {
197        "configuration.md"
198    }
199
200    /// A visible top-level command whose own page would be written to [`Self::config_page`].
201    ///
202    /// Vanishingly unlikely, and silence is what made the `settings.md` collision hard to see,
203    /// so the one case left is reported rather than guessed at.
204    pub fn config_page_collision(&self) -> Option<&str> {
205        let stem = self.config_page().trim_end_matches(".md");
206        self.spec()
207            .cmd
208            .subcommands
209            .values()
210            .find(|cmd| !cmd.hide && cmd.full_cmd == [stem])
211            .map(|cmd| cmd.name.as_str())
212    }
213
214    pub fn with_header_level(self, header_level: usize) -> Self {
215        self.with(|r| r.header_level = header_level)
216    }
217
218    pub fn with_multi(self, index: bool) -> Self {
219        self.with(|r| r.multi = index)
220    }
221
222    pub fn with_url_prefix<S: Into<String>>(self, url_prefix: S) -> Self {
223        self.with(|r| r.url_prefix = Some(url_prefix.into()))
224    }
225
226    pub fn with_html_encode(self, html_encode: bool) -> Self {
227        self.with(|r| r.html_encode = html_encode)
228    }
229
230    /// Extension for generated page links, including the dot (default `.md`).
231    /// Use `.html` for rendered websites or an empty string for extensionless URLs.
232    /// This does not change the names of generated Markdown files.
233    pub fn with_link_extension(self, extension: impl Into<String>) -> Self {
234        self.with(|r| r.link_extension = extension.into())
235    }
236
237    /// Turn four-space indented blocks in help text into fenced code blocks.
238    pub fn with_indented_blocks_to_code_fences(self, indented_blocks_to_code_fences: bool) -> Self {
239        self.with(|r| r.indented_blocks_to_code_fences = indented_blocks_to_code_fences)
240    }
241
242    /// The former name of [`Self::with_indented_blocks_to_code_fences`]. Prefer that one.
243    ///
244    /// A misnomer from the start: no `<pre>` tag was ever involved. Kept, rather than renamed
245    /// out from under callers, so an existing docs build kept compiling — and left in the
246    /// public API rather than `#[doc(hidden)]`, because hiding it is itself the kind of
247    /// removal that costs downstreams a major version.
248    pub fn with_replace_pre_with_code_fences(self, indented_blocks_to_code_fences: bool) -> Self {
249        self.with_indented_blocks_to_code_fences(indented_blocks_to_code_fences)
250    }
251
252    /// Select a built-in Markdown presentation.
253    pub fn with_theme(self, theme: MarkdownTheme) -> Self {
254        self.with(|r| r.theme = theme)
255    }
256
257    /// Replace one built-in Markdown template.
258    ///
259    /// Templates use [Tera](https://keats.github.io/tera/). A replacement may include any of the
260    /// templates it did not replace; for example, a custom [`MarkdownTemplate::Spec`] can still
261    /// contain `{% include "cmd_template.md.tera" %}`. Replacing the same member more than once
262    /// uses the last value. Syntax and include errors are returned by the render method.
263    pub fn with_template(self, template: MarkdownTemplate, source: impl Into<String>) -> Self {
264        let source = source.into();
265        self.with(|r| {
266            if let Some((_, current)) = r
267                .templates
268                .iter_mut()
269                .find(|(current, _)| *current == template)
270            {
271                *current = source;
272            } else {
273                r.templates.push((template, source));
274            }
275        })
276    }
277
278    fn tera_ctx(&self) -> tera::Context {
279        let mut ctx = tera::Context::new();
280        ctx.insert("spec", self.spec());
281        ctx.insert("header_level", &self.header_level);
282        ctx.insert("multi", &self.multi);
283        ctx.insert("url_prefix", &self.url_prefix);
284        ctx.insert("link_extension", &self.link_extension);
285        ctx.insert(
286            "config_link",
287            &format!("configuration{}", self.link_extension),
288        );
289        ctx.insert("html_encode", &self.html_encode);
290        ctx
291    }
292
293    /// Render with values that belong only to this page.
294    ///
295    /// A page used to clone the whole renderer — including the complete command tree — merely
296    /// to insert one local into its stored context. Multi-page output paid that deep clone once
297    /// per command. The context already has to be materialized for Tera, so enrich that directly.
298    pub(crate) fn render_with(
299        &self,
300        template_name: &str,
301        enrich: impl FnOnce(&mut tera::Context),
302    ) -> Result<String, UsageErr> {
303        let mut tera = match self.theme {
304            MarkdownTheme::Compact => TERA.clone(),
305            MarkdownTheme::Detailed => crate::docs::markdown::tera::DETAILED_TERA.clone(),
306        };
307
308        for (template, source) in &self.templates {
309            tera.add_raw_template(template.name(), source)?;
310        }
311
312        let html_encode = self.html_encode;
313        tera.register_filter(
314            "escape_md",
315            move |value: &tera::Value,
316                  _: tera::Kwargs,
317                  _: &tera::State|
318                  -> tera::TeraResult<String> {
319                let value = value.as_str().unwrap();
320                let value = escape_md(value, html_encode);
321                Ok(value)
322            },
323        );
324        tera.register_filter(
325            "escape_md_indented",
326            move |value: &tera::Value,
327                  _: tera::Kwargs,
328                  _: &tera::State|
329                  -> tera::TeraResult<String> {
330                let value = value.as_str().unwrap();
331                Ok(escape_md_with_indent(value, html_encode, true))
332            },
333        );
334
335        let mut ctx = self.tera_ctx();
336        enrich(&mut ctx);
337        Ok(tera.render(template_name, &ctx)?)
338    }
339
340    /// Rewrite four-space indented blocks as fenced ones, when the caller asked for it.
341    pub(crate) fn fence_indented_blocks(&self, md: String) -> String {
342        if !self.indented_blocks_to_code_fences {
343            return md;
344        }
345        use pulldown_cmark::{CodeBlockKind, Event, Parser, Tag, TagEnd};
346        let mut edits = Vec::new();
347        let mut block = None;
348        for (event, range) in Parser::new(&md).into_offset_iter() {
349            match event {
350                Event::Start(Tag::CodeBlock(CodeBlockKind::Indented)) => {
351                    block = Some((range, String::new()));
352                }
353                Event::Text(text) => {
354                    if let Some((_, content)) = &mut block {
355                        content.push_str(&text);
356                    }
357                }
358                Event::End(TagEnd::CodeBlock) => {
359                    if let Some((range, content)) = block.take() {
360                        let start = md[..range.start].rfind('\n').map_or(0, |i| i + 1);
361                        // A same-line list marker or blockquote prefix belongs to its
362                        // container. Keep these blocks indented rather than replacing
363                        // the container along with the code block.
364                        if md[start..range.start].chars().any(|c| !c.is_whitespace()) {
365                            continue;
366                        }
367                        let source_line = md[start..].lines().next().unwrap_or_default();
368                        let source_indent =
369                            source_line.len() - source_line.trim_start_matches(' ').len();
370                        let content_line = content
371                            .lines()
372                            .find(|line| !line.trim().is_empty())
373                            .unwrap_or_default();
374                        let content_indent =
375                            content_line.len() - content_line.trim_start_matches(' ').len();
376                        let prefix = " ".repeat(source_indent.saturating_sub(4 + content_indent));
377                        let fence = "`".repeat(
378                            content
379                                .split(|c| c != '`')
380                                .map(str::len)
381                                .max()
382                                .unwrap_or(0)
383                                .max(2)
384                                + 1,
385                        );
386                        let mut replacement = format!("{prefix}{fence}\n");
387                        for line in content.lines() {
388                            replacement.push_str(&format!("{prefix}{line}\n"));
389                        }
390                        replacement.push_str(&format!("{prefix}{fence}\n"));
391                        edits.push((start..range.end, replacement));
392                    }
393                }
394                _ => {}
395            }
396        }
397        let mut result = md;
398        for (range, replacement) in edits.into_iter().rev() {
399            result.replace_range(range, &replacement);
400        }
401        result
402    }
403}
404
405#[cfg(test)]
406mod tests {
407    use super::{MarkdownRenderer, MarkdownTemplate, escape_md};
408    use pretty_assertions::assert_eq;
409
410    #[test]
411    fn conversion_preserves_markdown_structure_and_code_contents() {
412        use pulldown_cmark::{CodeBlockKind, Event, Parser, Tag};
413        let renderer = MarkdownRenderer::new("bin ex".parse().unwrap())
414            .with_indented_blocks_to_code_fences(true);
415        for source in [
416            "- outer\n  - inner\n\n        first\n\n          indented\n\n    prose\n",
417            "```json\n{\n    \"key\": 1\n}\n```\n",
418            "    [tools]\n    node = \"20\"\n\n    ```literal```\n",
419            "    first\n\n      second\n",
420            ">     quoted code\n>\n>       indented\n",
421            "-     same-line list code\n",
422            "Examples:\n    paragraph continuation\n\n    actual code\n",
423        ] {
424            let normalized = |text: &str| {
425                pulldown_cmark::TextMergeStream::new(Parser::new(text))
426                    .map(|event| match event {
427                        Event::Start(Tag::CodeBlock(_)) => {
428                            Event::Start(Tag::CodeBlock(CodeBlockKind::Indented))
429                        }
430                        event => event,
431                    })
432                    .map(Event::into_static)
433                    .collect::<Vec<_>>()
434            };
435            let output = renderer.fence_indented_blocks(source.into());
436            assert_eq!(normalized(source), normalized(&output), "{output}");
437            assert_eq!(renderer.fence_indented_blocks(output.clone()), output);
438        }
439    }
440
441    #[test]
442    fn legacy_examples_with_markdown_separation_form_one_code_block() {
443        let renderer = MarkdownRenderer::new("bin ex".parse().unwrap())
444            .with_indented_blocks_to_code_fences(true);
445        let source = "Examples:\n\n    # first\n    ex first\n\n    # second\n    ex second\n";
446        assert_eq!(
447            renderer.fence_indented_blocks(source.into()),
448            "Examples:\n\n```\n# first\nex first\n\n# second\nex second\n```\n"
449        );
450        let spec =
451            crate::Spec::parse_file(std::path::Path::new("../benches/mise.usage.kdl")).unwrap();
452        let page = MarkdownRenderer::new(spec.clone())
453            .with_indented_blocks_to_code_fences(true)
454            .render_cmd(&spec.cmd.subcommands["settings"])
455            .unwrap();
456        assert!(
457            page.contains("Examples:\n\n```\n# list all settings"),
458            "{page}"
459        );
460    }
461
462    #[test]
463    fn visible_children_get_a_heading_and_index_has_one_synopsis() {
464        let spec: crate::Spec = "bin ex\ncmd a hide=#true\ncmd b\n".parse().unwrap();
465        for theme in [
466            super::MarkdownTheme::Compact,
467            super::MarkdownTheme::Detailed,
468        ] {
469            let renderer = MarkdownRenderer::new(spec.clone())
470                .with_theme(theme)
471                .with_multi(true);
472            assert!(
473                renderer
474                    .render_cmd(&spec.cmd)
475                    .unwrap()
476                    .contains("## Subcommands")
477            );
478            assert_eq!(
479                renderer
480                    .render_index()
481                    .unwrap()
482                    .matches("**Usage:**")
483                    .count(),
484                1
485            );
486            let hidden: crate::Spec = "bin ex\ncmd a hide=#true\n".parse().unwrap();
487            assert!(
488                !MarkdownRenderer::new(hidden.clone())
489                    .with_theme(theme)
490                    .with_multi(true)
491                    .render_cmd(&hidden.cmd)
492                    .unwrap()
493                    .contains("## Subcommands")
494            );
495        }
496    }
497
498    #[test]
499    fn page_links_follow_extension_without_renaming_files() {
500        let spec: crate::Spec = "bin ex\ncmd go\nconfig {\n prop jobs type=\"uint\"\n}\n"
501            .parse()
502            .unwrap();
503        for extension in [".md", ".html", ""] {
504            let renderer = MarkdownRenderer::new(spec.clone())
505                .with_url_prefix("/cli")
506                .with_link_extension(extension);
507            let index = renderer.render_index().unwrap();
508            assert!(index.contains(&format!("(/cli/go{extension})")), "{index}");
509            assert!(
510                index.contains(&format!("(/cli/configuration{extension})")),
511                "{index}"
512            );
513            assert_eq!(renderer.config_page(), "configuration.md");
514            let custom = renderer.with_template(MarkdownTemplate::Index, "{{ link_extension }}");
515            assert_eq!(custom.render_index().unwrap(), extension);
516        }
517    }
518
519    #[test]
520    fn escapes_html_around_fenced_code_blocks() {
521        let input = "before <\n```\ninside <\n```  \nafter <";
522        let expected = "before &lt;\n```\ninside <\n```  \nafter &lt;";
523
524        assert_eq!(escape_md(input, true), expected);
525    }
526
527    #[test]
528    fn supports_fence_info_strings() {
529        let input = "```bash\necho <value>\n```\nafter <";
530        let expected = "```bash\necho <value>\n```\nafter &lt;";
531
532        assert_eq!(escape_md(input, true), expected);
533    }
534
535    #[test]
536    fn leaves_unclosed_fences_unescaped() {
537        let input = "```\necho <value>";
538
539        assert_eq!(escape_md(input, true), input);
540    }
541
542    #[test]
543    fn handles_longer_fences_and_indented_code() {
544        let input = "    ```\nindented <\n````\nlonger <";
545        let expected = "    ```\nindented &lt;\n````\nlonger <";
546
547        assert_eq!(escape_md(input, true), expected);
548    }
549
550    #[test]
551    fn leaves_markdown_unchanged_when_html_encoding_is_disabled() {
552        let input = "before <\n```\ninside <\n```\nafter <";
553
554        assert_eq!(escape_md(input, false), input);
555    }
556
557    #[test]
558    fn strips_terminal_styling_from_generated_markdown() {
559        let input =
560            "\u{1b}[1m\u{1b}[4mExamples:\u{1b}[22m\u{1b}[24m\n\n    \u{1b}[1mmise run\u{1b}[22m";
561        let expected = "Examples:\n\n    mise run";
562
563        assert_eq!(escape_md(input, true), expected);
564        assert_eq!(escape_md(input, false), expected);
565    }
566
567    #[test]
568    fn one_template_can_be_replaced_without_copying_its_includes() {
569        let spec = "bin \"ex\"\nflag \"--force\" help=\"Do it anyway\"\n"
570            .parse()
571            .unwrap();
572        let page = MarkdownRenderer::new(spec)
573            .with_template(
574                MarkdownTemplate::Spec,
575                "# Custom {{ spec.bin }}\n{% set cmd = spec.cmd %}\n{% include \"cmd_template.md.tera\" %}",
576            )
577            .render_spec()
578            .unwrap();
579
580        assert!(page.starts_with("# Custom ex\n"), "{page}");
581        assert!(page.contains("- **`--force`**"), "{page}");
582    }
583
584    #[test]
585    fn the_last_replacement_of_a_template_wins() {
586        let spec = "bin \"ex\"\n".parse().unwrap();
587        let page = MarkdownRenderer::new(spec)
588            .with_template(MarkdownTemplate::Spec, "{{")
589            .with_template(MarkdownTemplate::Spec, "second")
590            .render_spec()
591            .unwrap();
592
593        assert_eq!(page, "second");
594    }
595
596    #[test]
597    fn a_bad_custom_template_is_a_render_error() {
598        let spec = "bin \"ex\"\n".parse().unwrap();
599        let err = MarkdownRenderer::new(spec)
600            .with_template(MarkdownTemplate::Spec, "{{")
601            .render_spec()
602            .unwrap_err();
603
604        assert!(err.to_string().contains("template"), "{err}");
605    }
606
607    #[test]
608    fn the_detailed_theme_keeps_addressable_entry_headings() {
609        let spec = "bin \"ex\"\nflag \"--force\" help=\"Do it anyway\"\n"
610            .parse()
611            .unwrap();
612        let page = MarkdownRenderer::new(spec)
613            .with_theme(super::MarkdownTheme::Detailed)
614            .render_spec()
615            .unwrap();
616
617        assert!(page.contains("### `--force`"), "{page}");
618    }
619
620    #[test]
621    fn entry_template_overrides_apply_to_the_compact_theme() {
622        let spec = "bin \"ex\"\narg \"<file>\"\nflag \"--force\"\n"
623            .parse()
624            .unwrap();
625        let page = MarkdownRenderer::new(spec)
626            .with_template(MarkdownTemplate::Argument, "argument: {{ arg.usage }}")
627            .with_template(MarkdownTemplate::Flag, "flag: {{ flag.usage }}")
628            .render_spec()
629            .unwrap();
630
631        assert!(page.contains("argument: <file>"), "{page}");
632        assert!(page.contains("flag: --force"), "{page}");
633    }
634}