use std::{fmt::Debug, sync::LazyLock};
use regex::Regex;
use crate::{
Parser,
attributes::Attrlist,
parser::{ResolvedReference, SafeMode},
};
pub trait InlineSubstitutionRenderer: Debug {
fn render_special_character(&self, type_: SpecialCharacter, dest: &mut String);
fn render_quoted_substitition(
&self,
type_: QuoteType,
scope: QuoteScope,
attrlist: Option<Attrlist<'_>>,
id: Option<String>,
body: &str,
dest: &mut String,
);
fn render_character_replacement(&self, type_: CharacterReplacementType, dest: &mut String);
fn render_line_break(&self, dest: &mut String);
fn render_image(&self, params: &ImageRenderParams, dest: &mut String);
fn image_uri(
&self,
target_image_path: &str,
parser: &Parser,
asset_dir_key: Option<&str>,
) -> String;
fn render_icon(&self, params: &IconRenderParams, dest: &mut String);
fn icon_uri(&self, name: &str, _attrlist: &Attrlist, parser: &Parser) -> String {
let icon = if has_extname(name) {
name.to_owned()
} else {
let icontype = parser
.attribute_value("icontype")
.as_maybe_str()
.unwrap_or("png")
.to_owned();
format!("{name}.{icontype}")
};
self.image_uri(&icon, parser, Some("iconsdir"))
}
fn render_link(&self, params: &LinkRenderParams, dest: &mut String);
fn render_anchor(&self, id: &str, reftext: Option<String>, dest: &mut String);
fn render_xref(&self, params: &XrefRenderParams, dest: &mut String);
fn render_callout(&self, params: &CalloutRenderParams, dest: &mut String);
fn render_index_term(&self, params: &IndexTermRenderParams, dest: &mut String);
fn render_button(&self, text: &str, dest: &mut String);
fn render_keyboard(&self, keys: &[String], dest: &mut String);
fn render_menu(&self, params: &MenuRenderParams, dest: &mut String);
fn render_footnote(&self, params: &FootnoteRenderParams, dest: &mut String);
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SpecialCharacter {
Lt,
Gt,
Ampersand,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum QuoteType {
Strong,
DoubleQuote,
SingleQuote,
Monospaced,
Emphasis,
Mark,
Superscript,
Subscript,
Unquoted,
AsciiMath,
LatexMath,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum QuoteScope {
Constrained,
Unconstrained,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum CharacterReplacementType {
Copyright,
Registered,
Trademark,
EmDashSurroundedBySpaces,
EmDashWithoutSpace,
Ellipsis,
SingleRightArrow,
DoubleRightArrow,
SingleLeftArrow,
DoubleLeftArrow,
TypographicApostrophe,
CharacterReference(String),
}
#[derive(Clone, Debug)]
pub struct ImageRenderParams<'a> {
pub target: &'a str,
pub alt: String,
pub width: Option<&'a str>,
pub height: Option<&'a str>,
pub attrlist: &'a Attrlist<'a>,
pub parser: &'a Parser,
}
#[derive(Clone, Debug)]
pub struct IconRenderParams<'a> {
pub target: &'a str,
pub alt: String,
pub size: Option<&'a str>,
pub attrlist: &'a Attrlist<'a>,
pub parser: &'a Parser,
}
#[derive(Clone, Debug)]
pub struct LinkRenderParams<'a> {
pub target: String,
pub link_text: String,
pub extra_roles: Vec<&'a str>,
pub window: Option<&'static str>,
pub type_: LinkRenderType,
pub attrlist: &'a Attrlist<'a>,
pub parser: &'a Parser,
}
#[derive(Clone, Debug)]
pub enum LinkRenderType {
Link,
}
#[derive(Clone, Debug)]
pub struct CalloutRenderParams<'a> {
pub number: &'a str,
pub guard: CalloutGuard<'a>,
pub parser: &'a Parser,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum CalloutGuard<'a> {
LineComment(&'a str),
Xml,
}
#[derive(Clone, Debug)]
pub struct XrefRenderParams<'a> {
pub target: &'a str,
pub provided_text: Option<&'a str>,
pub window: Option<&'a str>,
pub roles: &'a [String],
pub resolved: Option<&'a ResolvedReference>,
}
#[derive(Clone, Debug)]
pub struct IndexTermRenderParams<'a> {
pub visible_term: Option<&'a str>,
}
#[derive(Clone, Debug)]
pub struct MenuRenderParams<'a> {
pub menu: &'a str,
pub submenus: &'a [String],
pub menuitem: Option<&'a str>,
pub parser: &'a Parser,
}
#[derive(Clone, Debug)]
pub struct FootnoteRenderParams<'a> {
pub index: Option<&'a str>,
pub id: Option<&'a str>,
pub is_reference: bool,
pub text: &'a str,
}
#[derive(Debug)]
pub struct HtmlSubstitutionRenderer {}
impl HtmlSubstitutionRenderer {
fn image_src(&self, target: &str, attrlist: &Attrlist, parser: &Parser) -> String {
match attrlist.named_attribute("imagesdir") {
Some(imagesdir) => normalize_web_path(target, parser, Some(imagesdir.value()), true),
None => self.image_uri(target, parser, None),
}
}
}
impl InlineSubstitutionRenderer for HtmlSubstitutionRenderer {
fn render_special_character(&self, type_: SpecialCharacter, dest: &mut String) {
match type_ {
SpecialCharacter::Lt => {
dest.push_str("<");
}
SpecialCharacter::Gt => {
dest.push_str(">");
}
SpecialCharacter::Ampersand => {
dest.push_str("&");
}
}
}
fn render_quoted_substitition(
&self,
type_: QuoteType,
_scope: QuoteScope,
attrlist: Option<Attrlist<'_>>,
mut id: Option<String>,
body: &str,
dest: &mut String,
) {
let mut roles: Vec<&str> = attrlist.as_ref().map(|a| a.roles()).unwrap_or_default();
if let Some(block_style) = attrlist
.as_ref()
.and_then(|a| a.nth_attribute(1))
.and_then(|attr1| attr1.block_style())
{
roles.insert(0, block_style);
}
if id.is_none() {
id = attrlist
.as_ref()
.and_then(|a| a.nth_attribute(1))
.and_then(|attr1| attr1.id())
.map(|id| id.to_owned())
}
match type_ {
QuoteType::Strong => {
wrap_body_in_html_tag(attrlist.as_ref(), "strong", id, roles, body, dest);
}
QuoteType::DoubleQuote => {
dest.push_str("“");
dest.push_str(body);
dest.push_str("”");
}
QuoteType::SingleQuote => {
dest.push_str("‘");
dest.push_str(body);
dest.push_str("’");
}
QuoteType::Monospaced => {
wrap_body_in_html_tag(attrlist.as_ref(), "code", id, roles, body, dest);
}
QuoteType::Emphasis => {
wrap_body_in_html_tag(attrlist.as_ref(), "em", id, roles, body, dest);
}
QuoteType::Mark => {
if roles.is_empty() && id.is_none() {
wrap_body_in_html_tag(attrlist.as_ref(), "mark", id, roles, body, dest);
} else {
wrap_body_in_html_tag(attrlist.as_ref(), "span", id, roles, body, dest);
}
}
QuoteType::Superscript => {
wrap_body_in_html_tag(attrlist.as_ref(), "sup", id, roles, body, dest);
}
QuoteType::Subscript => {
wrap_body_in_html_tag(attrlist.as_ref(), "sub", id, roles, body, dest);
}
QuoteType::Unquoted => {
if roles.is_empty() && id.is_none() {
dest.push_str(body);
} else {
wrap_body_in_html_tag(attrlist.as_ref(), "span", id, roles, body, dest);
}
}
QuoteType::AsciiMath => {
dest.push_str(r"\$");
dest.push_str(body);
dest.push_str(r"\$");
}
QuoteType::LatexMath => {
dest.push_str(r"\(");
dest.push_str(body);
dest.push_str(r"\)");
}
}
}
fn render_character_replacement(&self, type_: CharacterReplacementType, dest: &mut String) {
match type_ {
CharacterReplacementType::Copyright => {
dest.push_str("©");
}
CharacterReplacementType::Registered => {
dest.push_str("®");
}
CharacterReplacementType::Trademark => {
dest.push_str("™");
}
CharacterReplacementType::EmDashSurroundedBySpaces => {
dest.push_str(" — ");
}
CharacterReplacementType::EmDashWithoutSpace => {
dest.push_str("—​");
}
CharacterReplacementType::Ellipsis => {
dest.push_str("…​");
}
CharacterReplacementType::SingleLeftArrow => {
dest.push_str("←");
}
CharacterReplacementType::DoubleLeftArrow => {
dest.push_str("⇐");
}
CharacterReplacementType::SingleRightArrow => {
dest.push_str("→");
}
CharacterReplacementType::DoubleRightArrow => {
dest.push_str("⇒");
}
CharacterReplacementType::TypographicApostrophe => {
dest.push_str("’");
}
CharacterReplacementType::CharacterReference(name) => {
dest.push('&');
dest.push_str(&name);
dest.push(';');
}
}
}
fn render_line_break(&self, dest: &mut String) {
dest.push_str("<br>");
}
fn render_image(&self, params: &ImageRenderParams, dest: &mut String) {
let src = self.image_src(params.target, params.attrlist, params.parser);
let alt_encoded = encode_attribute_value(params.alt.clone());
let mut dimension_attrs = String::new();
if let Some(width) = params.width {
dimension_attrs.push_str(&format!(r#" width="{width}""#));
}
if let Some(height) = params.height {
dimension_attrs.push_str(&format!(r#" height="{height}""#));
}
if let Some(title) = params.attrlist.named_attribute("title") {
dimension_attrs.push_str(&format!(
r#" title="{title}""#,
title = encode_attribute_value(title.value().to_owned())
));
}
let format = params
.attrlist
.named_attribute("format")
.map(|format| format.value());
let svg_active = (format == Some("svg") || params.target.contains(".svg"))
&& params.parser.safe_mode() < SafeMode::Secure;
let img = if svg_active && params.attrlist.has_option("inline") {
read_svg_contents(&src, params.width, params.height, params.parser)
.unwrap_or_else(|| format!(r#"<span class="alt">{alt}</span>"#, alt = params.alt))
} else if svg_active && params.attrlist.has_option("interactive") {
let fallback = if let Some(fallback) = params.attrlist.named_attribute("fallback") {
let fallback_src = self.image_src(fallback.value(), params.attrlist, params.parser);
format!(r#"<img src="{fallback_src}" alt="{alt_encoded}"{dimension_attrs}>"#)
} else {
format!(r#"<span class="alt">{alt}</span>"#, alt = params.alt)
};
format!(
r#"<object type="image/svg+xml" data="{src}"{dimension_attrs}>{fallback}</object>"#
)
} else {
format!(r#"<img src="{src}" alt="{alt_encoded}"{dimension_attrs}>"#)
};
render_icon_or_image(params.attrlist, &img, "image", dest);
}
fn image_uri(
&self,
target_image_path: &str,
parser: &Parser,
asset_dir_key: Option<&str>,
) -> String {
let asset_dir_key = asset_dir_key.unwrap_or("imagesdir");
let asset_dir = parser
.attribute_value(asset_dir_key)
.as_maybe_str()
.map(|s| s.to_string());
normalize_web_path(target_image_path, parser, asset_dir.as_deref(), true)
}
fn render_icon(&self, params: &IconRenderParams, dest: &mut String) {
let src = self.icon_uri(params.target, params.attrlist, params.parser);
let img = if params.parser.is_attribute_set("icons") {
let icons = params.parser.attribute_value("icons");
if let Some(icons) = icons.as_maybe_str()
&& icons == "font"
{
let mut i_class_attrs: Vec<String> = vec![
"fa".to_owned(),
format!("fa-{target}", target = params.target),
];
if let Some(size) = params.attrlist.named_or_positional_attribute("size", 1) {
i_class_attrs.push(format!("fa-{size}", size = size.value()));
}
if let Some(flip) = params.attrlist.named_attribute("flip") {
i_class_attrs.push(format!("fa-flip-{flip}", flip = flip.value()));
} else if let Some(rotate) = params.attrlist.named_attribute("rotate") {
i_class_attrs.push(format!("fa-rotate-{rotate}", rotate = rotate.value()));
}
format!(
r##"<i class="{i_class_attr_val}"{title_attr}></i>"##,
i_class_attr_val = i_class_attrs.join(" "),
title_attr = if let Some(title) = params.attrlist.named_attribute("title") {
format!(r#" title="{title}""#, title = title.value())
} else {
"".to_owned()
}
)
} else {
let mut attrs: Vec<String> = vec![
format!(r#"src="{src}""#),
format!(
r#"alt="{alt}""#,
alt = encode_attribute_value(params.alt.to_string())
),
];
if let Some(width) = params.attrlist.named_attribute("width") {
attrs.push(format!(r#"width="{width}""#, width = width.value()));
}
if let Some(height) = params.attrlist.named_attribute("height") {
attrs.push(format!(r#"height="{height}""#, height = height.value()));
}
if let Some(title) = params.attrlist.named_attribute("title") {
attrs.push(format!(r#"title="{title}""#, title = title.value()));
}
format!(
"<img {attrs}{void_element_slash}>",
attrs = attrs.join(" "),
void_element_slash = "",
)
}
} else {
format!("[{alt}]", alt = params.alt)
};
render_icon_or_image(params.attrlist, &img, "icon", dest);
}
fn render_link(&self, params: &LinkRenderParams, dest: &mut String) {
let id = params.attrlist.id();
let mut roles = params.extra_roles.clone();
let mut attrlist_roles = params.attrlist.roles().clone();
roles.append(&mut attrlist_roles);
let link = format!(
r##"<a href="{target}"{id}{class}{link_constraint_attrs}>{link_text}</a>"##,
target = params.target,
id = if let Some(id) = id {
format!(r#" id="{id}""#)
} else {
"".to_owned()
},
class = if roles.is_empty() {
"".to_owned()
} else {
format!(r#" class="{roles}""#, roles = roles.join(" "))
},
link_constraint_attrs = link_constraint_attrs(params.attrlist, params.window),
link_text = params.link_text,
);
dest.push_str(&link);
}
fn render_anchor(&self, id: &str, _reftext: Option<String>, dest: &mut String) {
dest.push_str(&format!("<a id=\"{id}\"></a>"));
}
fn render_xref(&self, params: &XrefRenderParams, dest: &mut String) {
let class = if params.roles.is_empty() {
String::new()
} else {
let roles = params
.roles
.iter()
.map(|role| encode_html_attribute(role))
.collect::<Vec<_>>()
.join(" ");
format!(r#" class="{roles}""#)
};
let constraint_attrs = xref_constraint_attrs(params.window);
match params.resolved {
Some(resolved) => {
let text = params
.provided_text
.map(str::to_string)
.or_else(|| resolved.text.clone())
.unwrap_or_else(|| format!("[{target}]", target = params.target));
dest.push_str(&format!(
r#"<a href="{href}"{class}{constraint_attrs}>{text}</a>"#,
href = resolved.href
));
}
None => {
let text = params
.provided_text
.map(str::to_string)
.unwrap_or_else(|| format!("[{target}]", target = params.target));
dest.push_str(&format!(
r##"<a href="#{target}"{class}{constraint_attrs}>{text}</a>"##,
target = params.target
));
}
}
}
fn render_callout(&self, params: &CalloutRenderParams, dest: &mut String) {
let n = params.number;
let parser = params.parser;
if parser.attribute_value("icons").as_maybe_str() == Some("font") {
dest.push_str(&format!(
r#"<i class="conum" data-value="{n}"></i><b>({n})</b>"#
));
} else if parser.is_attribute_set("icons") {
let icontype = parser
.attribute_value("icontype")
.as_maybe_str()
.unwrap_or("png")
.to_owned();
let icon = format!("callouts/{n}.{icontype}");
let src = self.image_uri(&icon, parser, Some("iconsdir"));
dest.push_str(&format!(r#"<img src="{src}" alt="{n}">"#));
} else {
match params.guard {
CalloutGuard::Xml => {
dest.push_str(&format!(r#"<!--<b class="conum">({n})</b>-->"#));
}
CalloutGuard::LineComment(prefix) => {
dest.push_str(prefix);
dest.push_str(&format!(r#"<b class="conum">({n})</b>"#));
}
}
}
}
fn render_index_term(&self, params: &IndexTermRenderParams, dest: &mut String) {
if let Some(term) = params.visible_term {
dest.push_str(term);
}
}
fn render_button(&self, text: &str, dest: &mut String) {
dest.push_str(&format!(r#"<b class="button">{text}</b>"#));
}
fn render_keyboard(&self, keys: &[String], dest: &mut String) {
if let [key] = keys {
dest.push_str(&format!("<kbd>{key}</kbd>"));
} else {
dest.push_str(&format!(
r#"<span class="keyseq"><kbd>{keys}</kbd></span>"#,
keys = keys.join("</kbd>+<kbd>")
));
}
}
fn render_menu(&self, params: &MenuRenderParams, dest: &mut String) {
let caret = if params.parser.attribute_value("icons").as_maybe_str() == Some("font") {
r#" <i class="fa fa-angle-right caret"></i> "#
} else {
r#" <b class="caret">›</b> "#
};
let menu = params.menu;
if params.submenus.is_empty() {
if let Some(menuitem) = params.menuitem {
dest.push_str(&format!(
r#"<span class="menuseq"><b class="menu">{menu}</b>{caret}<b class="menuitem">{menuitem}</b></span>"#
));
} else {
dest.push_str(&format!(r#"<b class="menuref">{menu}</b>"#));
}
} else {
let submenu_joiner = format!(r#"</b>{caret}<b class="submenu">"#);
dest.push_str(&format!(
r#"<span class="menuseq"><b class="menu">{menu}</b>{caret}<b class="submenu">{submenus}</b>{caret}<b class="menuitem">{menuitem}</b></span>"#,
submenus = params.submenus.join(&submenu_joiner),
menuitem = params.menuitem.unwrap_or_default(),
));
}
}
fn render_footnote(&self, params: &FootnoteRenderParams, dest: &mut String) {
match params.index {
Some(index) if params.is_reference => {
dest.push_str(&format!(
r##"<sup class="footnoteref">[<a class="footnote" href="#_footnotedef_{index}" title="View footnote.">{index}</a>]</sup>"##
));
}
Some(index) => {
let id_attr = params
.id
.map(|id| format!(r#" id="_footnote_{id}""#))
.unwrap_or_default();
dest.push_str(&format!(
r##"<sup class="footnote"{id_attr}>[<a id="_footnoteref_{index}" class="footnote" href="#_footnotedef_{index}" title="View footnote.">{index}</a>]</sup>"##
));
}
None => {
dest.push_str(&format!(
r#"<sup class="footnoteref red" title="Unresolved footnote reference.">[{text}]</sup>"#,
text = params.text
));
}
}
}
}
fn wrap_body_in_html_tag(
_attrlist: Option<&Attrlist<'_>>,
tag: &'static str,
id: Option<String>,
roles: Vec<&str>,
body: &str,
dest: &mut String,
) {
dest.push('<');
dest.push_str(tag);
if let Some(id) = id.as_ref() {
dest.push_str(" id=\"");
dest.push_str(id);
dest.push('"');
}
if !roles.is_empty() {
let roles = roles.join(" ");
dest.push_str(" class=\"");
dest.push_str(&roles);
dest.push('"');
}
dest.push('>');
dest.push_str(body);
dest.push_str("</");
dest.push_str(tag);
dest.push('>');
}
fn render_icon_or_image(attrlist: &Attrlist, img: &str, type_: &'static str, dest: &mut String) {
let mut img = img.to_string();
if let Some(link) = attrlist.named_attribute("link") {
img = format!(
r#"<a class="image" href="{link}"{link_constraint_attrs}>{img}</a>"#,
link = link.value(),
link_constraint_attrs = link_constraint_attrs(attrlist, None)
);
}
let mut roles: Vec<&str> = attrlist.roles();
if let Some(float) = attrlist.named_attribute("float") {
roles.insert(0, float.value());
}
roles.insert(0, type_);
dest.push_str(r#"<span class=""#);
dest.push_str(&roles.join(" "));
dest.push_str(r#"">"#);
dest.push_str(&img);
dest.push_str("</span>");
}
fn encode_attribute_value(value: String) -> String {
value.replace('"', """)
}
fn encode_html_attribute(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for c in value.chars() {
match c {
'&' => out.push_str("&"),
'"' => out.push_str("""),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
_ => out.push(c),
}
}
out
}
fn normalize_web_path(
target: &str,
parser: &Parser,
start: Option<&str>,
preserve_uri_target: bool,
) -> String {
if preserve_uri_target && is_uri_ish(target) {
encode_spaces_in_uri(target)
} else {
parser.path_resolver.web_path(target, start)
}
}
fn is_uri_ish(path: &str) -> bool {
path.contains(':') && URI_SNIFF.is_match(path)
}
fn has_extname(path: &str) -> bool {
let segment = path.rsplit(['/', '\\']).next().unwrap_or(path);
match segment.rfind('.') {
Some(i) => i > 0 && i < segment.len() - 1,
None => false,
}
}
fn encode_spaces_in_uri(s: &str) -> String {
s.replace(' ', "%20")
}
static SVG_START_TAG_RX: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::unwrap_used)]
Regex::new(r"\A<svg[^>]*>").unwrap()
});
static SVG_SNIFF_WIDTH_HEIGHT_RX: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::unwrap_used)]
Regex::new(r#"(?s)\s+(?:width|height|style)=(?:"[^"]*"|'[^']*')"#).unwrap()
});
fn read_svg_contents(
src: &str,
width: Option<&str>,
height: Option<&str>,
parser: &Parser,
) -> Option<String> {
let handler = parser.svg_file_handler.as_ref()?;
let mut svg = handler.resolve_svg(src, parser)?;
if svg.starts_with('<')
&& let Some(start) = svg.find("<svg")
&& start > 0
{
svg = svg[start..].to_string();
}
if (width.is_some() || height.is_some())
&& let Some(start_tag) = SVG_START_TAG_RX.find(&svg).map(|m| m.as_str().to_string())
{
let rest = svg[start_tag.len()..].to_string();
let inner = &start_tag[4..start_tag.len() - 1];
let mut new_tag = format!("<svg{}", SVG_SNIFF_WIDTH_HEIGHT_RX.replace_all(inner, ""));
if let Some(width) = width {
new_tag.push_str(&format!(r#" width="{width}""#));
}
if let Some(height) = height {
new_tag.push_str(&format!(r#" height="{height}""#));
}
new_tag.push('>');
svg = format!("{new_tag}{rest}");
}
Some(svg)
}
static URI_SNIFF: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::unwrap_used)]
Regex::new(
r#"(?x)
\A # Anchor to start of string
\p{Alphabetic} # First character must be a letter
[\p{Alphabetic}\p{Nd}.+-]+ # Followed by one or more alphanum or . + -
: # Literal colon
/{0,2} # Zero to two slashes
"#,
)
.unwrap()
});
fn xref_constraint_attrs(window: Option<&str>) -> String {
let Some(window) = window else {
return String::new();
};
let rel_noopener = if window == "_blank" {
r#" rel="noopener""#
} else {
""
};
format!(
r#" target="{window}"{rel_noopener}"#,
window = encode_html_attribute(window)
)
}
fn link_constraint_attrs(attrlist: &Attrlist<'_>, window: Option<&'static str>) -> String {
let rel = if attrlist.has_option("nofollow") {
Some("nofollow")
} else {
None
};
if let Some(window) = attrlist
.named_attribute("window")
.map(|a| a.value())
.or(window)
{
let rel_noopener = if window == "_blank" || attrlist.has_option("noopener") {
if let Some(rel) = rel {
format!(r#" rel="{rel}" noopener"#)
} else {
r#" rel="noopener""#.to_owned()
}
} else {
"".to_string()
};
format!(r#" target="{window}"{rel_noopener}"#)
} else if let Some(rel) = rel {
format!(r#" rel="{rel}""#)
} else {
"".to_string()
}
}
#[cfg(test)]
mod tests {
use super::encode_html_attribute;
#[test]
fn encode_html_attribute_escapes_special_characters() {
assert_eq!(
encode_html_attribute(r#"a&b"c<d>e"#),
"a&b"c<d>e"
);
assert_eq!(encode_html_attribute("plain"), "plain");
}
}