autumn_plugin_markstream/
markstream.rs1use maud::{Markup, Render, html};
27
28#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
46#[non_exhaustive]
47pub enum Mode {
48 Docs,
50 Chat,
52 Minimal,
54}
55
56impl Mode {
57 #[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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
73#[non_exhaustive]
74pub enum HtmlPolicy {
75 #[default]
77 Escape,
78 Safe,
80 Trusted,
84}
85
86impl HtmlPolicy {
87 #[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
98pub const ALLOW_TRUSTED_META: &str = "markstream-allow-trusted";
100
101#[must_use]
107pub fn markstream_allow_trusted() -> Markup {
108 html! { meta name=(ALLOW_TRUSTED_META) content="true"; }
109}
110
111#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
113#[non_exhaustive]
114pub enum Theme {
115 #[default]
117 Auto,
118 Light,
120 Dark,
122}
123
124impl Theme {
125 #[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#[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 pub fn new(markdown: impl Into<String>) -> Self {
172 Self {
173 content: markdown.into(),
174 ..Self::default()
175 }
176 }
177
178 pub fn stream(url: impl Into<String>) -> Self {
182 Self::default().stream_from(url)
183 }
184
185 pub fn stream_from(mut self, url: impl Into<String>) -> Self {
190 self.src = Some(url.into());
191 self
192 }
193
194 pub const fn mode(mut self, mode: Mode) -> Self {
197 self.mode = Some(mode);
198 self
199 }
200
201 pub const fn html_policy(mut self, policy: HtmlPolicy) -> Self {
203 self.html_policy = policy;
204 self
205 }
206
207 pub const fn theme(mut self, theme: Theme) -> Self {
209 self.theme = theme;
210 self
211 }
212
213 pub const fn typewriter(mut self, on: bool) -> Self {
215 self.typewriter = on;
216 self
217 }
218
219 pub const fn max_live_nodes(mut self, nodes: u32) -> Self {
221 self.max_live_nodes = Some(nodes);
222 self
223 }
224
225 pub fn id(mut self, id: impl Into<String>) -> Self {
227 self.id = Some(id.into());
228 self
229 }
230
231 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("</pre><script>"), "{html}");
291 assert!(html.contains("&"), "{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&b="><script>\""),
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}