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, 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}
63
64#[derive(Clone)]
65pub(crate) enum MarkdownNode {
66 Tldr,
67 DocumentRoot,
68 DocumentSection {
69 section: MarkdownSection,
70 source: Option<SourceSpan>,
71 },
72 DocumentEntry {
73 path: OutlinePath,
74 id: NodeId,
75 title: String,
76 role: DefinitionRole,
77 case: DefinitionCase,
78 names: Vec<String>,
79 section: Option<MarkdownSection>,
80 source: Option<SourceSpan>,
81 },
82}
83
84pub(crate) fn render_addressable_markdown(query: &ResolvedContent) -> MarkdownArtifact {
85 render_markdown_artifact(query, MarkdownOptions::ADDRESSABLE)
86}
87
88fn render_markdown_artifact(query: &ResolvedContent, options: MarkdownOptions) -> MarkdownArtifact {
89 let mut output = ArtifactBuilder::default();
90 output.push(&heading(1, &query.label));
91
92 if let Some(tldr) = &query.tldr {
93 for (index, block) in render_tldr(tldr).into_iter().enumerate() {
94 let range = output.push(&block);
95 if index == 0 {
96 output.begin_tldr(range.start);
97 }
98 }
99 if query.document.is_some() {
100 output.push("---");
101 }
102 }
103
104 if let Some(document) = &query.document {
105 if !document.blocks.is_empty() {
106 let start = if options.preserve_anchors {
107 output.push(&inline::html_anchor(DOCUMENT_ROOT_ID)).start
108 } else {
109 output.text.len()
110 };
111 output.begin_root(start);
112 let rendered = render_blocks_with_entries(&document.blocks, options);
113 output.push_scope(rendered, None, None);
114 }
115 render_artifact_sections(&mut output, &document.sections, &[], 2, options);
116 }
117 output.finish()
118}
119
120#[derive(Default)]
121struct ArtifactBuilder {
122 text: String,
123 nodes: Vec<MarkdownNodeRange>,
124 tldr: Option<usize>,
125 root: Option<usize>,
126 last_section: Option<usize>,
127}
128
129impl ArtifactBuilder {
130 fn push(&mut self, block: &str) -> Range<usize> {
131 if block.is_empty() {
132 return self.text.len()..self.text.len();
133 }
134 if !self.text.is_empty() {
135 self.text.push_str("\n\n");
136 }
137 let start = self.text.len();
138 self.text.push_str(block);
139 start..self.text.len()
140 }
141
142 fn begin_tldr(&mut self, start: usize) {
143 self.tldr = Some(self.node(start, MarkdownNode::Tldr));
144 }
145
146 fn begin_root(&mut self, start: usize) {
147 self.close_tldr(start);
148 self.root = Some(self.node(start, MarkdownNode::DocumentRoot));
149 }
150
151 fn begin_section(
152 &mut self,
153 start: usize,
154 section: MarkdownSection,
155 source: Option<SourceSpan>,
156 ) {
157 self.close_tldr(start);
158 if let Some(root) = self.root.take() {
159 self.nodes[root].range.end = start;
160 }
161 if let Some(previous) = self.last_section {
162 self.nodes[previous].range.end = start;
163 }
164 self.last_section =
165 Some(self.node(start, MarkdownNode::DocumentSection { section, source }));
166 }
167
168 fn push_scope(
169 &mut self,
170 rendered: RenderedBlocks,
171 section: Option<&MarkdownSection>,
172 coordinates: Option<&[usize]>,
173 ) {
174 if rendered.text.is_empty() {
175 return;
176 }
177 let block = self.push(&rendered.text);
178 for entry in rendered.entries {
179 let path = OutlinePath::entry(coordinates, entry.index)
180 .expect("enumerated entry paths are one-based");
181 self.nodes.push(MarkdownNodeRange {
182 range: block.start + entry.start..block.start + entry.end,
183 node: MarkdownNode::DocumentEntry {
184 path,
185 id: entry.identity.id,
186 title: entry.identity.names.join(", "),
187 role: entry.identity.role,
188 case: entry.identity.case,
189 names: entry.identity.names,
190 section: section.cloned(),
191 source: entry.source,
192 },
193 });
194 }
195 }
196
197 fn node(&mut self, start: usize, node: MarkdownNode) -> usize {
198 let index = self.nodes.len();
199 self.nodes.push(MarkdownNodeRange {
200 range: start..self.text.len(),
201 node,
202 });
203 index
204 }
205
206 fn close_tldr(&mut self, end: usize) {
207 if let Some(tldr) = self.tldr.take() {
208 self.nodes[tldr].range.end = end;
209 }
210 }
211
212 fn finish(mut self) -> MarkdownArtifact {
213 let end = self.text.trim_end().len();
214 self.text.truncate(end);
215 self.close_tldr(end);
216 if let Some(root) = self.root.take() {
217 self.nodes[root].range.end = end;
218 }
219 if let Some(section) = self.last_section {
220 self.nodes[section].range.end = end;
221 }
222 for node in &mut self.nodes {
223 node.range.end = node.range.end.min(end);
224 }
225 MarkdownArtifact {
226 text: self.text,
227 nodes: self.nodes,
228 }
229 }
230}
231
232fn render_artifact_sections(
233 output: &mut ArtifactBuilder,
234 sections: &[Section],
235 parent: &[usize],
236 depth: usize,
237 options: MarkdownOptions,
238) {
239 for (index, section) in sections.iter().enumerate() {
240 let mut coordinates = parent.to_vec();
241 coordinates.push(index + 1);
242 let path =
243 OutlinePath::section(&coordinates).expect("enumerated section paths are one-based");
244 let rendered_heading = if options.preserve_anchors {
245 format!(
246 "{}\n\n{}",
247 inline::html_anchor(§ion.id),
248 heading(depth, §ion.title)
249 )
250 } else {
251 heading(depth, §ion.title)
252 };
253 let range = output.push(&rendered_heading);
254 let reference = MarkdownSection {
255 path,
256 id: section.id.clone(),
257 title: section.title.clone(),
258 };
259 output.begin_section(range.start, reference.clone(), section.source);
260 output.push_scope(
261 render_blocks_with_entries(§ion.blocks, options),
262 Some(&reference),
263 Some(&coordinates),
264 );
265 render_artifact_sections(
266 output,
267 §ion.children,
268 &coordinates,
269 depth.saturating_add(1),
270 options,
271 );
272 }
273}
274
275#[must_use]
277pub fn render_outline_markdown(outline: &QueryOutline) -> String {
278 let label = document_label(
279 &outline.label,
280 outline
281 .meta
282 .as_ref()
283 .and_then(|meta| meta.manual_section.as_deref()),
284 );
285 let mut blocks = vec![heading(1, &format!("{label} outline"))];
286 if !outline.nodes.is_empty() {
287 blocks.push(outline_list(&outline.nodes, 0));
288 }
289 blocks.join("\n\n").trim_end().to_owned()
290}
291
292#[must_use]
294pub fn render_excerpt_markdown(excerpt: &QueryExcerpt) -> String {
295 render_excerpt_markdown_with_options(excerpt, MarkdownOptions::default())
296}
297
298#[must_use]
300pub fn render_excerpt_markdown_with_options(
301 excerpt: &QueryExcerpt,
302 options: MarkdownOptions,
303) -> String {
304 let label = document_label(
305 &excerpt.label,
306 excerpt
307 .meta
308 .as_ref()
309 .and_then(|meta| meta.manual_section.as_deref()),
310 );
311 let mut output = vec![heading(1, &label)];
312 for (index, selection) in excerpt.selections.iter().enumerate() {
313 if index > 0 {
314 output.push("---".to_owned());
315 }
316 output.push(selection_context(selection));
317 match selection {
318 ExcerptSelection::Tldr { document, .. } => output.extend(render_tldr(document)),
319 ExcerptSelection::DocumentRoot { blocks, .. } => {
320 output.extend(render_blocks(blocks, options));
321 }
322 ExcerptSelection::DocumentSection { section, .. } => {
323 render_sections(&mut output, std::slice::from_ref(section), 2, options);
324 }
325 ExcerptSelection::DocumentEntry { entry, .. } => {
326 output.extend(render_blocks(
327 &[Block::DefinitionList {
328 items: vec![entry.clone()],
329 compact: true,
330 layout: LayoutHint::default(),
331 source: None,
332 }],
333 options,
334 ));
335 }
336 }
337 }
338 output
339 .into_iter()
340 .filter(|block| !block.is_empty())
341 .collect::<Vec<_>>()
342 .join("\n\n")
343 .trim_end()
344 .to_owned()
345}
346
347fn outline_list(nodes: &[OutlineNode], depth: usize) -> String {
348 let mut lines = Vec::new();
349 for node in nodes {
350 lines.push(format!(
351 "{}- {} ({}) {}",
352 " ".repeat(depth),
353 code_span(node.path()),
354 code_span(node.id()),
355 escape_text(node.title())
356 ));
357 let children = outline_list(node.children(), depth + 1);
358 if !children.is_empty() {
359 lines.push(children);
360 }
361 }
362 lines.join("\n")
363}
364
365fn selection_context(selection: &ExcerptSelection) -> String {
366 match selection {
367 ExcerptSelection::Tldr { path, title, .. }
368 | ExcerptSelection::DocumentRoot { path, title, .. } => {
369 format!("*Outline {}: {}*", code_span(path), escape_text(title))
370 }
371 ExcerptSelection::DocumentSection {
372 path,
373 title,
374 breadcrumbs,
375 ..
376 } => {
377 let breadcrumb = breadcrumbs
378 .iter()
379 .map(|ancestor| escape_text(&ancestor.title))
380 .chain(std::iter::once(escape_text(title)))
381 .collect::<Vec<_>>()
382 .join(" → ");
383 format!("*Outline {}: {breadcrumb}*", code_span(path))
384 }
385 ExcerptSelection::DocumentEntry {
386 path,
387 title,
388 breadcrumbs,
389 ..
390 } => {
391 let breadcrumb = breadcrumbs
392 .iter()
393 .map(|ancestor| escape_text(&ancestor.title))
394 .chain(std::iter::once(escape_text(title)))
395 .collect::<Vec<_>>()
396 .join(" → ");
397 format!("*Outline {}: {breadcrumb}*", code_span(path))
398 }
399 }
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_label(label: &str, section: Option<&str>) -> String {
489 section.map_or_else(|| label.to_owned(), |section| format!("{label}({section})"))
490}
491
492#[cfg(test)]
493mod tests;