Skip to main content

mant_core/output/markdown/
mod.rs

1//! Renders the native query contract as deterministic portable `CommonMark`.
2
3mod 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/// Markdown serialization controls that do not alter the query AST.
18#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
19pub struct MarkdownOptions {
20    /// Emit stable raw-HTML destinations and links for document-local references.
21    pub preserve_anchors: bool,
22}
23
24impl MarkdownOptions {
25    /// Addressable Markdown used by consumers of `mant.markdown/v1`.
26    pub const ADDRESSABLE: Self = Self {
27        preserve_anchors: true,
28    };
29}
30
31/// Render a complete query as clean Markdown without a trailing newline.
32#[must_use]
33pub fn render_markdown(query: &QueryBundle) -> String {
34    render_markdown_with_options(query, MarkdownOptions::default())
35}
36
37/// Render a complete query using explicit presentation-only options.
38#[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/// Render a complete query outline as a nested `CommonMark` list.
67#[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/// Render selected query nodes with their outline context.
84#[must_use]
85pub fn render_excerpt_markdown(excerpt: &QueryExcerpt) -> String {
86    render_excerpt_markdown_with_options(excerpt, MarkdownOptions::default())
87}
88
89/// Render selected nodes using explicit presentation-only options.
90#[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(&section.id),
204                heading(depth, &section.title)
205            ));
206        } else {
207            output.push(heading(depth, &section.title));
208        }
209        output.extend(render_blocks(&section.blocks, options));
210        render_sections(output, &section.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;