Skip to main content

autumn_plugin_markstream/
markstream.rs

1//! The typed [`Markstream`] builder.
2//!
3//! A [`Markstream`] value renders one container. The init module finds the
4//! container and mounts markstream on it.
5//!
6//! # Attributes
7//!
8//! You can also write these attributes by hand:
9//!
10//! - `data-markstream`: marks the container. Required.
11//! - `class="markstream"`: the plugin styles need it.
12//! - `data-markstream-html="escape|safe|trusted"`: the HTML policy for raw
13//!   HTML in the Markdown. Missing or unknown values mean `escape`.
14//! - `data-markstream-mode="docs|chat|minimal"`: the render mode.
15//! - `data-markstream-theme="light|dark"`: the color theme. Missing means
16//!   the user's `prefers-color-scheme`.
17//! - `data-markstream-typewriter="true"`: animate new text.
18//! - `data-markstream-max-live-nodes="0"`: the maximum number of live
19//!   nodes. `0` keeps all nodes live.
20//! - `data-markstream-src="/url"`: read Server-Sent Events from this URL.
21//!   See [`crate::sse`].
22//!
23//! The Markdown source goes in a child `<pre class="markstream-source">`.
24//! Users without JavaScript see this source.
25
26use maud::{Markup, Render, html};
27
28/// Each `data-markstream-*` attribute name. `assets/core.js` reads the same
29/// names. A test keeps the two lists the same.
30#[cfg(test)]
31pub(crate) const ATTRIBUTES: [&str; 7] = [
32    "data-markstream",
33    "data-markstream-html",
34    "data-markstream-mode",
35    "data-markstream-theme",
36    "data-markstream-typewriter",
37    "data-markstream-max-live-nodes",
38    "data-markstream-src",
39];
40
41/// The markstream render mode (`mode` prop).
42///
43/// `Mode` has no default. Without [`Markstream::mode`], markstream uses its
44/// own default mode.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
46#[non_exhaustive]
47pub enum Mode {
48    /// Document layout.
49    Docs,
50    /// Chat message layout.
51    Chat,
52    /// Minimal layout.
53    Minimal,
54}
55
56impl Mode {
57    /// The attribute value.
58    #[must_use]
59    pub const fn as_str(self) -> &'static str {
60        match self {
61            Self::Docs => "docs",
62            Self::Chat => "chat",
63            Self::Minimal => "minimal",
64        }
65    }
66}
67
68/// What markstream does with raw HTML in the Markdown (`htmlPolicy` prop).
69///
70/// The default is [`HtmlPolicy::Escape`]. Use it for user input and model
71/// output.
72#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
73#[non_exhaustive]
74pub enum HtmlPolicy {
75    /// Show raw HTML as text. The safe default.
76    #[default]
77    Escape,
78    /// Keep an allowlist of tags and attributes.
79    Safe,
80    /// Keep all raw HTML except `<script>`. Use only for Markdown that you
81    /// wrote. The page head must also hold [`markstream_allow_trusted()`].
82    /// Without it, the browser uses [`HtmlPolicy::Safe`].
83    Trusted,
84}
85
86impl HtmlPolicy {
87    /// The attribute value.
88    #[must_use]
89    pub const fn as_str(self) -> &'static str {
90        match self {
91            Self::Escape => "escape",
92            Self::Safe => "safe",
93            Self::Trusted => "trusted",
94        }
95    }
96}
97
98/// Name of the meta tag that allows [`HtmlPolicy::Trusted`] on a page.
99pub const ALLOW_TRUSTED_META: &str = "markstream-allow-trusted";
100
101/// Writes the meta tag that allows [`HtmlPolicy::Trusted`] on this page.
102///
103/// Put it in the page `<head>`. Without it, the init module uses
104/// [`HtmlPolicy::Safe`] for `trusted` containers. Injected markup then
105/// cannot ask for raw HTML.
106#[must_use]
107pub fn markstream_allow_trusted() -> Markup {
108    html! { meta name=(ALLOW_TRUSTED_META) content="true"; }
109}
110
111/// The color theme.
112#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
113#[non_exhaustive]
114pub enum Theme {
115    /// Follow the user's `prefers-color-scheme`. The default.
116    #[default]
117    Auto,
118    /// Light colors.
119    Light,
120    /// Dark colors.
121    Dark,
122}
123
124impl Theme {
125    /// The theme name. The builder writes no attribute for [`Theme::Auto`].
126    #[must_use]
127    pub const fn as_str(self) -> &'static str {
128        match self {
129            Self::Auto => "auto",
130            Self::Light => "light",
131            Self::Dark => "dark",
132        }
133    }
134
135    const fn attr(self) -> Option<&'static str> {
136        match self {
137            Self::Auto => None,
138            other => Some(other.as_str()),
139        }
140    }
141}
142
143/// One markstream container: Markdown, an optional stream URL and options.
144///
145/// ```rust
146/// use autumn_plugin_markstream::{Markstream, Mode};
147/// use autumn_web::html;
148///
149/// let page = html! {
150///     (Markstream::new("# Hello\n\nSome *Markdown*."))
151///     (Markstream::stream("/chat/42/stream").mode(Mode::Chat))
152/// };
153/// assert!(page.into_string().contains("data-markstream-src"));
154/// ```
155#[derive(Debug, Clone, Default, PartialEq, Eq)]
156#[must_use]
157pub struct Markstream {
158    content: String,
159    src: Option<String>,
160    mode: Option<Mode>,
161    html_policy: HtmlPolicy,
162    theme: Theme,
163    typewriter: bool,
164    max_live_nodes: Option<u32>,
165    id: Option<String>,
166    class: Option<String>,
167}
168
169impl Markstream {
170    /// Renders static Markdown.
171    pub fn new(markdown: impl Into<String>) -> Self {
172        Self {
173            content: markdown.into(),
174            ..Self::default()
175        }
176    }
177
178    /// Streams Markdown from the Server-Sent Events at `url`.
179    ///
180    /// Make the events with [`crate::sse::markstream_sse`].
181    pub fn stream(url: impl Into<String>) -> Self {
182        Self::default().stream_from(url)
183    }
184
185    /// Streams more Markdown from `url` after the current content.
186    ///
187    /// The URL must have the same origin as the page. An empty URL means
188    /// no stream.
189    pub fn stream_from(mut self, url: impl Into<String>) -> Self {
190        self.src = Some(url.into());
191        self
192    }
193
194    /// Sets the render mode. Without this call, markstream uses its own
195    /// default mode.
196    pub const fn mode(mut self, mode: Mode) -> Self {
197        self.mode = Some(mode);
198        self
199    }
200
201    /// Sets the HTML policy. The default is [`HtmlPolicy::Escape`].
202    pub const fn html_policy(mut self, policy: HtmlPolicy) -> Self {
203        self.html_policy = policy;
204        self
205    }
206
207    /// Sets the color theme. The default is [`Theme::Auto`].
208    pub const fn theme(mut self, theme: Theme) -> Self {
209        self.theme = theme;
210        self
211    }
212
213    /// Animates new text when `on` is `true`.
214    pub const fn typewriter(mut self, on: bool) -> Self {
215        self.typewriter = on;
216        self
217    }
218
219    /// Sets the maximum number of live nodes. `0` keeps all nodes live.
220    pub const fn max_live_nodes(mut self, nodes: u32) -> Self {
221        self.max_live_nodes = Some(nodes);
222        self
223    }
224
225    /// Sets the container `id`.
226    pub fn id(mut self, id: impl Into<String>) -> Self {
227        self.id = Some(id.into());
228        self
229    }
230
231    /// Adds CSS classes to the container. It always has `markstream`.
232    pub fn class(mut self, class: impl Into<String>) -> Self {
233        self.class = Some(class.into());
234        self
235    }
236}
237
238impl Render for Markstream {
239    fn render(&self) -> Markup {
240        let class = self
241            .class
242            .as_ref()
243            .map_or_else(|| "markstream".to_owned(), |c| format!("markstream {c}"));
244        let src = self.src.as_deref().filter(|s| !s.is_empty());
245        let streaming = src.map(|_| "true");
246        html! {
247            div class=(class)
248                id=[self.id.as_deref()]
249                data-markstream=""
250                data-markstream-html=(self.html_policy.as_str())
251                data-markstream-mode=[self.mode.map(Mode::as_str)]
252                data-markstream-theme=[self.theme.attr()]
253                data-markstream-typewriter=[self.typewriter.then_some("true")]
254                data-markstream-max-live-nodes=[self.max_live_nodes]
255                data-markstream-src=[src]
256                aria-live=[streaming.map(|_| "polite")]
257                aria-busy=[streaming]
258            {
259                pre class="markstream-source" { (self.content) }
260            }
261        }
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use crate::test_support::{js_array, js_object_values, js_string};
269
270    const CORE_JS: &str = include_str!("../assets/core.js");
271
272    fn render(m: &Markstream) -> String {
273        m.render().into_string()
274    }
275
276    #[test]
277    fn static_markdown_renders_escaped_source_with_safe_defaults() {
278        let html = render(&Markstream::new("# Hi\n\n- a"));
279        assert_eq!(
280            html,
281            "<div class=\"markstream\" data-markstream=\"\" data-markstream-html=\"escape\">\
282             <pre class=\"markstream-source\"># Hi\n\n- a</pre></div>"
283        );
284    }
285
286    #[test]
287    fn markdown_cannot_break_out_of_the_fallback() {
288        let html = render(&Markstream::new("</pre><script>alert(1)</script>&"));
289        assert!(!html.contains("<script>"), "{html}");
290        assert!(html.contains("&lt;/pre&gt;&lt;script&gt;"), "{html}");
291        assert!(html.contains("&amp;"), "{html}");
292    }
293
294    #[test]
295    fn attribute_values_cannot_break_out() {
296        let html = render(
297            &Markstream::stream("/s?a=1&b=\"><script>")
298                .id("x\"onmouseover=\"y")
299                .class("c\"><b>"),
300        );
301        assert!(
302            !html.contains("<script>") && !html.contains("<b>"),
303            "{html}"
304        );
305        assert!(!html.contains("\"onmouseover"), "{html}");
306        assert!(
307            html.contains("data-markstream-src=\"/s?a=1&amp;b=&quot;&gt;&lt;script&gt;\""),
308            "{html}"
309        );
310    }
311
312    #[test]
313    fn stream_sets_source_url_and_live_region() {
314        let html = render(&Markstream::stream("/chat/1"));
315        assert!(html.contains(r#"data-markstream-src="/chat/1""#), "{html}");
316        assert!(html.contains(r#"aria-live="polite""#), "{html}");
317        assert!(html.contains(r#"aria-busy="true""#), "{html}");
318        assert!(
319            html.contains(r#"<pre class="markstream-source"></pre>"#),
320            "{html}"
321        );
322    }
323
324    #[test]
325    fn stream_from_keeps_initial_content() {
326        let html = render(&Markstream::new("Hello").stream_from("/s"));
327        assert!(html.contains(">Hello</pre>"), "{html}");
328        assert!(html.contains(r#"data-markstream-src="/s""#), "{html}");
329    }
330
331    #[test]
332    fn options_map_to_data_attributes() {
333        let html = render(
334            &Markstream::new("x")
335                .mode(Mode::Chat)
336                .html_policy(HtmlPolicy::Trusted)
337                .theme(Theme::Dark)
338                .typewriter(true)
339                .max_live_nodes(0)
340                .id("answer")
341                .class("prose wide"),
342        );
343        for want in [
344            r#"class="markstream prose wide""#,
345            r#"id="answer""#,
346            r#"data-markstream-mode="chat""#,
347            r#"data-markstream-html="trusted""#,
348            r#"data-markstream-theme="dark""#,
349            r#"data-markstream-typewriter="true""#,
350            r#"data-markstream-max-live-nodes="0""#,
351        ] {
352            assert!(html.contains(want), "missing {want}: {html}");
353        }
354    }
355
356    #[test]
357    fn defaults_omit_optional_attributes() {
358        let html = render(&Markstream::new("x").typewriter(false));
359        for absent in [
360            "mode",
361            "theme",
362            "typewriter",
363            "max-live-nodes",
364            "src",
365            " id=",
366            "aria-",
367        ] {
368            assert!(!html.contains(absent), "{absent} in {html}");
369        }
370        assert_eq!(Markstream::default(), Markstream::new(""));
371    }
372
373    #[test]
374    fn enum_values_match_markstream_props() {
375        assert_eq!(
376            [Mode::Docs, Mode::Chat, Mode::Minimal].map(Mode::as_str),
377            ["docs", "chat", "minimal"]
378        );
379        assert_eq!(
380            [HtmlPolicy::Escape, HtmlPolicy::Safe, HtmlPolicy::Trusted].map(HtmlPolicy::as_str),
381            ["escape", "safe", "trusted"]
382        );
383        assert_eq!(
384            [Theme::Light, Theme::Dark].map(Theme::as_str),
385            ["light", "dark"]
386        );
387        assert_eq!(HtmlPolicy::default(), HtmlPolicy::Escape);
388        assert_eq!(Theme::default(), Theme::Auto);
389    }
390
391    fn sorted(mut v: Vec<String>) -> Vec<String> {
392        v.sort_unstable();
393        v
394    }
395
396    #[test]
397    fn rust_and_js_agree_on_attribute_names() {
398        let full = render(
399            &Markstream::stream("/s")
400                .mode(Mode::Chat)
401                .theme(Theme::Dark)
402                .typewriter(true)
403                .max_live_nodes(1),
404        );
405        let re = regex::Regex::new(r"(data-markstream[-a-z]*)=").expect("regex");
406        let rendered = sorted(re.captures_iter(&full).map(|c| c[1].to_owned()).collect());
407        let js = sorted(js_object_values(CORE_JS, "ATTR"));
408        assert_eq!(rendered, js);
409        assert_eq!(sorted(ATTRIBUTES.map(str::to_owned).to_vec()), js);
410    }
411
412    #[test]
413    fn rust_and_js_agree_on_attribute_values() {
414        let names = |v: &[&str]| v.iter().map(|s| (*s).to_owned()).collect::<Vec<_>>();
415        assert_eq!(
416            js_array(CORE_JS, "MODES"),
417            names(&[Mode::Docs, Mode::Chat, Mode::Minimal].map(Mode::as_str))
418        );
419        assert_eq!(
420            js_array(CORE_JS, "HTML_POLICIES"),
421            names(
422                &[HtmlPolicy::Escape, HtmlPolicy::Safe, HtmlPolicy::Trusted]
423                    .map(HtmlPolicy::as_str)
424            )
425        );
426        assert_eq!(
427            js_array(CORE_JS, "THEMES"),
428            names(&[Theme::Light, Theme::Dark].map(Theme::as_str))
429        );
430        assert_eq!(js_string(CORE_JS, "ALLOW_TRUSTED"), ALLOW_TRUSTED_META);
431    }
432
433    #[test]
434    fn an_empty_stream_url_renders_a_static_container() {
435        let html = render(&Markstream::stream("").stream_from(""));
436        assert!(!html.contains("data-markstream-src"), "{html}");
437        assert!(!html.contains("aria-busy"), "{html}");
438    }
439
440    #[test]
441    fn allow_trusted_meta_names_the_js_opt_in() {
442        assert_eq!(
443            markstream_allow_trusted().into_string(),
444            format!(r#"<meta name="{ALLOW_TRUSTED_META}" content="true">"#)
445        );
446    }
447}