mant_core/output/markdown/
mod.rs1mod blocks;
4mod inline;
5
6use mant_ast::{
7 Block, ExcerptSelection, LayoutHint, OutlineNode, QueryBundle, QueryExcerpt, QueryOutline,
8 Section, TldrCommandPart, TldrDocument, TldrOrigin,
9};
10
11use self::{
12 blocks::render_blocks,
13 inline::{code_span, escape_text},
14};
15use crate::projection::DOCUMENT_ROOT_ID;
16
17#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
19pub struct MarkdownOptions {
20 pub preserve_anchors: bool,
22}
23
24impl MarkdownOptions {
25 pub const ADDRESSABLE: Self = Self {
27 preserve_anchors: true,
28 };
29}
30
31#[must_use]
33pub fn render_markdown(query: &QueryBundle) -> String {
34 render_markdown_with_options(query, MarkdownOptions::default())
35}
36
37#[must_use]
39pub fn render_markdown_with_options(query: &QueryBundle, options: MarkdownOptions) -> String {
40 let mut output = Vec::new();
41 output.push(heading(1, &query.label));
42
43 if let Some(tldr) = &query.tldr {
44 output.extend(render_tldr(tldr));
45 if query.document.is_some() {
46 output.push("---".to_owned());
47 }
48 }
49
50 if let Some(document) = &query.document {
51 if options.preserve_anchors && !document.blocks.is_empty() {
52 output.push(inline::html_anchor(DOCUMENT_ROOT_ID));
53 }
54 output.extend(render_blocks(&document.blocks, options));
55 render_sections(&mut output, &document.sections, 2, options);
56 }
57 output
58 .into_iter()
59 .filter(|block| !block.is_empty())
60 .collect::<Vec<_>>()
61 .join("\n\n")
62 .trim_end()
63 .to_owned()
64}
65
66#[must_use]
68pub fn render_outline_markdown(outline: &QueryOutline) -> String {
69 let label = document_label(
70 &outline.label,
71 outline
72 .meta
73 .as_ref()
74 .and_then(|meta| meta.section.as_deref()),
75 );
76 let mut blocks = vec![heading(1, &format!("{label} outline"))];
77 if !outline.nodes.is_empty() {
78 blocks.push(outline_list(&outline.nodes, 0));
79 }
80 blocks.join("\n\n").trim_end().to_owned()
81}
82
83#[must_use]
85pub fn render_excerpt_markdown(excerpt: &QueryExcerpt) -> String {
86 render_excerpt_markdown_with_options(excerpt, MarkdownOptions::default())
87}
88
89#[must_use]
91pub fn render_excerpt_markdown_with_options(
92 excerpt: &QueryExcerpt,
93 options: MarkdownOptions,
94) -> String {
95 let label = document_label(
96 &excerpt.label,
97 excerpt
98 .meta
99 .as_ref()
100 .and_then(|meta| meta.section.as_deref()),
101 );
102 let mut output = vec![heading(1, &label)];
103 for (index, selection) in excerpt.selections.iter().enumerate() {
104 if index > 0 {
105 output.push("---".to_owned());
106 }
107 output.push(selection_context(selection));
108 match selection {
109 ExcerptSelection::Tldr { document, .. } => output.extend(render_tldr(document)),
110 ExcerptSelection::DocumentRoot { blocks, .. } => {
111 output.extend(render_blocks(blocks, options));
112 }
113 ExcerptSelection::DocumentSection { section, .. } => {
114 render_sections(&mut output, std::slice::from_ref(section), 2, options);
115 }
116 ExcerptSelection::DocumentEntry { entry, .. } => {
117 output.extend(render_blocks(
118 &[Block::DefinitionList {
119 items: vec![entry.clone()],
120 compact: true,
121 layout: LayoutHint::default(),
122 source: None,
123 }],
124 options,
125 ));
126 }
127 }
128 }
129 output
130 .into_iter()
131 .filter(|block| !block.is_empty())
132 .collect::<Vec<_>>()
133 .join("\n\n")
134 .trim_end()
135 .to_owned()
136}
137
138fn outline_list(nodes: &[OutlineNode], depth: usize) -> String {
139 let mut lines = Vec::new();
140 for node in nodes {
141 lines.push(format!(
142 "{}- {} ({}) {}",
143 " ".repeat(depth),
144 code_span(node.path()),
145 code_span(node.id()),
146 escape_text(node.title())
147 ));
148 let children = outline_list(node.children(), depth + 1);
149 if !children.is_empty() {
150 lines.push(children);
151 }
152 }
153 lines.join("\n")
154}
155
156fn selection_context(selection: &ExcerptSelection) -> String {
157 match selection {
158 ExcerptSelection::Tldr { path, title, .. }
159 | ExcerptSelection::DocumentRoot { path, title, .. } => {
160 format!("*Outline {}: {}*", code_span(path), escape_text(title))
161 }
162 ExcerptSelection::DocumentSection {
163 path,
164 title,
165 breadcrumbs,
166 ..
167 } => {
168 let breadcrumb = breadcrumbs
169 .iter()
170 .map(|ancestor| escape_text(&ancestor.title))
171 .chain(std::iter::once(escape_text(title)))
172 .collect::<Vec<_>>()
173 .join(" → ");
174 format!("*Outline {}: {breadcrumb}*", code_span(path))
175 }
176 ExcerptSelection::DocumentEntry {
177 path,
178 title,
179 breadcrumbs,
180 ..
181 } => {
182 let breadcrumb = breadcrumbs
183 .iter()
184 .map(|ancestor| escape_text(&ancestor.title))
185 .chain(std::iter::once(escape_text(title)))
186 .collect::<Vec<_>>()
187 .join(" → ");
188 format!("*Outline {}: {breadcrumb}*", code_span(path))
189 }
190 }
191}
192
193fn render_sections(
194 output: &mut Vec<String>,
195 sections: &[Section],
196 depth: usize,
197 options: MarkdownOptions,
198) {
199 for section in sections {
200 if options.preserve_anchors {
201 output.push(format!(
202 "{}\n\n{}",
203 inline::html_anchor(§ion.id),
204 heading(depth, §ion.title)
205 ));
206 } else {
207 output.push(heading(depth, §ion.title));
208 }
209 output.extend(render_blocks(§ion.blocks, options));
210 render_sections(output, §ion.children, depth.saturating_add(1), options);
211 }
212}
213
214fn render_tldr(page: &TldrDocument) -> Vec<String> {
215 let mut output = vec![heading(2, "TLDR")];
216 output.extend(
217 page.description
218 .iter()
219 .filter(|line| !line.trim().is_empty())
220 .map(|line| escape_text(line.trim())),
221 );
222
223 if let Some(value) = page.more_information.as_deref() {
224 output.push(render_more_information(value));
225 }
226 if !page.examples.is_empty() {
227 output.push(heading(3, "Examples"));
228 for example in &page.examples {
229 if !example.description.trim().is_empty() {
230 output.push(format!("**{}**", escape_text(example.description.trim())));
231 }
232 if !example.command.is_empty() {
233 let resolved = example
234 .command_parts
235 .iter()
236 .map(|part| match part {
237 TldrCommandPart::Text { value }
238 | TldrCommandPart::Placeholder { value } => value.as_str(),
239 })
240 .collect::<String>();
241 output.push(inline::fenced_code(
242 if resolved.is_empty() {
243 &example.command
244 } else {
245 &resolved
246 },
247 Some("sh"),
248 ));
249 }
250 }
251 }
252 if page.origin == TldrOrigin::TldrPages {
253 output.push(format!(
254 "*tldr-pages · CC BY 4.0 · {} · {}*",
255 escape_text(&page.platform),
256 escape_text(&page.language)
257 ));
258 }
259 output
260}
261
262pub(crate) use inline::html_anchor;
263
264fn render_more_information(value: &str) -> String {
265 let value = value.trim();
266 if value.starts_with("http://") || value.starts_with("https://") {
267 let (url, punctuation) = value
268 .strip_suffix('.')
269 .map_or((value, ""), |url| (url, "."));
270 if !url.chars().any(char::is_whitespace) && !url.contains(['<', '>']) {
271 return format!("**More information:** <{url}>{punctuation}");
272 }
273 }
274 format!("**More information:** {}", escape_text(value))
275}
276
277fn heading(depth: usize, title: &str) -> String {
278 format!("{} {}", "#".repeat(depth.clamp(1, 6)), escape_text(title))
279}
280
281fn document_label(label: &str, section: Option<&str>) -> String {
282 section.map_or_else(|| label.to_owned(), |section| format!("{label}({section})"))
283}
284
285#[cfg(test)]
286mod tests;