fandhe_frontend_app/lib.rs
1//! `fandhe-frontend-app`: SSR / SSG / CSR 三モード共通のモード非依存コンポーネントライブラリ。
2//!
3//! `fandhe-frontend-core`(`Node` / `el` / `text` / `render` 等)**のみ**に依存する。マクロ DSL
4//! には依存せず、`Node` を返す通常の Rust 関数としてコンポーネントを記述する
5//! (`docs/api/component-api.md` の「コンポーネント記述の標準規約」に従う)。
6//!
7//! # 三モード契約(REQ-6)
8//!
9//! [`list_page`] / [`detail_page`] / [`page_shell`] は、SSR(`fandhe-frontend-server` の
10//! axum ハンドラ想定・TASK-6.1c)・SSG(同クレートの静的書き出しバイナリ想定)・
11//! CSR(`fandhe-frontend-wasm-client` 想定・TASK-6.2 系)の**いずれのモードからも同一関数が
12//! そのまま呼ばれる**ことを前提とする。モード別の分岐・モード別の出力差異を
13//! 本クレートに持ち込まない(同一入力に対し常に同一の [`fandhe_frontend_core::render`]
14//! 出力を返すことをテストで固定する)。
15//!
16//! # 既定エスケープの引き継ぎ(REQ-1)
17//!
18//! 本クレートはテキスト・属性値をすべて `fandhe_frontend_core::text` / `fandhe_frontend_core::el` の
19//! attrs 経由で組み立て、独自のエスケープ処理・独自の raw 出力経路を持たない。
20//! `format!` によるタグ文字列の直接組み立ては行わない(`coding-rust.md`
21//! 「HTML 文字列の直接組み立て禁止」)。[`page_shell`] が前置する
22//! `<!DOCTYPE html>` のみ、ユーザー入力を一切含まない固定リテラルとして
23//! 文字列結合する(`fandhe_frontend_core::render` 済みの既定エスケープ済み HTML の前に
24//! 付与するのみであり、新たな迂回経路ではない)。
25//!
26//! # スコープ外
27//!
28//! ハイドレーション支援 API(`find_attr_values`/`find_nav_targets` 相当)は
29//! `fandhe-frontend-core` 側の TASK-6.2 系で追加予定であり、本クレートでは使用しない。
30//! `server/src/main.rs`(SSR/SSG エントリ)は TASK-6.1c、三モード統合テストは
31//! TASK-6.1d のスコープであり本クレートには含めない。
32//!
33//! # ルーティング([`router`] / [`routes`]、イシュー #407)
34//!
35//! `server`(SSR/SSG)・`wasm-full`(CSR)双方から依存可能な唯一の層
36//! (`structure.toml` の `allowed_dependents` 参照)として、パスマッチング
37//! エンジン([`router`])とルート表の単一定義([`routes`])を本クレートへ
38//! 集約する。詳細は各モジュールの doc コメントと
39//! `docs/design/route-definition-sharing.md` を参照。
40
41#![forbid(unsafe_code)]
42#![warn(missing_docs)]
43
44use fandhe_frontend_core::{a, div, el, h1, li, main_tag, p, text, ul, Node};
45use std::convert::Infallible;
46
47pub mod router;
48pub mod routes;
49
50/// ハイドレーション後にクライアント側の `click` イベントで参照される
51/// `id` 属性値。`fandhe-frontend-wasm-client`(TASK-6.2 系)がこの定数で DOM 要素を
52/// 検索する前提の契約であり、値を変更する場合はクライアント側と合わせて
53/// 更新する必要がある。
54pub const LIKE_BUTTON_ID: &str = "like-btn";
55
56/// 一覧・詳細画面の最小データモデル。
57///
58/// PoC-3 の固定データ構造を踏襲しつつ、フィールドをすべて所有型
59/// (`String`)に一般化している。SSR/SSG/CSR いずれの呼び出し元も、
60/// データベース・API・埋め込みデータ等の由来を問わず本構造体を組み立てて
61/// [`list_page`] / [`detail_page`] に渡すことを想定する(PoC-3 のような
62/// クレート内固定データへの決め打ちを避けるための一般化)。
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct Item {
65 /// 一覧・詳細間の参照キー。URL パス片(`/items/{id}`)にそのまま使う。
66 pub id: String,
67 /// 表示用タイトル。[`text`] 経由で必ず既定エスケープされる。
68 pub title: String,
69 /// 本文。タイトルと同様に既定エスケープ対象。
70 pub body: String,
71}
72
73/// デモ・テスト用の固定データ(TASK-6.1c 以降が実データ接続するまでの暫定値)。
74///
75/// `demo_items()[1]` の title に意図的な XSS ペイロードを含めており、
76/// [`list_page`] / [`detail_page`] の既定エスケープ回帰テストの入力として
77/// も利用する(PoC-2/PoC-3 の XSS 実証データを踏襲)。
78pub fn demo_items() -> Vec<Item> {
79 vec![
80 Item {
81 id: "1".to_string(),
82 title: "Rust 製フロントエンド基盤の構想".to_string(),
83 body: "安全性・Web 標準尊重・思想のグラデーション・単一バイナリ配布を統合する。"
84 .to_string(),
85 },
86 Item {
87 id: "2".to_string(),
88 title: "<script>alert('xss')</script><img src=x onerror=alert(1)>".to_string(),
89 body: "このタイトルは意図的な XSS ペイロードであり、既定エスケープの実証に使う。"
90 .to_string(),
91 },
92 Item {
93 id: "3".to_string(),
94 title: "View Transitions API の薄いラッパー評価".to_string(),
95 body: "標準 API を直接呼び出す形でページ遷移を演出できるかを検証する。".to_string(),
96 },
97 ]
98}
99
100/// SSR・SSG・CSR の三モードから同一実装が呼ばれるデータ取得契約
101/// (イシュー #346 設計確定書 `docs/design/loader-trait-design.md` §3.2 の
102/// 凍結シグネチャに一字一句準拠する。実装が本 trait と乖離した場合は
103/// 同設計書を正とする)。
104///
105/// `load()` の実装は 1 箇所のみとし、モード別の分岐を持たない
106/// (REQ-6 の三モード契約を Loader にも適用する)。`fandhe-frontend-server`(#348)・
107/// `fandhe-frontend-wasm-full`(#349)はいずれも本 trait の同一 `impl` を呼ぶのみで、
108/// モードごとに別実装を作らない。
109///
110/// # 型で保証する範囲(設計書 §3.4)
111///
112/// 保証するのは `Output` 型とページ関数([`list_page`] / [`detail_page`])
113/// への型接続のみであり、`load` 自体の実行時決定性(外界 I/O を含みうる)
114/// は型システムの外側(テスト)の責務とする。
115pub trait Loader {
116 /// ルートパラメータ等の解決入力(例: 一覧 = `()`、詳細 = id)。
117 type Input;
118 /// ページ関数への唯一のデータ源。
119 type Output;
120 /// 解決失敗を表す型。
121 ///
122 /// 表示用文字列に内部パス・スタックトレース・接続情報等の内部情報を
123 /// 含めない契約とする(fail-closed、`security.md`「機微情報の露出」・
124 /// 設計書 §5・§9-5)。エラー時の実際の応答(500 / ビルド失敗 / 固定
125 /// エラービュー)は呼び出し元(`fandhe-frontend-server` #348・`fandhe-frontend-wasm-full` #349)
126 /// の責務であり、本 trait はエラーの型のみを規定する。
127 type Error;
128
129 /// `input` からデータを解決する。三モードいずれの呼び出し元からも
130 /// 同一実装が呼ばれる(型で保証する範囲は本 trait の rustdoc 冒頭を
131 /// 参照)。
132 fn load(&self, input: &Self::Input) -> Result<Self::Output, Self::Error>;
133}
134
135/// 一覧画面([`list_page`])向けの参照 loader 実装。
136///
137/// 内部で [`demo_items()`] を呼ぶのみであり、デモデータは解決に失敗しない
138/// ため `Error = Infallible` とする(設計書 §7.1 の参照実装)。#348 が
139/// エラー経路をテストする際は、この loader とは別に失敗する loader を
140/// server 側テストで定義できる(`Loader` は汎用のまま)。
141#[derive(Debug, Clone, Copy, Default)]
142pub struct DemoItemsLoader;
143
144impl Loader for DemoItemsLoader {
145 type Input = ();
146 type Output = Vec<Item>;
147 type Error = Infallible;
148
149 fn load(&self, _input: &()) -> Result<Vec<Item>, Infallible> {
150 Ok(demo_items())
151 }
152}
153
154/// 詳細画面([`detail_page`])向けの参照 loader 実装。
155///
156/// `Input` は URL パス片(`/items/{id}`)に対応する id 文字列。id が
157/// [`demo_items()`] に存在しない場合は `Output = None` を返す(見つから
158/// ない、を `Error` ではなく `Output` の一部として表現することで、404
159/// 相当を fail-closed なエラー扱いにしない — 設計書 §3.3 の
160/// `detail_page(item: Option<&Item>)` 契約とそのまま接続する)。
161#[derive(Debug, Clone, Copy, Default)]
162pub struct DemoItemDetailLoader;
163
164impl Loader for DemoItemDetailLoader {
165 type Input = String;
166 type Output = Option<Item>;
167 type Error = Infallible;
168
169 fn load(&self, id: &String) -> Result<Option<Item>, Infallible> {
170 Ok(demo_items().into_iter().find(|it| &it.id == id))
171 }
172}
173
174/// loader の解決結果を一覧ページへ型接続する。
175///
176/// `L::Output` が `Vec<Item>` でない loader を渡すとコンパイルエラーに
177/// なる(`where` 束縛による型接続。設計書 §3.4 の「保証する範囲」)。
178/// `load` が失敗した場合は `?` で即座に `Err` を返し、未解決データで
179/// 描画を続行しない(fail-closed、設計書 §5)。呼び出し元(`fandhe-frontend-server`
180/// #348・`fandhe-frontend-wasm-full` #349)がこの `Err` をモードごとの応答(500 /
181/// ビルド失敗 / 固定エラービュー)へ変換する。
182///
183/// `list_page` の引数は `&[Item]` であり `&L::Output`(`&Vec<Item>`)とは
184/// 借用の形が異なるため、`.as_slice()` で薄く変換する(設計書 §3.3 注記。
185/// `list_page` 自体の純関数シグネチャは変更しない)。
186pub fn assemble_list_page<L>(loader: &L, input: &L::Input) -> Result<Node, L::Error>
187where
188 L: Loader<Output = Vec<Item>>,
189{
190 Ok(list_page(loader.load(input)?.as_slice()))
191}
192
193/// loader の解決結果を詳細ページへ型接続する。[`assemble_list_page`] と
194/// 同様に `where` 束縛で `Output` を `Option<Item>` に固定し、型不整合を
195/// コンパイルエラーにする。
196///
197/// `detail_page` の引数は `Option<&Item>` であり `&L::Output`
198/// (`&Option<Item>`)とは `Option` の内外どちらを参照で包むかが異なる
199/// ため、`.as_ref()` で薄く変換する(設計書 §3.3 注記)。
200pub fn assemble_detail_page<L>(loader: &L, input: &L::Input) -> Result<Node, L::Error>
201where
202 L: Loader<Output = Option<Item>>,
203{
204 Ok(detail_page(loader.load(input)?.as_ref()))
205}
206
207/// 共通レイアウト(ヘッダー相当)。[`list_page`] / [`detail_page`] の両方から
208/// 呼ばれる、モード非依存の骨格コンポーネント。
209///
210/// `title` は [`fandhe_frontend_core::text`] 経由で渡すため既定エスケープされる
211/// (呼び出し元が信頼できない文字列を渡しても生タグとして解釈されない)。
212pub fn layout(title: &str, body: Node) -> Node {
213 el(
214 "div",
215 vec![("id", "app-root"), ("data-fandhe-frontend", "root")],
216 vec![h1(vec![], vec![text(title)]), main_tag(vec![], vec![body])],
217 )
218}
219
220/// 画面 1: 一覧画面。各項目へのリンクに `data-nav` 属性を付与する
221/// (`fandhe-frontend-core` 側 TASK-6.2 系のハイドレーション支援 API がこの属性を
222/// 実 DOM なしに機械的検出する前提の契約。本クレートでは検出処理自体は
223/// 実装しない=スコープ外)。
224///
225/// `items` は呼び出し元(SSR/SSG/CSR いずれの層)が用意したデータをそのまま
226/// 受け取る。本関数はモード分岐を持たず、同一引数には常に同一の [`Node`]
227/// 木を返す(REQ-6 のモード非依存性契約)。
228pub fn list_page(items: &[Item]) -> Node {
229 let list_items: Vec<Node> = items
230 .iter()
231 .map(|it| {
232 let href = format!("/items/{}", it.id);
233 li(
234 vec![],
235 vec![a(
236 vec![("href", &href), ("data-nav", &href)],
237 vec![text(it.title.clone())],
238 )],
239 )
240 })
241 .collect();
242 layout(
243 "記事一覧",
244 ul(vec![("data-testid", "item-list")], list_items),
245 )
246}
247
248/// 画面 2: 詳細画面。呼び出し元が対象 `Item` の解決(ID 引き当て)を
249/// 済ませた結果を `Option<&Item>` として受け取る(本クレートは検索・
250/// データストアの責務を持たない)。`None` の場合は 404 相当のノードを返し、
251/// ライブラリコードで `panic!` しない(`coding-rust.md` のエラー処理規約)。
252pub fn detail_page(item: Option<&Item>) -> Node {
253 match item {
254 Some(item) => layout(
255 "記事詳細",
256 div(
257 vec![("data-testid", "item-detail")],
258 vec![
259 p(
260 vec![("data-testid", "item-title")],
261 vec![text(item.title.clone())],
262 ),
263 p(
264 vec![("data-testid", "item-body")],
265 vec![text(item.body.clone())],
266 ),
267 el(
268 "button",
269 vec![("id", LIKE_BUTTON_ID), ("data-hydrate", "like")],
270 vec![text("いいね")],
271 ),
272 a(
273 vec![("href", "/"), ("data-nav", "/")],
274 vec![text("一覧へ戻る")],
275 ),
276 ],
277 ),
278 ),
279 None => layout(
280 "見つかりません",
281 p(vec![], vec![text("指定された記事は存在しません。")]),
282 ),
283 }
284}
285
286/// ページ全体(`<!DOCTYPE html>` を含む完全文書)を組み立てる。
287/// SSR(axum ハンドラ想定)・SSG(静的書き出しバイナリ想定)の両方から
288/// 呼ばれる共通関数(TASK-6.1c で実際のエントリポイントが接続される)。
289///
290/// `title` は [`fandhe_frontend_core::el`] の `<title>` 子ノードとして [`text`] 経由で
291/// 渡すため既定エスケープされる(PoC-3 の手動 `escape_html` 呼び出しより
292/// 安全な構造。`text()` を経由しない独自のエスケープ処理を持たない)。
293/// `<!DOCTYPE html>` はユーザー入力を一切含まない固定リテラルとして
294/// [`fandhe_frontend_core::render`] 済みの文字列の前に結合するのみであり、新たな
295/// エスケープ迂回経路ではない。
296///
297/// `@view-transition { navigation: auto; }`(CSS Level 2 の at-rule)は
298/// Cross-Document View Transitions を有効化する。過去の `<meta
299/// name="view-transition" content="same-origin">` は現行ブラウザ・仕様で
300/// 廃止扱いのため採用しない(Bugbot 指摘対応)。この CSS はユーザー入力を
301/// 含まない固定リテラルであり `text()` 経由で `<style>` 子ノードとして
302/// 出力するため、既定エスケープ経路を迂回しない。フレームワーク固有の
303/// JS ラッパーを必要としない(PoC-3 の検証結果を踏襲)。
304pub fn page_shell(title: &str, body: Node) -> String {
305 let head = el(
306 "head",
307 vec![],
308 vec![
309 el("meta", vec![("charset", "utf-8")], vec![]),
310 el(
311 "meta",
312 vec![
313 ("name", "viewport"),
314 ("content", "width=device-width, initial-scale=1"),
315 ],
316 vec![],
317 ),
318 el(
319 "style",
320 vec![],
321 vec![text("@view-transition { navigation: auto; }")],
322 ),
323 el("title", vec![], vec![text(title)]),
324 el(
325 "link",
326 vec![("rel", "stylesheet"), ("href", "/static/style.css")],
327 vec![],
328 ),
329 ],
330 );
331 let document_body = el(
332 "body",
333 vec![],
334 vec![
335 body,
336 el(
337 "script",
338 vec![("type", "module"), ("src", "/static/hydrate.js")],
339 vec![],
340 ),
341 ],
342 );
343 let html = el("html", vec![("lang", "ja")], vec![head, document_body]);
344 format!("<!DOCTYPE html>\n{}", fandhe_frontend_core::render(&html))
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350 use fandhe_frontend_core::render;
351
352 /// REQ-6 中核: 同一関数(`list_page`)を 2 回呼び出しても完全一致する
353 /// ことを固定する。SSR で呼んでも CSR で呼んでも同一関数・同一入力なら
354 /// 同一 DOM が得られるという三モード契約をそのまま証明する。
355 #[test]
356 fn list_page_render_is_mode_independent_and_matches_expected_dom() {
357 let items = demo_items();
358 let html_as_ssr = render(&list_page(&items));
359 let html_as_csr = render(&list_page(&items));
360 assert_eq!(
361 html_as_ssr, html_as_csr,
362 "SSR/CSR で同一コードから同一 DOM が得られること"
363 );
364
365 assert!(html_as_ssr.contains(r#"data-testid="item-list""#));
366 assert!(html_as_ssr.contains("Rust 製フロントエンド基盤の構想"));
367 assert!(html_as_ssr.contains(r#"data-nav="/items/1""#));
368 // XSS ペイロードはテキストノード経由のため既定エスケープされる。
369 assert!(!html_as_ssr.contains("<script>alert"));
370 assert!(html_as_ssr.contains("<script>alert"));
371 }
372
373 #[test]
374 fn detail_page_render_matches_expected_dom_for_existing_item() {
375 let items = demo_items();
376 let item = items.iter().find(|it| it.id == "1");
377 let html = render(&detail_page(item));
378 assert!(html.contains(r#"data-testid="item-detail""#));
379 assert!(html.contains("Rust 製フロントエンド基盤の構想"));
380 assert!(html.contains("一覧へ戻る"));
381 assert!(html.contains(LIKE_BUTTON_ID));
382 }
383
384 #[test]
385 fn detail_page_render_handles_missing_item() {
386 let html = render(&detail_page(None));
387 assert!(html.contains("見つかりません"));
388 }
389
390 /// PoC-3 成功基準 1(SSG 側): SSG が書き出す文字列は SSR が返す文字列と
391 /// 完全一致すること(同一コードであることの直接証明)。
392 #[test]
393 fn ssg_output_equals_ssr_output_for_list_and_detail() {
394 let items = demo_items();
395 let ssr_list = render(&list_page(&items));
396 let ssg_list = render(&list_page(&items));
397 assert_eq!(ssr_list, ssg_list);
398
399 let item = items.iter().find(|it| it.id == "2");
400 let ssr_detail = render(&detail_page(item));
401 let ssg_detail = render(&detail_page(item));
402 assert_eq!(ssr_detail, ssg_detail);
403 // demo_items()[1] の title は XSS ペイロード。detail_page 経由でも
404 // 既定エスケープされることを確認する。
405 assert!(!ssr_detail.contains("<script>alert"));
406 assert!(ssr_detail.contains("<script>alert"));
407 }
408
409 #[test]
410 fn page_shell_includes_view_transition_at_rule_and_matches_across_ssr_and_ssg() {
411 let items = demo_items();
412 let ssr_doc = page_shell("記事一覧", list_page(&items));
413 let ssg_doc = page_shell("記事一覧", list_page(&items));
414 assert_eq!(ssr_doc, ssg_doc);
415 assert!(ssr_doc.contains("<style>@view-transition { navigation: auto; }</style>"));
416 assert!(ssr_doc.starts_with("<!DOCTYPE html>"));
417 }
418
419 /// `page_shell` の `title` は既定エスケープされ、`<title>` タグ内で
420 /// XSS ペイロードがそのまま解釈されないことを確認する(REQ-1 の
421 /// 三経路目: レイアウト title・詳細/一覧本文に続く page_shell title 経路)。
422 #[test]
423 fn page_shell_escapes_title_to_prevent_xss() {
424 let doc = page_shell("<script>alert('xss')</script>", div(vec![], vec![]));
425 assert!(!doc.contains("<title><script>alert"));
426 assert!(doc.contains("<title><script>alert('xss')</script></title>"));
427 }
428
429 /// `layout` 単体の既定エスケープ回帰(`h1` タイトル経由)。
430 #[test]
431 fn layout_escapes_title_to_prevent_xss() {
432 let html = render(&layout(
433 "<script>alert('xss')</script>",
434 p(vec![], vec![text("body")]),
435 ));
436 assert!(!html.contains("<script>alert"));
437 assert!(html.contains("<script>alert"));
438 }
439
440 /// 設計書 §3.4「型で保証しない範囲」の実行時側: 同一 `Input` を渡した
441 /// `DemoItemsLoader::load` の呼び出し結果が完全一致することを固定する
442 /// (REQ-6 の三モード契約を loader にも適用したことの決定性証明)。
443 #[test]
444 fn demo_items_loader_load_is_deterministic_for_same_input() {
445 let loader = DemoItemsLoader;
446 let first = loader.load(&()).expect("Infallible は必ず Ok");
447 let second = loader.load(&()).expect("Infallible は必ず Ok");
448 assert_eq!(first, second);
449 }
450
451 /// 受け入れ条件 1 の直接証明: loader 経由(`assemble_list_page`)と
452 /// 純関数直呼び(`list_page(&demo_items())`)が完全一致する Node 木を
453 /// 生成する。`Output = Vec<Item>` という `where` 束縛が
454 /// `list_page` の引数型と接続していることを実行結果でも裏付ける。
455 #[test]
456 fn assemble_list_page_matches_direct_list_page_call() {
457 let via_loader =
458 render(&assemble_list_page(&DemoItemsLoader, &()).expect("Infallible は必ず Ok"));
459 let direct = render(&list_page(&demo_items()));
460 assert_eq!(via_loader, direct);
461 }
462
463 /// `assemble_detail_page` の型接続版。存在する id では
464 /// `detail_page(Some(..))` と、存在しない id では `detail_page(None)`
465 /// と同一の Node 木になることを確認する。
466 #[test]
467 fn assemble_detail_page_matches_direct_detail_page_call_for_existing_and_missing_id() {
468 let loader = DemoItemDetailLoader;
469
470 let via_loader =
471 render(&assemble_detail_page(&loader, &"1".to_string()).expect("Infallible は必ず Ok"));
472 let items = demo_items();
473 let direct = render(&detail_page(items.iter().find(|it| it.id == "1")));
474 assert_eq!(via_loader, direct);
475 assert!(via_loader.contains(r#"data-testid="item-detail""#));
476
477 let via_loader_missing = render(
478 &assemble_detail_page(&loader, &"does-not-exist".to_string())
479 .expect("Infallible は必ず Ok"),
480 );
481 assert!(via_loader_missing.contains("見つかりません"));
482 }
483
484 /// loader 経由の XSS 回帰: `demo_items()[1]` の XSS ペイロードが
485 /// `assemble_list_page` / `assemble_detail_page` 経路でも既定エスケープ
486 /// されることを確認する(既存の直接呼び出し経路の XSS 回帰テストを
487 /// 弱体化させず、loader 経路の同等テストを追加する形を取る)。
488 #[test]
489 fn assemble_pages_escape_xss_payload_via_loader_path() {
490 let list_html = render(&assemble_list_page(&DemoItemsLoader, &()).expect("Infallible"));
491 assert!(!list_html.contains("<script>alert"));
492 assert!(list_html.contains("<script>alert"));
493
494 let detail_html = render(
495 &assemble_detail_page(&DemoItemDetailLoader, &"2".to_string()).expect("Infallible"),
496 );
497 assert!(!detail_html.contains("<script>alert"));
498 assert!(detail_html.contains("<script>alert"));
499 }
500}