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/// An ANSI escape sequence: what `color_print::cstr!` leaves in help text.
9static SGR: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\x1b\[[0-?]*[ -/]*[@-~]").unwrap());
10/// A backtick span, or a bare `<` outside one.
11static CODE_SPAN_OR_LT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(`[^`]*`)|(<)").unwrap());
12
13fn escape_md(value: &str, html_encode: bool) -> String {
14    let mut in_fenced_code_block = false;
15    // Help text is allowed to contain terminal styling. clap-era applications commonly build
16    // their examples with `color_print::cstr!`, which embeds SGR sequences even when color is
17    // disabled at runtime. Terminal styling has no meaning in generated Markdown, and leaving
18    // it here publishes literal escape bytes in docs and downstream static sites.
19    let value = SGR.replace_all(value, "");
20
21    value
22        .lines()
23        .map(|line| {
24            if !html_encode {
25                return line.to_string();
26            }
27            // Indented code is handled before fence state. This is safe because
28            // `replace_code_fences` always emits closing fences at column zero.
29            if line.starts_with("    ") {
30                return line.to_string();
31            }
32            if in_fenced_code_block {
33                if line.trim_end() == "```" {
34                    in_fenced_code_block = false;
35                }
36                return line.to_string();
37            }
38            // Support the conventional fence shape emitted by `replace_code_fences`
39            // without attempting to parse the full Markdown specification.
40            if line
41                .strip_prefix("```")
42                .is_some_and(|suffix| !suffix.starts_with('`'))
43            {
44                in_fenced_code_block = true;
45                return line.to_string();
46            }
47            // replace '<' with '&lt;' but not inside code blocks
48            CODE_SPAN_OR_LT
49                .replace_all(line, |caps: &regex::Captures| {
50                    if caps.get(1).is_some() {
51                        caps.get(1).unwrap().as_str().to_string()
52                    } else {
53                        "&lt;".to_string()
54                    }
55                })
56                .to_string()
57        })
58        .join("\n")
59}
60
61#[derive(Debug, Clone)]
62pub struct MarkdownRenderer {
63    pub(crate) spec: Spec,
64    /// The config block as the spec wrote it, before any rendering.
65    ///
66    /// `new` renders the whole docs model eagerly, which happens *before* the builder methods
67    /// that set `replace_pre_with_code_fences` and friends — and rendering marks each item
68    /// done, so a later pass no-ops. `render_cmd` avoids this by re-deriving from the raw
69    /// command its caller hands it; config has no such argument, so the raw form is kept here
70    /// instead. Without it, `--replace-pre-with-code-fences` silently did nothing to a
71    /// setting's long help.
72    pub(crate) raw_config: crate::spec::config::SpecConfig,
73    pub(crate) header_level: usize,
74    pub(crate) multi: bool,
75    url_prefix: Option<String>,
76    html_encode: bool,
77    replace_pre_with_code_fences: bool,
78}
79
80impl MarkdownRenderer {
81    pub fn new(spec: crate::Spec) -> Self {
82        let mut renderer = Self {
83            raw_config: spec.config.clone(),
84            spec: spec.into(),
85            header_level: 1,
86            multi: false,
87            url_prefix: None,
88            html_encode: true,
89            replace_pre_with_code_fences: false,
90        };
91        let mut spec = renderer.spec.clone();
92        spec.render_md(&renderer);
93        renderer.spec = spec;
94        renderer
95    }
96
97    /// The file name the settings page gets in `--multi` mode.
98    ///
99    /// One name, always. `settings.md` was the obvious choice and the wrong one: mise has a
100    /// `settings` command, whose own page lands there, and config is written second — so for
101    /// the very CLI this feature is aimed at, the command's page was silently replaced and the
102    /// index linked both entries to the same file.
103    ///
104    /// Choosing between two names by whether a command is in the way would fix that and
105    /// introduce something worse: the name would move when a command is added or removed
106    /// between runs, leaving the abandoned one behind as a stale page nothing links but the
107    /// docs site still serves. A fixed name cannot do that, and `configuration` is a name CLIs
108    /// give to a *file*, not to a command — the command is called `config`.
109    pub fn config_page(&self) -> &'static str {
110        "configuration.md"
111    }
112
113    /// A visible top-level command whose own page would be written to [`Self::config_page`].
114    ///
115    /// Vanishingly unlikely, and silence is what made the `settings.md` collision hard to see,
116    /// so the one case left is reported rather than guessed at.
117    pub fn config_page_collision(&self) -> Option<&str> {
118        let stem = self.config_page().trim_end_matches(".md");
119        self.spec
120            .cmd
121            .subcommands
122            .values()
123            .find(|cmd| !cmd.hide && cmd.full_cmd == [stem])
124            .map(|cmd| cmd.name.as_str())
125    }
126
127    pub fn with_header_level(mut self, header_level: usize) -> Self {
128        self.header_level = header_level;
129        self
130    }
131
132    pub fn with_multi(mut self, index: bool) -> Self {
133        self.multi = index;
134        self
135    }
136
137    pub fn with_url_prefix<S: Into<String>>(mut self, url_prefix: S) -> Self {
138        self.url_prefix = Some(url_prefix.into());
139        self
140    }
141
142    pub fn with_html_encode(mut self, html_encode: bool) -> Self {
143        self.html_encode = html_encode;
144        self
145    }
146
147    pub fn with_replace_pre_with_code_fences(mut self, replace_pre_with_code_fences: bool) -> Self {
148        self.replace_pre_with_code_fences = replace_pre_with_code_fences;
149        self
150    }
151
152    fn tera_ctx(&self) -> tera::Context {
153        let mut ctx = tera::Context::new();
154        ctx.insert("spec", &self.spec);
155        ctx.insert("header_level", &self.header_level);
156        ctx.insert("multi", &self.multi);
157        ctx.insert("url_prefix", &self.url_prefix);
158        ctx.insert("html_encode", &self.html_encode);
159        ctx
160    }
161
162    /// Render with values that belong only to this page.
163    ///
164    /// A page used to clone the whole renderer — including the complete command tree — merely
165    /// to insert one local into its stored context. Multi-page output paid that deep clone once
166    /// per command. The context already has to be materialized for Tera, so enrich that directly.
167    pub(crate) fn render_with(
168        &self,
169        template_name: &str,
170        enrich: impl FnOnce(&mut tera::Context),
171    ) -> Result<String, UsageErr> {
172        let mut tera = TERA.clone();
173
174        let html_encode = self.html_encode;
175        tera.register_filter(
176            "escape_md",
177            move |value: &tera::Value,
178                  _: tera::Kwargs,
179                  _: &tera::State|
180                  -> tera::TeraResult<String> {
181                let value = value.as_str().unwrap();
182                let value = escape_md(value, html_encode);
183                Ok(value)
184            },
185        );
186
187        let mut ctx = self.tera_ctx();
188        enrich(&mut ctx);
189        Ok(tera.render(template_name, &ctx)?)
190    }
191
192    pub(crate) fn replace_code_fences(&self, md: String) -> String {
193        if !self.replace_pre_with_code_fences {
194            return md;
195        }
196        // TODO: handle fences inside of <pre> or <code>
197        let mut in_code_block = false;
198        let mut new_md = String::new();
199        for line in md.lines() {
200            if let Some(line) = line.strip_prefix("    ") {
201                if in_code_block {
202                    new_md.push_str(&format!("{line}\n"));
203                } else {
204                    new_md.push_str(&format!("```\n{line}\n"));
205                    in_code_block = true;
206                }
207            } else {
208                if in_code_block {
209                    new_md.push_str("```\n");
210                    in_code_block = false;
211                }
212                new_md.push_str(&format!("{line}\n"));
213            }
214        }
215        if in_code_block {
216            new_md.push_str("```\n");
217        }
218        new_md.replace("```\n\n```\n", "\n")
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::escape_md;
225    use pretty_assertions::assert_eq;
226
227    #[test]
228    fn escapes_html_around_fenced_code_blocks() {
229        let input = "before <\n```\ninside <\n```  \nafter <";
230        let expected = "before &lt;\n```\ninside <\n```  \nafter &lt;";
231
232        assert_eq!(escape_md(input, true), expected);
233    }
234
235    #[test]
236    fn supports_fence_info_strings() {
237        let input = "```bash\necho <value>\n```\nafter <";
238        let expected = "```bash\necho <value>\n```\nafter &lt;";
239
240        assert_eq!(escape_md(input, true), expected);
241    }
242
243    #[test]
244    fn leaves_unclosed_fences_unescaped() {
245        let input = "```\necho <value>";
246
247        assert_eq!(escape_md(input, true), input);
248    }
249
250    #[test]
251    fn ignores_indented_and_longer_fences() {
252        let input = "    ```\nindented <\n````\nlonger <";
253        let expected = "    ```\nindented &lt;\n````\nlonger &lt;";
254
255        assert_eq!(escape_md(input, true), expected);
256    }
257
258    #[test]
259    fn leaves_markdown_unchanged_when_html_encoding_is_disabled() {
260        let input = "before <\n```\ninside <\n```\nafter <";
261
262        assert_eq!(escape_md(input, false), input);
263    }
264
265    #[test]
266    fn strips_terminal_styling_from_generated_markdown() {
267        let input =
268            "\u{1b}[1m\u{1b}[4mExamples:\u{1b}[22m\u{1b}[24m\n\n    \u{1b}[1mmise run\u{1b}[22m";
269        let expected = "Examples:\n\n    mise run";
270
271        assert_eq!(escape_md(input, true), expected);
272        assert_eq!(escape_md(input, false), expected);
273    }
274}