#![forbid(unsafe_code)]
#![warn(missing_docs)]
use fandhe_frontend_core::{a, div, el, h1, li, main_tag, p, text, ul, Node};
use std::convert::Infallible;
pub mod router;
pub mod routes;
pub const LIKE_BUTTON_ID: &str = "like-btn";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Item {
pub id: String,
pub title: String,
pub body: String,
}
pub fn demo_items() -> Vec<Item> {
vec![
Item {
id: "1".to_string(),
title: "Rust 製フロントエンド基盤の構想".to_string(),
body: "安全性・Web 標準尊重・思想のグラデーション・単一バイナリ配布を統合する。"
.to_string(),
},
Item {
id: "2".to_string(),
title: "<script>alert('xss')</script><img src=x onerror=alert(1)>".to_string(),
body: "このタイトルは意図的な XSS ペイロードであり、既定エスケープの実証に使う。"
.to_string(),
},
Item {
id: "3".to_string(),
title: "View Transitions API の薄いラッパー評価".to_string(),
body: "標準 API を直接呼び出す形でページ遷移を演出できるかを検証する。".to_string(),
},
]
}
pub trait Loader {
type Input;
type Output;
type Error;
fn load(&self, input: &Self::Input) -> Result<Self::Output, Self::Error>;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct DemoItemsLoader;
impl Loader for DemoItemsLoader {
type Input = ();
type Output = Vec<Item>;
type Error = Infallible;
fn load(&self, _input: &()) -> Result<Vec<Item>, Infallible> {
Ok(demo_items())
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct DemoItemDetailLoader;
impl Loader for DemoItemDetailLoader {
type Input = String;
type Output = Option<Item>;
type Error = Infallible;
fn load(&self, id: &String) -> Result<Option<Item>, Infallible> {
Ok(demo_items().into_iter().find(|it| &it.id == id))
}
}
pub fn assemble_list_page<L>(loader: &L, input: &L::Input) -> Result<Node, L::Error>
where
L: Loader<Output = Vec<Item>>,
{
Ok(list_page(loader.load(input)?.as_slice()))
}
pub fn assemble_detail_page<L>(loader: &L, input: &L::Input) -> Result<Node, L::Error>
where
L: Loader<Output = Option<Item>>,
{
Ok(detail_page(loader.load(input)?.as_ref()))
}
pub fn layout(title: &str, body: Node) -> Node {
el(
"div",
vec![("id", "app-root"), ("data-fandhe-frontend", "root")],
vec![h1(vec![], vec![text(title)]), main_tag(vec![], vec![body])],
)
}
pub fn list_page(items: &[Item]) -> Node {
let list_items: Vec<Node> = items
.iter()
.map(|it| {
let href = format!("/items/{}", it.id);
li(
vec![],
vec![a(
vec![("href", &href), ("data-nav", &href)],
vec![text(it.title.clone())],
)],
)
})
.collect();
layout(
"記事一覧",
ul(vec![("data-testid", "item-list")], list_items),
)
}
pub fn detail_page(item: Option<&Item>) -> Node {
match item {
Some(item) => layout(
"記事詳細",
div(
vec![("data-testid", "item-detail")],
vec![
p(
vec![("data-testid", "item-title")],
vec![text(item.title.clone())],
),
p(
vec![("data-testid", "item-body")],
vec![text(item.body.clone())],
),
el(
"button",
vec![("id", LIKE_BUTTON_ID), ("data-hydrate", "like")],
vec![text("いいね")],
),
a(
vec![("href", "/"), ("data-nav", "/")],
vec![text("一覧へ戻る")],
),
],
),
),
None => layout(
"見つかりません",
p(vec![], vec![text("指定された記事は存在しません。")]),
),
}
}
pub fn page_shell(title: &str, body: Node) -> String {
let head = el(
"head",
vec![],
vec![
el("meta", vec![("charset", "utf-8")], vec![]),
el(
"meta",
vec![
("name", "viewport"),
("content", "width=device-width, initial-scale=1"),
],
vec![],
),
el(
"style",
vec![],
vec![text("@view-transition { navigation: auto; }")],
),
el("title", vec![], vec![text(title)]),
el(
"link",
vec![("rel", "stylesheet"), ("href", "/static/style.css")],
vec![],
),
],
);
let document_body = el(
"body",
vec![],
vec![
body,
el(
"script",
vec![("type", "module"), ("src", "/static/hydrate.js")],
vec![],
),
],
);
let html = el("html", vec![("lang", "ja")], vec![head, document_body]);
format!("<!DOCTYPE html>\n{}", fandhe_frontend_core::render(&html))
}
#[cfg(test)]
mod tests {
use super::*;
use fandhe_frontend_core::render;
#[test]
fn list_page_render_is_mode_independent_and_matches_expected_dom() {
let items = demo_items();
let html_as_ssr = render(&list_page(&items));
let html_as_csr = render(&list_page(&items));
assert_eq!(
html_as_ssr, html_as_csr,
"SSR/CSR で同一コードから同一 DOM が得られること"
);
assert!(html_as_ssr.contains(r#"data-testid="item-list""#));
assert!(html_as_ssr.contains("Rust 製フロントエンド基盤の構想"));
assert!(html_as_ssr.contains(r#"data-nav="/items/1""#));
assert!(!html_as_ssr.contains("<script>alert"));
assert!(html_as_ssr.contains("<script>alert"));
}
#[test]
fn detail_page_render_matches_expected_dom_for_existing_item() {
let items = demo_items();
let item = items.iter().find(|it| it.id == "1");
let html = render(&detail_page(item));
assert!(html.contains(r#"data-testid="item-detail""#));
assert!(html.contains("Rust 製フロントエンド基盤の構想"));
assert!(html.contains("一覧へ戻る"));
assert!(html.contains(LIKE_BUTTON_ID));
}
#[test]
fn detail_page_render_handles_missing_item() {
let html = render(&detail_page(None));
assert!(html.contains("見つかりません"));
}
#[test]
fn ssg_output_equals_ssr_output_for_list_and_detail() {
let items = demo_items();
let ssr_list = render(&list_page(&items));
let ssg_list = render(&list_page(&items));
assert_eq!(ssr_list, ssg_list);
let item = items.iter().find(|it| it.id == "2");
let ssr_detail = render(&detail_page(item));
let ssg_detail = render(&detail_page(item));
assert_eq!(ssr_detail, ssg_detail);
assert!(!ssr_detail.contains("<script>alert"));
assert!(ssr_detail.contains("<script>alert"));
}
#[test]
fn page_shell_includes_view_transition_at_rule_and_matches_across_ssr_and_ssg() {
let items = demo_items();
let ssr_doc = page_shell("記事一覧", list_page(&items));
let ssg_doc = page_shell("記事一覧", list_page(&items));
assert_eq!(ssr_doc, ssg_doc);
assert!(ssr_doc.contains("<style>@view-transition { navigation: auto; }</style>"));
assert!(ssr_doc.starts_with("<!DOCTYPE html>"));
}
#[test]
fn page_shell_escapes_title_to_prevent_xss() {
let doc = page_shell("<script>alert('xss')</script>", div(vec![], vec![]));
assert!(!doc.contains("<title><script>alert"));
assert!(doc.contains("<title><script>alert('xss')</script></title>"));
}
#[test]
fn layout_escapes_title_to_prevent_xss() {
let html = render(&layout(
"<script>alert('xss')</script>",
p(vec![], vec![text("body")]),
));
assert!(!html.contains("<script>alert"));
assert!(html.contains("<script>alert"));
}
#[test]
fn demo_items_loader_load_is_deterministic_for_same_input() {
let loader = DemoItemsLoader;
let first = loader.load(&()).expect("Infallible は必ず Ok");
let second = loader.load(&()).expect("Infallible は必ず Ok");
assert_eq!(first, second);
}
#[test]
fn assemble_list_page_matches_direct_list_page_call() {
let via_loader =
render(&assemble_list_page(&DemoItemsLoader, &()).expect("Infallible は必ず Ok"));
let direct = render(&list_page(&demo_items()));
assert_eq!(via_loader, direct);
}
#[test]
fn assemble_detail_page_matches_direct_detail_page_call_for_existing_and_missing_id() {
let loader = DemoItemDetailLoader;
let via_loader =
render(&assemble_detail_page(&loader, &"1".to_string()).expect("Infallible は必ず Ok"));
let items = demo_items();
let direct = render(&detail_page(items.iter().find(|it| it.id == "1")));
assert_eq!(via_loader, direct);
assert!(via_loader.contains(r#"data-testid="item-detail""#));
let via_loader_missing = render(
&assemble_detail_page(&loader, &"does-not-exist".to_string())
.expect("Infallible は必ず Ok"),
);
assert!(via_loader_missing.contains("見つかりません"));
}
#[test]
fn assemble_pages_escape_xss_payload_via_loader_path() {
let list_html = render(&assemble_list_page(&DemoItemsLoader, &()).expect("Infallible"));
assert!(!list_html.contains("<script>alert"));
assert!(list_html.contains("<script>alert"));
let detail_html = render(
&assemble_detail_page(&DemoItemDetailLoader, &"2".to_string()).expect("Infallible"),
);
assert!(!detail_html.contains("<script>alert"));
assert!(detail_html.contains("<script>alert"));
}
}