Skip to main content

hypen_server/
app.rs

1use std::sync::Arc;
2
3use crate::context::GlobalContext;
4use crate::discovery::ComponentRegistry;
5use crate::module::{create_nested_instance, ModuleBuilder, ModuleDefinition, ModuleInstance};
6use crate::router::HypenRouter;
7use crate::state::State;
8
9/// Top-level application builder and registry.
10///
11/// `HypenApp` is the entry point for building Hypen applications. It provides:
12/// - A shorthand for creating module builders
13/// - A component registry for custom components
14/// - A global context and router
15/// - Route-based module management
16///
17/// # Example
18///
19/// ```rust,ignore
20/// use hypen_server::prelude::*;
21/// use serde::{Deserialize, Serialize};
22///
23/// #[derive(Clone, Default, Serialize, Deserialize)]
24/// struct Home { title: String }
25///
26/// #[derive(Clone, Default, Serialize, Deserialize)]
27/// struct Counter { count: i32 }
28///
29/// let app = HypenApp::builder()
30///     .route("/", HypenApp::module::<Home>("Home")
31///         .state(Home { title: "Welcome".into() })
32///         .ui(r#"Column { Text("@{state.title}") }"#)
33///         .build())
34///     .route("/counter", HypenApp::module::<Counter>("Counter")
35///         .state(Counter { count: 0 })
36///         .ui(r#"Column { Text("@{state.count}") }"#)
37///         .on_action::<()>("increment", |state, _, _| { state.count += 1; })
38///         .build())
39///     .components_dir("./components")
40///     .build();
41/// ```
42pub struct HypenApp {
43    context: Arc<GlobalContext>,
44    router: Arc<HypenRouter>,
45    components: ComponentRegistry,
46    routes: Vec<RouteEntry>,
47}
48
49/// A route entry mapping a path pattern to a module name.
50struct RouteEntry {
51    pattern: String,
52    module_name: String,
53}
54
55impl HypenApp {
56    pub fn builder() -> HypenAppBuilder {
57        HypenAppBuilder {
58            components: ComponentRegistry::new(),
59            routes: Vec::new(),
60            components_dirs: Vec::new(),
61        }
62    }
63
64    /// Shorthand to start building a module definition.
65    ///
66    /// ```rust,ignore
67    /// let module = HypenApp::module::<MyState>("MyModule")
68    ///     .state(MyState::default())
69    ///     .on_action::<()>("do_thing", |state, _, _ctx| { /* ... */ })
70    ///     .build();
71    /// ```
72    pub fn module<S: State>(name: impl Into<String>) -> ModuleBuilder<S> {
73        ModuleBuilder::new(name)
74    }
75
76    /// Access the global context.
77    pub fn context(&self) -> &GlobalContext {
78        &self.context
79    }
80
81    /// Access the router.
82    pub fn router(&self) -> &HypenRouter {
83        &self.router
84    }
85
86    /// Access the component registry.
87    pub fn components(&self) -> &ComponentRegistry {
88        &self.components
89    }
90
91    /// Instantiate a module by its definition, connecting it to this app's context.
92    pub fn instantiate<S: State>(
93        &self,
94        definition: Arc<ModuleDefinition<S>>,
95    ) -> crate::error::Result<ModuleInstance<S>> {
96        ModuleInstance::new(definition, Some(Arc::clone(&self.context)))
97    }
98
99    /// Instantiate a module as a nested child, auto-registering it in the global context.
100    ///
101    /// The module's state is registered in the `GlobalContext` under its lowercase name,
102    /// and the instance is automatically mounted.
103    ///
104    /// # Example
105    ///
106    /// ```rust,ignore
107    /// let app = HypenApp::default();
108    /// let def = Arc::new(HypenApp::module::<MyState>("Feed")
109    ///     .state(MyState::default())
110    ///     .build());
111    ///
112    /// let instance = app.instantiate_nested(def).unwrap();
113    /// assert!(app.context().has_module("feed"));
114    /// ```
115    pub fn instantiate_nested<S: State>(
116        &self,
117        definition: Arc<ModuleDefinition<S>>,
118    ) -> crate::error::Result<ModuleInstance<S>> {
119        create_nested_instance(definition, Arc::clone(&self.context))
120    }
121
122    /// Instantiate a module with access to the app's component registry.
123    ///
124    /// This enables the module's UI template to reference custom components
125    /// (e.g., `Column { Card {} }`) registered in the app.
126    pub fn instantiate_with_components<S: State>(
127        &self,
128        definition: Arc<ModuleDefinition<S>>,
129    ) -> crate::error::Result<ModuleInstance<S>> {
130        ModuleInstance::new_with_components(
131            definition,
132            Some(Arc::clone(&self.context)),
133            &self.components,
134        )
135    }
136
137    /// Navigate to a route.
138    pub fn navigate(&self, path: &str) {
139        self.router.push(path);
140    }
141
142    /// Get the active route pattern match for a given path.
143    pub fn match_route(&self, path: &str) -> Option<(&str, &str)> {
144        for entry in &self.routes {
145            if self.router.match_path(&entry.pattern, path).is_some() {
146                return Some((&entry.pattern, &entry.module_name));
147            }
148        }
149        None
150    }
151}
152
153impl Default for HypenApp {
154    fn default() -> Self {
155        let context = Arc::new(GlobalContext::new());
156        let router = Arc::new(HypenRouter::new());
157        context.set_router(Arc::clone(&router));
158        Self {
159            context,
160            router,
161            components: ComponentRegistry::new(),
162            routes: Vec::new(),
163        }
164    }
165}
166
167/// Builder for `HypenApp`.
168pub struct HypenAppBuilder {
169    components: ComponentRegistry,
170    routes: Vec<RouteEntry>,
171    components_dirs: Vec<String>,
172}
173
174impl HypenAppBuilder {
175    /// Add a route mapping a URL pattern to a module definition.
176    ///
177    /// The module name is used to identify which module to mount when
178    /// the route matches.
179    pub fn route<S: State>(
180        mut self,
181        pattern: impl Into<String>,
182        definition: ModuleDefinition<S>,
183    ) -> Self {
184        let pattern = pattern.into();
185        let name = definition.name().to_string();
186        self.routes.push(RouteEntry {
187            pattern,
188            module_name: name,
189        });
190        // Store the definition's UI source in the component registry if present
191        if let Some(source) = definition.ui_source() {
192            self.components.register(definition.name(), source, None);
193        }
194        self
195    }
196
197    /// Register a component from inline DSL source.
198    pub fn component(mut self, name: impl Into<String>, source: impl Into<String>) -> Self {
199        self.components.register(name, source, None);
200        self
201    }
202
203    /// Load components from a directory of `.hypen` files.
204    pub fn components_dir(mut self, dir: impl Into<String>) -> Self {
205        self.components_dirs.push(dir.into());
206        self
207    }
208
209    /// Build the `HypenApp`.
210    pub fn build(mut self) -> HypenApp {
211        // Load component directories
212        for dir in &self.components_dirs {
213            // Best-effort: log errors but don't fail the build
214            if let Err(e) = self.components.load_dir(dir) {
215                eprintln!("[hypen-server] Warning: failed to load components from {dir}: {e}");
216            }
217        }
218
219        let context = Arc::new(GlobalContext::new());
220        let router = Arc::new(HypenRouter::new());
221        context.set_router(Arc::clone(&router));
222
223        HypenApp {
224            context,
225            router,
226            components: self.components,
227            routes: self.routes,
228        }
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use serde::{Deserialize, Serialize};
236
237    #[derive(Clone, Default, Serialize, Deserialize)]
238    struct TestState {
239        value: i32,
240    }
241
242    #[test]
243    fn test_module_shorthand() {
244        let def = HypenApp::module::<TestState>("MyModule")
245            .state(TestState { value: 42 })
246            .on_action::<()>("inc", |state, _, _| {
247                state.value += 1;
248            })
249            .build();
250
251        assert_eq!(def.name(), "MyModule");
252    }
253
254    #[test]
255    fn test_app_builder() {
256        let app = HypenApp::builder()
257            .component("Button", r#"Button { Text("Click") }"#)
258            .build();
259
260        assert!(app.components().has("Button"));
261    }
262
263    #[test]
264    fn test_app_with_routes() {
265        let app = HypenApp::builder()
266            .route(
267                "/",
268                HypenApp::module::<TestState>("Home")
269                    .state(TestState::default())
270                    .build(),
271            )
272            .route(
273                "/about",
274                HypenApp::module::<TestState>("About")
275                    .state(TestState::default())
276                    .build(),
277            )
278            .build();
279
280        let (_, module) = app.match_route("/").unwrap();
281        assert_eq!(module, "Home");
282
283        let (_, module) = app.match_route("/about").unwrap();
284        assert_eq!(module, "About");
285
286        assert!(app.match_route("/nonexistent").is_none());
287    }
288
289    #[test]
290    fn test_app_navigate() {
291        let app = HypenApp::default();
292        app.navigate("/users/42");
293        assert_eq!(app.router().current_path(), "/users/42");
294    }
295
296    #[test]
297    fn test_components_dir() {
298        let dir = std::env::temp_dir().join("hypen_test_app_components");
299        let _ = std::fs::remove_dir_all(&dir);
300        std::fs::create_dir_all(&dir).unwrap();
301
302        std::fs::write(dir.join("my-widget.hypen"), r#"Column { Text("Widget") }"#).unwrap();
303
304        let app = HypenApp::builder()
305            .components_dir(dir.to_str().unwrap())
306            .build();
307
308        assert!(app.components().has("MyWidget"));
309
310        let _ = std::fs::remove_dir_all(&dir);
311    }
312
313    #[test]
314    fn test_instantiate_module() {
315        let app = HypenApp::default();
316
317        let def = HypenApp::module::<TestState>("Test")
318            .state(TestState { value: 10 })
319            .on_action::<()>("double", |state, _, _| {
320                state.value *= 2;
321            })
322            .build();
323
324        let instance = app.instantiate(Arc::new(def)).unwrap();
325        instance.mount();
326        assert_eq!(instance.get_state().value, 10);
327
328        instance.dispatch_action("double", None).unwrap();
329        assert_eq!(instance.get_state().value, 20);
330    }
331}