Skip to main content

ytcli/render/
markdown.rs

1//! Markdown, rendered for a terminal.
2//!
3//! Tracker's descriptions and comments are markdown, and printing them raw
4//! makes a person read the syntax instead of the text. This module renders them
5//! with [`termimad`], under two constraints that are not negotiable:
6//!
7//! 1. **Only for a terminal.** Machine output keeps the source text verbatim —
8//!    an agent reads markdown perfectly well, and reflowing it would change
9//!    bytes a caller may be diffing.
10//! 2. **The block stays marked as someone else's.** Rendering makes the text
11//!    readable; it must not make it look like the tool talking. Every line keeps
12//!    a dim margin bar, so where the quoted block starts and ends is never a
13//!    matter of interpretation (ADR 1).
14
15use std::fmt::Write as _;
16
17use termimad::MadSkin;
18use termimad::crossterm::style::Attribute;
19use termimad::minimad::Alignment;
20
21use crate::render::image::Inline;
22use crate::render::style::{Painter, Palette};
23
24/// The bar that marks every line of quoted text.
25const MARGIN: &str = "\u{258f} ";
26
27/// Render `body` as markdown, wrapped to `width`.
28///
29/// Returns the lines without the margin; [`quoted`] is what callers normally
30/// want.
31#[must_use]
32pub fn render(body: &str, width: usize) -> String {
33    skin().text(body, Some(width.max(20))).to_string()
34}
35
36/// Render `body` and mark every line as quoted text.
37///
38/// `width` is the width available to the whole block, margin included.
39///
40/// Where a line is nothing but a markdown image reference and `images` has the
41/// picture it names, the picture is drawn in its place. That is where the
42/// author put it, and a screenshot that appears three paragraphs from the
43/// sentence about it is a different document.
44#[must_use]
45pub fn quoted(body: &str, width: usize, paint: Painter, images: &Inline) -> String {
46    let bar = paint.paint(MARGIN, Palette::untrusted());
47    let inner = width.saturating_sub(MARGIN.chars().count());
48    let mut out = String::with_capacity(body.len() * 2);
49
50    if images.is_empty() {
51        push_rendered(&mut out, body, inner, &bar);
52        return out;
53    }
54
55    // Text between the pictures is rendered in runs rather than line by line:
56    // a list or a fenced block means nothing to markdown once it is split into
57    // separate documents.
58    let mut run = String::new();
59    for line in body.lines() {
60        if let Some(picture) = image_reference(line).and_then(|(_, url)| images.get(url)) {
61            push_rendered(&mut out, &run, inner, &bar);
62            run.clear();
63            let _ = write!(out, "{bar}{}", picture.escape);
64            // Under the picture: a caption read before there is anything to
65            // attach it to is just a filename.
66            let _ = writeln!(
67                out,
68                "{bar}{}",
69                paint.paint(&picture.caption, Palette::label())
70            );
71        } else {
72            run.push_str(line);
73            run.push('\n');
74        }
75    }
76    push_rendered(&mut out, &run, inner, &bar);
77
78    out
79}
80
81fn push_rendered(out: &mut String, body: &str, inner: usize, bar: &str) {
82    if body.trim().is_empty() {
83        return;
84    }
85    for line in render(body, inner).lines() {
86        // termimad pads lines out to the full width; the trailing run of spaces
87        // is invisible until something copies it, so it goes.
88        let _ = writeln!(out, "{bar}{}", line.trim_end());
89    }
90}
91
92/// `![alt](url)`, when that is the whole line.
93///
94/// Only the whole-line form is substituted. An image reference in the middle of
95/// a sentence is part of that sentence, and replacing it with a picture would
96/// cut the sentence in half.
97#[must_use]
98pub fn image_reference(line: &str) -> Option<(&str, &str)> {
99    let line = line.trim();
100    let rest = line.strip_prefix("![")?;
101    let (alt, rest) = rest.split_once("](")?;
102    let url = rest.strip_suffix(')')?;
103    (!url.is_empty() && !url.contains(char::is_whitespace)).then_some((alt, url))
104}
105
106/// Every whole-line image reference in `body`, in the order they appear.
107#[must_use]
108pub fn image_references(body: &str) -> Vec<(&str, &str)> {
109    body.lines().filter_map(image_reference).collect()
110}
111
112/// The skin: structure, no palette of our own.
113///
114/// Bold, italics, bullets and quote marks say what the author meant. Colour
115/// would say something else — that this text belongs to the tool — which is the
116/// one thing the block must not claim. `termimad`'s default is built from grey
117/// levels that hold on both light and dark terminals, so it needs only two
118/// corrections.
119fn skin() -> MadSkin {
120    let mut skin = MadSkin::default();
121    // The default centres H1 for a full-screen view. Inside a quoted block it
122    // reads as a title of our output rather than of theirs.
123    for header in &mut skin.headers {
124        header.align = Alignment::Left;
125    }
126    skin.headers[0].add_attr(Attribute::Bold);
127    skin
128}
129
130#[cfg(test)]
131// A failing `expect` in a test is the test failing, and it says why.
132#[allow(clippy::expect_used)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn markup_becomes_style_not_syntax() {
138        let out = render("**loud**", 40);
139        assert!(out.contains("loud"));
140        assert!(!out.contains("**"));
141    }
142
143    #[test]
144    fn every_line_of_a_quoted_block_carries_the_margin() {
145        let out = quoted(
146            "# Title\n\nbody text\n",
147            40,
148            Painter::plain(),
149            &Inline::default(),
150        );
151        assert!(out.lines().count() >= 2);
152        assert!(out.lines().all(|line| line.starts_with(MARGIN)));
153    }
154
155    /// The margin is the boundary marker, so an empty line inside the block
156    /// still gets one — otherwise a blank line would look like the end of it.
157    #[test]
158    fn a_blank_line_inside_the_block_is_still_marked() {
159        let out = quoted("one\n\ntwo", 40, Painter::plain(), &Inline::default());
160        assert!(out.lines().any(|line| line.trim_end() == MARGIN.trim_end()));
161    }
162
163    fn picture() -> Inline {
164        let mut inline = Inline::default();
165        inline.insert(
166            "/ajax/v2/attachments/29?inline=true".to_owned(),
167            crate::render::image::Picture {
168                escape: "<PICTURE>\n".to_owned(),
169                caption: "screenshot.png".to_owned(),
170            },
171        );
172        inline
173    }
174
175    /// The point of the whole exercise: the picture appears where the author
176    /// put it, not three paragraphs later.
177    #[test]
178    fn a_picture_replaces_the_reference_that_names_it() {
179        let body = "before\n\n![shot](/ajax/v2/attachments/29?inline=true)\n\nafter";
180        let out = quoted(body, 60, Painter::plain(), &picture());
181
182        let lines: Vec<&str> = out.lines().collect();
183        let at = lines
184            .iter()
185            .position(|line| line.contains("<PICTURE>"))
186            .expect("the picture was not drawn");
187
188        assert!(lines[..at].iter().any(|line| line.contains("before")));
189        assert!(lines[at..].iter().any(|line| line.contains("after")));
190        // The caption goes under the picture, and it is still inside the block.
191        assert!(lines[at + 1].contains("screenshot.png"));
192        assert!(lines[at + 1].starts_with(MARGIN));
193        // The markdown it replaced is gone.
194        assert!(!out.contains("!["));
195    }
196
197    /// A reference nothing matches stays what it was. Substituting only what we
198    /// actually fetched is what keeps a description honest.
199    #[test]
200    fn an_unmatched_reference_is_left_alone() {
201        let body = "![shot](/ajax/v2/attachments/999)";
202        let out = quoted(body, 60, Painter::plain(), &picture());
203        assert!(out.contains("shot"));
204        assert!(!out.contains("<PICTURE>"));
205    }
206
207    #[test]
208    fn only_a_whole_line_reference_is_a_picture() {
209        assert_eq!(
210            image_reference("![alt](/a/b.png)"),
211            Some(("alt", "/a/b.png"))
212        );
213        assert_eq!(image_reference("  ![](/a/b.png)  "), Some(("", "/a/b.png")));
214        // Part of a sentence: replacing it would cut the sentence in half.
215        assert_eq!(image_reference("see ![alt](/a/b.png) here"), None);
216        assert_eq!(image_reference("[link](/a/b)"), None);
217        assert_eq!(image_reference("![alt]()"), None);
218    }
219
220    #[test]
221    fn lines_do_not_carry_trailing_padding() {
222        let out = quoted("short", 60, Painter::plain(), &Inline::default());
223        assert!(out.lines().all(|line| !line.ends_with(' ')));
224    }
225}