1mod blocks;
4mod inline;
5
6use std::ops::Range;
7
8use mant_ir::{
9 Block, DefinitionCase, DefinitionRole, LayoutHint, NodeId, OutlinePath, Section, SourceSpan,
10 TldrCommandPart, TldrDocument, TldrOrigin,
11};
12use mant_protocol::{ExcerptSelection, OutlineNode, OutlineReference, QueryExcerpt, QueryOutline};
13
14use self::{
15 blocks::{RenderedBlocks, render_blocks, render_blocks_with_entries},
16 inline::{code_span, escape_text},
17};
18use crate::{ResolvedContent, projection::DOCUMENT_ROOT_ID};
19
20#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
22pub struct MarkdownOptions {
23 pub preserve_anchors: bool,
25}
26
27impl MarkdownOptions {
28 pub const ADDRESSABLE: Self = Self {
30 preserve_anchors: true,
31 };
32}
33
34#[must_use]
36pub fn render_markdown(query: &ResolvedContent) -> String {
37 render_markdown_with_options(query, MarkdownOptions::default())
38}
39
40#[must_use]
42pub fn render_markdown_with_options(query: &ResolvedContent, options: MarkdownOptions) -> String {
43 render_markdown_artifact(query, options).text
44}
45
46pub(crate) struct MarkdownArtifact {
47 pub(crate) text: String,
48 pub(crate) nodes: Vec<MarkdownNodeRange>,
49}
50
51#[derive(Clone)]
52pub(crate) struct MarkdownNodeRange {
53 pub(crate) range: Range<usize>,
54 pub(crate) node: MarkdownNode,
55}
56
57#[derive(Clone)]
58pub(crate) struct MarkdownSection {
59 pub(crate) path: OutlinePath,
60 pub(crate) id: NodeId,
61 pub(crate) title: String,
62 pub(crate) ancestors: Vec<OutlineReference>,
63}
64
65#[derive(Clone)]
66pub(crate) enum MarkdownNode {
67 Tldr,
68 DocumentRoot,
69 DocumentSection {
70 section: MarkdownSection,
71 source: Option<SourceSpan>,
72 },
73 DocumentEntry {
74 path: OutlinePath,
75 id: NodeId,
76 title: String,
77 role: DefinitionRole,
78 case: DefinitionCase,
79 names: Vec<String>,
80 section: Option<MarkdownSection>,
81 source: Option<SourceSpan>,
82 },
83}
84
85pub(crate) fn render_addressable_markdown(query: &ResolvedContent) -> MarkdownArtifact {
86 render_markdown_artifact(query, MarkdownOptions::ADDRESSABLE)
87}
88
89fn render_markdown_artifact(query: &ResolvedContent, options: MarkdownOptions) -> MarkdownArtifact {
90 let mut output = ArtifactBuilder::default();
91 output.push(&heading(1, &query.label));
92
93 if let Some(tldr) = &query.tldr {
94 for (index, block) in render_tldr(tldr).into_iter().enumerate() {
95 let range = output.push(&block);
96 if index == 0 {
97 output.begin_tldr(range.start);
98 }
99 }
100 if query.document.is_some() {
101 output.push("---");
102 }
103 }
104
105 if let Some(document) = &query.document {
106 if !document.blocks.is_empty() {
107 let start = if options.preserve_anchors {
108 output.push(&inline::html_anchor(DOCUMENT_ROOT_ID)).start
109 } else {
110 output.text.len()
111 };
112 output.begin_root(start);
113 let rendered = render_blocks_with_entries(&document.blocks, options);
114 output.push_scope(rendered, None, None);
115 }
116 render_artifact_sections(&mut output, &document.sections, &[], &[], 2, options);
117 }
118 output.finish()
119}
120
121#[derive(Default)]
122struct ArtifactBuilder {
123 text: String,
124 nodes: Vec<MarkdownNodeRange>,
125 tldr: Option<usize>,
126 root: Option<usize>,
127 last_section: Option<usize>,
128}
129
130impl ArtifactBuilder {
131 fn push(&mut self, block: &str) -> Range<usize> {
132 if block.is_empty() {
133 return self.text.len()..self.text.len();
134 }
135 if !self.text.is_empty() {
136 self.text.push_str("\n\n");
137 }
138 let start = self.text.len();
139 self.text.push_str(block);
140 start..self.text.len()
141 }
142
143 fn begin_tldr(&mut self, start: usize) {
144 self.tldr = Some(self.node(start, MarkdownNode::Tldr));
145 }
146
147 fn begin_root(&mut self, start: usize) {
148 self.close_tldr(start);
149 self.root = Some(self.node(start, MarkdownNode::DocumentRoot));
150 }
151
152 fn begin_section(
153 &mut self,
154 start: usize,
155 section: MarkdownSection,
156 source: Option<SourceSpan>,
157 ) {
158 self.close_tldr(start);
159 if let Some(root) = self.root.take() {
160 self.nodes[root].range.end = start;
161 }
162 if let Some(previous) = self.last_section {
163 self.nodes[previous].range.end = start;
164 }
165 self.last_section =
166 Some(self.node(start, MarkdownNode::DocumentSection { section, source }));
167 }
168
169 fn push_scope(
170 &mut self,
171 rendered: RenderedBlocks,
172 section: Option<&MarkdownSection>,
173 coordinates: Option<&[usize]>,
174 ) {
175 if rendered.text.is_empty() {
176 return;
177 }
178 let block = self.push(&rendered.text);
179 for entry in rendered.entries {
180 let path = OutlinePath::entry(coordinates, entry.index)
181 .expect("enumerated entry paths are one-based");
182 self.nodes.push(MarkdownNodeRange {
183 range: block.start + entry.start..block.start + entry.end,
184 node: MarkdownNode::DocumentEntry {
185 path,
186 id: entry.identity.id,
187 title: entry.identity.names.join(", "),
188 role: entry.identity.role,
189 case: entry.identity.case,
190 names: entry.identity.names,
191 section: section.cloned(),
192 source: entry.source,
193 },
194 });
195 }
196 }
197
198 fn node(&mut self, start: usize, node: MarkdownNode) -> usize {
199 let index = self.nodes.len();
200 self.nodes.push(MarkdownNodeRange {
201 range: start..self.text.len(),
202 node,
203 });
204 index
205 }
206
207 fn close_tldr(&mut self, end: usize) {
208 if let Some(tldr) = self.tldr.take() {
209 self.nodes[tldr].range.end = end;
210 }
211 }
212
213 fn finish(mut self) -> MarkdownArtifact {
214 let end = self.text.trim_end().len();
215 self.text.truncate(end);
216 self.close_tldr(end);
217 if let Some(root) = self.root.take() {
218 self.nodes[root].range.end = end;
219 }
220 if let Some(section) = self.last_section {
221 self.nodes[section].range.end = end;
222 }
223 for node in &mut self.nodes {
224 node.range.end = node.range.end.min(end);
225 }
226 MarkdownArtifact {
227 text: self.text,
228 nodes: self.nodes,
229 }
230 }
231}
232
233fn render_artifact_sections(
234 output: &mut ArtifactBuilder,
235 sections: &[Section],
236 parent: &[usize],
237 ancestors: &[OutlineReference],
238 depth: usize,
239 options: MarkdownOptions,
240) {
241 for (index, section) in sections.iter().enumerate() {
242 let mut coordinates = parent.to_vec();
243 coordinates.push(index + 1);
244 let path =
245 OutlinePath::section(&coordinates).expect("enumerated section paths are one-based");
246 let rendered_heading = if options.preserve_anchors {
247 format!(
248 "{}\n\n{}",
249 inline::html_anchor(§ion.id),
250 heading(depth, §ion.title)
251 )
252 } else {
253 heading(depth, §ion.title)
254 };
255 let range = output.push(&rendered_heading);
256 let reference = MarkdownSection {
257 path: path.clone(),
258 id: section.id.clone(),
259 title: section.title.clone(),
260 ancestors: ancestors.to_vec(),
261 };
262 output.begin_section(range.start, reference.clone(), section.source);
263 output.push_scope(
264 render_blocks_with_entries(§ion.blocks, options),
265 Some(&reference),
266 Some(&coordinates),
267 );
268 let mut child_ancestors = ancestors.to_vec();
269 child_ancestors.push(OutlineReference {
270 path: path.to_string().into(),
271 id: section.id.clone(),
272 title: section.title.clone(),
273 });
274 render_artifact_sections(
275 output,
276 §ion.children,
277 &coordinates,
278 &child_ancestors,
279 depth.saturating_add(1),
280 options,
281 );
282 }
283}
284
285#[must_use]
287pub fn render_outline_markdown(outline: &QueryOutline) -> String {
288 let label = document_label(
289 &outline.label,
290 outline
291 .meta
292 .as_ref()
293 .and_then(|meta| meta.manual_section.as_deref()),
294 );
295 let mut blocks = vec![heading(1, &format!("{label} outline"))];
296 if !outline.nodes.is_empty() {
297 blocks.push(outline_list(&outline.nodes, 0));
298 }
299 blocks.join("\n\n").trim_end().to_owned()
300}
301
302#[must_use]
304pub fn render_excerpt_markdown(excerpt: &QueryExcerpt) -> String {
305 render_excerpt_markdown_with_options(excerpt, MarkdownOptions::default())
306}
307
308#[must_use]
310pub fn render_excerpt_markdown_with_options(
311 excerpt: &QueryExcerpt,
312 options: MarkdownOptions,
313) -> String {
314 let label = document_label(
315 &excerpt.label,
316 excerpt
317 .meta
318 .as_ref()
319 .and_then(|meta| meta.manual_section.as_deref()),
320 );
321 let mut output = vec![heading(1, &label)];
322 for (index, selection) in excerpt.selections.iter().enumerate() {
323 if index > 0 {
324 output.push("---".to_owned());
325 }
326 output.push(selection_context(selection));
327 match selection {
328 ExcerptSelection::Tldr { document, .. } => output.extend(render_tldr(document)),
329 ExcerptSelection::DocumentRoot { blocks, .. } => {
330 output.extend(render_blocks(blocks, options));
331 }
332 ExcerptSelection::DocumentSection { section, .. } => {
333 render_sections(&mut output, std::slice::from_ref(section), 2, options);
334 }
335 ExcerptSelection::DocumentEntry { entry, .. } => {
336 output.extend(render_blocks(
337 &[Block::DefinitionList {
338 items: vec![entry.clone()],
339 compact: true,
340 layout: LayoutHint::default(),
341 source: None,
342 }],
343 options,
344 ));
345 }
346 }
347 }
348 output
349 .into_iter()
350 .filter(|block| !block.is_empty())
351 .collect::<Vec<_>>()
352 .join("\n\n")
353 .trim_end()
354 .to_owned()
355}
356
357fn outline_list(nodes: &[OutlineNode], depth: usize) -> String {
358 let mut lines = Vec::new();
359 for node in nodes {
360 lines.push(format!(
361 "{}- {} ({}) {}",
362 " ".repeat(depth),
363 code_span(node.path()),
364 code_span(node.id()),
365 escape_text(node.title())
366 ));
367 let children = outline_list(node.children(), depth + 1);
368 if !children.is_empty() {
369 lines.push(children);
370 }
371 }
372 lines.join("\n")
373}
374
375fn selection_context(selection: &ExcerptSelection) -> String {
376 let trail = selection.outline();
377 let breadcrumb = trail
378 .ancestors
379 .iter()
380 .map(|ancestor| escape_text(&ancestor.title))
381 .chain(std::iter::once(escape_text(trail.title())))
382 .collect::<Vec<_>>()
383 .join(" → ");
384 format!("*Outline {}: {breadcrumb}*", code_span(trail.path()))
385}
386
387fn render_sections(
388 output: &mut Vec<String>,
389 sections: &[Section],
390 depth: usize,
391 options: MarkdownOptions,
392) {
393 for section in sections {
394 if options.preserve_anchors {
395 output.push(format!(
396 "{}\n\n{}",
397 inline::html_anchor(§ion.id),
398 heading(depth, §ion.title)
399 ));
400 } else {
401 output.push(heading(depth, §ion.title));
402 }
403 output.extend(render_blocks(§ion.blocks, options));
404 render_sections(output, §ion.children, depth.saturating_add(1), options);
405 }
406}
407
408fn render_tldr(page: &TldrDocument) -> Vec<String> {
409 let mut output = vec![heading(2, "TLDR")];
410 output.extend(
411 page.description
412 .iter()
413 .filter(|line| !line.trim().is_empty())
414 .map(|line| escape_text(line.trim())),
415 );
416
417 if let Some(value) = page.more_information.as_deref() {
418 output.push(render_more_information(value));
419 }
420 if !page.examples.is_empty() {
421 output.push(heading(3, "Examples"));
422 for example in &page.examples {
423 if !example.description.trim().is_empty() {
424 output.push(format!("**{}**", escape_text(example.description.trim())));
425 }
426 if !example.command.is_empty() {
427 let resolved = example
428 .command_parts
429 .iter()
430 .map(|part| match part {
431 TldrCommandPart::Text { value }
432 | TldrCommandPart::Placeholder { value } => value.as_str(),
433 })
434 .collect::<String>();
435 output.push(inline::fenced_code(
436 if resolved.is_empty() {
437 &example.command
438 } else {
439 &resolved
440 },
441 Some("sh"),
442 ));
443 }
444 }
445 }
446 if page.origin == TldrOrigin::TldrPages {
447 output.push(format!(
448 "*tldr-pages · CC BY 4.0 · {} · {}*",
449 escape_text(&page.platform),
450 escape_text(&page.language)
451 ));
452 }
453 output
454}
455
456fn render_more_information(value: &str) -> String {
457 let value = value.trim();
458 if value.starts_with("http://") || value.starts_with("https://") {
459 let (url, punctuation) = value
460 .strip_suffix('.')
461 .map_or((value, ""), |url| (url, "."));
462 if !url.chars().any(char::is_whitespace) && !url.contains(['<', '>']) {
463 return format!("**More information:** <{url}>{punctuation}");
464 }
465 }
466 format!("**More information:** {}", escape_text(value))
467}
468
469fn heading(depth: usize, title: &str) -> String {
470 format!("{} {}", "#".repeat(depth.clamp(1, 6)), escape_text(title))
471}
472
473fn document_label(label: &str, section: Option<&str>) -> String {
474 section.map_or_else(|| label.to_owned(), |section| format!("{label}({section})"))
475}
476
477#[cfg(test)]
478mod tests;