//! Inline markdown for descriptions: links on a short allow-list of //! schemes, images gated and framed, with layout hints read from the //! image title. Never raw HTML. /// Descriptions are inline markdown: `[text](https://…)` links, /// `*emphasis*`, `**strong**`, `` `code` ``. Block structure is /// flattened (a description is one paragraph) and raw HTML in the /// source is dropped, so content can link out without being able to /// inject markup. Link targets are limited to https, mailto and /// site-relative paths; anything else renders as plain text. pub fn render_inline_markdown(text: &str) -> String { use pulldown_cmark::{html, Event, Options, Parser, Tag, TagEnd}; let parser = Parser::new_ext(text, Options::empty()); let mut in_link = 0usize; let mut in_dropped_image = 0usize; let filtered = parser.filter_map(|event| match event { Event::Html(_) | Event::InlineHtml(_) => None, Event::Start(Tag::Paragraph) | Event::End(TagEnd::Paragraph) => None, Event::SoftBreak => Some(Event::Text(" ".into())), Event::Start(Tag::Link { dest_url, .. }) if !(dest_url.starts_with("https://") || dest_url.starts_with("mailto:") || dest_url.starts_with("tel:") || dest_url.starts_with('/')) => { in_link += 1; None } Event::End(TagEnd::Link) if in_link > 0 => { in_link -= 1; None } // Images take the same gate as links: https or same-origin // only - no data:, no plain http. Unlike a dropped link (whose // label is real text worth keeping), a dropped image's alt // text is a caption for something that isn't there - swallow it. Event::Start(Tag::Image { dest_url, .. }) if !(dest_url.starts_with("https://") || dest_url.starts_with('/')) => { in_dropped_image += 1; None } Event::End(TagEnd::Image) if in_dropped_image > 0 => { in_dropped_image -= 1; None } Event::Text(_) if in_dropped_image > 0 => None, other => Some(other), }); let mut out = String::new(); html::push_html(&mut out, filtered); apply_image_hints(out.trim()) } /// Translate a markdown image's title (`![alt](url "right 9rem")`) /// into layout: `left`/`right` float via a class, a bare number /// (optionally with `rem`) into a validated `max-width`. Unknown /// tokens are ignored; the title attribute is dropped either way. /// Only touches `` tags in our own render output, and only ever /// emits a numeric max-width - no arbitrary CSS reaches the page. pub(crate) fn apply_image_hints(html: &str) -> String { let mut out = String::new(); let mut rest = html; while let Some(pos) = rest.find("').map(|e| e + 1).unwrap_or(after.len()); out.push_str(&rewrite_img_tag(&after[..end])); rest = &after[end..]; } out.push_str(rest); out } pub(crate) fn rewrite_img_tag(tag: &str) -> String { // Pull the title value, if any. let title = tag .find("title=\"") .map(|i| &tag[i + 7..]) .and_then(|r| r.find('"').map(|e| &r[..e])) .unwrap_or(""); let mut class = String::new(); let mut max_rem: Option = None; for tok in title.split_whitespace() { match tok { "left" => class = "md-float-left".into(), "right" => class = "md-float-right".into(), other => { if let Ok(n) = other.trim_end_matches("rem").parse::() { if n > 0.0 && n <= 60.0 { max_rem = Some(n); } } } } } // Strip the title attribute from the tag. let mut cleaned = tag.to_string(); if let Some(i) = cleaned.find(" title=\"") { if let Some(e) = cleaned[i + 8..].find('"') { cleaned.replace_range(i..i + 8 + e + 1, ""); } } // Inject class/style right after ` String { use pulldown_cmark::{html, Event, Options, Parser, Tag, TagEnd}; let parser = Parser::new_ext(text, Options::ENABLE_STRIKETHROUGH); let mut in_dropped_link = 0usize; let mut in_image = 0usize; let filtered = parser.filter_map(|event| match event { Event::Html(_) | Event::InlineHtml(_) => None, Event::Start(Tag::Image { .. }) => { in_image += 1; None } Event::End(TagEnd::Image) => { in_image = in_image.saturating_sub(1); None } _ if in_image > 0 => None, Event::Start(Tag::Heading { .. }) => Some(Event::Start(Tag::Paragraph)), Event::End(TagEnd::Heading(_)) => Some(Event::End(TagEnd::Paragraph)), // What someone typed on a new line is on a new line. Event::SoftBreak => Some(Event::HardBreak), Event::Start(Tag::Link { dest_url, .. }) if !(dest_url.starts_with("https://") || dest_url.starts_with("mailto:") || dest_url.starts_with("tel:")) => { in_dropped_link += 1; None } Event::End(TagEnd::Link) if in_dropped_link > 0 => { in_dropped_link -= 1; None } other => Some(other), }); let mut out = String::new(); html::push_html(&mut out, filtered); out.trim().replace("Line one
\nline two

"); assert!(md("- NS 3424\n- NS 3423").contains("