Skip to content
fandhe-frontend
GitHub

コンポーネント記述 API 設計確定(TASK-5.1a)

1. 目的とトレーサビリティ

本ドキュメントは REQ-5「独自 DSL に依存しないプレーン Rust コンポーネント記述」 (docs/spec/04-requirements.md REQ-5 節)が求める「マクロ DSL を使わず、通常の Rust 関数・enum でノード木を組み立てる」記述方式について、公開 API の表面・命名・型・ セキュリティ不変条件を設計として確定するための成果物です。

docs/spec/05-tasks.md の TASK-5.1(#29)は本タスクを含め 3 段階に分割されています。

  • TASK-5.1a(本ドキュメント・#30): コンポーネント記述 API の設計確定
  • TASK-5.1b(#31): 本書に従った公開 API 実装(タグショートカット追加)と rustdoc 整備
  • TASK-5.1c(#32): 利用者向けチュートリアル docs/guides/component-authoring.md の作成

本文書のステータス: TASK-5.1a 確定版。TASK-5.1b は本書の設計に従って実装し、 実装と本書の記述に乖離が生じた場合は本書を正として PR レビューで指摘する。 TASK-5.1c(利用者向けチュートリアル)は本書とはファイル・役割を分ける (本書は設計判断の記録、docs/guides/component-authoring.md は使い方の解説)。

本書は docs/policy/dependency-graph-policy.md(TASK-3.3a)と同じく docs/ 直下のフラット 配置とし、「本文書のステータス」「トレーサビリティ」を明記する形式に揃える。

本タスクのスコープ: 設計確定書の作成のみ(docs-only 変更)。crates/core/src/lib.rs の コード変更は TASK-5.1b(#31)のスコープであり、本タスクでは行わない。 docs/spec/ はサブモジュールのため編集禁止(変更が必要な場合は fandhe-frontend-spec リポジトリで行う)。

2. 確定 API 表面(凍結表)

現行 crates/core/src/lib.rs(TASK-1.1 系で実装済み)のシグネチャをそのまま標準 API として 凍結する。TASK-5.1b はこれらのシグネチャを変更せず、タグショートカットのみを追加する。

項目シグネチャ役割
Nodeenum Node { Element { tag: &'static str, attrs: Vec<(String, String)>, children: Vec<Node> }, Text(String), RawHtml(String) }HTML ノード木の値表現
elfn el(tag: &'static str, attrs: Vec<(&str, &str)>, children: Vec<Node>) -> Node要素ノードを組み立てる
textfn text(s: impl Into<String>) -> Nodeテキストノードを組み立てる(既定エスケープ対象)
raw_htmlfn raw_html(s: impl Into<String>) -> Node生 HTML ノードを組み立てる(唯一の明示的オプトイン)
renderfn render(node: &Node) -> Stringノード木を HTML 文字列へレンダリングする(SSR/SSG/CSR 共通)
escape_html / escape_html_intoescape モジュール re-exportHTML エンティティエスケープの実装
json_ldfn json_ld(json: impl Into<String>) -> Node(イシュー #1117 で追加)JSON-LD(構造化データ)を安全に <script type="application/ld+json"> として埋め込む正規 API。既存シグネチャは変更せず追加のみ(bind/keyed 系と同型の凍結表拡張、#164/#342/#344 の先例)
el_ownedfn el_owned(tag: &'static str, attrs: Vec<(String, String)>, children: Vec<Node>) -> Node(イシュー #1121 で追加)el の所有属性値版。動的な属性値・attr_if/attr_if_value との合成に向く。エスケープ・属性名ホワイトリスト・タグ名固定は el と完全に共有し、既存シグネチャは変更しない(追加のみ)
attr_if / attr_if_valuefn attr_if(cond: bool, name: &str) -> Option<(String, String)> / fn attr_if_value(cond: bool, name: &str, value: impl Into<String>) -> Option<(String, String)>(イシュー #1121 で追加)条件付き属性を組み立てる。condfalse のとき None を返し、el_ownedattrsflatten() で合成すると当該属性が出力から欠落する

コンポーネント記述の標準規約: コンポーネントは「Node を返す通常の Rust 関数」 として記述する(fn list_page() -> Node { ... } 形式、PoC-3 の fandhe-frontend-app 実績)。 マクロ・トレイト実装・特別な戻り値型は要求しない。関数の引数・戻り値は通常の Rust の型検査を受けるため、コンパイルエラーはマクロ展開後のコードではなく、利用者が 書いた関数そのものを指す(REQ-5 受け入れ基準 3 点目、第 7 節参照)。

3. 設計判断と根拠

#判断根拠
1マクロ DSL(view!/rsx!/html! 相当)を提供しないPoC-1 が特定した差別化空白 D。PoC-2 の依存グラフ実測(マクロ DSL 構成 202 件/深さ14 → 純 Rust 構成 52 件/深さ5、docs/policy/dependency-graph-policy.md 第 2 節)。REQ-5 概要・詳細
2タグ名は tag: &'static str に固定し、かつ出力前に is_valid_tag_name ホワイトリスト検証を行う多層防御を維持する&'static str は値の有効期間のみを保証し文字内容は保証しないため(Box::leak によるタグ名注入が型検査をすり抜け得る、PR #166 Bugbot 指摘)。crates/core/src/lib.rs 不変条件 5
3属性名はホワイトリスト検証(英数字・-_: のみ許可)し、不正な属性名は panic させず出力からスキップする属性名スロット経由の注入(追加属性の割り込み)を遮断するため。crates/core/src/lib.rs 不変条件 4、ライブラリコードでの panic 回避規約(.claude/rules/coding-rust.md
4void 要素(HTML Standard 13.1.2 の 13 要素: area/base/br/col/embed/hr/img/input/link/meta/source/track/wbr)は start tag のみで自己終端し、終了タグを出力しない(<br> であって <br></br> ではない)。渡した children は一切出力されないイシュー #1139(v1 の「常に終了タグを出力する」凍結仕様からの破壊的変更)。</br> が HTML パーサーに 2 個目の <br> として解釈される SSR/ハイドレーション DOM 乖離を解消する。詳細は docs/design/void-element-serialization.md
5大文字を含むタグ名(例: DIV)はそのまま出力する現行挙動を凍結するis_valid_tag_nameis_ascii_alphabetic/is_ascii_alphanumeric で判定するため許可される。HTML の小文字化はフレームワークの責務外という現状の実装どおりの挙動を明文化する
6JSON-LD 埋め込み(json_ld)は、シリアライズ済み JSON 文字列を受け取り < > &・U+2028/U+2029 のみを \uXXXX へ中立化してから内部で raw_html を呼ぶ wrapper 方式とする(イシュー #1117)<script> は raw text 要素で実体参照が復号されないため既定エスケープをそのまま適用すると JSON が壊れる。妥当な JSON では上記の文字はリテラル内にしか出現し得ないため全置換は意味保存(JSON.parse 結果を変えない)。JSON バリデーションは行わない fail-safe 設計とし、不正入力でも出力に生の < > & が残らないことをテストで固定する。json_ldraw_html の唯一のフレームワーク内呼び出し元として審査済みとし、利用者コード側に個別の #[expect(clippy::disallowed_methods)] を書かせない(docs/policy/raw-html-review-gate.md の正規書式に従う)

4. タグショートカットの方針(TASK-5.1b が実装する範囲)

TASK-5.1b で追加する標準タグショートカットは、PoC-3(docs/spec/03-poc/rendering-web-standards/core/src/lib.rs) で実証済みの以下の最小セットに限定する。

div, p, ul, li, a, h1, main_tag("main" タグへの薄い委譲)

定義規則(すべてのショートカットが満たすべき制約):

  1. シグネチャは fn <name>(attrs: Vec<(&str, &str)>, children: Vec<Node>) -> Node とし、 本体は el("<tag>", attrs, children) への薄い委譲のみとする。
  2. 独自のエスケープ経路・独自の raw 出力を一切持たない(el を経由する以上、 第 2 節の凍結 API がそのまま適用され、既定エスケープの迂回経路が新たに増えることはない)。
  3. タグ名はショートカット関数内で &'static str リテラルとして固定し、 el に渡す(is_valid_tag_name 検証は el/render 側の責務のまま変更しない)。
  4. main は Rust の予約語ではないが可読性のため main_tag の名前を用いる (PoC-3 の命名をそのまま踏襲する)。

本タスクのスコープ外とする範囲: 上記 7 個を超える網羅的なタグヘルパー群(例: span/img/table/form 等)およびノード木のインデント・整形規約の充実は、 既存 backlog Issue #164(「ノード木記述の可読性向上(ヘルパー関数・インデント規約)」) のスコープとする。TASK-5.1b は上記最小セットのみを実装し、#164 との二重実装を避ける。

5. スコープ外の明記

以下は本設計・TASK-5.1 系列全体のスコープ外とし、後続タスクへ引き継ぐ。

項目引き継ぎ先
ハイドレーション支援 API(find_attr_values/find_nav_targetsTASK-6.2 系
状態管理(fandhe-frontend-interactive クレート)別クレート(TASK-5.1 系の対象外)
イベントハンドラ APIWASM 層(fandhe-frontend-wasm-client/fandhe-frontend-wasm-full)のタスク
網羅的タグヘルパー群・インデント規約Issue #164
void 要素の自己終了出力イシュー #1139 で実装済み(第 3 節・判断 4、第 8 節参照)

6. セキュリティ不変条件の引き継ぎ

crates/core/src/lib.rs 冒頭に記載された不変条件 1〜7(REQ-1・REQ-2 の直接根拠)を、 本設計が確定する API 拡張(タグショートカット追加を含む)に対する制約として そのまま再掲・固定する。

  1. Node::Text の内容・Element の属性値は render() 内で必ず escape_html/escape_html_into を経由して出力する。
  2. エスケープを迂回できる経路は Node::RawHtml(コンストラクタ raw_html)のみとする。 タグショートカットを含むいかなる API 拡張も、新たなエスケープ迂回経路を作らない (第 4 節・定義規則 2)。
  3. format!("<div>{}</div>", user_input) のような HTML 文字列の直接組み立てを 内部にも作らない。
  4. 属性名はホワイトリスト検証を行い、不正な属性名は panic させず出力からスキップする。
  5. タグ名は &'static str に限定し、かつ出力前にホワイトリスト検証(is_valid_tag_name) も行う多層防御とする。
  6. #![forbid(unsafe_code)] によりクレート全体で unsafe を機械的に禁止する。
  7. crates/core/Cargo.toml[dependencies] は常に空を維持する(外部依存ゼロ)。 依存クレートの追加は事前に cargo metadata で影響を確認し、ユーザー承認を得る (.claude/rules/coding-rust.md)。標準サーバー構成での依存パッケージ上限 60 件・深さ 6 の制約(docs/policy/dependency-graph-policy.md)も維持する。
  8. (イシュー #373) href/srcURL_ATTRS に該当する属性の値は fandhe_frontend_core::is_safe_url の許可スキーム検証を通過したものだけを出力する。 不合格の値(javascript: 等)は属性ごと出力からスキップする(fail-closed)。 on* で始まるイベントハンドラ属性は値によらず一律出力しない。詳細な脅威 整理・許可リストの正は docs/policy/attribute-output-policy.md を参照する。
  9. (イシュー #1117) json_ld< > &・U+2028/U+2029 を \uXXXX へ 中立化した JSON のみを raw_html へ渡す審査済み wrapper であり、 raw_html の新たな迂回経路の追加ではない(不変条件 2 を破らない)。 中立化後の出力には生の < > & が一切残らず、</script> による script 要素の早期終了・<!-- コメント注入が構造的に不可能であることをテストで 固定する。

これらは「設計制約」であり、TASK-5.1b の実装レビューではこの一覧との整合を確認する。

7. REQ-5 受け入れ基準との対応表

REQ-5 受け入れ基準満たす API 特性検証タスク
標準のコンポーネント記述方式が、手続きマクロを経由しない通常の Rust コードで完結することNode/el/text/raw_html/render はすべて素の Rust 関数・enum(第 2 節)。crates/core/Cargo.toml に proc-macro 依存を追加しない(第 6 節・不変条件 7)TASK-5.3(コンパイルエラー品質の定性レビュー)
生成される HTML が、data-* 以外にフレームワーク固有のカスタム要素・不透明なマーカーを含まないことタグショートカットは el() への薄い委譲のみで独自マーカーを出力しない(第 4 節・定義規則)TASK-5.2(生成 HTML の「素直さ」検証、crates/core/tests/plain_html_output.rs
コンパイルエラーが、マクロ展開後のコードを指す読みにくいメッセージではなく通常の Rust の型エラーとして表示されることコンポーネントは通常の Rust 関数であり、el/text/raw_html/タグショートカットの引数・戻り値はマクロ展開を経ない通常の型検査を受ける(第 2 節)TASK-5.3(人間によるコンパイルエラー品質の定性レビュー)

8. 設計書内のコード例

以下は第 2 節で凍結した既存 API のみを用いたコード例であり、crates/core/src/lib.rs の doctest(el/text/raw_html/render# Examples)と同一のシグネチャ・出力になる ことを照合済みである。

use fandhe_frontend_core::{el, text, render};

let node = el("p", vec![("class", "greeting")], vec![text("hello")]);
assert_eq!(render(&node), r#"<p class="greeting">hello</p>"#);
use fandhe_frontend_core::{el, text, render};

// テキストノードは既定でエスケープされる(REQ-1 の中核)。
let node = el("p", vec![], vec![text("<script>alert(1)</script>")]);
assert_eq!(render(&node), "<p>&lt;script&gt;alert(1)&lt;/script&gt;</p>");
use fandhe_frontend_core::{el, raw_html, render};

// raw_html は唯一の明示的オプトイン。信頼できる固定文字列のみを渡す。
let node = el("div", vec![], vec![raw_html("<b>bold</b>")]);
assert_eq!(render(&node), "<div><b>bold</b></div>");

JSON-LD 埋め込み(イシュー #1117、第 2・3 節)の例。json_ld はシリアライズ済み JSON 文字列を受け取る(serde_json::to_string 等、利用者側の任意依存として 接続できる。本クレートは外部依存ゼロ契約のため serde_json を依存に追加しない):

use fandhe_frontend_core::{json_ld, render};

let node = json_ld(r#"{"@context":"https://schema.org","@type":"Article","name":"A"}"#);
assert_eq!(
    render(&node),
    r#"<script type="application/ld+json">{"@context":"https://schema.org","@type":"Article","name":"A"}</script>"#
);
use fandhe_frontend_core::json_ld;

let value = serde_json::json!({ "@context": "https://schema.org", "@type": "Article" });
let node = json_ld(serde_json::to_string(&value).expect("serialize"));

9. 追記: 動的値・条件付き属性(el_owned/attr_if/attr_if_value、イシュー #1121)

イシュー #1121 は el()attrs: Vec<(&str, &str)> が、動的な属性値 (format! した値)・条件付き属性(hidden/checked 等、条件によって 属性の有無自体が変わるケース)と相性が悪い(呼び出し元が &str の一時 変数を束縛し続ける必要がある)という指摘を受け、第 2 節の凍結表へ 3 つの 関数を追加した(既存 el/text/raw_html/render のシグネチャは一切 変更しない、追加のみ)。

use fandhe_frontend_core::{el_owned, attr_if, attr_if_value, text, render};

let disabled = false;
let node = el_owned(
    "button",
    vec![("class".to_string(), "btn".to_string())]
        .into_iter()
        .chain(attr_if(disabled, "disabled"))
        .chain(attr_if_value(true, "data-count", "3"))
        .collect(),
    vec![text("送信")],
);
assert_eq!(
    render(&node),
    r#"<button class="btn" data-count="3">送信</button>"#
);
  • el_ownedNode::Element へ属性を素通しするだけの薄いコンストラクタ であり、render() 時のエスケープ・属性名ホワイトリスト検証(第 6 節)・ タグ名 &'static str 固定(不変条件 5)は el と完全に共有する。新たな エスケープ迂回経路は作らない。
  • attr_if/attr_if_valuecondfalse のとき None を返す。 el_ownedattrs.into_iter().flatten().collect()(または .chain(...))で合成すると、条件不成立時はその属性が出力から完全に 欠落する。
  • 利用パターンの詳細は docs/guides/component-authoring.md 第 3.6 節を 参照する。

TASK-5.1b で追加予定のタグショートカット(第 4 節)を用いた場合の想定コード例 (div/pel への薄い委譲のため、出力は el を直接使った場合と完全に一致する):

use fandhe_frontend_core::{div, p, text, render};

let node = div(vec![("class", "card")], vec![p(vec![], vec![text("hello")])]);
assert_eq!(render(&node), r#"<div class="card"><p>hello</p></div>"#);

void 要素の挙動(第 3 節・判断 4 の再掲、イシュー #1139): void 要素(br/img/input 等)に対して 上記と同じ形式でショートカットを追加した場合、start tag のみで自己終端し(例: <br>)、 渡した children は一切出力されない。