kotoba_server/frontend/
route_ir.rs

1//! App RouterルーティングIR定義
2//!
3//! Next.js App RouterのファイルベースルーティングをKotoba IRで表現します。
4
5use kotoba_core::prelude::*;
6use crate::frontend::component_ir::{ComponentIR, ComponentType};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10/// ルートタイプ
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12pub enum RouteType {
13    /// 静的ルート (/about)
14    Static,
15    /// 動的ルート (/users/[id])
16    Dynamic,
17    /// キャッチオールルート (/docs/[...slug])
18    CatchAll,
19    /// オプションキャッチオール (/docs/[[...slug]])
20    OptionalCatchAll,
21    /// ルートグループ ((group))
22    Group,
23    /// 並列ルート (@parallel)
24    Parallel,
25    /// インターセプトされたルート (..)(..)
26    Intercept,
27}
28
29/// ルートセグメント
30#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
31pub struct RouteSegment {
32    pub name: String,
33    pub segment_type: RouteType,
34    pub params: Vec<String>, // 動的パラメータ名
35    pub is_optional: bool,
36}
37
38impl RouteSegment {
39    pub fn static_segment(name: String) -> Self {
40        Self {
41            name,
42            segment_type: RouteType::Static,
43            params: Vec::new(),
44            is_optional: false,
45        }
46    }
47
48    pub fn dynamic_segment(param: String) -> Self {
49        Self {
50            name: format!("[{}]", param),
51            segment_type: RouteType::Dynamic,
52            params: vec![param],
53            is_optional: false,
54        }
55    }
56
57    pub fn catch_all_segment(param: String) -> Self {
58        Self {
59            name: format!("[...{}]", param),
60            segment_type: RouteType::CatchAll,
61            params: vec![param],
62            is_optional: false,
63        }
64    }
65
66    pub fn optional_catch_all_segment(param: String) -> Self {
67        Self {
68            name: format!("[[...{}]]", param),
69            segment_type: RouteType::OptionalCatchAll,
70            params: vec![param],
71            is_optional: true,
72        }
73    }
74}
75
76/// ルートIR
77#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
78pub struct RouteIR {
79    pub path: String,
80    pub segments: Vec<RouteSegment>,
81    pub components: RouteComponents,
82    pub metadata: RouteMetadata,
83    pub children: Vec<RouteIR>, // ネストされたルート
84}
85
86#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
87pub struct RouteComponents {
88    pub page: Option<ComponentIR>,
89    pub layout: Option<ComponentIR>,
90    pub loading: Option<ComponentIR>,
91    pub error: Option<ComponentIR>,
92    pub template: Option<ComponentIR>,
93    pub not_found: Option<ComponentIR>,
94}
95
96impl RouteComponents {
97    pub fn new() -> Self {
98        Self {
99            page: None,
100            layout: None,
101            loading: None,
102            error: None,
103            template: None,
104            not_found: None,
105        }
106    }
107}
108
109#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
110pub struct RouteMetadata {
111    pub title: Option<String>,
112    pub description: Option<String>,
113    pub keywords: Vec<String>,
114    pub open_graph: Option<OpenGraphMetadata>,
115    pub twitter_card: Option<TwitterCardMetadata>,
116    pub canonical_url: Option<String>,
117    pub robots: Option<RobotsMetadata>,
118}
119
120#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
121pub struct OpenGraphMetadata {
122    pub title: Option<String>,
123    pub description: Option<String>,
124    pub image: Option<String>,
125    pub url: Option<String>,
126    pub type_: Option<String>, // "website", "article", etc.
127}
128
129#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
130pub struct TwitterCardMetadata {
131    pub card: Option<String>, // "summary", "summary_large_image", etc.
132    pub title: Option<String>,
133    pub description: Option<String>,
134    pub image: Option<String>,
135}
136
137#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
138pub struct RobotsMetadata {
139    pub index: bool,
140    pub follow: bool,
141    pub noarchive: bool,
142    pub nosnippet: bool,
143    pub noimageindex: bool,
144    pub nocache: bool,
145}
146
147impl RouteIR {
148    pub fn new(path: String) -> Self {
149        Self {
150            path: path.clone(),
151            segments: Self::parse_path_segments(&path),
152            components: RouteComponents::new(),
153            metadata: RouteMetadata {
154                title: None,
155                description: None,
156                keywords: Vec::new(),
157                open_graph: None,
158                twitter_card: None,
159                canonical_url: None,
160                robots: None,
161            },
162            children: Vec::new(),
163        }
164    }
165
166    /// パスをセグメントにパース
167    fn parse_path_segments(path: &str) -> Vec<RouteSegment> {
168        path.trim_matches('/')
169            .split('/')
170            .filter(|s| !s.is_empty())
171            .map(|segment| {
172                if segment.starts_with("[[") && segment.ends_with("]]") {
173                    // オプションキャッチオール [[...slug]]
174                    let param = segment.strip_prefix("[[").unwrap()
175                        .strip_suffix("]]").unwrap()
176                        .strip_prefix("...").unwrap();
177                    RouteSegment::optional_catch_all_segment(param.to_string())
178                } else if segment.starts_with("[...") && segment.ends_with("]") {
179                    // キャッチオール [...slug]
180                    let param = segment.strip_prefix("[...").unwrap()
181                        .strip_suffix("]").unwrap();
182                    RouteSegment::catch_all_segment(param.to_string())
183                } else if segment.starts_with('[') && segment.ends_with(']') {
184                    // 動的 [id]
185                    let param = segment.strip_prefix('[').unwrap()
186                        .strip_suffix(']').unwrap();
187                    RouteSegment::dynamic_segment(param.to_string())
188                } else if segment.starts_with('(') && segment.ends_with(')') {
189                    // ルートグループ (group) - 実際のルーティングには影響しない
190                    RouteSegment {
191                        name: segment.to_string(),
192                        segment_type: RouteType::Group,
193                        params: Vec::new(),
194                        is_optional: false,
195                    }
196                } else if segment.starts_with('@') {
197                    // 並列ルート @parallel
198                    RouteSegment {
199                        name: segment.to_string(),
200                        segment_type: RouteType::Parallel,
201                        params: Vec::new(),
202                        is_optional: false,
203                    }
204                } else {
205                    // 静的セグメント
206                    RouteSegment::static_segment(segment.to_string())
207                }
208            })
209            .collect()
210    }
211
212    /// ページコンポーネントを設定
213    pub fn set_page(&mut self, component: ComponentIR) {
214        self.components.page = Some(component);
215    }
216
217    /// レイアウトコンポーネントを設定
218    pub fn set_layout(&mut self, component: ComponentIR) {
219        self.components.layout = Some(component);
220    }
221
222    /// ローディングコンポーネントを設定
223    pub fn set_loading(&mut self, component: ComponentIR) {
224        self.components.loading = Some(component);
225    }
226
227    /// エラーコンポーネントを設定
228    pub fn set_error(&mut self, component: ComponentIR) {
229        self.components.error = Some(component);
230    }
231
232    /// 子ルートを追加
233    pub fn add_child(&mut self, child: RouteIR) {
234        self.children.push(child);
235    }
236
237    /// パスがこのルートにマッチするかチェック
238    pub fn matches_path(&self, request_path: &str) -> Option<HashMap<String, String>> {
239        let request_segments: Vec<&str> = request_path.trim_matches('/')
240            .split('/')
241            .filter(|s| !s.is_empty())
242            .collect();
243
244        if self.segments.len() != request_segments.len() {
245            // 長さが違う場合はマッチしない(ただしオプションキャッチオールの場合を除く)
246            if let Some(last_segment) = self.segments.last() {
247                if matches!(last_segment.segment_type, RouteType::OptionalCatchAll) && request_segments.len() < self.segments.len() {
248                    // オプションキャッチオールの場合、長さが短くてもOK
249                } else {
250                    return None;
251                }
252            } else {
253                return None;
254            }
255        }
256
257        let mut params = HashMap::new();
258
259        for (i, segment) in self.segments.iter().enumerate() {
260            let request_segment = request_segments.get(i);
261
262            match segment.segment_type {
263                RouteType::Static => {
264                    if request_segment != Some(&segment.name.as_str()) {
265                        return None;
266                    }
267                }
268                RouteType::Dynamic => {
269                    if let Some(param_name) = segment.params.first() {
270                        if let Some(value) = request_segment {
271                            params.insert(param_name.clone(), value.to_string());
272                        } else {
273                            return None;
274                        }
275                    }
276                }
277                RouteType::CatchAll => {
278                    if let Some(param_name) = segment.params.first() {
279                        let remaining_segments: Vec<String> = request_segments[i..].iter()
280                            .map(|s| s.to_string())
281                            .collect();
282                        params.insert(param_name.clone(), remaining_segments.join("/"));
283                        break; // キャッチオールは残りを全て消費
284                    }
285                }
286                RouteType::OptionalCatchAll => {
287                    if let Some(param_name) = segment.params.first() {
288                        if i < request_segments.len() {
289                            let remaining_segments: Vec<String> = request_segments[i..].iter()
290                                .map(|s| s.to_string())
291                                .collect();
292                            params.insert(param_name.clone(), remaining_segments.join("/"));
293                        } else {
294                            params.insert(param_name.clone(), "".to_string());
295                        }
296                        break;
297                    }
298                }
299                RouteType::Group | RouteType::Parallel | RouteType::Intercept => {
300                    // これらのセグメントはルーティングに影響しない
301                    continue;
302                }
303            }
304        }
305
306        Some(params)
307    }
308}
309
310/// ルーティングテーブルIR
311#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
312pub struct RouteTableIR {
313    pub routes: Vec<RouteIR>,
314    pub base_path: String,
315    pub middleware: Vec<ComponentIR>, // グローバルミドルウェア
316}
317
318impl RouteTableIR {
319    pub fn new() -> Self {
320        Self {
321            routes: Vec::new(),
322            base_path: "/".to_string(),
323            middleware: Vec::new(),
324        }
325    }
326
327    /// ルートを追加
328    pub fn add_route(&mut self, route: RouteIR) {
329        self.routes.push(route);
330    }
331
332    /// ミドルウェアを追加
333    pub fn add_middleware(&mut self, middleware: ComponentIR) {
334        self.middleware.push(middleware);
335    }
336
337    /// パスにマッチするルートを検索
338    pub fn find_route(&self, path: &str) -> Option<(&RouteIR, HashMap<String, String>)> {
339        for route in &self.routes {
340            if let Some(params) = route.matches_path(path) {
341                return Some((route, params));
342            }
343        }
344        None
345    }
346
347    /// ネストされたルート構造を構築
348    pub fn build_nested_routes(&mut self) {
349        // TODO: ファイルシステム構造からネストされたルートを構築
350        // app/ ディレクトリの構造を解析してRouteIRツリーを構築
351    }
352}
353
354/// ナビゲーションIR
355#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
356pub struct NavigationIR {
357    pub from_path: String,
358    pub to_path: String,
359    pub navigation_type: NavigationType,
360    pub state: Properties,
361    pub replace: bool,
362}
363
364#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
365pub enum NavigationType {
366    Push,
367    Replace,
368    Back,
369    Forward,
370    Reload,
371}
372
373impl NavigationIR {
374    pub fn new(from_path: String, to_path: String) -> Self {
375        Self {
376            from_path,
377            to_path,
378            navigation_type: NavigationType::Push,
379            state: Properties::new(),
380            replace: false,
381        }
382    }
383
384    pub fn replace(mut self, replace: bool) -> Self {
385        self.replace = replace;
386        self
387    }
388
389    pub fn with_state(mut self, state: Properties) -> Self {
390        self.state = state;
391        self
392    }
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    #[test]
400    fn test_route_parsing() {
401        // 静的ルート
402        let route = RouteIR::new("/about".to_string());
403        assert_eq!(route.segments.len(), 1);
404        assert_eq!(route.segments[0].name, "about");
405        assert_eq!(route.segments[0].segment_type, RouteType::Static);
406
407        // 動的ルート
408        let route = RouteIR::new("/users/[id]".to_string());
409        assert_eq!(route.segments.len(), 2);
410        assert_eq!(route.segments[1].segment_type, RouteType::Dynamic);
411        assert_eq!(route.segments[1].params, vec!["id"]);
412
413        // キャッチオールルート
414        let route = RouteIR::new("/docs/[...slug]".to_string());
415        assert_eq!(route.segments[1].segment_type, RouteType::CatchAll);
416        assert_eq!(route.segments[1].params, vec!["slug"]);
417    }
418
419    #[test]
420    fn test_route_matching() {
421        let route = RouteIR::new("/users/[id]".to_string());
422
423        // マッチするパス
424        let params = route.matches_path("/users/123").unwrap();
425        assert_eq!(params.get("id"), Some(&"123".to_string()));
426
427        // マッチしないパス
428        assert!(route.matches_path("/users").is_none());
429        assert!(route.matches_path("/users/123/profile").is_none());
430
431        // キャッチオールルート
432        let catch_all_route = RouteIR::new("/docs/[...slug]".to_string());
433        let params = catch_all_route.matches_path("/docs/getting-started/installation").unwrap();
434        assert_eq!(params.get("slug"), Some(&"getting-started/installation".to_string()));
435    }
436
437    #[test]
438    fn test_route_table() {
439        let mut table = RouteTableIR::new();
440
441        let mut route1 = RouteIR::new("/".to_string());
442        let page1 = ComponentIR::new("HomePage".to_string(), ComponentType::Page);
443        route1.set_page(page1);
444        table.add_route(route1);
445
446        let mut route2 = RouteIR::new("/about".to_string());
447        let page2 = ComponentIR::new("AboutPage".to_string(), ComponentType::Page);
448        route2.set_page(page2);
449        table.add_route(route2);
450
451        // ルート検索
452        let (route, params) = table.find_route("/about").unwrap();
453        assert_eq!(route.path, "/about");
454        assert!(params.is_empty());
455
456        assert!(table.find_route("/nonexistent").is_none());
457    }
458}