#![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::cell::Cell;
use std::collections::BTreeMap;
use std::fmt::Write as _;
use maud::{Markup, PreEscaped};
use serde_json::{Map, Value};
use super::ast::{EventHandler, Node, PayloadSpec, Prop};
use super::error::{ConstelaError, Diagnostic, codes};
use super::eval::{Env, EvalCtx, RouteValues, eval, text_of, 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 escape_attr(input: &str) -> String {
escape_text(input)
}
struct SlotCtx<'a> {
children: &'a [Node],
env: Env,
outer: Option<&'a Self>,
}
pub struct Renderer<'a> {
pub document: &'a super::Document,
pub ctx: &'a RenderContext,
nodes: usize,
portals: Vec<RenderedPortal>,
budget: Cell<usize>,
committed_bytes: usize,
}
pub fn render(
document: &super::Document,
ctx: &RenderContext,
) -> Result<RenderedUi, ConstelaError> {
let mut renderer = Renderer {
document,
ctx,
nodes: 0,
portals: Vec::new(),
budget: Cell::new(ctx.limits.max_output_bytes),
committed_bytes: 0,
};
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 Renderer<'_> {
const fn eval_ctx<'e>(
&'e self,
env: &'e Env,
depth: usize,
budget: &'e Cell<usize>,
) -> EvalCtx<'e> {
EvalCtx {
state: &self.ctx.state,
env,
route: &self.ctx.route,
styles: &self.document.program().styles,
depth,
budget,
}
}
fn eval(
&self,
expr: &super::ast::Expr,
env: &Env,
path: &str,
depth: usize,
) -> Result<Value, ConstelaError> {
eval(expr, &self.eval_ctx(env, depth, &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 text = text_of(&self.eval(expr, env, "route.title", depth)?);
self.charge_budget(text.len(), "route.title")?;
Some(text)
}
None => None,
};
let mut meta = BTreeMap::new();
for (key, expr) in &route.meta {
let value = self.eval(expr, env, &format!("route.meta.{key}"), depth)?;
let text = text_of(&value);
self.charge_budget(text.len(), &format!("route.meta.{key}"))?;
meta.insert(key.clone(), text);
}
Ok((title, meta))
}
fn reserve(&self, emitted: usize, extra: usize, path: &str) -> Result<(), ConstelaError> {
let projected = emitted
.saturating_add(self.committed_bytes)
.saturating_add(extra);
if projected > self.ctx.limits.max_output_bytes {
return Err(ConstelaError::Render(Diagnostic::new(
path,
codes::RENDER_LIMIT,
format!(
"render would produce more than {} bytes of markup",
self.ctx.limits.max_output_bytes
),
)));
}
Ok(())
}
fn charge_budget(&self, bytes: usize, path: &str) -> Result<(), ConstelaError> {
let remaining = self.budget.get();
if bytes > remaining {
return Err(ConstelaError::Render(Diagnostic::new(
path,
codes::RENDER_LIMIT,
format!(
"render would allocate more than {} bytes",
self.ctx.limits.max_output_bytes
),
)));
}
self.budget.set(remaining.saturating_sub(bytes));
Ok(())
}
fn push_escaped(&self, out: &mut String, text: &str, path: &str) -> Result<(), ConstelaError> {
self.reserve(out.len(), text.len(), path)?;
let escaped = escape_text(text);
self.reserve(out.len(), escaped.len(), path)?;
out.push_str(&escaped);
Ok(())
}
fn charge(&mut self, path: &str, emitted: usize) -> 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
),
)));
}
let produced = emitted.saturating_add(self.committed_bytes);
if produced > self.ctx.limits.max_output_bytes {
return Err(ConstelaError::Render(Diagnostic::new(
path,
codes::RENDER_LIMIT,
format!(
"render produced more than {} bytes of markup",
self.ctx.limits.max_output_bytes
),
)));
}
Ok(())
}
fn node(
&mut self,
node: &Node,
env: &Env,
slot: Option<&SlotCtx<'_>>,
path: &str,
depth: usize,
out: &mut String,
) -> Result<(), ConstelaError> {
self.charge(path, out.len())?;
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(value, env, &format!("{path}.value"), depth)?;
self.push_escaped(out, &text_of(&value), 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(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 } => {
let language = self.eval(language, env, &format!("{path}.language"), depth)?;
let content = self.eval(content, env, &format!("{path}.content"), depth)?;
open_code_block(&text_of(&language), out);
self.push_escaped(out, &text_of(&content), path)?;
out.push_str("</code></pre>");
}
Node::Markdown { content } => {
let content = self.eval(content, env, &format!("{path}.content"), depth)?;
let source = text_of(&content);
self.reserve(out.len(), source.len(), path)?;
let mut rendered = String::new();
render_markdown(&source, &mut rendered);
self.reserve(out.len(), rendered.len(), path)?;
out.push_str(&rendered);
}
Node::Portal { target, children } => {
self.portal(target, children, env, slot, path, depth, out.len())?;
}
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: &Node,
env: &Env,
slot: Option<&SlotCtx<'_>>,
path: &str,
depth: usize,
out: &mut String,
) -> Result<(), ConstelaError> {
let _ = write!(
out,
"<div data-constela-island=\"{}\" data-constela-strategy=\"{}\">",
escape_attr(id),
strategy.as_str()
);
self.node(content, env, slot, &format!("{path}.content"), depth, out)?;
out.push_str("</div>");
Ok(())
}
fn fill_slot(
&mut self,
slot: Option<&SlotCtx<'_>>,
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(())
}
#[expect(
clippy::too_many_arguments,
reason = "the portal's own parts plus the render state threaded \
through every node, and the caller's buffer length the \
budget needs; see `element`"
)]
fn portal(
&mut self,
target: &str,
children: &[Node],
env: &Env,
slot: Option<&SlotCtx<'_>>,
path: &str,
depth: usize,
caller_bytes: usize,
) -> Result<(), ConstelaError> {
let outer = self.committed_bytes;
let entry = outer.saturating_add(caller_bytes);
self.committed_bytes = entry;
let mut inner = String::new();
let rendered = (|renderer: &mut Self| {
for (i, child) in children.iter().enumerate() {
renderer.node(
child,
env,
slot,
&format!("{path}.children[{i}]"),
depth,
&mut inner,
)?;
}
Ok::<(), ConstelaError>(())
})(self);
let nested = self.committed_bytes.saturating_sub(entry);
self.committed_bytes = outer.saturating_add(nested);
rendered?;
self.committed_bytes = self.committed_bytes.saturating_add(inner.len());
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: &super::ast::Expr,
binding: &str,
index: Option<&str>,
body: &Node,
env: &Env,
slot: Option<&SlotCtx<'_>>,
path: &str,
depth: usize,
out: &mut String,
) -> Result<(), ConstelaError> {
let items = self.eval(items, env, &format!("{path}.items"), depth)?;
let Value::Array(items) = items else {
return Ok(());
};
if items.len() > self.ctx.limits.max_each_items {
return Err(ConstelaError::Render(Diagnostic::new(
path,
codes::RENDER_LIMIT,
format!(
"`each` would iterate {} times, over the {} limit",
items.len(),
self.ctx.limits.max_each_items
),
)));
}
for (i, item) in items.into_iter().enumerate() {
let mut inner = env.with_var(binding, item);
if let Some(index_name) = index {
inner = inner.with_var(index_name, Value::from(i));
}
self.node(body, &inner, slot, &format!("{path}.body[{i}]"), depth, out)?;
}
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: &BTreeMap<String, super::ast::Expr>,
children: &[Node],
env: &Env,
slot: Option<&SlotCtx<'_>>,
path: &str,
depth: usize,
out: &mut String,
) -> Result<(), ConstelaError> {
let Some(component) = self.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(expr, env, &format!("{path}.props.{key}"), depth)?;
params.insert(key.clone(), value);
}
let inner = Env {
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: &BTreeMap<String, Prop>,
children: &[Node],
env: &Env,
slot: Option<&SlotCtx<'_>>,
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();
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 value = self.eval(expr, env, &format!("{path}.props.{raw_name}"), depth)?;
if let Some(rendered) = self.attribute_value(
&name,
&value,
out.len(),
&format!("{path}.props.{raw_name}"),
)? {
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)?;
}
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) })
.sum();
self.reserve(out.len(), attr_bytes.saturating_add(tag.len()), path)?;
let _ = write!(out, "<{tag}");
for (name, value) in &attrs {
match value {
Some(value) if value.is_empty() => {
let _ = write!(out, " {name}");
}
Some(value) => {
let _ = write!(out, " {name}=\"{value}\"");
}
None => {}
}
}
out.push('>');
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,
)?;
}
let _ = write!(out, "</{tag}>");
Ok(())
}
fn attribute_value(
&self,
name: &str,
value: &Value,
emitted: 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 => text_of(other),
};
if policy::is_url_attr(name) && !policy::is_allowed_url(&text) {
return Err(ConstelaError::Render(Diagnostic::new(
path,
codes::URL_SCHEME,
format!("computed URL {text:?} uses a scheme that is not allowed"),
)));
}
self.reserve(emitted, text.len(), path)?;
let text = if policy::is_id_ref_attr(name) {
prefix_id_refs(&self.ctx.id_prefix, &text)
} else if policy::is_url_attr(name) {
prefix_fragment_link(&self.ctx.id_prefix, &text)
} else {
text
};
Ok(Some(escape_attr(&text)))
}
fn push_handler_attrs(
&self,
handler: &EventHandler,
env: &Env,
path: &str,
depth: usize,
attrs: &mut Vec<(String, Option<String>)>,
) -> 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 value = match payload {
PayloadSpec::Single(expr) => {
self.eval(expr, env, &format!("{path}.payload"), depth)?
}
PayloadSpec::Map(props) => {
let mut map = Map::new();
for (key, expr) in props {
map.insert(
key.clone(),
self.eval(expr, env, &format!("{path}.payload.{key}"), depth)?,
);
}
Value::Object(map)
}
};
let serialized = value.to_string();
self.charge_budget(serialized.len(), &format!("{path}.payload"))?;
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 open_code_block(language: &str, out: &mut String) {
out.push_str("<pre><code");
let language = sanitize_language(language);
if !language.is_empty() {
let _ = write!(out, " class=\"language-{}\"", escape_attr(&language));
}
out.push('>');
}
#[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(prefix: &str, value: &str) -> String {
match value.strip_prefix('#') {
Some(fragment) if !fragment.is_empty() => format!("#{prefix}{fragment}"),
_ => value.to_string(),
}
}
fn prefix_id_refs(prefix: &str, value: &str) -> String {
value
.split_whitespace()
.map(|id| format!("{prefix}{id}"))
.collect::<Vec<_>>()
.join(" ")
}
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 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-", ""), "");
}
#[test]
fn same_document_fragment_links_are_prefixed_like_the_ids_they_target() {
assert_eq!(prefix_fragment_link("c-", "#section"), "#c-section");
assert_eq!(prefix_fragment_link("c-", "#"), "#");
assert_eq!(
prefix_fragment_link("c-", "/other#section"),
"/other#section"
);
assert_eq!(prefix_fragment_link("c-", "https://x/y#z"), "https://x/y#z");
assert_eq!(prefix_fragment_link("c-", ""), "");
}
#[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"));
}
}