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 serde::Serialize;
6
7fn escape_md(value: &str, html_encode: bool) -> String {
8    let mut in_fenced_code_block = false;
9
10    value
11        .lines()
12        .map(|line| {
13            if !html_encode {
14                return line.to_string();
15            }
16            // Indented code is handled before fence state. This is safe because
17            // `replace_code_fences` always emits closing fences at column zero.
18            if line.starts_with("    ") {
19                return line.to_string();
20            }
21            if in_fenced_code_block {
22                if line.trim_end() == "```" {
23                    in_fenced_code_block = false;
24                }
25                return line.to_string();
26            }
27            // Support the conventional fence shape emitted by `replace_code_fences`
28            // without attempting to parse the full Markdown specification.
29            if line
30                .strip_prefix("```")
31                .is_some_and(|suffix| !suffix.starts_with('`'))
32            {
33                in_fenced_code_block = true;
34                return line.to_string();
35            }
36            // replace '<' with '&lt;' but not inside code blocks
37            xx::regex!(r"(`[^`]*`)|(<)")
38                .replace_all(line, |caps: &regex::Captures| {
39                    if caps.get(1).is_some() {
40                        caps.get(1).unwrap().as_str().to_string()
41                    } else {
42                        "&lt;".to_string()
43                    }
44                })
45                .to_string()
46        })
47        .join("\n")
48}
49
50#[derive(Debug, Clone)]
51pub struct MarkdownRenderer {
52    pub(crate) spec: Spec,
53    pub(crate) header_level: usize,
54    pub(crate) multi: bool,
55    tera_ctx: tera::Context,
56    url_prefix: Option<String>,
57    html_encode: bool,
58    replace_pre_with_code_fences: bool,
59}
60
61impl MarkdownRenderer {
62    pub fn new(spec: crate::Spec) -> Self {
63        let mut renderer = Self {
64            spec: spec.into(),
65            header_level: 1,
66            multi: false,
67            tera_ctx: tera::Context::new(),
68            url_prefix: None,
69            html_encode: true,
70            replace_pre_with_code_fences: false,
71        };
72        let mut spec = renderer.spec.clone();
73        spec.render_md(&renderer);
74        renderer.spec = spec;
75        renderer
76    }
77
78    pub fn with_header_level(mut self, header_level: usize) -> Self {
79        self.header_level = header_level;
80        self
81    }
82
83    pub fn with_multi(mut self, index: bool) -> Self {
84        self.multi = index;
85        self
86    }
87
88    pub fn with_url_prefix<S: Into<String>>(mut self, url_prefix: S) -> Self {
89        self.url_prefix = Some(url_prefix.into());
90        self
91    }
92
93    pub fn with_html_encode(mut self, html_encode: bool) -> Self {
94        self.html_encode = html_encode;
95        self
96    }
97
98    pub fn with_replace_pre_with_code_fences(mut self, replace_pre_with_code_fences: bool) -> Self {
99        self.replace_pre_with_code_fences = replace_pre_with_code_fences;
100        self
101    }
102
103    pub(crate) fn insert<T: Serialize + ?Sized, S: Into<String>>(&mut self, key: S, val: &T) {
104        self.tera_ctx.insert(key.into(), val);
105    }
106
107    fn tera_ctx(&self) -> tera::Context {
108        let mut ctx = self.tera_ctx.clone();
109        ctx.insert("spec", &self.spec);
110        ctx.insert("header_level", &self.header_level);
111        ctx.insert("multi", &self.multi);
112        ctx.insert("url_prefix", &self.url_prefix);
113        ctx.insert("html_encode", &self.html_encode);
114        ctx
115    }
116
117    pub(crate) fn render(&self, template_name: &str) -> Result<String, UsageErr> {
118        let mut tera = TERA.clone();
119
120        let html_encode = self.html_encode;
121        tera.register_filter(
122            "escape_md",
123            move |value: &tera::Value,
124                  _: tera::Kwargs,
125                  _: &tera::State|
126                  -> tera::TeraResult<String> {
127                let value = value.as_str().unwrap();
128                let value = escape_md(value, html_encode);
129                Ok(value)
130            },
131        );
132
133        Ok(tera.render(template_name, &self.tera_ctx())?)
134    }
135
136    pub(crate) fn replace_code_fences(&self, md: String) -> String {
137        if !self.replace_pre_with_code_fences {
138            return md;
139        }
140        // TODO: handle fences inside of <pre> or <code>
141        let mut in_code_block = false;
142        let mut new_md = String::new();
143        for line in md.lines() {
144            if let Some(line) = line.strip_prefix("    ") {
145                if in_code_block {
146                    new_md.push_str(&format!("{line}\n"));
147                } else {
148                    new_md.push_str(&format!("```\n{line}\n"));
149                    in_code_block = true;
150                }
151            } else {
152                if in_code_block {
153                    new_md.push_str("```\n");
154                    in_code_block = false;
155                }
156                new_md.push_str(&format!("{line}\n"));
157            }
158        }
159        if in_code_block {
160            new_md.push_str("```\n");
161        }
162        new_md.replace("```\n\n```\n", "\n")
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::escape_md;
169    use pretty_assertions::assert_eq;
170
171    #[test]
172    fn escapes_html_around_fenced_code_blocks() {
173        let input = "before <\n```\ninside <\n```  \nafter <";
174        let expected = "before &lt;\n```\ninside <\n```  \nafter &lt;";
175
176        assert_eq!(escape_md(input, true), expected);
177    }
178
179    #[test]
180    fn supports_fence_info_strings() {
181        let input = "```bash\necho <value>\n```\nafter <";
182        let expected = "```bash\necho <value>\n```\nafter &lt;";
183
184        assert_eq!(escape_md(input, true), expected);
185    }
186
187    #[test]
188    fn leaves_unclosed_fences_unescaped() {
189        let input = "```\necho <value>";
190
191        assert_eq!(escape_md(input, true), input);
192    }
193
194    #[test]
195    fn ignores_indented_and_longer_fences() {
196        let input = "    ```\nindented <\n````\nlonger <";
197        let expected = "    ```\nindented &lt;\n````\nlonger &lt;";
198
199        assert_eq!(escape_md(input, true), expected);
200    }
201
202    #[test]
203    fn leaves_markdown_unchanged_when_html_encoding_is_disabled() {
204        let input = "before <\n```\ninside <\n```\nafter <";
205
206        assert_eq!(escape_md(input, false), input);
207    }
208}