use crate::filter::ImageFilter;
use crate::parser::DomHighlighter;
use crate::{Highlighter, Markup};
use ahash::HashSet;
use ammonia::{Builder, Url, UrlRelative, UrlRelativeEvaluate};
use log::warn;
pub use markup5ever_rcdom::{Handle, NodeData};
use once_cell::sync::Lazy;
use regex::{Captures, Regex};
use std::borrow::Cow;
use std::error::Error;
use std::io::Write;
use std::process::{Command, Stdio};
use std::sync::Arc;
use std::time::Instant;
use std::{iter, thread};
#[cfg(test)]
use crate::FilteredImage;
#[cfg(test)]
use std::time::Duration;
pub type ArcImageFilter = Arc<dyn ImageFilter>;
const RST_TEMPLATE: &[u8] = include_bytes!("../rst_template.txt");
#[derive(Debug, Copy, Clone)]
pub enum Links {
Ugc,
FollowUgc,
Trusted,
}
pub type LinkFixer<'a> = &'a dyn Fn(&str) -> Option<(String, String)>;
pub struct LinksContext<'a> {
pub base_url: Option<(&'a str, &'a str)>,
pub nofollow: Links,
pub own_crate_name: Option<&'a str>,
pub link_own_crate_to_crates_io: bool,
pub link_fixer: Option<LinkFixer<'a>>,
}
pub struct Renderer {
hilite: Option<Highlighter>,
image_filter: Arc<dyn ImageFilter>,
}
impl Renderer {
pub fn new(hilite: Option<Highlighter>) -> Self {
Self::new_filter(hilite, Arc::new(()))
}
pub fn new_filter(hilite: Option<Highlighter>, image_filter: Arc<dyn ImageFilter>) -> Self {
Self { hilite, image_filter }
}
pub fn page(&self, markup: &Markup, links_context: &LinksContext<'_>, rustdoc_extenions: bool, deadline: Instant) -> (String, Vec<String>) {
let html = self.page_markup(markup, links_context);
self.syntax_highlight_html(&html, rustdoc_extenions, deadline)
}
pub fn page_node(&self, markup: &Markup, links_context: &LinksContext<'_>, rustdoc_extenions: bool, deadline: Instant) -> Handle {
let html = self.page_markup(markup, links_context);
self.syntax_highlight_node(&html, rustdoc_extenions, deadline)
}
pub fn visible_text_by_section(&self, markup: &Markup) -> Vec<(String, String)> {
let dummy_links = LinksContext { link_fixer: None, base_url: None, nofollow: Links::Ugc, own_crate_name: None, link_own_crate_to_crates_io: false };
let html = self.page_markup_unsafe(markup, &dummy_links);
let dom = crate::parser::parse(&html);
let mut out = Vec::new();
let mut last_section = (String::new(), String::new());
Self::extract_text(&mut out, &dom.document, false, false, &mut last_section);
if !last_section.1.is_empty() {
out.push(last_section);
}
out.retain(|(name, content)| !content.trim_start().is_empty() || !name.trim_start().is_empty());
out
}
fn extract_text(out: &mut Vec<(String, String)>, node: &Handle, mut in_pre: bool, mut in_header: bool, current_section: &mut (String, String)) {
let mut push_later = None;
match node.data {
NodeData::Text { ref contents } => {
let contents = contents.borrow();
if in_header {
current_section.0 += &contents;
} else if in_pre {
current_section.1.push_str(&contents);
} else {
current_section.1.extend(contents.chars().map(|c| if c.is_whitespace() { ' ' } else { c }));
}
return; },
NodeData::Element { ref name, ref attrs, .. } => {
for attr in attrs.borrow().iter() {
match &*attr.name.local {
"src" | "href" if is_badge_url(&attr.value) || attr.value.starts_with('#') => { return;
},
_ => {},
}
}
match &*name.local {
"script" | "style" | "head" | "del" | "strike" | "s" => return,
"hr" => {
out.push(std::mem::take(current_section));
},
"p" | "div" | "blockquote" | "li" | "ul" | "ol" | "tr" | "table" |
"dt" | "dd" | "section" | "figure" | "summary" | "details" => {
current_section.1.push('\n');
push_later = Some("\n");
},
"h1" | "h2" | "h3" | "h4" | "h5" | "h6" => {
in_header = true;
out.push(std::mem::take(current_section));
push_later = Some("\n");
},
"pre" => {
in_pre = true;
current_section.1.push('\n');
push_later = Some("\n");
},
"td" | "code" | "img" => { current_section.1.push(' ');
push_later = Some(" ");
},
"aside" => {
current_section.1.push_str("\n(");
push_later = Some(")\n");
},
_ => {},
};
for attr in attrs.borrow().iter() {
match &*attr.name.local {
"title" | "alt" => if attr.value.as_ref() != "screenshot" {
current_section.1.push_str(", (");
current_section.1.push_str(&attr.value);
current_section.1.push_str("), ");
},
_ => {},
}
}
},
_ => {},
}
for ch in node.children.borrow().iter() {
Self::extract_text(out, ch, in_pre, in_header, current_section);
}
if let Some(push_later) = push_later {
current_section.1.push_str(push_later);
}
}
pub fn page_markup(&self, markup: &Markup, links: &LinksContext<'_>) -> String {
let html = self.page_markup_unsafe(markup, links);
let mut am = Builder::default();
am.add_generic_attributes(iter::once("align"));
self.clean_html(&html, am, links.base_url, links.nofollow, if links.link_own_crate_to_crates_io { links.own_crate_name } else { None })
}
fn page_markup_unsafe(&self, markup: &Markup, links_context: &LinksContext<'_>) -> String {
match markup {
Markup::Markdown(ref s) => self.render_markdown(s, false, links_context),
Markup::AsciiDoc(ref s) => {
self.render_markdown(&very_basic_asciidoc_to_markdown(s), false, links_context)
}
Markup::Rst(ref s) => {
self.render_rst(s.clone())
.map_err(|e| {
eprintln!("rst2html failed. Is docutils installed? {e}");
})
.unwrap_or_else(|_| self.render_markdown(s, false, links_context))
},
Markup::Html(ref h) => h.clone(),
}
}
fn render_rst(&self, markup: String) -> Result<String, Box<dyn Error + Send + Sync + 'static>> {
static TEMP_TPL_ARG: Lazy<String> = Lazy::new(|| {
let tmp = tempdir::TempDir::new("crates-rs-rst").expect("rst");
let path = tmp.into_path().join("rst_template.txt");
std::fs::write(&path, RST_TEMPLATE).expect("rst");
format!("--template={}", path.display())
});
static COMMAND: Lazy<&'static str> = Lazy::new(|| {
if Command::new("rst2html5.py").arg("-V").output().ok().map_or(false, |c| c.status.success()) {
"rst2html5.py"
} else {
"rst2html"
}
});
let mut cmd = Command::new(*COMMAND)
.arg("--strip-comments")
.arg("--input-encoding=UTF-8:strict")
.arg("--no-file-insertion")
.arg("--quiet")
.arg("--no-toc-backlinks")
.arg("--no-doc-info")
.arg("--no-doc-title")
.arg(TEMP_TPL_ARG.as_str())
.arg("--initial-header-level=2")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()?;
let mut stdin = cmd.stdin.take().ok_or("cmd stdin")?;
let writer = thread::spawn(move || stdin.write_all(markup.as_bytes()));
let output = cmd.wait_with_output()?;
if !output.status.success() {
return Err(format!("rst2html reported error: {}", String::from_utf8_lossy(&output.stderr)).into());
}
writer.join().expect("rst")?;
Ok(String::from_utf8(output.stdout)?)
}
pub fn markdown_str(&self, markdown: &str, allow_links: bool, links: &LinksContext<'_>, deadline: Instant) -> String {
let mut b = Builder::default();
b.rm_tags([
"a",
"p","div","h1","h2","h3","h4","h5","blockquote","col",
"dd","dt","figure","table","td","tr","footer","header","hr",
"li","ul","ol","nav","pre",
].into_iter().skip(if allow_links {1} else {0}));
let html = self.render_markdown(markdown, true, links);
let html = self.clean_html(&html, b, None, Links::Ugc, if links.link_own_crate_to_crates_io { links.own_crate_name } else { None });
self.syntax_highlight_html(&html, false, deadline).0
}
fn render_markdown(&self, markdown: &str, is_inline: bool, links: &LinksContext<'_>) -> String {
use comrak::*;
let mut options = ComrakOptions::default();
options.extension = ExtensionOptionsBuilder::default()
.superscript(false)
.autolink(true)
.strikethrough(true)
.table(!is_inline)
.tagfilter(true)
.tasklist(!is_inline)
.header_ids(Some("readme-".to_string()))
.footnotes(false)
.description_lists(false)
.front_matter_delimiter(None)
.build().unwrap();
options.parse = ParseOptionsBuilder::default()
.relaxed_autolinks(false)
.relaxed_tasklist_matching(true)
.smart(is_inline)
.build().unwrap();
options.render = RenderOptionsBuilder::default()
.hardbreaks(false)
.unsafe_(!is_inline) .github_pre_lang(true)
.width(999_999)
.escape(false)
.sourcepos(false)
.full_info_string(false)
.build().unwrap();
let markdown = if !is_inline { Self::fix_markdown(markdown, links.link_fixer) } else { markdown.into() };
let mut link_fixer = links.link_fixer.map(|cb| move |arg: &str| (cb)(arg));
let arena = Arena::new();
let root = parse_document_with_broken_link_callback(&arena, &markdown, &options, if let Some(cb) = &mut link_fixer { Some(cb) } else { None });
let mut vec = Vec::with_capacity(markdown.len() * 3 / 2);
let _ = format_html_with_plugins(root, &options, &mut vec, &ComrakPlugins::default()); String::from_utf8(vec).unwrap()
}
fn fix_markdown<'m>(markdown: &'m str, link_fixer: Option<LinkFixer<'_>>) -> Cow<'m, str> {
static FOOTER_ANCHOR: Lazy<Regex> = Lazy::new(|| Regex::new(r#"^\[([^]]+)\]:\s*(#.*|https?://.*|/.*|[a-zA-Z][a-zA-Z0-9_]*::[a-zA-Z0-9_:]*+)$"#).unwrap());
static LINK_TO_ANCHOR: Lazy<Regex> = Lazy::new(|| Regex::new(r#"\[([^]]+)\](?:[^:]|$)"#).unwrap());
let footer_anchor = &*FOOTER_ANCHOR;
let link_to_anchor = &*LINK_TO_ANCHOR;
let mut anchor_hacks = String::new();
let mut needs_to_move_anchors = HashSet::default();
if markdown.lines().any(move |l| footer_anchor.is_match(l)) {
let tmp: String = markdown.lines().map(|l| {
let mut normal_line = false;
for anchor in link_to_anchor.captures_iter(l) {
let (_, [label]) = anchor.extract();
needs_to_move_anchors.insert(label);
normal_line = true;
}
if normal_line {
return Cow::Borrowed(l);
}
footer_anchor.replace(l, |caps: &Captures| {
let (all, [label, url]) = caps.extract();
if needs_to_move_anchors.contains(label) {
let new_url = link_fixer.and_then(|l| (l)(url));
anchor_hacks.push_str(&format!("[{label}]: {}\n", new_url.as_ref().map_or(url, |(u, _)| u.as_str())));
String::new()
} else {
all.to_string()
}
})
}).flat_map(|c| [c, Cow::Borrowed("\n")]).collect();
if !anchor_hacks.is_empty() {
anchor_hacks.push_str(&tmp);
return Cow::Owned(anchor_hacks);
}
}
Cow::Borrowed(markdown)
}
fn syntax_highlight_html(&self, html: &str, rustdoc_extenions: bool, deadline: Instant) -> (String, Vec<String>) {
let dh = DomHighlighter::new(self.hilite.as_ref(), html, &*self.image_filter, rustdoc_extenions, deadline);
(dh.filtered(), dh.warnings)
}
fn syntax_highlight_node(&self, html: &str, rustdoc_extenions: bool, deadline: Instant) -> Handle {
let dh = DomHighlighter::new(self.hilite.as_ref(), html, &*self.image_filter, rustdoc_extenions, deadline);
dh.filtered_node()
}
fn clean_html(&self, unsafe_html: &str, mut am: Builder<'_>, base_url: Option<(&str, &str)>, nofollow: Links, own_crate_name: Option<&str>) -> String {
if let Some((base_url, base_image_url_str)) = base_url {
if let Ok(base_image_url) = Url::parse(base_image_url_str) {
let base_url = base_url.to_owned();
let base_image_url_str = base_image_url_str.to_owned();
let image_needs_rebase = base_url != base_image_url_str;
let own_crate_name = own_crate_name.map(|s| s.to_owned());
am.attribute_filter(move |element, attribute, value| {
match (element, attribute) {
("a", "href") => {
let crate_name = value.strip_prefix("https://crates.io/crates/").map(|s| s.trim_end_matches('/'));
if let Some(crate_name) = crate_name {
if !crate_name.contains('/') && own_crate_name.as_ref().map_or(true, |n| !n.eq_ignore_ascii_case(crate_name)) {
return Some(format!("https://lib.rs/crates/{crate_name}").into());
}
}
},
("img", "src") => {
let str_url;
let parsed_url;
let img_url = if value.starts_with('/') {
str_url = format!("{base_image_url}{value}");
&str_url
} else {
parsed_url = base_image_url.join(value)
.inspect_err(|e| {
warn!("failed to join base image url '{value}' on '{base_image_url}': {e}");
}).ok()?;
if parsed_url.host_str().map_or(false, is_defunct_host) {
return None;
}
parsed_url.as_str()
};
return if image_needs_rebase && img_url.starts_with(&base_url) {
let rewritten = base_image_url_str.clone() + &img_url[base_url.len()..];
base_image_url.join(&rewritten).map(|s| s.to_string().into()).ok()
} else {
Some(img_url.to_owned().into())
};
},
_ => {},
}
Some(value.into())
});
}
}
am.url_relative(Self::url_policy(base_url));
am.link_rel(Some(match nofollow {
Links::Ugc => "noopener ugc nofollow",
Links::FollowUgc => "ugc noopener",
Links::Trusted => "noopener",
}));
am.id_prefix(Some("readme-"));
am.add_tag_attributes("a", &["id"]);
am.add_tag_attributes("h1", &["id"]);
am.add_tag_attributes("h2", &["id"]);
am.add_tag_attributes("h3", &["id"]);
am.add_tag_attributes("h4", &["id"]);
am.add_tags(["input"]); am.add_tag_attribute_values("input", "type", &["checkbox"]);
am.add_tag_attribute_values("input", "disabled", &["", "disabled"]);
am.add_tag_attribute_values("input", "checked", &["", "checked"]);
am.rm_tags(["center", "map", "hgroup", "header", "footer", "main", "video"]);
am.clean(unsafe_html).to_string()
}
fn url_policy(base_url: Option<(&str, &str)>) -> UrlRelative {
let links_base = base_url.and_then(|(links, _)| Url::parse(links).ok());
let image_base = base_url.and_then(|(_, img)| Url::parse(img).ok());
UrlRelative::Custom(Box::new(CustomUrlRewrite { links_base, image_base }))
}
}
struct CustomUrlRewrite {links_base: Option<Url>, image_base: Option<Url>}
impl UrlRelativeEvaluate for CustomUrlRewrite {
fn evaluate<'a>(&self, href: &'a str) -> Option<Cow<'a, str>> {
if let Some(anchor) = href.strip_prefix('#') {
return Some(format!("#readme-{anchor}").into());
}
let ext = href.rsplit('.').next().unwrap_or_default();
let base = if matches!(ext, "png" | "jpeg" | "jpg" | "gif" | "svg" | "webp" | "avif" | "jxl") {
self.image_base.as_ref().or(self.links_base.as_ref())?
} else {
self.links_base.as_ref()?
};
let url = base.join(href).ok()?;
if matches!(url.scheme(), "http" | "https") {
Some(url.to_string().into())
} else {
None
}
}
}
fn is_defunct_host(host: &str) -> bool {
host == "meritbadge.herokuapp.com"
}
#[must_use]
pub fn is_badge_url(url: &str) -> bool {
let url = url
.trim_start_matches("http://")
.trim_start_matches("https://")
.trim_start_matches("www.")
.trim_start_matches("flat.")
.trim_start_matches("images.")
.trim_start_matches("img.")
.trim_start_matches("api.")
.trim_start_matches("ci.")
.trim_start_matches("rust-")
.trim_start_matches("build.");
url.starts_with("appveyor.com") ||
url.starts_with("badge.") ||
url.starts_with("badgen.") ||
url.starts_with("badges.") ||
url.starts_with("reportcard.") ||
url.contains("/badges/") ||
url.contains("/badge/") ||
url.starts_with("codecov.io") ||
url.starts_with("coveralls.io") ||
url.starts_with("docs.rs") ||
url.starts_with("gitlab.com") ||
url.starts_with("isitmaintained.com") ||
url.starts_with("meritbadge") ||
url.starts_with("microbadger") ||
url.starts_with("ohloh.net") ||
url.starts_with("openhub.net") ||
url.starts_with("repostatus.org") ||
url.starts_with("shields.io") ||
url.starts_with("snapcraft.io") ||
url.starts_with("spearow.io") ||
url.starts_with("travis-ci.") ||
url.starts_with("circleci.com") ||
url.starts_with("cirrus-ci.com") ||
url.starts_with("buymeacoffee.com") ||
url.starts_with("dev.azure.com") ||
url.starts_with("zenodo.org") ||
url.starts_with("tokei.rs/b1") ||
url.ends_with("?branch=master") ||
url.ends_with("?branchName=master") ||
url.ends_with("/pipeline.svg") ||
url.ends_with("/coverage.svg") ||
url.ends_with("/build.svg") ||
url.ends_with("/status.svg") ||
url.ends_with("badge.svg") ||
url.ends_with("badge.png")
}
fn rust_closures_suck_at_lifetimes<E, H>(f: E) -> E where E: for<'a, 'b> FnMut(&'a regex::Captures<'b>) -> H { f }
#[must_use]
pub fn very_basic_asciidoc_to_markdown(markdown: &str) -> String {
static LINKS: Lazy<Regex> = Lazy::new(|| Regex::new(r#"https?:([^ \[]+)\[([^\]]+)\]"#).unwrap());
static LOCAL_LINKS: Lazy<Regex> = Lazy::new(|| Regex::new(r#"<<([^>]+)>>"#).unwrap());
static IMAGE_LINK: Lazy<Regex> = Lazy::new(|| Regex::new(r#"image:([^]\[ #]+)\[link=([^]\[ ]+)\]"#).unwrap());
static IMAGE_ALT: Lazy<Regex> = Lazy::new(|| Regex::new(r#"image:([^]\[ #]+)\[([^]]*)]"#).unwrap());
let mut out = String::with_capacity(markdown.len());
out.extend(markdown.split_inclusive('\n').map(|line_with_nl| {
let line_with_nl = IMAGE_LINK.replace_all(line_with_nl, "[]($2)");
let line_with_nl = match IMAGE_ALT.replace_all(&line_with_nl, "") {
Cow::Owned(x) => Cow::Owned(x),
Cow::Borrowed(_) => line_with_nl,
};
let line_with_nl = match LINKS.replace_all(&line_with_nl, "[$2]($1)") {
Cow::Owned(x) => Cow::Owned(x),
Cow::Borrowed(_) => line_with_nl,
};
let mut anchorizer = comrak::Anchorizer::new();
let line_with_nl = match LOCAL_LINKS.replace_all(&line_with_nl, rust_closures_suck_at_lifetimes(|cap| {
let cap = cap.get(1).unwrap().as_str();
format!("[{}](#{})", cap, anchorizer.anchorize(cap.to_string()))
})) {
Cow::Owned(x) => Cow::Owned(x),
Cow::Borrowed(_) => line_with_nl,
};
let line = line_with_nl.trim_end();
if line == "----" {
return Cow::Borrowed("```\n");
}
let heading = line.bytes().take_while(|&c| c == b'=').count();
if heading > 0 {
let rest = &line_with_nl[heading..];
return Cow::Owned(format!("{:#<width$}{rest}", "", width = heading));
}
let list = line.bytes().take_while(|&c| c == b'*').count();
if list > 1 {
let rest = &line_with_nl[list..];
return Cow::Owned(format!("{: <width$}*{rest}", "", width = list - 1));
}
line_with_nl
}));
out
}
#[test]
fn adoc() {
assert_eq!("
```
code
```
[](/url)

# h1
## h2
* l2
* oklist
not == heading
", very_basic_asciidoc_to_markdown("
----
code
----
image:https://img[link=/url]
image:https://img[alt]
= h1
== h2
** l2
* oklist
not == heading
"));
}
#[test]
fn text_from_html() {
let r = Renderer::new(None);
let txt = r.visible_text_by_section(&Markup::Html("<html><script>boo</script><img src=badge.svg alt=badge><textarea>area\
</textarea><style>sytle</style><p>hello<h1>wor<b>l</b>d<img alt=alt></h1><pre>line<del> nope</del>\nbreak</pre>".to_string()));
let flat_txt = txt.iter().flat_map(|(a,b)| [a.as_str(),":",b.as_str(),";"]).collect::<String>();
assert_eq!(flat_txt, ":area\nhello\n;world: , (alt), \n\nline\nbreak\n;");
}
#[test]
fn text_from_html_ignoring_license_boilerplate() {
let r = Renderer::new(None);
let txt = r.visible_text_by_section(&Markup::Markdown("# hello\n## License\n Boring license text\n ## Other stuff\n ok bye".to_string()));
let flat_txt = txt.iter().filter(|(s,_)| s != "License").flat_map(|(a,b)| [a.as_str(),"=",b.as_str()]).collect::<String>();
assert_eq!(&flat_txt, "hello=\n Other stuff=\n \nok bye\n ");
}
#[test]
fn rewrite_urls() {
let r = Renderer::new(Some(Highlighter::new()));
let links = LinksContext { link_fixer: None, base_url: Some(("http://test", "http://test")), nofollow: Links::Trusted, own_crate_name: None, link_own_crate_to_crates_io: true };
let deadline = Instant::now() + Duration::from_secs(4);
let v = r.page(&Markup::Markdown("[helo](https://crates.io/crates/hi)".to_owned()), &links, false, deadline).0;
assert_eq!(r#"<p><a href="https://lib.rs/crates/hi""#, &v[0..37]);
}
#[test]
fn code_with_link() {
let r = Renderer::new(Some(Highlighter::new()));
let links = LinksContext { link_fixer: None, base_url: Some(("http://test", "http://test")), nofollow: Links::Trusted, own_crate_name: None, link_own_crate_to_crates_io: true };
let deadline = Instant::now() + Duration::from_secs(4);
let v = r.page(&Markup::Markdown("<code>wtf [this](https://example.com/is-valid)!</code>".to_owned()), &links, false, deadline).0;
assert_eq!("<p><code>wtf <a href=\"https://example.com/is-valid\" rel=\"noopener\">this</a>!</code></p>\n", v);
}
#[test]
fn checkbox_checked() {
let r = Renderer::new(Some(Highlighter::new()));
let links = LinksContext { link_fixer: None, base_url: None, nofollow: Links::Trusted, own_crate_name: None, link_own_crate_to_crates_io: true };
let deadline = Instant::now() + Duration::from_secs(4);
let v = r.page(&Markup::Markdown("* [x] yes".to_owned()), &links, false, deadline).0;
assert_eq!("<ul>\n<li><input type=\"checkbox\" disabled=\"\" checked=\"\"> yes</li>\n</ul>\n", v.replace("checked=\"\" disabled=\"\"", "disabled=\"\" checked=\"\""));
}
#[test]
fn checkbox_unchecked() {
let r = Renderer::new(Some(Highlighter::new()));
let links = LinksContext { link_fixer: None, base_url: None, nofollow: Links::Trusted, own_crate_name: None, link_own_crate_to_crates_io: true };
let deadline = Instant::now() + Duration::from_secs(4);
let v = r.page(&Markup::Markdown("* [ ] no".to_owned()), &links, false, deadline).0;
assert_eq!("<ul>\n<li><input type=\"checkbox\" disabled=\"\"> no</li>\n</ul>\n", v);
}
#[test]
fn header() {
let r = Renderer::new(Some(Highlighter::new()));
let links = LinksContext { link_fixer: None, base_url: None, nofollow: Links::Trusted, own_crate_name: None, link_own_crate_to_crates_io: true };
let deadline = Instant::now() + Duration::from_secs(4);
let v = r.page(&Markup::Markdown("# Header\n### This is another header! [hi](#header)".to_owned()), &links, false, deadline).0;
assert_eq!("<h1 id=\"readme-header\">Header</h1>\n<h3 id=\"readme-this-is-another-header-hi\">This is another header! <a href=\"#readme-header\" rel=\"noopener\">hi</a></h3>\n", v);
}
#[test]
fn rewrite_img_urls() {
struct Foo;
impl ImageFilter for Foo {
fn filter_url<'a>(&self, url: &'a str, width: Option<u32>, height: Option<u32>, _w: u32, _deadline: Instant) -> FilteredImage<'a> {
FilteredImage {
src: format!("https://proxy/{url}").into(),
srcset: None,
width, height,
}
}
}
let r = Renderer::new_filter(Some(Highlighter::new()), Arc::new(Foo));
let links = LinksContext { link_fixer: None, base_url: Some(("https://example.com/html", "https://example.com/images")), nofollow: Links::Trusted, own_crate_name: None, link_own_crate_to_crates_io: true };
let deadline = Instant::now() + Duration::from_secs(4);
let v = r.page(&Markup::Markdown(" ".to_owned()), &links, false, deadline).0;
assert_eq!(r#"<p><img alt="helo" decoding="async" crossorigin="anonymous" src="https://proxy/https://example.com/images/img.jpg"> </p>"#, v.trim());
}
#[test]
fn rewrite_img_urls_srcset() {
struct Foo;
impl ImageFilter for Foo {
fn filter_url<'a>(&self, url: &'a str, w: Option<u32>, h: Option<u32>, _w: u32, _deadline: Instant) -> FilteredImage<'a> {
let w = w.unwrap_or(0);
let h = h.unwrap_or(0);
FilteredImage {
src: format!("https://proxy/{w}x{h}/{url}").into(),
srcset: Some(format!("https://2xproxy/{w}x{h},2x/{url} 2x").into()),
width: Some(200),
height: Some(100),
}
}
}
let r = Renderer::new_filter(Some(Highlighter::new()), Arc::new(Foo));
let links = LinksContext { link_fixer: None, base_url: Some(("https://example.com/html", "https://example.com/images")), nofollow: Links::Trusted, own_crate_name: None, link_own_crate_to_crates_io: true };
let deadline = Instant::now() + Duration::from_secs(4);
let v = r.page(&Markup::Markdown(r#"hi <img alt="helo" src="https://example.com/html/img.svg" srcset="no" width=800 height=333>"#.to_owned()), &links, false, deadline).0;
assert_eq!(r#"<p>hi <img alt="helo" decoding="async" crossorigin="anonymous" src="https://proxy/800x333/https://example.com/images/img.svg" srcset="https://2xproxy/800x333,2x/https://example.com/images/img.svg 2x" width="200" height="100"></p>"#, v.trim());
}
#[test]
fn render_rst_test() {
let r = Renderer::new(Some(Highlighter::new()));
let links = LinksContext { link_fixer: None, base_url: None, nofollow: Links::Trusted, own_crate_name: None, link_own_crate_to_crates_io: true };
let deadline = Instant::now() + Duration::from_secs(4);
let v = r.page(&Markup::Rst(r#"
=====
hello
=====
world
"#.to_owned()), &links, false, deadline).0;
assert_eq!(r#"<h2>hello</h2><p>world</p>"#, &v.replace('\n',""));
}