usage/docs/markdown/
renderer.rs1use 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 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 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 xx::regex!(r"(`[^`]*`)|(<)")
38 .replace_all(line, |caps: ®ex::Captures| {
39 if caps.get(1).is_some() {
40 caps.get(1).unwrap().as_str().to_string()
41 } else {
42 "<".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 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 <\n```\ninside <\n``` \nafter <";
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 <";
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 <\n````\nlonger <";
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}