usage/docs/markdown/
renderer.rs1use 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
8static SGR: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\x1b\[[0-?]*[ -/]*[@-~]").unwrap());
10static 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 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 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 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 CODE_SPAN_OR_LT
49 .replace_all(line, |caps: ®ex::Captures| {
50 if caps.get(1).is_some() {
51 caps.get(1).unwrap().as_str().to_string()
52 } else {
53 "<".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 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 pub fn config_page(&self) -> &'static str {
110 "configuration.md"
111 }
112
113 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 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 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 <\n```\ninside <\n``` \nafter <";
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 <";
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 <\n````\nlonger <";
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}