use actix_web::HttpRequest;
use maud::{DOCTYPE, Markup, html};
pub mod auth;
mod form;
pub mod tickets;
pub fn is_htmx(req: &HttpRequest) -> bool {
req.headers().contains_key("hx-request")
}
/// The full HTML document every page is wrapped in.
pub fn render_layout(
main_content: &Markup,
username: Option<&str>,
page_title: Option<&str>,
) -> Markup {
html! {
(DOCTYPE)
html lang="en" data-theme="light" {
head {
meta charset="utf-8";
meta name="viewport" content="width=device-width, initial-scale=1.0";
title {
@if let Some(page_title) = page_title {
(page_title) " · Pear"
} @else {
"Pear"
}
}
link rel="stylesheet" href="/assets/daisyui.css";
link rel="stylesheet" href="/assets/app.css";
meta name="htmx-config" content=(r#"{"implicitInheritance":true,"noSwap":[204,304]}"#);
script src="/assets/h.js" {}
}
body {
header class="site-header" {
div class="header-inner" {
a class="brand" href="/" aria-label="Pear home" { "Pear" }
nav class="primary-nav" aria-label="Main navigation" {
@if username.is_some() {
a href="/" { "Tasks" }
a href="/tickets/new" { "New task" }
}
}
div class="account" {
@match username {
Some(name) => {
span class="account-name" { (name) }
form method="POST" action="/auth/logout" {
button class="link-button" type="submit" { "Log out" }
}
}
None => {
a class="btn btn-outline btn-sm" href="/auth/login" { "Log in" }
}
}
}
}
}
main class="page-shell" {
(main_content)
}
}
}
}
}
/// An announced error notice, swapped into a form's result target.
pub fn render_error(message: &str) -> Markup {
html! {
div class="alert alert-error mt-4" role="alert" {
span { (message) }
}
}
}
/// An announced success notice.
pub fn render_success(message: &str) -> Markup {
html! {
div class="alert alert-success mt-4" role="status" {
span { (message) }
}
}
}
/// Renders `content` as a bare partial for HTMX requests, or wraps it in the
/// full document for a normal browser navigation.
pub fn page_or_partial(
req: &HttpRequest,
content: Markup,
username: Option<&str>,
title: &str,
) -> Markup {
if is_htmx(req) {
content
} else {
render_layout(&content, username, Some(title))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_layout_contains_shell_and_assets() {
let content = html! { p { "hello" } };
let rendered = render_layout(&content, None, Some("Home")).into_string();
assert!(rendered.contains("<!DOCTYPE html>"));
assert!(rendered.contains("<title>Home · Pear</title>"));
assert!(rendered.contains("/assets/daisyui.css"));
assert!(rendered.contains("/assets/app.css"));
assert!(rendered.contains("/assets/h.js"));
assert!(rendered.contains("hello"));
}
#[test]
fn test_layout_anonymous_nav_offers_login_without_open_signup() {
let content = html! {};
let rendered = render_layout(&content, None, None).into_string();
assert!(rendered.contains("/auth/login"));
assert!(!rendered.contains("/auth/signup"));
assert!(!rendered.contains("/auth/logout"));
}
#[test]
fn test_layout_authenticated_nav_offers_logout() {
let content = html! {};
let rendered = render_layout(&content, Some("alice"), None).into_string();
assert!(rendered.contains("alice"));
assert!(rendered.contains("/auth/logout"));
assert!(!rendered.contains("href=\"/auth/login\""));
}
#[test]
fn test_layout_authenticated_nav_links_to_new_task_page() {
let content = html! {};
let rendered = render_layout(&content, Some("alice"), None).into_string();
assert!(rendered.contains("href=\"/tickets/new\""));
assert!(!rendered.contains("/#new-task"));
}
#[test]
fn test_notices_escape_message_markup() {
for html in [
render_error("<script>boom()</script>").into_string(),
render_success("<script>boom()</script>").into_string(),
] {
assert!(!html.contains("<script>"));
assert!(html.contains("<script>"));
}
}
}