Skip to main content

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("&lt;script&gt;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("&lt;script&gt;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>&lt;script&gt;alert(&#x27;xss&#x27;)&lt;/script&gt;</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("&lt;script&gt;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("&lt;script&gt;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("&lt;script&gt;alert"));
499    }
500}