#![cfg_attr(
not(test),
deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unreachable,
clippy::todo,
clippy::unimplemented,
clippy::indexing_slicing,
clippy::string_slice,
clippy::arithmetic_side_effects,
)
)]
use std::borrow::Cow;
use std::collections::BTreeMap;
use std::rc::Rc;
use maud::{Markup, PreEscaped};
use serde_json::{Map, Value};
use super::ast::{EventHandler, Expr, Node, PayloadSpec, Prop};
use super::budget::{Budget, ChargingWriter, Output};
use super::error::{ConstelaError, Diagnostic, codes, excerpt};
use super::eval::{Bound, Env, EvalCtx, RouteValues, eval_ref, text_coerce, truthy};
use super::policy;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RenderLimits {
pub max_depth: usize,
pub max_nodes: usize,
pub max_each_items: usize,
pub max_output_bytes: usize,
}
impl Default for RenderLimits {
fn default() -> Self {
Self {
max_depth: 128,
max_nodes: 50_000,
max_each_items: 5_000,
max_output_bytes: 4 * 1024 * 1024,
}
}
}
#[derive(Debug, Clone)]
pub struct RenderContext {
pub state: Map<String, Value>,
pub route: RouteValues,
pub id_prefix: String,
pub limits: RenderLimits,
}
impl Default for RenderContext {
fn default() -> Self {
Self {
state: Map::new(),
route: RouteValues::default(),
id_prefix: "c-".to_string(),
limits: RenderLimits::default(),
}
}
}
#[derive(Debug, Clone)]
pub struct RenderedPortal {
pub target: String,
pub content: Markup,
}
#[derive(Debug, Clone)]
pub struct RenderedUi {
pub body: Markup,
pub portals: Vec<RenderedPortal>,
pub title: Option<String>,
pub meta: BTreeMap<String, String>,
}
impl RenderedUi {
#[must_use]
pub fn portals_for(&self, target: &str) -> Vec<&RenderedPortal> {
self.portals
.iter()
.filter(|portal| portal.target == target)
.collect()
}
}
fn escape_text(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for c in input.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
other => out.push(other),
}
}
out
}
fn escaped_len(input: &str) -> usize {
input
.bytes()
.map(|byte| match byte {
b'&' | b'\'' => 5,
b'<' | b'>' => 4,
b'"' => 6,
_ => 1,
})
.fold(0usize, usize::saturating_add)
}
fn escape_attr(input: &str) -> String {
escape_text(input)
}
struct SlotCtx<'d, 's> {
children: &'d [Node],
env: Env<'s>,
outer: Option<&'s Self>,
}
pub struct Renderer<'a> {
pub document: &'a super::Document,
pub ctx: &'a RenderContext,
nodes: usize,
portals: Vec<RenderedPortal>,
budget: &'a Budget,
output: &'a Output,
ids: BTreeMap<String, String>,
}
pub fn render(
document: &super::Document,
ctx: &RenderContext,
) -> Result<RenderedUi, ConstelaError> {
let budget = Budget::new(ctx.limits.max_output_bytes);
let output = Output::new(ctx.limits.max_output_bytes);
let mut renderer = Renderer {
document,
ctx,
nodes: 0,
portals: Vec::new(),
budget: &budget,
output: &output,
ids: BTreeMap::new(),
};
let env = Env::default();
let mut body = String::new();
renderer.node(
&document.program().view,
&env,
None,
"view",
ctx.limits.max_depth,
&mut body,
)?;
let (title, meta) = renderer.route_metadata(&env)?;
Ok(RenderedUi {
body: PreEscaped(body),
portals: renderer.portals,
title,
meta,
})
}
impl<'a> Renderer<'a> {
fn eval_ref<'c>(
&self,
expr: &'c Expr,
env: &'c Env<'c>,
path: &str,
depth: usize,
) -> Result<Cow<'c, Value>, ConstelaError>
where
'a: 'c,
{
let ctx: &'a RenderContext = self.ctx;
let document: &'a super::Document = self.document;
let eval_ctx = EvalCtx {
state: &ctx.state,
env,
route: &ctx.route,
styles: &document.program().styles,
depth,
budget: self.budget,
};
eval_ref(expr, &eval_ctx, path)
}
fn text<'v>(&self, value: &'v Value, path: &str) -> Result<Cow<'v, str>, ConstelaError> {
text_coerce(value, self.budget, path)
}
fn route_metadata(
&self,
env: &Env<'_>,
) -> Result<(Option<String>, BTreeMap<String, String>), ConstelaError> {
let Some(route) = &self.document.program().route else {
return Ok((None, BTreeMap::new()));
};
let depth = self.ctx.limits.max_depth;
let title = match &route.title {
Some(expr) => {
let value = self.eval_ref(expr, env, "route.title", depth)?;
let text = self.text(&value, "route.title")?;
Some(self.budget.own_str(text, "route.title")?)
}
None => None,
};
let mut meta = BTreeMap::new();
for (key, expr) in &route.meta {
let path = format!("route.meta.{key}");
let value = self.eval_ref(expr, env, &path, depth)?;
let text = self.text(&value, &path)?;
self.budget.charge(key.len(), &path)?;
meta.insert(key.clone(), self.budget.own_str(text, &path)?);
}
Ok((title, meta))
}
fn write(&self, out: &mut String, markup: &str, path: &str) -> Result<(), ConstelaError> {
self.output.write(out, markup, path)
}
fn push_escaped(&self, out: &mut String, text: &str, path: &str) -> Result<(), ConstelaError> {
self.output.check(escaped_len(text), path)?;
self.write(out, &escape_text(text), path)
}
fn count_node(&mut self, path: &str) -> Result<(), ConstelaError> {
self.nodes = self.nodes.saturating_add(1);
if self.nodes > self.ctx.limits.max_nodes {
return Err(ConstelaError::Render(Diagnostic::new(
path,
codes::RENDER_LIMIT,
format!(
"render produced more than {} nodes",
self.ctx.limits.max_nodes
),
)));
}
Ok(())
}
fn node(
&mut self,
node: &'a Node,
env: &Env<'_>,
slot: Option<&SlotCtx<'a, '_>>,
path: &str,
depth: usize,
out: &mut String,
) -> Result<(), ConstelaError> {
self.count_node(path)?;
let Some(depth) = depth.checked_sub(1) else {
return Err(ConstelaError::Render(Diagnostic::new(
path,
codes::RENDER_LIMIT,
format!(
"view nests deeper than the {}-level render limit",
self.ctx.limits.max_depth
),
)));
};
match node {
Node::Text { value } => {
let value = self.eval_ref(value, env, &format!("{path}.value"), depth)?;
let text = self.text(&value, path)?;
self.push_escaped(out, &text, path)?;
}
Node::Element {
tag,
element_ref,
props,
children,
} => self.element(
tag,
element_ref.as_deref(),
props,
children,
env,
slot,
path,
depth,
out,
)?,
Node::If {
condition,
then,
otherwise,
} => {
let test = self.eval_ref(condition, env, &format!("{path}.condition"), depth)?;
let (branch, label) = if truthy(&test) {
(Some(&**then), "then")
} else {
(otherwise.as_deref(), "else")
};
if let Some(branch) = branch {
self.node(branch, env, slot, &format!("{path}.{label}"), depth, out)?;
}
}
Node::Each {
items,
binding,
index,
body,
..
} => self.each(
items,
binding,
index.as_deref(),
body,
env,
slot,
path,
depth,
out,
)?,
Node::Component {
name,
props,
children,
} => self.component(name, props, children, env, slot, path, depth, out)?,
Node::Slot { .. } => self.fill_slot(slot, path, depth, out)?,
Node::Code { language, content } => {
self.code(language, content, env, path, depth, out)?;
}
Node::Markdown { content } => {
let content = self.eval_ref(content, env, &format!("{path}.content"), depth)?;
let source = self.text(&content, path)?;
self.output.check(source.len(), path)?;
let mut rendered = String::new();
render_markdown(&source, &mut rendered);
self.write(out, &rendered, path)?;
}
Node::Portal { target, children } => {
self.portal(target, children, env, slot, path, depth)?;
}
Node::Island {
id,
strategy,
content,
} => self.island(id, *strategy, content, env, slot, path, depth, out)?,
Node::Suspense { content, .. } | Node::ErrorBoundary { content, .. } => {
self.node(content, env, slot, &format!("{path}.content"), depth, out)?;
}
}
Ok(())
}
#[expect(
clippy::too_many_arguments,
reason = "the island's own parts plus the render state threaded \
through every node; see `element`"
)]
fn island(
&mut self,
id: &str,
strategy: super::ast::IslandStrategy,
content: &'a Node,
env: &Env<'_>,
slot: Option<&SlotCtx<'a, '_>>,
path: &str,
depth: usize,
out: &mut String,
) -> Result<(), ConstelaError> {
self.write(out, "<div data-constela-island=\"", path)?;
self.push_escaped(out, id, path)?;
self.write(out, "\" data-constela-strategy=\"", path)?;
self.write(out, strategy.as_str(), path)?;
self.write(out, "\">", path)?;
self.node(content, env, slot, &format!("{path}.content"), depth, out)?;
self.write(out, "</div>", path)
}
fn fill_slot(
&mut self,
slot: Option<&SlotCtx<'a, '_>>,
path: &str,
depth: usize,
out: &mut String,
) -> Result<(), ConstelaError> {
let Some(slot) = slot else {
return Ok(());
};
for (i, child) in slot.children.iter().enumerate() {
self.node(
child,
&slot.env,
slot.outer,
&format!("{path}.slot[{i}]"),
depth,
out,
)?;
}
Ok(())
}
fn portal(
&mut self,
target: &str,
children: &'a [Node],
env: &Env<'_>,
slot: Option<&SlotCtx<'a, '_>>,
path: &str,
depth: usize,
) -> Result<(), ConstelaError> {
self.budget.charge(
target.len().saturating_add(size_of::<RenderedPortal>()),
&format!("{path}.target"),
)?;
let mut inner = String::new();
for (i, child) in children.iter().enumerate() {
self.node(
child,
env,
slot,
&format!("{path}.children[{i}]"),
depth,
&mut inner,
)?;
}
self.portals.push(RenderedPortal {
target: target.to_string(),
content: PreEscaped(inner),
});
Ok(())
}
#[expect(
clippy::too_many_arguments,
reason = "the loop's own parts plus the render state threaded through \
every node; see `element`"
)]
fn each(
&mut self,
items: &'a Expr,
binding: &'a str,
index: Option<&'a str>,
body: &'a Node,
env: &Env<'_>,
slot: Option<&SlotCtx<'a, '_>>,
path: &str,
depth: usize,
out: &mut String,
) -> Result<(), ConstelaError> {
let items = self.eval_ref(items, env, &format!("{path}.items"), depth)?;
let passes: Box<dyn Iterator<Item = Bound<'_>> + '_> = match items {
Cow::Borrowed(Value::Array(list)) => {
self.check_iterations(list.len(), path)?;
Box::new(list.iter().map(Bound::Borrowed))
}
Cow::Owned(Value::Array(list)) => {
self.check_iterations(list.len(), path)?;
Box::new(list.into_iter().map(|item| Bound::Shared(Rc::new(item))))
}
_ => return Ok(()),
};
for (i, item) in passes.enumerate() {
let mut inner = env.with_var(binding, item);
if let Some(index_name) = index {
inner = inner.with_var(index_name, Bound::Shared(Rc::new(Value::from(i))));
}
self.node(body, &inner, slot, &format!("{path}.body[{i}]"), depth, out)?;
}
Ok(())
}
fn check_iterations(&self, len: usize, path: &str) -> Result<(), ConstelaError> {
if len > self.ctx.limits.max_each_items {
return Err(ConstelaError::Render(Diagnostic::new(
path,
codes::RENDER_LIMIT,
format!(
"`each` would iterate {len} times, over the {} limit",
self.ctx.limits.max_each_items
),
)));
}
Ok(())
}
#[expect(
clippy::too_many_arguments,
reason = "the invocation's own parts plus the render state threaded \
through every node; see `element`"
)]
fn component(
&mut self,
name: &str,
props: &'a BTreeMap<String, Expr>,
children: &'a [Node],
env: &Env<'_>,
slot: Option<&SlotCtx<'a, '_>>,
path: &str,
depth: usize,
out: &mut String,
) -> Result<(), ConstelaError> {
let document: &'a super::Document = self.document;
let Some(component) = document.program().components.get(name) else {
return Err(ConstelaError::Render(Diagnostic::new(
format!("{path}.name"),
codes::UNKNOWN_REF,
format!("no component named {name:?} is declared"),
)));
};
let mut params = BTreeMap::new();
for (key, expr) in props {
let value = self.eval_ref(expr, env, &format!("{path}.props.{key}"), depth)?;
params.insert(key.as_str(), Bound::from(value));
}
let inner = Env {
params: Rc::new(params),
vars: Vec::new(),
};
let nested = SlotCtx {
children,
env: env.clone(),
outer: slot,
};
self.node(
&component.view,
&inner,
Some(&nested),
&format!("{path}<{name}>"),
depth,
out,
)
}
#[expect(
clippy::too_many_arguments,
reason = "the element's own parts plus the render state threaded \
through every node; bundling them would only move the \
arguments into a struct built at one call site"
)]
fn element(
&mut self,
tag: &str,
element_ref: Option<&str>,
props: &'a BTreeMap<String, Prop>,
children: &'a [Node],
env: &Env<'_>,
slot: Option<&SlotCtx<'a, '_>>,
path: &str,
depth: usize,
out: &mut String,
) -> Result<(), ConstelaError> {
let tag = tag.to_ascii_lowercase();
if !policy::is_allowed_tag(&tag) {
return Err(ConstelaError::Render(Diagnostic::new(
path,
codes::TAG_NOT_ALLOWED,
format!("tag {tag:?} is not on the allowlist"),
)));
}
let mut attrs: Vec<(String, Option<String>)> = Vec::new();
let mut handlers: Vec<(&str, &EventHandler)> = Vec::new();
let mut pending = 0usize;
for (raw_name, prop) in props {
match prop {
Prop::Handler(handler) => handlers.push((raw_name.as_str(), handler)),
Prop::Value(expr) => {
let name = policy::canonical_attr(raw_name);
if policy::is_reserved_attr(&name) || !policy::is_allowed_attr(&name) {
return Err(ConstelaError::Render(Diagnostic::new(
format!("{path}.props.{raw_name}"),
codes::ATTR_NOT_ALLOWED,
format!("attribute {name:?} is not on the allowlist"),
)));
}
let attr_path = format!("{path}.props.{raw_name}");
let value = self.eval_ref(expr, env, &attr_path, depth)?;
if name == "id" && !self.record_id(&value, &attr_path)? {
continue;
}
if let Some(rendered) =
self.attribute_value(&name, &value, pending, &attr_path)?
{
pending = pending.saturating_add(rendered.len());
attrs.push((name, Some(rendered)));
}
}
}
}
if let Some(name) = element_ref {
attrs.push(("data-constela-ref".to_string(), Some(escape_attr(name))));
}
for (_, handler) in &handlers {
self.push_handler_attrs(handler, env, path, depth, &mut attrs, &mut pending)?;
}
harden_blank_target(&mut attrs);
let attr_bytes: usize = attrs
.iter()
.map(|(name, value)| {
name.len()
.saturating_add(value.as_ref().map_or(0, String::len))
.saturating_add(4) })
.fold(0usize, usize::saturating_add);
self.output
.check(attr_bytes.saturating_add(tag.len()).saturating_add(2), path)?;
self.write(out, "<", path)?;
self.write(out, &tag, path)?;
for (name, value) in &attrs {
match value {
Some(value) if value.is_empty() => {
self.write(out, " ", path)?;
self.write(out, name, path)?;
}
Some(value) => {
self.write(out, " ", path)?;
self.write(out, name, path)?;
self.write(out, "=\"", path)?;
self.write(out, value, path)?;
self.write(out, "\"", path)?;
}
None => {}
}
}
self.write(out, ">", path)?;
if policy::is_void_tag(&tag) {
return Ok(());
}
for (i, child) in children.iter().enumerate() {
self.node(
child,
env,
slot,
&format!("{path}.children[{i}]"),
depth,
out,
)?;
}
self.write(out, "</", path)?;
self.write(out, &tag, path)?;
self.write(out, ">", path)
}
fn record_id(&mut self, value: &Value, path: &str) -> Result<bool, ConstelaError> {
let id = match value {
Value::Bool(_) | Value::Null => return Ok(false),
other => self.text(other, path)?,
};
if id.is_empty() {
return Ok(false);
}
if id.chars().any(char::is_whitespace) {
return Err(ConstelaError::Render(Diagnostic::new(
path,
codes::EVAL,
format!("id {:?} has whitespace; an id is one token", excerpt(&id)),
)));
}
let prefix = &self.ctx.id_prefix;
self.budget.charge(
prefix
.len()
.saturating_add(id.len())
.saturating_add(path.len())
.saturating_add(size_of::<String>().saturating_mul(2)),
path,
)?;
let rendered = format!("{prefix}{id}");
if let Some(first) = self.ids.get(&rendered) {
return Err(ConstelaError::Render(Diagnostic::new(
path,
codes::DUPLICATE,
format!(
"id {:?} (rendered {:?}) is already used at {}; give each use its own id: pass a different param value to each use, or build it from the loop index",
excerpt(&id),
excerpt(&rendered),
excerpt(first)
),
)));
}
self.ids.insert(rendered, path.to_string());
Ok(true)
}
fn attribute_value(
&self,
name: &str,
value: &Value,
pending: usize,
path: &str,
) -> Result<Option<String>, ConstelaError> {
let is_data = name.starts_with("data-");
let text = match value {
Value::Bool(true) => return Ok(Some(String::new())),
Value::Bool(false) | Value::Null if !is_data => return Ok(None),
other => self.text(other, path)?,
};
if policy::is_url_attr(name) && !policy::is_allowed_url(&text) {
return Err(ConstelaError::Render(Diagnostic::new(
path,
codes::URL_SCHEME,
format!(
"computed URL {:?} uses a scheme that is not allowed",
excerpt(&text)
),
)));
}
self.output
.check(pending.saturating_add(text.len()), path)?;
let text = if policy::is_id_ref_attr(name) {
let prefix = &self.ctx.id_prefix;
self.output
.check(pending.saturating_add(prefixed_len(prefix, &text)), path)?;
Cow::Owned(prefix_id_refs(prefix, &text))
} else if policy::is_url_attr(name) {
prefix_fragment_link(&self.ctx.id_prefix, text)
} else {
text
};
self.output
.check(pending.saturating_add(escaped_len(&text)), path)?;
Ok(Some(escape_attr(&text)))
}
fn push_handler_attrs(
&self,
handler: &'a EventHandler,
env: &Env<'_>,
path: &str,
depth: usize,
attrs: &mut Vec<(String, Option<String>)>,
pending: &mut usize,
) -> Result<(), ConstelaError> {
let event = policy::canonical_event(&handler.event);
if event.is_empty() {
return Ok(());
}
attrs.push((
format!("data-constela-on-{event}"),
Some(escape_attr(&handler.action)),
));
if let Some(payload) = &handler.payload {
let payload_path = format!("{path}.payload");
let writer = ChargingWriter::new(self.budget, &payload_path);
let serialized = match payload {
PayloadSpec::Single(expr) => {
let value = self.eval_ref(expr, env, &payload_path, depth)?;
writer.serialize(&value)?
}
PayloadSpec::Map(props) => {
let mut map = BTreeMap::new();
for (key, expr) in props {
let entry_path = format!("{payload_path}.{key}");
map.insert(key.as_str(), self.eval_ref(expr, env, &entry_path, depth)?);
}
writer.serialize(&map)?
}
};
let escaped_bytes = escaped_len(&serialized);
self.output
.check(pending.saturating_add(escaped_bytes), &payload_path)?;
*pending = pending.saturating_add(escaped_bytes);
attrs.push((
format!("data-constela-payload-{event}"),
Some(escape_attr(&serialized)),
));
}
if let Some(ms) = handler.debounce {
attrs.push((
format!("data-constela-debounce-{event}"),
Some(ms.to_string()),
));
}
if let Some(ms) = handler.throttle {
attrs.push((
format!("data-constela-throttle-{event}"),
Some(ms.to_string()),
));
}
Ok(())
}
fn code(
&self,
language: &Expr,
content: &Expr,
env: &Env<'_>,
path: &str,
depth: usize,
out: &mut String,
) -> Result<(), ConstelaError> {
let language = self.eval_ref(language, env, &format!("{path}.language"), depth)?;
let content = self.eval_ref(content, env, &format!("{path}.content"), depth)?;
self.write(out, "<pre><code", path)?;
let language = sanitize_language(&self.text(&language, path)?);
if !language.is_empty() {
self.write(out, " class=\"language-", path)?;
self.push_escaped(out, &language, path)?;
self.write(out, "\"", path)?;
}
self.write(out, ">", path)?;
self.push_escaped(out, &self.text(&content, path)?, path)?;
self.write(out, "</code></pre>", path)
}
}
#[cfg(feature = "markdown")]
fn render_markdown(source: &str, out: &mut String) {
out.push_str(&crate::markdown::render_user_content_html(source));
}
#[cfg(not(feature = "markdown"))]
const fn render_markdown(source: &str, out: &mut String) {
let _ = (source, out);
}
fn sanitize_language(language: &str) -> String {
language
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '+' || *c == '#')
.take(32)
.collect()
}
fn prefix_fragment_link<'t>(prefix: &str, value: Cow<'t, str>) -> Cow<'t, str> {
match value.strip_prefix('#') {
Some(fragment) if !fragment.is_empty() => Cow::Owned(format!("#{prefix}{fragment}")),
_ => value,
}
}
fn prefixed_len(prefix: &str, value: &str) -> usize {
value
.split_whitespace()
.map(|id| prefix.len().saturating_add(id.len()).saturating_add(1))
.fold(0usize, usize::saturating_add)
.saturating_sub(1)
}
fn prefix_id_refs(prefix: &str, value: &str) -> String {
let mut out = String::with_capacity(prefixed_len(prefix, value));
for id in value.split_whitespace() {
if !out.is_empty() {
out.push(' ');
}
out.push_str(prefix);
out.push_str(id);
}
out
}
fn harden_blank_target(attrs: &mut Vec<(String, Option<String>)>) {
let opens_new_tab = attrs.iter().any(|(name, value)| {
name == "target" && value.as_deref().is_some_and(|value| value == "_blank")
});
if !opens_new_tab {
return;
}
match attrs.iter_mut().find(|(name, _)| name == "rel") {
Some((_, value)) => {
let existing = value.clone().unwrap_or_default();
let mut tokens: Vec<&str> = existing.split_whitespace().collect();
for required in ["noopener", "noreferrer"] {
if !tokens.contains(&required) {
tokens.push(required);
}
}
*value = Some(tokens.join(" "));
}
None => attrs.push(("rel".to_string(), Some("noopener noreferrer".to_string()))),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn escaping_covers_the_five_character_set() {
assert_eq!(
escape_text(r#"<a href="x">&'"#),
"<a href="x">&'"
);
}
#[test]
fn escaped_len_matches_the_escaped_text() {
for input in ["", "plain", r#"<a href="x">&'"#, "€ & <€>"] {
assert_eq!(escaped_len(input), escape_text(input).len(), "{input}");
}
}
#[test]
fn language_hints_are_reduced_to_class_safe_text() {
assert_eq!(sanitize_language("rust"), "rust");
assert_eq!(sanitize_language("c++"), "c++");
assert_eq!(sanitize_language("a\" onload=\"x"), "aonloadx");
}
#[test]
fn id_refs_are_prefixed_token_by_token() {
assert_eq!(prefix_id_refs("c-", "a"), "c-a");
assert_eq!(prefix_id_refs("c-", "a b"), "c-a c-b");
assert_eq!(prefix_id_refs("c-", ""), "");
for value in ["", "a", "a b", " a b c "] {
assert_eq!(
prefixed_len("c-", value),
prefix_id_refs("c-", value).len(),
"{value:?}"
);
}
}
#[test]
fn same_document_fragment_links_are_prefixed_like_the_ids_they_target() {
let link = |value: &'static str| prefix_fragment_link("c-", Cow::Borrowed(value));
assert_eq!(link("#section"), "#c-section");
assert_eq!(link("#"), "#");
assert_eq!(link("/other#section"), "/other#section");
assert_eq!(link("https://x/y#z"), "https://x/y#z");
assert_eq!(link(""), "");
}
#[test]
fn blank_targets_get_noopener() {
let mut attrs = vec![("target".into(), Some("_blank".into()))];
harden_blank_target(&mut attrs);
assert_eq!(
attrs.iter().find(|(n, _)| n == "rel").unwrap().1.as_deref(),
Some("noopener noreferrer")
);
}
#[test]
fn existing_rel_is_extended_not_replaced() {
let mut attrs = vec![
("target".into(), Some("_blank".into())),
("rel".into(), Some("nofollow noopener".into())),
];
harden_blank_target(&mut attrs);
assert_eq!(
attrs.iter().find(|(n, _)| n == "rel").unwrap().1.as_deref(),
Some("nofollow noopener noreferrer")
);
}
#[test]
fn same_tab_links_are_left_alone() {
let mut attrs = vec![("target".into(), Some("_self".into()))];
harden_blank_target(&mut attrs);
assert!(attrs.iter().all(|(n, _)| n != "rel"));
}
}