Skip to main content

plates_render/
attachment.rs

1//! An attachment's page: what a sidecar renders as.
2//!
3//! A sidecar is a document — it has a title, a place in the tree, an audience
4//! of its own — whose body is not prose but a payload: a photograph, a PDF, a
5//! recording. It is a node like any other, so it publishes as a page like any
6//! other, in the site frame, listed by its parent, walked by the pager, and
7//! that page's body is the one thing the payload can be shown as. Nothing here
8//! reads a file: the collector shipped the payload beside the page
9//! (`plates::collect`), and the page reaches it by the sibling reference the
10//! sidecar itself declares.
11//!
12//! The embed is chosen by the payload's extension, which is all a renderer
13//! that reads no bytes can go on: an image is a figure, a video or a recording
14//! is a player, a PDF is a frame with a download link beneath it for the
15//! readers whose browser will not scroll one, and anything else is the link
16//! alone. Every reference is a `src` or an `href`, on purpose — those are the
17//! attributes an encrypted site's reader shell knows to decrypt, and an
18//! `<object data>` would ship ciphertext into the viewer.
19
20use std::path::Path;
21
22use prov::Mapping;
23
24use crate::page::html_escape;
25
26/// The payload a sidecar's metadata names — its `content`, when the document
27/// is an attachment (an explicit `attachment: true`, or a `content` prov
28/// reads as an opaque payload rather than prose) — or `None` for a page.
29///
30/// The same two spellings [`prov::Document::is_attachment`] accepts, read
31/// off the collected frontmatter rather than a parsed document because the
32/// renderer only ever holds the former.
33pub fn payload_of(frontmatter: &Mapping) -> Option<&str> {
34    let content = frontmatter.get("content").and_then(|v| v.as_str())?;
35    let flagged = frontmatter
36        .get("attachment")
37        .and_then(|v| v.as_bool())
38        .unwrap_or(false);
39    (flagged || prov::document::is_opaque_payload(Path::new(content))).then_some(content)
40}
41
42/// What kind of thing the payload is, from its name alone.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44enum Kind {
45    Image,
46    Video,
47    Audio,
48    Document,
49    Other,
50}
51
52fn kind_of(payload: &str) -> Kind {
53    match Path::new(payload)
54        .extension()
55        .and_then(|e| e.to_str())
56        .map(|e| e.to_ascii_lowercase())
57        .as_deref()
58    {
59        Some(
60            "png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "avif" | "bmp" | "heic" | "heif",
61        ) => Kind::Image,
62        Some("mp4" | "webm" | "mov" | "m4v" | "ogv") => Kind::Video,
63        Some("mp3" | "m4a" | "aac" | "ogg" | "oga" | "wav" | "flac" | "opus") => Kind::Audio,
64        Some("pdf") => Kind::Document,
65        _ => Kind::Other,
66    }
67}
68
69/// The page body for an attachment titled `title` whose payload sits beside
70/// the page at `payload` (a sibling reference, as the sidecar declares it).
71/// Returns the HTML and the Markdown a template reads as the page's source
72/// body — an image or a link, which is what an author would have written.
73pub fn render(title: &str, payload: &str) -> (String, String) {
74    let href = html_escape(payload);
75    let name = html_escape(
76        Path::new(payload)
77            .file_name()
78            .and_then(|n| n.to_str())
79            .unwrap_or(payload),
80    );
81    let alt = html_escape(title);
82    let download = format!(
83        r#"<p class="attachment-download"><a href="{href}" download>Download {name}</a></p>"#
84    );
85    let html = match kind_of(payload) {
86        Kind::Image => format!(
87            r#"<figure class="attachment attachment-image"><img src="{href}" alt="{alt}"></figure>"#
88        ),
89        Kind::Video => format!(
90            r#"<figure class="attachment attachment-video"><video controls src="{href}"></video></figure>
91{download}"#
92        ),
93        Kind::Audio => format!(
94            r#"<figure class="attachment attachment-audio"><audio controls src="{href}"></audio></figure>
95{download}"#
96        ),
97        Kind::Document => format!(
98            r#"<figure class="attachment attachment-document"><iframe src="{href}" title="{alt}"></iframe></figure>
99{download}"#
100        ),
101        Kind::Other => download,
102    };
103    let markdown = match kind_of(payload) {
104        Kind::Image => format!("![{title}]({payload})\n"),
105        _ => format!("[{title}]({payload})\n"),
106    };
107    (html, markdown)
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use prov::Value;
114
115    fn fm(pairs: &[(&str, Value)]) -> Mapping {
116        pairs
117            .iter()
118            .map(|(k, v)| (k.to_string(), v.clone()))
119            .collect()
120    }
121
122    #[test]
123    fn a_flagged_sidecar_and_an_opaque_content_are_both_attachments() {
124        let flagged = fm(&[
125            ("content", Value::String("notes.bin".into())),
126            ("attachment", Value::Bool(true)),
127        ]);
128        assert_eq!(payload_of(&flagged), Some("notes.bin"));
129        let opaque = fm(&[("content", Value::String("scan.pdf".into()))]);
130        assert_eq!(payload_of(&opaque), Some("scan.pdf"));
131        // Separated prose is a page, not an attachment.
132        let prose = fm(&[("content", Value::String("body.md".into()))]);
133        assert_eq!(payload_of(&prose), None);
134        assert_eq!(payload_of(&fm(&[])), None);
135    }
136
137    #[test]
138    fn each_kind_embeds_as_itself() {
139        let (img, md) = render("Loon Lake", "loon lake.jpg");
140        assert!(
141            img.contains(r#"<img src="loon lake.jpg" alt="Loon Lake">"#),
142            "{img}"
143        );
144        assert!(
145            !img.contains("download"),
146            "a picture needs no download link"
147        );
148        assert_eq!(md, "![Loon Lake](loon lake.jpg)\n");
149
150        let (pdf, md) = render("Scan", "scan.pdf");
151        assert!(
152            pdf.contains(r#"<iframe src="scan.pdf" title="Scan">"#),
153            "{pdf}"
154        );
155        assert!(
156            pdf.contains(r#"<a href="scan.pdf" download>Download scan.pdf</a>"#),
157            "{pdf}"
158        );
159        assert_eq!(md, "[Scan](scan.pdf)\n");
160
161        let (video, _) = render("Clip", "clip.mp4");
162        assert!(
163            video.contains(r#"<video controls src="clip.mp4">"#),
164            "{video}"
165        );
166        let (audio, _) = render("Voice", "voice.m4a");
167        assert!(
168            audio.contains(r#"<audio controls src="voice.m4a">"#),
169            "{audio}"
170        );
171
172        let (other, _) = render("Archive", "backup.zip");
173        assert_eq!(
174            other,
175            r#"<p class="attachment-download"><a href="backup.zip" download>Download backup.zip</a></p>"#
176        );
177    }
178
179    #[test]
180    fn the_reference_is_escaped_for_an_attribute() {
181        let (html, _) = render("A & B", r#"a"b.png"#);
182        assert!(html.contains(r#"src="a&quot;b.png""#), "{html}");
183        assert!(html.contains(r#"alt="A &amp; B""#), "{html}");
184    }
185}