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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum MarkdownTemplate {
16 Spec,
18 Index,
20 Command,
22 Argument,
24 Flag,
26 Config,
28}
29
30#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
32pub enum MarkdownTheme {
33 #[default]
35 Compact,
36 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
53static SGR: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\x1b\[[0-?]*[ -/]*[@-~]").unwrap());
55static CODE_SPAN_OR_LT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(`[^`]*`)|(<)").unwrap());
57
58fn escape_md_with_indent(value: &str, html_encode: bool, indent: bool) -> String {
59 let mut in_fenced_code_block = false;
60 let value = SGR.replace_all(value, "");
65
66 value
67 .lines()
68 .enumerate()
69 .map(|(index, line)| {
70 let line = if !html_encode {
71 line.to_string()
72 } else {
73 if line.starts_with(" ") {
76 line.to_string()
77 } else if in_fenced_code_block {
78 if line.trim_end() == "```" {
79 in_fenced_code_block = false;
80 }
81 line.to_string()
82 } else if line
85 .strip_prefix("```")
86 .is_some_and(|suffix| !suffix.starts_with('`'))
87 {
88 in_fenced_code_block = true;
89 line.to_string()
90 } else {
91 CODE_SPAN_OR_LT
93 .replace_all(line, |caps: ®ex::Captures| {
94 if caps.get(1).is_some() {
95 caps.get(1).unwrap().as_str().to_string()
96 } else {
97 "<".to_string()
98 }
99 })
100 .to_string()
101 }
102 };
103 if indent && index > 0 && !line.is_empty() {
104 format!(" {line}")
105 } else {
106 line
107 }
108 })
109 .join("\n")
110}
111
112fn escape_md(value: &str, html_encode: bool) -> String {
113 escape_md_with_indent(value, html_encode, false)
114}
115
116#[derive(Debug, Clone)]
117pub struct MarkdownRenderer {
118 pub(crate) spec: Spec,
119 pub(crate) raw_config: crate::spec::config::SpecConfig,
128 pub(crate) header_level: usize,
129 pub(crate) multi: bool,
130 url_prefix: Option<String>,
131 html_encode: bool,
132 replace_pre_with_code_fences: bool,
133 theme: MarkdownTheme,
134 templates: Vec<(MarkdownTemplate, String)>,
135}
136
137impl MarkdownRenderer {
138 pub fn new(spec: crate::Spec) -> Self {
139 let mut renderer = Self {
140 raw_config: spec.config.clone(),
141 spec: spec.into(),
142 header_level: 1,
143 multi: false,
144 url_prefix: None,
145 html_encode: true,
146 replace_pre_with_code_fences: false,
147 theme: MarkdownTheme::default(),
148 templates: Vec::new(),
149 };
150 let mut spec = renderer.spec.clone();
151 spec.render_md(&renderer);
152 renderer.spec = spec;
153 renderer
154 }
155
156 pub fn config_page(&self) -> &'static str {
169 "configuration.md"
170 }
171
172 pub fn config_page_collision(&self) -> Option<&str> {
177 let stem = self.config_page().trim_end_matches(".md");
178 self.spec
179 .cmd
180 .subcommands
181 .values()
182 .find(|cmd| !cmd.hide && cmd.full_cmd == [stem])
183 .map(|cmd| cmd.name.as_str())
184 }
185
186 pub fn with_header_level(mut self, header_level: usize) -> Self {
187 self.header_level = header_level;
188 self
189 }
190
191 pub fn with_multi(mut self, index: bool) -> Self {
192 self.multi = index;
193 self
194 }
195
196 pub fn with_url_prefix<S: Into<String>>(mut self, url_prefix: S) -> Self {
197 self.url_prefix = Some(url_prefix.into());
198 self
199 }
200
201 pub fn with_html_encode(mut self, html_encode: bool) -> Self {
202 self.html_encode = html_encode;
203 self
204 }
205
206 pub fn with_replace_pre_with_code_fences(mut self, replace_pre_with_code_fences: bool) -> Self {
207 self.replace_pre_with_code_fences = replace_pre_with_code_fences;
208 self
209 }
210
211 pub fn with_theme(mut self, theme: MarkdownTheme) -> Self {
213 self.theme = theme;
214 self
215 }
216
217 pub fn with_template(mut self, template: MarkdownTemplate, source: impl Into<String>) -> Self {
224 let source = source.into();
225 if let Some((_, current)) = self
226 .templates
227 .iter_mut()
228 .find(|(current, _)| *current == template)
229 {
230 *current = source;
231 } else {
232 self.templates.push((template, source));
233 }
234 self
235 }
236
237 fn tera_ctx(&self) -> tera::Context {
238 let mut ctx = tera::Context::new();
239 ctx.insert("spec", &self.spec);
240 ctx.insert("header_level", &self.header_level);
241 ctx.insert("multi", &self.multi);
242 ctx.insert("url_prefix", &self.url_prefix);
243 ctx.insert("html_encode", &self.html_encode);
244 ctx
245 }
246
247 pub(crate) fn render_with(
253 &self,
254 template_name: &str,
255 enrich: impl FnOnce(&mut tera::Context),
256 ) -> Result<String, UsageErr> {
257 let mut tera = match self.theme {
258 MarkdownTheme::Compact => TERA.clone(),
259 MarkdownTheme::Detailed => crate::docs::markdown::tera::DETAILED_TERA.clone(),
260 };
261
262 for (template, source) in &self.templates {
263 tera.add_raw_template(template.name(), source)?;
264 }
265
266 let html_encode = self.html_encode;
267 tera.register_filter(
268 "escape_md",
269 move |value: &tera::Value,
270 _: tera::Kwargs,
271 _: &tera::State|
272 -> tera::TeraResult<String> {
273 let value = value.as_str().unwrap();
274 let value = escape_md(value, html_encode);
275 Ok(value)
276 },
277 );
278 tera.register_filter(
279 "escape_md_indented",
280 move |value: &tera::Value,
281 _: tera::Kwargs,
282 _: &tera::State|
283 -> tera::TeraResult<String> {
284 let value = value.as_str().unwrap();
285 Ok(escape_md_with_indent(value, html_encode, true))
286 },
287 );
288
289 let mut ctx = self.tera_ctx();
290 enrich(&mut ctx);
291 Ok(tera.render(template_name, &ctx)?)
292 }
293
294 pub(crate) fn replace_code_fences(&self, md: String) -> String {
295 if !self.replace_pre_with_code_fences {
296 return md;
297 }
298 let mut in_code_block = false;
300 let mut new_md = String::new();
301 for line in md.lines() {
302 if let Some(line) = line.strip_prefix(" ") {
303 if in_code_block {
304 new_md.push_str(&format!("{line}\n"));
305 } else {
306 new_md.push_str(&format!("```\n{line}\n"));
307 in_code_block = true;
308 }
309 } else {
310 if in_code_block {
311 new_md.push_str("```\n");
312 in_code_block = false;
313 }
314 new_md.push_str(&format!("{line}\n"));
315 }
316 }
317 if in_code_block {
318 new_md.push_str("```\n");
319 }
320 new_md.replace("```\n\n```\n", "\n")
321 }
322}
323
324#[cfg(test)]
325mod tests {
326 use super::{escape_md, MarkdownRenderer, MarkdownTemplate};
327 use pretty_assertions::assert_eq;
328
329 #[test]
330 fn escapes_html_around_fenced_code_blocks() {
331 let input = "before <\n```\ninside <\n``` \nafter <";
332 let expected = "before <\n```\ninside <\n``` \nafter <";
333
334 assert_eq!(escape_md(input, true), expected);
335 }
336
337 #[test]
338 fn supports_fence_info_strings() {
339 let input = "```bash\necho <value>\n```\nafter <";
340 let expected = "```bash\necho <value>\n```\nafter <";
341
342 assert_eq!(escape_md(input, true), expected);
343 }
344
345 #[test]
346 fn leaves_unclosed_fences_unescaped() {
347 let input = "```\necho <value>";
348
349 assert_eq!(escape_md(input, true), input);
350 }
351
352 #[test]
353 fn ignores_indented_and_longer_fences() {
354 let input = " ```\nindented <\n````\nlonger <";
355 let expected = " ```\nindented <\n````\nlonger <";
356
357 assert_eq!(escape_md(input, true), expected);
358 }
359
360 #[test]
361 fn leaves_markdown_unchanged_when_html_encoding_is_disabled() {
362 let input = "before <\n```\ninside <\n```\nafter <";
363
364 assert_eq!(escape_md(input, false), input);
365 }
366
367 #[test]
368 fn strips_terminal_styling_from_generated_markdown() {
369 let input =
370 "\u{1b}[1m\u{1b}[4mExamples:\u{1b}[22m\u{1b}[24m\n\n \u{1b}[1mmise run\u{1b}[22m";
371 let expected = "Examples:\n\n mise run";
372
373 assert_eq!(escape_md(input, true), expected);
374 assert_eq!(escape_md(input, false), expected);
375 }
376
377 #[test]
378 fn one_template_can_be_replaced_without_copying_its_includes() {
379 let spec = "bin \"ex\"\nflag \"--force\" help=\"Do it anyway\"\n"
380 .parse()
381 .unwrap();
382 let page = MarkdownRenderer::new(spec)
383 .with_template(
384 MarkdownTemplate::Spec,
385 "# Custom {{ spec.bin }}\n{% set cmd = spec.cmd %}\n{% include \"cmd_template.md.tera\" %}",
386 )
387 .render_spec()
388 .unwrap();
389
390 assert!(page.starts_with("# Custom ex\n"), "{page}");
391 assert!(page.contains("- **`--force`**"), "{page}");
392 }
393
394 #[test]
395 fn the_last_replacement_of_a_template_wins() {
396 let spec = "bin \"ex\"\n".parse().unwrap();
397 let page = MarkdownRenderer::new(spec)
398 .with_template(MarkdownTemplate::Spec, "{{")
399 .with_template(MarkdownTemplate::Spec, "second")
400 .render_spec()
401 .unwrap();
402
403 assert_eq!(page, "second");
404 }
405
406 #[test]
407 fn a_bad_custom_template_is_a_render_error() {
408 let spec = "bin \"ex\"\n".parse().unwrap();
409 let err = MarkdownRenderer::new(spec)
410 .with_template(MarkdownTemplate::Spec, "{{")
411 .render_spec()
412 .unwrap_err();
413
414 assert!(err.to_string().contains("template"), "{err}");
415 }
416
417 #[test]
418 fn the_detailed_theme_keeps_addressable_entry_headings() {
419 let spec = "bin \"ex\"\nflag \"--force\" help=\"Do it anyway\"\n"
420 .parse()
421 .unwrap();
422 let page = MarkdownRenderer::new(spec)
423 .with_theme(super::MarkdownTheme::Detailed)
424 .render_spec()
425 .unwrap();
426
427 assert!(page.contains("### `--force`"), "{page}");
428 }
429
430 #[test]
431 fn entry_template_overrides_apply_to_the_compact_theme() {
432 let spec = "bin \"ex\"\narg \"<file>\"\nflag \"--force\"\n"
433 .parse()
434 .unwrap();
435 let page = MarkdownRenderer::new(spec)
436 .with_template(MarkdownTemplate::Argument, "argument: {{ arg.usage }}")
437 .with_template(MarkdownTemplate::Flag, "flag: {{ flag.usage }}")
438 .render_spec()
439 .unwrap();
440
441 assert!(page.contains("argument: <file>"), "{page}");
442 assert!(page.contains("flag: --force"), "{page}");
443 }
444}