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