#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReactionControls {
dom_id: String,
up_action: String,
down_action: Option<String>,
aggregate: i64,
current: Option<i16>,
label: String,
up_label: String,
down_label: String,
like_label: String,
hx_target: String,
csrf_token: Option<String>,
csrf_field: String,
preserve_pressed_state: bool,
}
impl ReactionControls {
fn new(dom_id: String, up_action: String, down_action: Option<String>) -> Self {
let hx_target = format!("#{dom_id}");
Self {
dom_id,
up_action,
down_action,
aggregate: 0,
current: None,
label: "Reactions".to_owned(),
up_label: "Upvote".to_owned(),
down_label: "Downvote".to_owned(),
like_label: "Like".to_owned(),
hx_target,
csrf_token: None,
csrf_field: "_csrf".to_owned(),
preserve_pressed_state: false,
}
}
#[must_use]
pub fn votes(
dom_id: impl Into<String>,
up_action: impl Into<String>,
down_action: impl Into<String>,
) -> Self {
Self::new(dom_id.into(), up_action.into(), Some(down_action.into()))
}
#[must_use]
pub fn likes(dom_id: impl Into<String>, action: impl Into<String>) -> Self {
Self::new(dom_id.into(), action.into(), None)
}
#[must_use]
pub const fn aggregate(mut self, n: i64) -> Self {
self.aggregate = n;
self
}
#[must_use]
pub const fn current(mut self, v: Option<i16>) -> Self {
self.current = v;
self
}
#[must_use]
pub fn label(mut self, s: impl Into<String>) -> Self {
self.label = s.into();
self
}
#[must_use]
pub fn up_label(mut self, s: impl Into<String>) -> Self {
self.up_label = s.into();
self
}
#[must_use]
pub fn down_label(mut self, s: impl Into<String>) -> Self {
self.down_label = s.into();
self
}
#[must_use]
pub fn like_label(mut self, s: impl Into<String>) -> Self {
self.like_label = s.into();
self
}
#[must_use]
pub fn hx_target(mut self, s: impl Into<String>) -> Self {
self.hx_target = s.into();
self
}
#[must_use]
pub fn csrf_token(mut self, token: impl Into<String>) -> Self {
self.csrf_token = Some(token.into());
self
}
#[must_use]
pub fn csrf_field(mut self, field: impl Into<String>) -> Self {
self.csrf_field = field.into();
self
}
#[must_use]
pub const fn preserve_pressed_state(mut self, preserve: bool) -> Self {
self.preserve_pressed_state = preserve;
self
}
#[must_use]
pub fn csrf(
mut self,
token: Option<&crate::security::CsrfToken>,
field: Option<&crate::security::CsrfFormField>,
) -> Self {
if let Some(tok) = token {
self = self.csrf_token(tok.token());
}
if let Some(name) = field {
self = self.csrf_field(name.0.as_str());
}
self
}
}
#[must_use]
pub fn reaction_controls(cfg: &ReactionControls) -> maud::Markup {
let up_pressed = cfg.current == Some(1);
let down_pressed = cfg.current == Some(-1);
let like_mode = cfg.down_action.is_none();
let (primary_form_class, primary_glyph, primary_label) = if like_mode {
("autumn-reaction autumn-reaction-like", "♥", &cfg.like_label)
} else {
("autumn-reaction autumn-reaction-up", "▲", &cfg.up_label)
};
let primary_button_class = if up_pressed {
"autumn-reaction-button autumn-reaction-active"
} else {
"autumn-reaction-button"
};
let down_button_class = if down_pressed {
"autumn-reaction-button autumn-reaction-active"
} else {
"autumn-reaction-button"
};
let primary_pressed = if up_pressed { "true" } else { "false" };
let down_pressed_attr = if down_pressed { "true" } else { "false" };
let primary_button_id = if like_mode {
format!("{}-like", cfg.dom_id)
} else {
format!("{}-up", cfg.dom_id)
};
let down_button_id = format!("{}-down", cfg.dom_id);
let preserve = cfg.preserve_pressed_state.then_some("true");
let hx_sync = format!("#{}:replace", cfg.dom_id);
maud::html! {
div id=(cfg.dom_id) class="autumn-reaction-controls" role="group" aria-label=(cfg.label) {
form method="post" action=(cfg.up_action) class=(primary_form_class)
hx-post=(cfg.up_action) hx-target=(cfg.hx_target) hx-swap="outerHTML"
hx-sync=(hx_sync) {
@if let Some(token) = &cfg.csrf_token {
input type="hidden" name=(cfg.csrf_field) value=(token);
}
button type="submit" id=(primary_button_id) class=(primary_button_class)
aria-pressed=(primary_pressed) aria-label=(primary_label)
hx-preserve=[preserve] {
span aria-hidden="true" { (primary_glyph) }
}
}
span class="autumn-reaction-count" aria-live="polite" { (cfg.aggregate) }
@if let Some(down_action) = &cfg.down_action {
form method="post" action=(down_action) class="autumn-reaction autumn-reaction-down"
hx-post=(down_action) hx-target=(cfg.hx_target) hx-swap="outerHTML"
hx-sync=(hx_sync) {
@if let Some(token) = &cfg.csrf_token {
input type="hidden" name=(cfg.csrf_field) value=(token);
}
button type="submit" id=(down_button_id) class=(down_button_class)
aria-pressed=(down_pressed_attr) aria-label=(cfg.down_label)
hx-preserve=[preserve] {
span aria-hidden="true" { "▼" }
}
}
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommentView {
pub id: i64,
pub author: String,
pub body: String,
pub datetime: Option<String>,
pub timestamp: String,
pub replies: Vec<Self>,
}
#[cfg(feature = "db")]
impl CommentView {
#[must_use]
pub fn from_thread(nodes: &[crate::commentable::CommentNode]) -> Vec<Self> {
nodes
.iter()
.map(|node| Self {
id: node.comment.id,
author: node
.comment
.author_name
.clone()
.unwrap_or_else(|| format!("user #{}", node.comment.author_id)),
body: node.comment.body.clone(),
datetime: Some(
node.comment
.created_at
.and_utc()
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
),
timestamp: node.comment.created_at.format("%Y-%m-%d %H:%M").to_string(),
replies: Self::from_thread(&node.replies),
})
.collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommentThread {
dom_id: String,
action: String,
csrf_token: Option<String>,
csrf_field: String,
body_field: String,
reply_field: String,
label: String,
empty_text: String,
submit_label: String,
reply_label: String,
placeholder: String,
hx_target: String,
max_depth: usize,
can_comment: bool,
sign_in_prompt: Option<String>,
return_to: Option<String>,
max_body_bytes: Option<usize>,
error: Option<String>,
draft: Option<String>,
draft_reply_to: Option<i64>,
}
impl CommentThread {
#[must_use]
pub fn new(dom_id: impl Into<String>, action: impl Into<String>) -> Self {
let dom_id = dom_id.into();
let hx_target = format!("#{dom_id}");
Self {
dom_id,
action: action.into(),
csrf_token: None,
csrf_field: "_csrf".to_owned(),
body_field: "body".to_owned(),
reply_field: "reply_to".to_owned(),
label: "Comments".to_owned(),
empty_text: "No comments yet.".to_owned(),
submit_label: "Post comment".to_owned(),
reply_label: "Reply".to_owned(),
placeholder: "Add a comment…".to_owned(),
hx_target,
max_depth: crate::widgets::DEFAULT_COMMENT_MAX_DEPTH,
can_comment: true,
sign_in_prompt: None,
return_to: None,
max_body_bytes: None,
error: None,
draft: None,
draft_reply_to: None,
}
}
#[cfg(feature = "db")]
#[must_use]
pub fn from_spec(
dom_id: impl Into<String>,
action: impl Into<String>,
spec: &crate::commentable::CommentableSpec,
) -> Self {
Self::new(dom_id, action)
.max_depth(usize::try_from(spec.max_depth).unwrap_or(usize::MAX))
.max_body_bytes(spec.max_body_bytes)
}
#[must_use]
pub const fn max_body_bytes(mut self, bytes: usize) -> Self {
self.max_body_bytes = Some(bytes);
self
}
#[must_use]
pub fn error(mut self, error: impl Into<String>) -> Self {
self.error = Some(error.into());
self
}
#[must_use]
pub fn draft(mut self, reply_to: Option<i64>, body: impl Into<String>) -> Self {
self.draft = Some(body.into());
self.draft_reply_to = reply_to;
self
}
#[must_use]
pub fn csrf_token(mut self, token: impl Into<String>) -> Self {
self.csrf_token = Some(token.into());
self
}
#[must_use]
pub fn csrf_field(mut self, field: impl Into<String>) -> Self {
self.csrf_field = field.into();
self
}
#[must_use]
pub fn body_field(mut self, field: impl Into<String>) -> Self {
self.body_field = field.into();
self
}
#[must_use]
pub fn reply_field(mut self, field: impl Into<String>) -> Self {
self.reply_field = field.into();
self
}
#[must_use]
pub fn label(mut self, label: impl Into<String>) -> Self {
self.label = label.into();
self
}
#[must_use]
pub fn empty_text(mut self, text: impl Into<String>) -> Self {
self.empty_text = text.into();
self
}
#[must_use]
pub fn submit_label(mut self, label: impl Into<String>) -> Self {
self.submit_label = label.into();
self
}
#[must_use]
pub fn reply_label(mut self, label: impl Into<String>) -> Self {
self.reply_label = label.into();
self
}
#[must_use]
pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
self.placeholder = placeholder.into();
self
}
#[must_use]
pub fn hx_target(mut self, target: impl Into<String>) -> Self {
self.hx_target = target.into();
self
}
#[must_use]
pub const fn max_depth(mut self, max_depth: usize) -> Self {
self.max_depth = max_depth;
self
}
#[must_use]
pub const fn read_only(mut self) -> Self {
self.can_comment = false;
self
}
#[must_use]
pub fn sign_in_prompt(mut self, prompt: impl Into<String>) -> Self {
self.sign_in_prompt = Some(prompt.into());
self
}
#[must_use]
pub fn return_to(mut self, path: impl Into<String>) -> Self {
self.return_to = Some(path.into());
self
}
}
pub const DEFAULT_COMMENT_MAX_DEPTH: usize = 5;
#[must_use]
pub fn comment_thread(cfg: &CommentThread, comments: &[CommentView]) -> maud::Markup {
maud::html! {
section id=(cfg.dom_id) class="autumn-comments" role="region" aria-label=(cfg.label) {
@if let Some(error) = &cfg.error {
p class="autumn-comments-error" role="alert" { (error) }
}
@if comments.is_empty() {
p class="autumn-comments-empty" { (cfg.empty_text) }
} @else {
(comment_list(cfg, comments, 0))
}
@if cfg.can_comment {
(comment_form(cfg, None, None))
} @else if let Some(prompt) = &cfg.sign_in_prompt {
p class="autumn-comments-prompt" { (prompt) }
}
}
}
}
fn comment_list(cfg: &CommentThread, comments: &[CommentView], depth: usize) -> maud::Markup {
let live = (depth == 0).then_some("polite");
maud::html! {
ol class="autumn-comment-list" aria-live=[live] {
@for comment in comments {
(comment_node(cfg, comment, depth))
}
}
}
}
#[must_use]
pub fn comment_dom_id(thread_dom_id: &str, comment_id: i64) -> String {
format!("{thread_dom_id}-c{comment_id}")
}
fn comment_paragraphs(body: &str) -> Vec<String> {
body.lines()
.collect::<Vec<_>>()
.split(|line| line.trim().is_empty())
.filter(|group| group.iter().any(|line| !line.trim().is_empty()))
.map(|group| group.join("\n").trim().to_string())
.collect()
}
fn comment_node(cfg: &CommentThread, comment: &CommentView, depth: usize) -> maud::Markup {
let node_id = comment_dom_id(&cfg.dom_id, comment.id);
let can_reply = cfg.can_comment && depth < cfg.max_depth;
let reply_label = format!("{} to {}", cfg.reply_label, comment.author);
maud::html! {
li id=(node_id) class="autumn-comment" {
div class="autumn-comment-meta" {
span class="autumn-comment-author" { (comment.author) }
@if let Some(datetime) = &comment.datetime {
" · "
time class="autumn-comment-time" datetime=(datetime) { (comment.timestamp) }
}
}
div class="autumn-comment-body" {
@for paragraph in comment_paragraphs(&comment.body) {
p { (paragraph) }
}
}
@if can_reply {
details class="autumn-comment-reply"
open[cfg.draft.is_some() && cfg.draft_reply_to == Some(comment.id)] {
summary class="autumn-comment-reply-toggle" aria-label=(reply_label) {
(cfg.reply_label)
}
(comment_form(cfg, Some(comment.id), Some(&comment.author)))
}
}
@if !comment.replies.is_empty() {
(comment_list(cfg, &comment.replies, depth + 1))
}
}
}
}
fn comment_form(
cfg: &CommentThread,
reply_to: Option<i64>,
reply_to_author: Option<&str>,
) -> maud::Markup {
let textarea_id = reply_to.map_or_else(
|| format!("{}-body", cfg.dom_id),
|id| format!("{}-c{id}-body", cfg.dom_id),
);
let submit_label = if reply_to.is_some() {
&cfg.reply_label
} else {
&cfg.submit_label
};
let field_label = reply_to_author.map_or_else(
|| cfg.placeholder.clone(),
|author| format!("{} to {author}", cfg.reply_label),
);
let maxlength = cfg.max_body_bytes.map(|bytes| bytes.to_string());
let hx_sync = format!("#{}:replace", cfg.dom_id);
maud::html! {
form class="autumn-comment-form" method="post" action=(cfg.action)
hx-post=(cfg.action) hx-target=(cfg.hx_target) hx-swap="outerHTML"
hx-sync=(hx_sync) {
@if let Some(token) = &cfg.csrf_token {
input type="hidden" name=(cfg.csrf_field) value=(token);
}
@if let Some(reply_to) = reply_to {
input type="hidden" name=(cfg.reply_field) value=(reply_to);
}
@if let Some(return_to) = &cfg.return_to {
input type="hidden" name="return_to" value=(return_to);
}
label class="autumn-comment-label" for=(textarea_id) { (field_label) }
textarea id=(textarea_id) class="autumn-comment-input" name=(cfg.body_field)
rows="3" required maxlength=[maxlength] placeholder=(cfg.placeholder) {
@if let Some(draft) = &cfg.draft
&& reply_to == cfg.draft_reply_to
{
(draft)
}
}
button type="submit" class="autumn-comment-submit" { (submit_label) }
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FeedMode {
#[default]
Reveal,
Button,
}
#[derive(Debug, Clone)]
pub struct FeedConfig<'a> {
pub url: &'a str,
pub mode: FeedMode,
pub cursor_param: &'a str,
pub load_more_label: &'a str,
}
impl<'a> FeedConfig<'a> {
#[must_use]
pub const fn new(url: &'a str) -> Self {
Self {
url,
mode: FeedMode::Reveal,
cursor_param: "cursor",
load_more_label: "Load more",
}
}
#[must_use]
pub const fn mode(mut self, mode: FeedMode) -> Self {
self.mode = mode;
self
}
#[must_use]
pub const fn button(mut self) -> Self {
self.mode = FeedMode::Button;
self
}
#[must_use]
pub const fn cursor_param(mut self, param: &'a str) -> Self {
self.cursor_param = param;
self
}
#[must_use]
pub const fn load_more_label(mut self, label: &'a str) -> Self {
self.load_more_label = label;
self
}
}
fn encode_query_component(value: &str) -> String {
use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
const QUERY_COMPONENT: &AsciiSet = &CONTROLS
.add(b' ')
.add(b'"')
.add(b'#')
.add(b'%')
.add(b'&')
.add(b'+')
.add(b'/')
.add(b';')
.add(b'=')
.add(b'?')
.add(b'@')
.add(b'[')
.add(b'\\')
.add(b']')
.add(b'^')
.add(b'`')
.add(b'{')
.add(b'|')
.add(b'}')
.add(b'<')
.add(b'>');
utf8_percent_encode(value, QUERY_COMPONENT).to_string()
}
fn feed_next_url(config: &FeedConfig<'_>, cursor: &str) -> String {
let (base, fragment) = match config.url.split_once('#') {
Some((base, frag)) => (base, Some(frag)),
None => (config.url, None),
};
let sep = if base.contains('?') { '&' } else { '?' };
let param = encode_query_component(config.cursor_param);
let value = encode_query_component(cursor);
let mut url = format!("{base}{sep}{param}={value}");
if let Some(frag) = fragment {
url.push('#');
url.push_str(frag);
}
url
}
fn feed_sentinel(next_cursor: &str, config: &FeedConfig<'_>) -> maud::Markup {
let next_url = feed_next_url(config, next_cursor);
let trigger = match config.mode {
FeedMode::Reveal => Some("revealed, click"),
FeedMode::Button => None,
};
maud::html! {
div class="autumn-feed__sentinel" {
a class="autumn-feed__more" href=(next_url)
hx-get=(next_url) hx-target="closest .autumn-feed__sentinel"
hx-swap="outerHTML" hx-trigger=[trigger] {
(config.load_more_label)
}
}
}
}
#[must_use]
#[allow(clippy::needless_pass_by_value)]
pub fn feed_page(
items: maud::Markup,
next_cursor: Option<&str>,
config: &FeedConfig<'_>,
) -> maud::Markup {
maud::html! {
(items)
@if let Some(cursor) = next_cursor {
(feed_sentinel(cursor, config))
}
}
}
#[must_use]
pub fn infinite_feed(
items: maud::Markup,
next_cursor: Option<&str>,
config: &FeedConfig<'_>,
) -> maud::Markup {
maud::html! {
div class="autumn-feed" {
(feed_page(items, next_cursor, config))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn comment_paragraphs_split_on_blank_lines_of_either_line_ending() {
let expected = vec!["first".to_string(), "second".to_string()];
assert_eq!(comment_paragraphs("first\n\nsecond"), expected, "LF");
assert_eq!(
comment_paragraphs("first\r\n\r\nsecond"),
expected,
"CRLF — the case a real form submission produces"
);
assert_eq!(comment_paragraphs("first\r\n\r\n\r\n\r\nsecond"), expected);
assert_eq!(
comment_paragraphs("one\r\ntwo"),
vec!["one\ntwo".to_string()]
);
assert!(comment_paragraphs(" \r\n\r\n ").is_empty());
assert!(comment_paragraphs("").is_empty());
}
#[test]
fn a_rejected_draft_is_refilled_into_the_form_that_sent_it() {
let view = vec![CommentView {
id: 7,
author: "ada".into(),
body: "parent".into(),
datetime: None,
timestamp: String::new(),
replies: Vec::new(),
}];
let widget = CommentThread::new("t", "/c").draft(None, "my long draft");
let html = comment_thread(&widget, &view).into_string();
assert_eq!(
html.matches("my long draft").count(),
1,
"exactly one form is prefilled:\n{html}"
);
let widget = CommentThread::new("t", "/c").draft(Some(7), "reply draft");
let html = comment_thread(&widget, &view).into_string();
assert_eq!(html.matches("reply draft").count(), 1, "{html}");
assert!(
html.contains("<details class=\"autumn-comment-reply\" open"),
"{html}"
);
let widget = CommentThread::new("t", "/c");
let html = comment_thread(&widget, &view).into_string();
assert!(html.contains("></textarea>"), "{html}");
assert!(
!html.contains("<details class=\"autumn-comment-reply\" open"),
"{html}"
);
let widget = CommentThread::new("t", "/c").draft(None, "<script>x</script>");
let html = comment_thread(&widget, &view).into_string();
assert!(!html.contains("<script>x</script>"), "{html}");
assert!(html.contains("<script>"), "{html}");
}
fn comment_fixture() -> Vec<CommentView> {
vec![
CommentView {
id: 1,
author: "ada".to_owned(),
body: "first\n\nsecond para".to_owned(),
datetime: Some("2026-06-21T17:10:33Z".to_owned()),
timestamp: "2026-06-21 17:10".to_owned(),
replies: vec![CommentView {
id: 2,
author: "grace".to_owned(),
body: "a reply".to_owned(),
datetime: None,
timestamp: String::new(),
replies: Vec::new(),
}],
},
CommentView {
id: 3,
author: "hopper".to_owned(),
body: "unrelated".to_owned(),
datetime: None,
timestamp: String::new(),
replies: Vec::new(),
},
]
}
#[test]
fn comment_thread_renders_an_inline_reply_form_per_node() {
let cfg = CommentThread::new("comments-post-42", "/comments/Post/42").csrf_token("tok");
let html = comment_thread(&cfg, &comment_fixture()).into_string();
assert!(html.contains(r#"id="comments-post-42""#));
assert!(html.contains(r#"class="autumn-comments""#));
for id in [1, 2, 3] {
assert!(
html.contains(&format!(r#"name="reply_to" value="{id}""#)),
"no inline reply form addressed to comment {id}"
);
}
assert_eq!(html.matches(r#"class="autumn-comment-form""#).count(), 4);
assert_eq!(html.matches(r#"name="_csrf" value="tok""#).count(), 4);
}
#[test]
fn comment_thread_forms_are_htmx_swaps_and_plain_posts() {
let cfg = CommentThread::new("comments-post-42", "/comments/Post/42");
let html = comment_thread(&cfg, &comment_fixture()).into_string();
assert!(html.contains(r##"hx-target="#comments-post-42""##));
assert!(html.contains(r#"hx-swap="outerHTML""#));
assert!(html.contains(r#"hx-post="/comments/Post/42""#));
assert!(html.contains(r#"method="post" action="/comments/Post/42""#));
assert!(html.contains("<details"));
}
#[test]
fn comment_thread_nests_replies_in_a_child_list() {
let cfg = CommentThread::new("c", "/comments/Post/1");
let html = comment_thread(&cfg, &comment_fixture()).into_string();
assert_eq!(html.matches(r#"class="autumn-comment-list""#).count(), 2);
assert!(
html.contains(r#"id="c-c2""#),
"the reply gets its own node id"
);
}
#[test]
fn comment_thread_escapes_bodies_and_splits_paragraphs() {
let views = vec![CommentView {
id: 1,
author: "<script>".to_owned(),
body: "<img src=x onerror=alert(1)>\n\ntwo".to_owned(),
datetime: None,
timestamp: String::new(),
replies: Vec::new(),
}];
let cfg = CommentThread::new("c", "/comments/Post/1");
let html = comment_thread(&cfg, &views).into_string();
assert!(!html.contains("<img src=x"));
assert!(html.contains("<img src=x"));
assert!(html.contains("<script>"));
assert_eq!(html.matches("<p>").count(), 2);
}
#[test]
fn comment_thread_stops_offering_replies_past_max_depth() {
let deep = vec![CommentView {
id: 1,
author: "ada".to_owned(),
body: "root".to_owned(),
datetime: None,
timestamp: String::new(),
replies: vec![CommentView {
id: 2,
author: "ada".to_owned(),
body: "child".to_owned(),
datetime: None,
timestamp: String::new(),
replies: Vec::new(),
}],
}];
let cfg = CommentThread::new("c", "/comments/Post/1").max_depth(1);
let html = comment_thread(&cfg, &deep).into_string();
assert!(html.contains(r#"name="reply_to" value="1""#));
assert!(
!html.contains(r#"name="reply_to" value="2""#),
"a reply to the depth-1 node would exceed max_depth = 1"
);
}
#[cfg(feature = "db")]
#[test]
fn comment_view_from_thread_maps_names_timestamps_and_replies() {
use crate::commentable::{Comment, CommentNode};
fn node(id: i64, author_name: Option<&str>, replies: Vec<CommentNode>) -> CommentNode {
CommentNode {
comment: Comment {
id,
parent_id: None,
author_id: 7,
body: "b".to_owned(),
created_at: chrono::DateTime::parse_from_rfc3339("2026-06-21T17:10:33Z")
.expect("timestamp")
.naive_utc(),
author_name: author_name.map(str::to_owned),
},
depth: 0,
replies,
}
}
let views = CommentView::from_thread(&[node(
1,
Some("ada"),
vec![node(2, None, vec![node(3, Some("grace"), Vec::new())])],
)]);
assert_eq!(views[0].author, "ada");
assert_eq!(views[0].replies[0].author, "user #7");
assert_eq!(views[0].replies[0].replies[0].author, "grace");
assert_eq!(
views[0].datetime.as_deref(),
Some("2026-06-21T17:10:33Z"),
"a machine-readable timestamp for <time datetime=…>"
);
assert_eq!(views[0].timestamp, "2026-06-21 17:10");
}
#[test]
fn comment_thread_read_only_hides_every_form() {
let cfg = CommentThread::new("c", "/comments/Post/1")
.read_only()
.sign_in_prompt("Sign in to comment.");
let html = comment_thread(&cfg, &comment_fixture()).into_string();
assert!(!html.contains("<form"));
assert!(html.contains("Sign in to comment."));
assert!(
html.contains("unrelated"),
"the thread itself still renders"
);
}
#[test]
fn comment_thread_renders_the_empty_state() {
let cfg = CommentThread::new("c", "/comments/Post/1").empty_text("Nothing here.");
let html = comment_thread(&cfg, &[]).into_string();
assert!(html.contains("Nothing here."));
assert!(html.contains(r#"class="autumn-comments-empty""#));
assert!(
html.contains("<form"),
"an empty thread still invites a first comment"
);
}
#[test]
fn comment_thread_gives_every_reply_control_a_distinct_accessible_name() {
let cfg = CommentThread::new("c", "/comments/Post/1");
let html = comment_thread(&cfg, &comment_fixture()).into_string();
assert!(html.contains(r#"aria-label="Reply to ada""#), "{html}");
assert!(html.contains(r#"aria-label="Reply to grace""#), "{html}");
assert!(html.contains(r#"aria-label="Reply to hopper""#), "{html}");
assert!(html.contains(">Reply to ada</label>"), "{html}");
assert!(html.contains(r#"aria-live="polite""#), "{html}");
assert!(html.contains(r#"role="region""#), "{html}");
}
#[test]
fn comment_thread_forms_share_one_htmx_sync_scope() {
let cfg = CommentThread::new("comments-post-42", "/comments/Post/42");
let html = comment_thread(&cfg, &comment_fixture()).into_string();
assert_eq!(
html.matches(r##"hx-sync="#comments-post-42:replace""##)
.count(),
4,
"every form, so no pair of them can race:\n{html}"
);
}
#[test]
fn comment_thread_caps_the_textarea_at_the_models_body_limit() {
let cfg = CommentThread::new("c", "/comments/Post/1").max_body_bytes(280);
let html = comment_thread(&cfg, &comment_fixture()).into_string();
assert_eq!(html.matches(r#"maxlength="280""#).count(), 4, "{html}");
let plain = comment_thread(
&CommentThread::new("c", "/comments/Post/1"),
&comment_fixture(),
)
.into_string();
assert!(!plain.contains("maxlength"), "{plain}");
}
#[test]
fn comment_thread_renders_a_rejection_above_the_form() {
let cfg = CommentThread::new("c", "/comments/Post/1").error("Comment cannot be empty");
let html = comment_thread(&cfg, &comment_fixture()).into_string();
assert!(html.contains(r#"role="alert""#), "{html}");
assert!(html.contains("Comment cannot be empty"), "{html}");
assert!(html.contains(r#"class="autumn-comments-error""#), "{html}");
}
#[test]
fn comment_thread_carries_return_to_on_every_form() {
let cfg = CommentThread::new("c", "/comments/Post/1").return_to("/r/rust/posts/hello");
let html = comment_thread(&cfg, &comment_fixture()).into_string();
assert_eq!(
html.matches(r#"name="return_to" value="/r/rust/posts/hello""#)
.count(),
4
);
}
#[test]
fn reaction_controls_csrf_sugar_renders_the_hidden_input_in_every_form() {
let token = crate::security::CsrfToken::new("secret-token".to_string());
let html = reaction_controls(
&ReactionControls::votes("votes-42", "/posts/42/upvote", "/posts/42/downvote")
.aggregate(7)
.csrf(Some(&token), None),
)
.into_string();
assert_eq!(
html.matches(r#"<input type="hidden" name="_csrf" value="secret-token">"#)
.count(),
2,
"{html}"
);
let anonymous = reaction_controls(
&ReactionControls::votes("votes-42", "/posts/42/upvote", "/posts/42/downvote")
.aggregate(7)
.csrf(None, None),
)
.into_string();
assert!(!anonymous.contains("_csrf"), "{anonymous}");
}
#[test]
fn reaction_controls_csrf_sugar_honors_a_custom_field_name() {
let token = crate::security::CsrfToken::new("secret-token".to_string());
let field = crate::security::CsrfFormField("csrf_custom".to_string());
let html = reaction_controls(
&ReactionControls::likes("likes-42", "/posts/42/like").csrf(Some(&token), Some(&field)),
)
.into_string();
assert!(
html.contains(r#"<input type="hidden" name="csrf_custom" value="secret-token">"#),
"{html}"
);
assert!(!html.contains(r#"name="_csrf""#), "{html}");
}
}