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