use std::sync::Arc;
use axum::response::{Html, IntoResponse, Response};
use serde::Deserialize;
use thiserror::Error;
use crate::AppState;
pub use autumn_macros::story;
mod builtin;
pub const STORIES_PATH: &str = "/_stories";
const STORY_DETAIL_PATH: &str = "/_stories/{slug}";
#[cfg(feature = "widgets-search")]
const STORIES_DEMO_SEARCH_PATH: &str = "/_stories/demo/search";
const STORIES_DEMO_SAVE_PATH: &str = "/_stories/demo/save";
const STORIES_DEMO_SAVE_DELAY: std::time::Duration = std::time::Duration::from_millis(800);
#[cfg(feature = "widgets-search")]
const STORIES_DEMO_TAG_SEARCH_PATH: &str = "/_stories/demo/tags/search";
#[cfg(feature = "widgets-search")]
const STORIES_DEMO_TAG_SUGGEST_PATH: &str = "/_stories/demo/tags/suggest";
#[cfg(feature = "widgets-search")]
async fn demo_tag_suggest(
crate::extract::Query(DemoQuery { q }): crate::extract::Query<DemoQuery>,
) -> Html<String> {
const TAGS: &[&str] = &["rust", "web", "axum", "htmx"];
let typed = q.trim();
let query = typed.to_lowercase();
let matches: Vec<&str> = TAGS
.iter()
.copied()
.filter(|tag| !query.is_empty() && tag.contains(&query))
.collect();
let exact = matches.contains(&query.as_str());
let markup = maud::html! {
@for tag in &matches {
(crate::widgets::autocomplete_option(tag, tag))
}
@if !typed.is_empty() && !exact {
(crate::widgets::autocomplete_create_option(
typed,
&format!("Create \u{201c}{typed}\u{201d}"),
))
}
};
Html(markup.into_string())
}
#[cfg(feature = "widgets-social")]
const STORIES_DEMO_FEED_PATH: &str = "/_stories/demo/posts/feed";
fn slugify(name: &str) -> String {
let mut slug = String::with_capacity(name.len());
let mut pending_separator = false;
for c in name.chars() {
if c.is_ascii_alphanumeric() {
if pending_separator && !slug.is_empty() {
slug.push('-');
}
pending_separator = false;
slug.push(c.to_ascii_lowercase());
} else {
pending_separator = true;
}
}
slug
}
#[derive(Clone)]
pub struct Story {
group: &'static str,
name: &'static str,
slug: String,
render: fn() -> maud::Markup,
source: &'static str,
}
impl std::fmt::Debug for Story {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Story")
.field("group", &self.group)
.field("name", &self.name)
.field("slug", &self.slug)
.finish_non_exhaustive()
}
}
impl Story {
#[must_use]
pub fn new(
group: &'static str,
name: &'static str,
render: fn() -> maud::Markup,
source: &'static str,
) -> Self {
let slug = slugify(name);
assert!(
!slug.is_empty(),
"story name {name:?} (group {group:?}) produces an empty slug; \
use a name with at least one alphanumeric character"
);
Self {
group,
name,
slug,
render,
source,
}
}
#[must_use]
pub const fn group(&self) -> &'static str {
self.group
}
#[must_use]
pub const fn name(&self) -> &'static str {
self.name
}
#[must_use]
pub fn slug(&self) -> &str {
&self.slug
}
#[must_use]
pub const fn source(&self) -> &'static str {
self.source
}
pub fn render(&self) -> Result<maud::Markup, StoryRenderError> {
std::panic::catch_unwind(self.render).map_err(|_| StoryRenderError::Panicked {
slug: self.slug.clone(),
})
}
}
#[derive(Debug, Error)]
pub enum StoryRenderError {
#[error("story `{slug}` panicked while rendering")]
Panicked {
slug: String,
},
}
#[derive(Debug, Clone, Default)]
pub struct StoryRegistry {
stories: Arc<Vec<Story>>,
stylesheets: Arc<Vec<String>>,
scripts: Arc<Vec<String>>,
}
impl StoryRegistry {
#[must_use]
pub fn new(stories: Vec<Story>) -> Self {
let mut seen: std::collections::HashMap<&str, &Story> = std::collections::HashMap::new();
for story in &stories {
if let Some(existing) = seen.insert(story.slug(), story) {
panic!(
"duplicate story slug `{}`: `{}` / `{}` collides with `{}` / `{}`; \
story slugs derive from names, so rename one of them",
story.slug(),
existing.group(),
existing.name(),
story.group(),
story.name(),
);
}
}
Self {
stories: Arc::new(stories),
stylesheets: Arc::default(),
scripts: Arc::default(),
}
}
#[must_use]
pub fn stories(&self) -> &[Story] {
&self.stories
}
#[must_use]
pub fn stylesheets(&self) -> &[String] {
&self.stylesheets
}
#[must_use]
pub fn scripts(&self) -> &[String] {
&self.scripts
}
fn find(&self, slug: &str) -> Option<&Story> {
self.stories.iter().find(|story| story.slug() == slug)
}
fn grouped(&self) -> Vec<(&'static str, Vec<&Story>)> {
let mut grouped: Vec<(&'static str, Vec<&Story>)> = Vec::new();
for story in self.stories.iter() {
match grouped
.iter_mut()
.find(|(group, _)| *group == story.group())
{
Some((_, stories)) => stories.push(story),
None => grouped.push((story.group(), vec![story])),
}
}
grouped
}
}
#[must_use]
pub fn builtin() -> StoryRegistry {
StoryRegistry::new(builtin::all_stories())
}
#[derive(Debug, Clone, Default)]
pub struct StoryGallery {
stories: Vec<Story>,
stylesheets: Vec<String>,
scripts: Vec<String>,
}
impl StoryGallery {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn builtin() -> Self {
Self {
stories: builtin::all_stories(),
..Self::default()
}
}
#[must_use]
pub fn extend(mut self, stories: impl IntoIterator<Item = Story>) -> Self {
self.stories.extend(stories);
self
}
#[must_use]
pub fn stylesheet(mut self, href: impl Into<String>) -> Self {
push_unique(&mut self.stylesheets, href.into());
self
}
#[must_use]
pub fn script(mut self, src: impl Into<String>) -> Self {
push_unique(&mut self.scripts, src.into());
self
}
#[must_use]
pub fn merge(mut self, other: Self) -> Self {
self.stories.extend(other.stories);
for href in other.stylesheets {
push_unique(&mut self.stylesheets, href);
}
for src in other.scripts {
push_unique(&mut self.scripts, src);
}
self
}
#[must_use]
pub fn stories(&self) -> &[Story] {
&self.stories
}
#[must_use]
pub fn stylesheets(&self) -> &[String] {
&self.stylesheets
}
#[must_use]
pub fn scripts(&self) -> &[String] {
&self.scripts
}
pub fn routes<S>() -> axum::Router<S>
where
S: Clone + Send + Sync + 'static,
AppState: axum::extract::FromRef<S>,
{
story_router()
}
pub(crate) fn into_registry(self) -> StoryRegistry {
StoryRegistry {
stylesheets: Arc::new(self.stylesheets),
scripts: Arc::new(self.scripts),
..StoryRegistry::new(self.stories)
}
}
}
fn push_unique(list: &mut Vec<String>, value: String) {
if !list.contains(&value) {
list.push(value);
}
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct StoriesConfig {
#[serde(default)]
pub enabled: bool,
}
pub(crate) fn demo_routes() -> Vec<(&'static str, &'static str)> {
#[allow(unused_mut, reason = "each family extend has its own #[cfg]")]
let mut routes = vec![(STORIES_DEMO_SAVE_PATH, "story_gallery_demo_save")];
#[cfg(feature = "widgets-search")]
routes.extend([
(STORIES_DEMO_SEARCH_PATH, "story_gallery_demo_search"),
(
STORIES_DEMO_TAG_SEARCH_PATH,
"story_gallery_demo_tag_search",
),
(
STORIES_DEMO_TAG_SUGGEST_PATH,
"story_gallery_demo_tag_suggest",
),
]);
#[cfg(feature = "widgets-social")]
routes.push((STORIES_DEMO_FEED_PATH, "story_gallery_demo_infinite_feed"));
routes
}
pub(crate) fn story_router<S>() -> axum::Router<S>
where
S: Clone + Send + Sync + 'static,
AppState: axum::extract::FromRef<S>,
{
let router = axum::Router::new()
.route(
STORIES_PATH,
axum::routing::get(
|axum::extract::State(state): axum::extract::State<AppState>,
nonce: Option<crate::security::CspNonce>| async move {
story_index_response(&state, nonce.as_ref())
},
),
)
.route(
STORY_DETAIL_PATH,
axum::routing::get(
|axum::extract::Path(slug): axum::extract::Path<String>,
axum::extract::State(state): axum::extract::State<AppState>,
nonce: Option<crate::security::CspNonce>| async move {
story_detail_response(&state, &slug, nonce.as_ref())
},
),
)
.route(STORIES_DEMO_SAVE_PATH, axum::routing::get(demo_save));
#[cfg(feature = "widgets-search")]
let router = router
.route(STORIES_DEMO_SEARCH_PATH, axum::routing::get(demo_search))
.route(
STORIES_DEMO_TAG_SEARCH_PATH,
axum::routing::get(demo_tag_search),
)
.route(
STORIES_DEMO_TAG_SUGGEST_PATH,
axum::routing::get(demo_tag_suggest),
);
#[cfg(feature = "widgets-social")]
let router = router.route(
STORIES_DEMO_FEED_PATH,
axum::routing::get(demo_infinite_feed),
);
router
}
async fn demo_save() -> Html<&'static str> {
tokio::time::sleep(STORIES_DEMO_SAVE_DELAY).await;
Html("Saved.")
}
#[derive(serde::Deserialize)]
#[cfg(feature = "widgets-search")]
struct DemoQuery {
#[serde(default)]
q: String,
}
#[cfg(feature = "widgets-search")]
async fn demo_search(
crate::extract::Query(DemoQuery { q }): crate::extract::Query<DemoQuery>,
) -> Html<String> {
const POSTS: &[&str] = &[
"The Long Autumn",
"Falling Leaves",
"Autumn in Practice",
"Shipping Widgets",
];
let query = q.to_lowercase();
let matches: Vec<&str> = POSTS
.iter()
.copied()
.filter(|title| query.is_empty() || title.to_lowercase().contains(&query))
.collect();
let markup = if matches.is_empty() {
crate::widgets::active_search_empty_state("No posts matched your search.")
} else {
maud::html! {
ul class="story-demo-results" {
@for title in matches {
li { (title) }
}
}
}
};
Html(markup.into_string())
}
#[cfg(feature = "widgets-search")]
async fn demo_tag_search(
crate::extract::Query(DemoQuery { q }): crate::extract::Query<DemoQuery>,
) -> Html<String> {
const TAGS: &[(&str, &str)] = &[("42", "rust"), ("7", "web"), ("13", "axum"), ("21", "htmx")];
let query = q.to_lowercase();
let matches: Vec<(&str, &str)> = TAGS
.iter()
.copied()
.filter(|(_, label)| query.is_empty() || label.to_lowercase().contains(&query))
.collect();
let markup = if matches.is_empty() {
crate::widgets::autocomplete_empty_state("No matching tags.")
} else {
maud::html! {
@for (value, label) in matches {
(crate::widgets::autocomplete_option(value, label))
}
}
};
Html(markup.into_string())
}
#[cfg(feature = "widgets-social")]
async fn demo_infinite_feed() -> Html<String> {
let markup = crate::widgets::feed_page(
maud::html! {
article class="post" { h3 { "Loaded live via a real request." } }
},
None,
&crate::widgets::FeedConfig::new(STORIES_DEMO_FEED_PATH),
);
Html(markup.into_string())
}
fn registry_from_state(state: &AppState) -> StoryRegistry {
state
.extension::<StoryRegistry>()
.map(|registry| (*registry).clone())
.unwrap_or_default()
}
fn story_index_response(state: &AppState, nonce: Option<&crate::security::CspNonce>) -> Response {
Html(render_story_index(®istry_from_state(state), nonce).into_string()).into_response()
}
fn story_detail_response(
state: &AppState,
slug: &str,
nonce: Option<&crate::security::CspNonce>,
) -> Response {
let registry = registry_from_state(state);
let Some(story) = registry.find(slug) else {
let page = story_page(
®istry,
"Story not found",
&maud::html! {
main class="story-content" {
h1 { "Story not found" }
p {
"No story is registered under the slug " code { (slug) } ". "
a href=(STORIES_PATH) { "Back to the gallery" }
}
}
},
nonce,
);
return (http::StatusCode::NOT_FOUND, Html(page.into_string())).into_response();
};
match story.render() {
Ok(rendered) => Html(render_story_detail(®istry, story, &rendered, nonce).into_string())
.into_response(),
Err(error) => {
let page = story_page(
®istry,
"Story failed to render",
&maud::html! {
main class="story-content" {
h1 { "Story failed to render" }
p { (error.to_string()) }
p { a href=(STORIES_PATH) { "Back to the gallery" } }
}
},
nonce,
);
(
http::StatusCode::INTERNAL_SERVER_ERROR,
Html(page.into_string()),
)
.into_response()
}
}
}
const STORY_GALLERY_CSS: &str = r"
body {
margin: 0;
font-family: var(--font-family);
color: var(--text);
background: var(--bg);
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
:root:has(#story-theme-light:checked) { color-scheme: light; }
:root:has(#story-theme-dark:checked) { color-scheme: dark; }
:root:has(#story-density-compact:checked) { --density-scale: 0.75; }
:root:has(#story-density-spacious:checked) { --density-scale: 1.25; }
.story-switches { display: flex; flex-wrap: wrap; gap: var(--space-4); padding: 0.6rem 1.25rem; border-bottom: 1px solid var(--border); background: var(--surface); }
.story-switch { display: flex; align-items: center; gap: 0.4rem; margin: 0; padding: 0; border: 0; }
.story-switch legend { float: left; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); margin-right: 0.25rem; padding: 0; }
.story-switch input[type='radio'] { position: absolute; opacity: 0; width: 1px; height: 1px; }
.story-switch label { padding: 0.3rem 0.75rem; border-radius: var(--radius-full); border: 1px solid var(--control-border); font-size: 0.8rem; line-height: 1; cursor: pointer; color: var(--text-muted); }
.story-switch input[type='radio']:checked + label { background: var(--primary); border-color: var(--primary); color: var(--on-primary); }
.story-switch input[type='radio']:focus-visible + label { outline: 2px solid var(--primary); outline-offset: 2px; }
.story-layout { display: flex; gap: 2rem; align-items: flex-start; }
.story-sidebar { flex: 0 0 14rem; padding: 1.25rem; border-right: 1px solid var(--border); min-height: 100vh; background: var(--surface); }
.story-sidebar h2 { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); margin: 1.5rem 0 0.35rem; }
.story-sidebar h2:first-child { margin-top: 0; }
.story-sidebar ul { list-style: none; margin: 0; padding: 0; }
.story-sidebar li { margin: 0.15rem 0; }
.story-sidebar a { color: var(--text); text-decoration: none; border-radius: 0.35rem; padding: 0.15rem 0.4rem; display: block; }
.story-sidebar a:hover { background: var(--primary-light); color: var(--primary); }
.story-content { flex: 1 1 auto; padding: 1.75rem 2rem 3rem; max-width: 60rem; }
.story-content h1 { margin-top: 0; }
.story-breadcrumb { color: var(--text-muted); font-size: 0.85rem; }
.story-breadcrumb a { color: var(--primary); }
.story-preview { padding: 1.75rem; border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 1.5rem; background: var(--surface); color: var(--text); box-shadow: var(--shadow); }
.story-content pre { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem; overflow-x: auto; }
.story-empty { padding: 2rem; border: 1px dashed var(--border); border-radius: var(--radius); color: var(--text-muted); }
";
const STORY_THEMES: [(&str, &str); 3] =
[("system", "System"), ("light", "Light"), ("dark", "Dark")];
const STORY_DENSITIES: [(&str, &str); 3] = [
("compact", "Compact"),
("comfortable", "Comfortable"),
("spacious", "Spacious"),
];
fn radio_switch(
group: &str,
legend: &str,
options: &[(&str, &str)],
default: &str,
) -> maud::Markup {
maud::html! {
fieldset class=(format!("story-switch story-{group}-switch")) {
legend { (legend) }
@for (value, label) in options {
@let id = format!("story-{group}-{value}");
input type="radio" name=(format!("story-{group}")) id=(id)
checked[*value == default];
label for=(id) { (label) }
}
}
}
}
fn theme_switch() -> maud::Markup {
radio_switch("theme", "Theme", &STORY_THEMES, "system")
}
fn density_switch() -> maud::Markup {
radio_switch("density", "Density", &STORY_DENSITIES, "comfortable")
}
fn story_page(
registry: &StoryRegistry,
title: &str,
body: &maud::Markup,
nonce: Option<&crate::security::CspNonce>,
) -> maud::Markup {
#[cfg(feature = "htmx")]
let scripts: &[&str] = &[
crate::htmx::HTMX_JS_PATH,
crate::htmx::AUTUMN_WIDGETS_JS_PATH,
];
#[cfg(not(feature = "htmx"))]
let scripts: &[&str] = &[];
#[cfg(feature = "flash")]
let stylesheets: &[&str] = &[crate::flash::FLASH_CSS_PATH];
#[cfg(not(feature = "flash"))]
let stylesheets: &[&str] = &[];
maud::html! {
(maud::DOCTYPE)
html lang="en" {
head {
meta charset="utf-8";
meta name="viewport" content="width=device-width, initial-scale=1";
title { (title) " — Autumn stories" }
link rel="stylesheet" href=(crate::ui::WIDGETS_CSS_PATH);
@for href in stylesheets {
link rel="stylesheet" href=(href);
}
@for href in registry.stylesheets() {
link rel="stylesheet" href=(href);
}
@if let Some(n) = nonce {
meta name="htmx-config" content=(format!(r#"{{"inlineStyleNonce":"{}"}}"#, n.value()));
}
@for src in scripts {
script src=(src) defer {}
}
@for src in registry.scripts() {
script src=(src) defer {}
}
style nonce=[nonce.map(crate::security::CspNonce::value)] {
(maud::PreEscaped(STORY_GALLERY_CSS))
}
}
body {
div class="story-switches" {
(theme_switch())
(density_switch())
}
(body)
}
}
}
}
fn render_story_index(
registry: &StoryRegistry,
nonce: Option<&crate::security::CspNonce>,
) -> maud::Markup {
story_page(
registry,
"Widget stories",
&maud::html! {
div class="story-layout" {
nav class="story-sidebar" aria-label="Stories" {
@for (group, stories) in registry.grouped() {
h2 { (group) }
ul {
@for story in stories {
li {
a href=(format!("{STORIES_PATH}/{}", story.slug())) {
(story.name())
}
}
}
}
}
}
main class="story-content" {
h1 { "Widget stories" }
@if registry.stories().is_empty() {
div class="story-empty" {
p {
"No stories are registered. Add "
code { ".with_story_gallery(StoryGallery::builtin())" }
" to your " code { "AppBuilder" }
" to serve the built-in widget gallery, or "
code { "StoryGallery::new().extend([...])" }
" for app-only stories."
}
}
} @else {
p {
"Every entry renders live from a zero-arg widget example; "
"its detail page shows the exact source that produced it. "
"Pick a story from the sidebar."
}
}
}
}
},
nonce,
)
}
fn render_story_detail(
registry: &StoryRegistry,
story: &Story,
rendered: &maud::Markup,
nonce: Option<&crate::security::CspNonce>,
) -> maud::Markup {
let rendered_html = rendered.clone().into_string();
story_page(
registry,
story.name(),
&maud::html! {
main class="story-content" {
p class="story-breadcrumb" {
a href=(STORIES_PATH) { "Widget stories" }
" / " (story.group())
}
h1 { (story.name()) }
section class="story-preview" { (rendered) }
(crate::widgets::tabs(
"story-tabs",
None,
&[
(
"story-source",
"Source",
maud::html! {
pre tabindex="0" role="region" aria-label="Source code" {
code { (story.source()) }
}
},
),
(
"story-html",
"Rendered HTML",
maud::html! {
pre tabindex="0" role="region" aria-label="Rendered HTML code" {
code { (rendered_html) }
}
},
),
],
))
}
},
nonce,
)
}
#[cfg(test)]
mod tests {
use super::*;
fn demo_markup() -> maud::Markup {
maud::html! { p { "demo" } }
}
fn demo_story(group: &'static str, name: &'static str) -> Story {
Story::new(group, name, demo_markup, r#"maud::html! { p { "demo" } }"#)
}
#[test]
fn slugify_handles_spaces_punctuation_unicode() {
assert_eq!(slugify("Data table"), "data-table");
assert_eq!(slugify("Nav / Links!"), "nav-links");
assert_eq!(slugify("HERO"), "hero");
assert_eq!(slugify("Δ delta"), "delta");
assert_eq!(slugify(" Stat Card "), "stat-card");
}
#[test]
#[should_panic(expected = "slug")]
fn story_new_panics_on_name_that_slugifies_to_empty() {
let _ = Story::new("Display", "!!!", demo_markup, "demo()");
}
#[test]
fn registry_find_matches_slug() {
let registry = StoryRegistry::new(vec![
demo_story("Display", "Data table"),
demo_story("Display", "Card"),
]);
assert_eq!(registry.stories().len(), 2);
let found = registry
.find("data-table")
.expect("data-table slug should resolve");
assert_eq!(found.name(), "Data table");
assert_eq!(found.group(), "Display");
assert!(registry.find("nope").is_none());
}
#[test]
#[should_panic(expected = "duplicate")]
fn registry_new_panics_on_duplicate_slug() {
let _ = StoryRegistry::new(vec![
demo_story("Display", "Card"),
demo_story("Marketing", "Card"),
]);
}
#[test]
fn grouped_preserves_first_seen_group_and_registration_order() {
let registry = StoryRegistry::new(vec![
demo_story("Display", "Data table"),
demo_story("Forms", "Active search"),
demo_story("Display", "Card"),
]);
let grouped = registry.grouped();
let groups: Vec<&str> = grouped.iter().map(|(group, _)| *group).collect();
assert_eq!(groups, ["Display", "Forms"]);
let display_names: Vec<&str> = grouped[0].1.iter().map(|s| s.name()).collect();
assert_eq!(display_names, ["Data table", "Card"]);
}
#[test]
fn gallery_new_is_empty_builtin_is_seeded_extend_appends() {
assert!(
StoryGallery::new().into_registry().stories().is_empty(),
"StoryGallery::new() must start builtin-free"
);
let builtin_count = builtin().stories().len();
assert!(builtin_count > 0, "builtin registry must not be empty");
assert_eq!(
StoryGallery::builtin().into_registry().stories().len(),
builtin_count,
"StoryGallery::builtin() must be seeded with exactly the builtin stories"
);
let custom = crate::stories::story! {
"App",
"Greeting",
{
maud::html! { span { "hi" } }
}
};
let registry = StoryGallery::builtin().extend([custom]).into_registry();
assert_eq!(registry.stories().len(), builtin_count + 1);
assert!(
registry.find("greeting").is_some(),
"extended custom story must be findable by its slug"
);
}
fn panicking_story() -> maud::Markup {
panic!("story exploded on purpose")
}
#[test]
fn story_render_catches_panic() {
let boom = Story::new("Display", "Boom", panicking_story, "panicking_story()");
let err = boom
.render()
.expect_err("panic must be caught and reported as an error");
assert!(
matches!(err, StoryRenderError::Panicked { .. }),
"expected StoryRenderError::Panicked, got {err:?}"
);
let fine = Story::new("Display", "Fine", demo_markup, "demo()");
assert!(fine.render().is_ok());
}
#[test]
fn index_page_links_widgets_css_and_groups_stories() {
assert_eq!(STORIES_PATH, "/_stories");
let registry = StoryRegistry::new(vec![
demo_story("Display", "Data table"),
demo_story("Forms", "Active search"),
]);
let page = render_story_index(®istry, None).into_string();
let dom = crate::test_html::parse(&page);
let css_selector = crate::test_html::SelectorList::parse(&format!(
"link[href=\"{}\"]",
crate::ui::WIDGETS_CSS_PATH
))
.expect("selector parses");
assert!(
!css_selector.matches(&dom).is_empty(),
"index must link the framework widget stylesheet: {page}"
);
#[cfg(feature = "htmx")]
{
let js_selector = crate::test_html::SelectorList::parse(&format!(
"script[src=\"{}\"]",
crate::htmx::AUTUMN_WIDGETS_JS_PATH
))
.expect("selector parses");
assert!(
!js_selector.matches(&dom).is_empty(),
"index must load the widget runtime script so interactive \
stories (modal, confirm-action, nav-bar) work live: {page}"
);
}
let link_selector =
crate::test_html::SelectorList::parse("a[href=\"/_stories/data-table\"]")
.expect("selector parses");
assert!(
!link_selector.matches(&dom).is_empty(),
"index must link each story detail page: {page}"
);
assert!(
page.contains("Display") && page.contains("Forms"),
"sidebar must show group headings: {page}"
);
}
#[test]
fn detail_page_has_live_render_source_tab_and_html_tab() {
let story = crate::stories::story! {
"Display",
"Proof",
{
maud::html! { p class="proof-marker" { "live proof" } }
}
};
let rendered = story.render().expect("proof story renders");
let page =
render_story_detail(&StoryRegistry::default(), &story, &rendered, None).into_string();
let dom = crate::test_html::parse(&page);
let preview = crate::test_html::SelectorList::parse(".story-preview .proof-marker")
.expect("selector parses");
let matches = preview.matches(&dom);
assert!(
!matches.is_empty(),
"live render must appear inside .story-preview: {page}"
);
assert!(
matches[0].text().contains("live proof"),
"live render must contain the story's output: {page}"
);
let tabs = crate::test_html::SelectorList::parse(".autumn-tabs").expect("selector parses");
assert!(
!tabs.matches(&dom).is_empty(),
"detail page must use the tabs widget for Source / Rendered HTML: {page}"
);
#[cfg(feature = "htmx")]
{
let js_selector = crate::test_html::SelectorList::parse(&format!(
"script[src=\"{}\"]",
crate::htmx::AUTUMN_WIDGETS_JS_PATH
))
.expect("selector parses");
assert!(
!js_selector.matches(&dom).is_empty(),
"detail page must load the widget runtime script: {page}"
);
let htmx_selector = crate::test_html::SelectorList::parse(&format!(
"script[src=\"{}\"]",
crate::htmx::HTMX_JS_PATH
))
.expect("selector parses");
assert!(
!htmx_selector.matches(&dom).is_empty(),
"detail page must load htmx.min.js: {page}"
);
}
assert!(
page.contains("maud::html!"),
"Source tab must show the captured snippet: {page}"
);
assert!(
page.contains("<p"),
"Rendered HTML tab must show the escaped markup: {page}"
);
}
#[test]
fn gallery_merge_appends_stories_and_dedupes_asset_links() {
let first = StoryGallery::new()
.extend([demo_story("Display", "Card")])
.stylesheet("/a.css")
.script("/a.js");
let second = StoryGallery::new()
.extend([demo_story("Pack", "Swatch picker")])
.stylesheet("/a.css")
.stylesheet("/b.css")
.script("/b.js");
let merged = first.merge(second);
let slugs: Vec<&str> = merged.stories().iter().map(Story::slug).collect();
assert_eq!(slugs, ["card", "swatch-picker"]);
assert_eq!(merged.stylesheets(), ["/a.css", "/b.css"]);
assert_eq!(merged.scripts(), ["/a.js", "/b.js"]);
let registry = merged.into_registry();
assert_eq!(registry.stylesheets(), ["/a.css", "/b.css"]);
assert_eq!(registry.scripts(), ["/a.js", "/b.js"]);
}
#[test]
fn gallery_pages_link_pack_assets_after_core_assets() {
let registry = StoryGallery::new()
.extend([demo_story("Pack", "Swatch picker")])
.stylesheet("/static/_plugins/swatch/swatch.css")
.script("/static/_plugins/swatch/swatch.js")
.into_registry();
let story = ®istry.stories()[0];
let rendered = story.render().expect("demo story renders");
for page in [
render_story_index(®istry, None).into_string(),
render_story_detail(®istry, story, &rendered, None).into_string(),
] {
let core_css = page
.find(crate::ui::WIDGETS_CSS_PATH)
.expect("core stylesheet");
let pack_css = page
.find(r#"<link rel="stylesheet" href="/static/_plugins/swatch/swatch.css">"#)
.unwrap_or_else(|| panic!("pack stylesheet missing: {page}"));
assert!(core_css < pack_css, "pack CSS must follow core CSS: {page}");
let pack_js = page
.find(r#"<script src="/static/_plugins/swatch/swatch.js" defer></script>"#)
.unwrap_or_else(|| panic!("pack script missing: {page}"));
#[cfg(feature = "htmx")]
assert!(
page.find(crate::htmx::AUTUMN_WIDGETS_JS_PATH)
.is_some_and(|core_js| core_js < pack_js),
"pack JS must follow the widget runtime: {page}"
);
let _ = pack_js;
}
}
#[test]
fn index_empty_state_mentions_with_story_gallery() {
let page = render_story_index(&StoryRegistry::default(), None).into_string();
assert!(
page.contains("with_story_gallery"),
"empty state must explain how to register stories: {page}"
);
}
fn assert_balanced_html(html: &str, context: &str) {
const VOID_ELEMENTS: &[&str] = &[
"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param",
"source", "track", "wbr",
];
const RAW_TEXT_ELEMENTS: &[&str] = &["script", "style", "textarea", "title"];
let bytes = html.as_bytes();
let mut stack: Vec<String> = Vec::new();
let mut i = 0;
while i < bytes.len() {
if bytes[i] != b'<' {
i += 1;
continue;
}
if html[i..].starts_with("<!--") {
let end = html[i + 4..]
.find("-->")
.unwrap_or_else(|| panic!("unterminated comment in {context}:\n{html}"));
i += 4 + end + 3;
continue;
}
if html[i..].starts_with("<!") {
let end = html[i..]
.find('>')
.unwrap_or_else(|| panic!("unterminated declaration in {context}:\n{html}"));
i += end + 1;
continue;
}
let closing = html[i..].starts_with("</");
let name_start = i + if closing { 2 } else { 1 };
let name_len = html[name_start..]
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '-'))
.unwrap_or(html.len() - name_start);
let name = html[name_start..name_start + name_len].to_ascii_lowercase();
assert!(
!name.is_empty(),
"malformed tag at byte {i} in {context}:\n{html}"
);
let mut j = name_start + name_len;
let mut quote: Option<u8> = None;
while j < bytes.len() {
match (quote, bytes[j]) {
(Some(q), c) if c == q => quote = None,
(None, b'"') => quote = Some(b'"'),
(None, b'\'') => quote = Some(b'\''),
(None, b'>') => break,
_ => {}
}
j += 1;
}
assert!(
j < bytes.len(),
"unterminated tag <{name} in {context}:\n{html}"
);
let self_closing = j > 0 && bytes[j - 1] == b'/';
if closing {
let open = stack.pop().unwrap_or_else(|| {
panic!("closing </{name}> with no open tag in {context}:\n{html}")
});
assert_eq!(
open, name,
"mismatched close tag in {context}: expected </{open}>, found </{name}>:\n{html}"
);
i = j + 1;
continue;
}
if !self_closing && !VOID_ELEMENTS.contains(&name.as_str()) {
if RAW_TEXT_ELEMENTS.contains(&name.as_str()) {
let close = format!("</{name}");
let rest_start = j + 1;
let end = html[rest_start..]
.to_ascii_lowercase()
.find(&close)
.unwrap_or_else(|| {
panic!("unclosed raw-text element <{name}> in {context}:\n{html}")
});
let close_gt = html[rest_start + end..]
.find('>')
.unwrap_or_else(|| panic!("unterminated </{name}> in {context}:\n{html}"));
i = rest_start + end + close_gt + 1;
continue;
}
stack.push(name);
}
i = j + 1;
}
assert!(
stack.is_empty(),
"unclosed tags {stack:?} in {context}:\n{html}"
);
}
#[test]
fn balanced_html_checker_rejects_malformed_markup() {
assert_balanced_html(
r#"<!DOCTYPE html><div class="a > b"><p>ok<br></p></div>"#,
"self-test",
);
for bad in ["<div><p></div>", "<div>", "</div>"] {
let result = std::panic::catch_unwind(|| assert_balanced_html(bad, "self-test"));
assert!(result.is_err(), "checker should reject {bad}");
}
}
#[test]
fn gallery_index_and_detail_pages_render_balanced_html() {
let registry = builtin();
assert_balanced_html(
&render_story_index(®istry, None).into_string(),
"story index page",
);
assert_balanced_html(
&render_story_index(&StoryRegistry::default(), None).into_string(),
"empty-state index page",
);
for story in registry.stories() {
let rendered = story
.render()
.unwrap_or_else(|err| panic!("builtin story `{}` failed: {err}", story.slug()));
assert_balanced_html(
&render_story_detail(®istry, story, &rendered, None).into_string(),
&format!("detail page for `{}`", story.slug()),
);
}
}
#[test]
fn story_pages_apply_csp_nonce_to_inline_style() {
let nonce = crate::security::CspNonce::new_for_tests("test-nonce-value");
let registry = StoryRegistry::new(vec![demo_story("Display", "Card")]);
let index = render_story_index(®istry, Some(&nonce)).into_string();
assert!(
index.contains(r#"<style nonce="test-nonce-value">"#),
"index inline style must carry the CSP nonce: {index}"
);
let story = ®istry.stories()[0];
let rendered = story.render().expect("demo story renders");
let detail = render_story_detail(®istry, story, &rendered, Some(&nonce)).into_string();
assert!(
detail.contains(r#"<style nonce="test-nonce-value">"#),
"detail inline style must carry the CSP nonce: {detail}"
);
let plain = render_story_index(®istry, None).into_string();
assert!(
plain.contains("<style>") && !plain.contains("nonce="),
"without the security layer no nonce attribute is emitted: {plain}"
);
}
#[cfg(feature = "htmx")]
#[test]
fn story_pages_tell_htmx_the_csp_nonce_via_meta_tag() {
let nonce = crate::security::CspNonce::new_for_tests("test-nonce-value");
let registry = StoryRegistry::new(vec![demo_story("Display", "Card")]);
let with_nonce = render_story_index(®istry, Some(&nonce)).into_string();
assert!(
with_nonce.contains(
r#"<meta name="htmx-config" content="{"inlineStyleNonce":"test-nonce-value"}">"#
),
"story page must tell htmx the CSP nonce via htmx-config: {with_nonce}"
);
let plain = render_story_index(®istry, None).into_string();
assert!(
!plain.contains("htmx-config"),
"without the security layer no htmx-config meta tag is needed: {plain}"
);
}
#[test]
fn theme_and_density_switchers_are_radios_wired_to_css() {
let registry = StoryRegistry::new(vec![demo_story("Display", "Card")]);
let index = render_story_index(®istry, None).into_string();
let dom = crate::test_html::parse(&index);
for (group, options, default, css) in [
(
"theme",
&STORY_THEMES[..],
"system",
&[
("light", "color-scheme: light;"),
("dark", "color-scheme: dark;"),
][..],
),
(
"density",
&STORY_DENSITIES[..],
"comfortable",
&[
("compact", "--density-scale: 0.75;"),
("spacious", "--density-scale: 1.25;"),
][..],
),
] {
let fieldset =
crate::test_html::SelectorList::parse(&format!("fieldset.story-{group}-switch"))
.expect("selector parses");
assert!(
!fieldset.matches(&dom).is_empty(),
"no {group} switcher: {index}"
);
for (value, label) in options {
let radio_id = format!("story-{group}-{value}");
let radio = crate::test_html::SelectorList::parse(&format!(
"input[type=\"radio\"][name=\"story-{group}\"]#{radio_id}"
))
.expect("selector parses");
assert!(
!radio.matches(&dom).is_empty(),
"no radio {radio_id}: {index}"
);
let label_el =
crate::test_html::SelectorList::parse(&format!("label[for=\"{radio_id}\"]"))
.expect("selector parses");
let labels = label_el.matches(&dom);
assert!(
labels.first().is_some_and(|l| l.text().contains(label)),
"label for {radio_id} must read {label:?}: {index}"
);
}
assert!(
index.contains(&format!(r#"id="story-{group}-{default}" checked"#)),
"{default} must be checked server-side: {index}"
);
for (value, declaration) in css {
let rule = format!(":root:has(#story-{group}-{value}:checked) {{ {declaration} }}");
assert!(STORY_GALLERY_CSS.contains(&rule), "missing `{rule}`");
}
}
assert!(
!index.contains("<script>")
&& !index.contains(" onclick=")
&& !index.contains(" onchange="),
"the switchers must stay pure CSS: {index}"
);
}
#[test]
fn gallery_css_does_not_override_color_tokens() {
for line in STORY_GALLERY_CSS.lines() {
let line = line.trim_start();
assert!(
!line.starts_with("--") || line.starts_with("--density-scale"),
"gallery redefines a token: {line}"
);
}
assert!(
!STORY_GALLERY_CSS.contains("color: #fff"),
"use var(--on-primary)"
);
}
#[cfg(feature = "htmx")]
#[test]
fn story_page_loads_htmx_before_the_widgets_runtime() {
let page = render_story_index(&StoryRegistry::default(), None).into_string();
let htmx_at = page
.find(crate::htmx::HTMX_JS_PATH)
.expect("story page must load htmx.min.js");
let widgets_at = page
.find(crate::htmx::AUTUMN_WIDGETS_JS_PATH)
.expect("story page must load autumn-widgets.js");
assert!(
htmx_at < widgets_at,
"htmx.min.js must appear before autumn-widgets.js so `defer` \
preserves that execution order: {page}"
);
}
#[cfg(feature = "widgets-search")]
#[test]
fn typeahead_stories_point_at_their_registered_demo_routes() {
let registry = builtin();
let active_search = registry
.find("active-search")
.expect("active-search story exists");
assert!(
active_search.source().contains(STORIES_DEMO_SEARCH_PATH),
"Active search story must point at STORIES_DEMO_SEARCH_PATH \
({STORIES_DEMO_SEARCH_PATH}): {}",
active_search.source()
);
let autocomplete = registry
.find("autocomplete")
.expect("autocomplete story exists");
assert!(
autocomplete.source().contains(STORIES_DEMO_TAG_SEARCH_PATH),
"Autocomplete story must point at STORIES_DEMO_TAG_SEARCH_PATH \
({STORIES_DEMO_TAG_SEARCH_PATH}): {}",
autocomplete.source()
);
}
#[cfg(feature = "widgets-social")]
#[test]
fn feed_story_points_at_its_registered_demo_route() {
let registry = builtin();
let feed = registry
.find("infinite-feed")
.expect("infinite-feed story exists");
assert!(
feed.source().contains(STORIES_DEMO_FEED_PATH),
"Infinite feed story must point at STORIES_DEMO_FEED_PATH \
({STORIES_DEMO_FEED_PATH}), or its Reveal-mode sentinel \
404-swaps on page load with no visitor action: {}",
feed.source()
);
}
#[cfg(feature = "widgets-search")]
#[tokio::test]
async fn demo_search_filters_case_insensitively_with_empty_state_fallback() {
let hit = demo_search(crate::extract::Query(DemoQuery {
q: "AUTUMN".to_owned(),
}))
.await
.0;
assert!(hit.contains("The Long Autumn"), "{hit}");
assert!(hit.contains("Autumn in Practice"), "{hit}");
assert!(!hit.contains("Falling Leaves"), "{hit}");
let miss = demo_search(crate::extract::Query(DemoQuery {
q: "nonexistent".to_owned(),
}))
.await
.0;
assert!(
miss.contains("No posts matched your search."),
"no-match query must fall back to active_search_empty_state: {miss}"
);
}
#[cfg(feature = "widgets-search")]
#[tokio::test]
async fn demo_tag_search_filters_case_insensitively_with_empty_state_fallback() {
let hit = demo_tag_search(crate::extract::Query(DemoQuery { q: "RU".to_owned() }))
.await
.0;
assert!(hit.contains("rust"), "{hit}");
assert!(!hit.contains("axum"), "{hit}");
let miss = demo_tag_search(crate::extract::Query(DemoQuery {
q: "nonexistent".to_owned(),
}))
.await
.0;
assert!(
miss.contains("No matching tags."),
"no-match query must fall back to autocomplete_empty_state: {miss}"
);
}
#[cfg(feature = "widgets-search")]
#[tokio::test]
async fn demo_tag_suggest_lists_matches_then_a_create_entry() {
let some = demo_tag_suggest(crate::extract::Query(DemoQuery { q: "A".to_owned() }))
.await
.0;
assert!(some.contains(r#"data-value="axum""#), "{some}");
assert!(!some.contains(r#"data-value="rust""#), "{some}");
assert!(some.contains("data-ac-create"), "{some}");
assert!(some.contains(r#"data-label="A""#), "the typed text: {some}");
let new_tag = demo_tag_suggest(crate::extract::Query(DemoQuery {
q: " zig ".to_owned(),
}))
.await
.0;
assert!(!new_tag.contains(r#"data-value="rust""#), "{new_tag}");
assert!(new_tag.contains(r#"data-value="zig""#), "{new_tag}");
assert!(new_tag.contains("Create \u{201c}zig\u{201d}"), "{new_tag}");
}
#[cfg(feature = "widgets-search")]
#[tokio::test]
async fn demo_tag_suggest_has_no_create_entry_for_an_exact_or_empty_query() {
let exact = demo_tag_suggest(crate::extract::Query(DemoQuery {
q: "Web".to_owned(),
}))
.await
.0;
assert!(exact.contains(r#"data-value="web""#), "{exact}");
assert!(!exact.contains("data-ac-create"), "{exact}");
let empty = demo_tag_suggest(crate::extract::Query(DemoQuery { q: " ".to_owned() }))
.await
.0;
assert!(empty.is_empty(), "{empty}");
}
#[cfg(feature = "widgets-search")]
#[test]
fn tags_input_story_points_at_its_registered_demo_route() {
let registry = builtin();
let tags = registry
.find("tags-input")
.expect("tags-input story exists");
assert!(
tags.source().contains(STORIES_DEMO_TAG_SUGGEST_PATH),
"Tags input story must point at {STORIES_DEMO_TAG_SUGGEST_PATH}: {}",
tags.source()
);
assert!(
demo_routes()
.iter()
.any(|(path, _)| *path == STORIES_DEMO_TAG_SUGGEST_PATH),
"the demo route is listed"
);
}
#[cfg(feature = "widgets-social")]
#[tokio::test]
async fn demo_infinite_feed_returns_a_terminal_page_with_no_further_sentinel() {
let body = demo_infinite_feed().await.0;
assert!(body.contains("Loaded live via a real request."), "{body}");
assert!(
!body.contains("autumn-feed__sentinel"),
"a next_cursor of None must emit no further sentinel, or the \
`revealed` trigger keeps auto-firing forever: {body}"
);
}
}