Skip to main content

logicaffeine_web/ui/
router.rs

1//! Application routing and navigation.
2//!
3//! Defines all routes for the Logicaffeine web application using the Dioxus
4//! router. Each route maps to a page component in [`crate::ui::pages`].
5//!
6//! # Routes
7//!
8//! | Path | Component | Description |
9//! |------|-----------|-------------|
10//! | `/` | [`Landing`] | Marketing homepage |
11//! | `/learn` | [`Learn`] | Main learning interface with curriculum |
12//! | `/studio?:file` | [`Studio`] | Playground for experimentation |
13//! | `/profile` | [`Profile`] | User settings and progress |
14//! | `/pricing` | [`Pricing`] | Subscription plans |
15//! | `/guide` | [`Guide`] | Documentation and tutorials |
16//! | `/registry?:token&:login&:error&:q` | [`Registry`] | Package browser |
17//! | `/workspace/:subject` | [`Workspace`] | Subject-specific workspace |
18//!
19//! # Navigation
20//!
21//! Use the Dioxus `Link` component with `Route` variants:
22//!
23//! ```no_run
24//! # use dioxus::prelude::*;
25//! use logicaffeine_web::ui::router::Route;
26//!
27//! # fn Example() -> Element {
28//! rsx! {
29//!     Link { to: Route::Learn {}, "Start Learning" }
30//!     Link { to: Route::Studio { file: None }, "Open Studio" }
31//! }
32//! # }
33//! ```
34//!
35//! # Query parameters are part of the route type
36//!
37//! On startup the router parses the browser URL into [`Route`], serializes it
38//! back, and replaces the browser URL with the serialization whenever the two
39//! differ. Any URL state the route type does not model is silently destroyed
40//! before page components can read it. Pages therefore MUST receive query
41//! parameters as route props (declared with `?:name` in the `#[route]`
42//! attribute) — never by scraping the browser location's search string; the
43//! `query_scraping_is_forbidden` lock enforces this.
44
45use dioxus::prelude::*;
46use crate::ui::pages::{Landing, Learn, Pricing, Privacy, Profile, Roadmap, Success, Terms, Workspace, Studio, Guide, Crates, News, NewsArticle, Benchmarks};
47use crate::ui::pages::registry::{Registry, PackageDetail};
48
49/// Application routes.
50///
51/// This enum is derived with `Routable` to generate route matching logic.
52/// Each variant corresponds to a URL pattern and its associated page component.
53#[derive(Clone, Routable, Debug, PartialEq)]
54pub enum Route {
55    /// Marketing homepage at `/`.
56    #[route("/")]
57    Landing {},
58
59    /// Contact and licensing at `/pricing`.
60    #[route("/pricing")]
61    Pricing {},
62
63    /// Privacy policy at `/privacy`.
64    #[route("/privacy")]
65    Privacy {},
66
67    /// Terms of service at `/terms`.
68    #[route("/terms")]
69    Terms {},
70
71    /// Product roadmap at `/roadmap`.
72    #[route("/roadmap")]
73    Roadmap {},
74
75    /// Documentation and tutorials at `/guide`.
76    #[route("/guide")]
77    Guide {},
78
79    /// Crate documentation at `/crates`.
80    #[route("/crates")]
81    Crates {},
82
83    /// Post-checkout success page at `/success`.
84    ///
85    /// Stripe redirects here with a `session_id` query parameter that the page
86    /// exchanges for a license key.
87    #[route("/success?:session_id")]
88    Success {
89        /// Stripe checkout session id from the redirect, if any.
90        session_id: Option<String>,
91    },
92
93    /// Playground for experimentation at `/studio`.
94    ///
95    /// Deep links carry the VFS path of the file to open as a `file` query
96    /// parameter (without the leading slash), e.g.
97    /// `/studio?file=examples/logic/prover-demo.logic`.
98    #[route("/studio?:file")]
99    Studio {
100        /// VFS path of the file to open, if any.
101        file: Option<String>,
102    },
103
104    /// Main learning interface at `/learn`.
105    ///
106    /// All learning happens here - curriculum browsing, exercises, and review.
107    #[route("/learn")]
108    Learn {},
109
110    /// User profile and settings at `/profile`.
111    #[route("/profile")]
112    Profile {},
113
114    /// Subject-specific workspace at `/workspace/:subject`.
115    #[route("/workspace/:subject")]
116    Workspace {
117        /// The workspace subject identifier (e.g., "logic", "proofs").
118        subject: String,
119    },
120
121    /// Package registry browser at `/registry`.
122    ///
123    /// The GitHub OAuth callback redirects here with `token`/`login` on
124    /// success or `error` on failure; `q` pre-fills the package search (the
125    /// SearchAction URL advertised in the JSON-LD).
126    #[route("/registry?:token&:login&:error&:q")]
127    Registry {
128        /// OAuth access token from the callback, if any.
129        token: Option<String>,
130        /// GitHub login name from the callback, if any.
131        login: Option<String>,
132        /// OAuth error message from the callback, if any.
133        error: Option<String>,
134        /// Package search query to pre-fill, if any.
135        q: Option<String>,
136    },
137
138    /// Package detail page at `/registry/package/:name`.
139    #[route("/registry/package/:name")]
140    PackageDetail {
141        /// The package name to display.
142        name: String,
143    },
144
145    /// Performance benchmarks at `/benchmarks`.
146    #[route("/benchmarks")]
147    Benchmarks {},
148
149    /// News index page at `/news`.
150    ///
151    /// An optional `tag` query parameter pre-applies a tag filter.
152    #[route("/news?:tag")]
153    News {
154        /// Tag to filter articles by, if any.
155        tag: Option<String>,
156    },
157
158    /// News article page at `/news/:slug`.
159    #[route("/news/:slug")]
160    NewsArticle {
161        /// The article slug.
162        slug: String,
163    },
164
165    /// Catch-all for unknown routes, renders 404 page.
166    #[route("/:..route")]
167    NotFound {
168        /// Path segments that didn't match any route.
169        route: Vec<String>,
170    },
171}
172
173/// The canonical shareable URL for a studio file.
174///
175/// Built from [`Route::Studio`] itself so link writers can never drift from
176/// what the router parses and re-serializes at boot. Accepts the VFS path
177/// with or without its leading slash.
178pub fn studio_file_url(vfs_path: &str) -> String {
179    Route::Studio {
180        file: Some(vfs_path.trim_start_matches('/').to_string()),
181    }
182    .to_string()
183}
184
185/// Replace the address-bar URL in place — no history entry, no router
186/// navigation. Pages use this to keep the bar on the canonical form of the
187/// page they are showing (e.g. after consuming one-shot query params, or
188/// because route serialization leaves a bare `?` when all params are absent).
189#[cfg(target_arch = "wasm32")]
190pub fn replace_bar_url(url: &str) {
191    if let Some(window) = web_sys::window() {
192        if let Ok(history) = window.history() {
193            let _ = history.replace_state_with_url(&wasm_bindgen::JsValue::NULL, "", Some(url));
194        }
195    }
196}
197
198/// The boot-normalization invariant: on startup the router parses the browser
199/// URL into [`Route`], serializes it back, and *replaces the browser URL* with
200/// the serialization whenever the two differ. Any URL state the route type
201/// does not model is silently destroyed before page components can read it —
202/// these tests lock every query-carrying route to a lossless round-trip.
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use std::str::FromStr;
207
208    #[track_caller]
209    fn roundtrip(url: &str) -> String {
210        let route = Route::from_str(url)
211            .unwrap_or_else(|e| panic!("{url} failed to parse: {e}"));
212        route.to_string()
213    }
214
215    #[test]
216    fn studio_deep_link_survives_roundtrip() {
217        let serialized = roundtrip("/studio?file=examples/logic/prover-demo.logic");
218        assert!(
219            serialized.contains("file=examples/logic/prover-demo.logic"),
220            "studio file param must survive boot normalization, got {serialized}"
221        );
222
223        let route = Route::from_str("/studio?file=examples/logic/prover-demo.logic").unwrap();
224        assert_eq!(
225            route,
226            Route::Studio {
227                file: Some("examples/logic/prover-demo.logic".to_string())
228            }
229        );
230        assert_eq!(
231            Route::from_str("/studio").unwrap(),
232            Route::Studio { file: None },
233            "plain /studio must still parse"
234        );
235    }
236
237    #[test]
238    fn studio_file_url_is_canonical() {
239        let url = studio_file_url("/examples/logic/prover-demo.logic");
240        assert_eq!(url, "/studio?file=examples/logic/prover-demo.logic");
241        assert_eq!(url, studio_file_url("examples/logic/prover-demo.logic"));
242
243        let route = Route::from_str(&url).expect("canonical studio URL parses");
244        assert_eq!(
245            route,
246            Route::Studio {
247                file: Some("examples/logic/prover-demo.logic".to_string())
248            }
249        );
250        assert_eq!(route.to_string(), url, "writer and router agree byte-for-byte");
251    }
252
253    /// The pit-of-success locks over the whole web app source. Read side: no
254    /// page may scrape the query string from the browser location — the
255    /// router destroys unmodeled query params at boot, so scraping is always
256    /// a latent deep-link bug; query state must be declared on the route
257    /// (`?:name`) and received as props. Write side: nobody may hand-build a
258    /// query URL — links come from the `Route` type ([`studio_file_url`],
259    /// `Link { to: Route::… }`) so writers can never drift from what the
260    /// router parses.
261    #[test]
262    fn query_scraping_is_forbidden() {
263        // Needles assembled at runtime so this test's own source never matches.
264        // (needle, files exempt from it)
265        let readers_forbidden_everywhere: &[String] = &[
266            format!("location(){}", ".search()"),
267            format!("location{}", ".search()"),
268            format!("UrlSearch{}", "Params"),
269        ];
270        let writers_allowed_only_in_router: &[String] = &[
271            format!("studio?{}", "file="),
272            format!("news?{}", "tag="),
273            format!("registry?{}", "q="),
274            format!("success?{}", "session_id="),
275        ];
276
277        let src_root = concat!(env!("CARGO_MANIFEST_DIR"), "/src");
278        let mut offenders = Vec::new();
279        let mut stack = vec![std::path::PathBuf::from(src_root)];
280        while let Some(dir) = stack.pop() {
281            for entry in std::fs::read_dir(&dir).expect("readable src dir") {
282                let path = entry.expect("readable dir entry").path();
283                if path.is_dir() {
284                    stack.push(path);
285                    continue;
286                }
287                if !path.extension().is_some_and(|e| e == "rs") {
288                    continue;
289                }
290                let source = std::fs::read_to_string(&path).expect("readable source file");
291                for needle in readers_forbidden_everywhere {
292                    if source.contains(needle.as_str()) {
293                        offenders.push(format!(
294                            "{} scrapes the query string (`{needle}`) — declare the \
295                             parameter on the route (`?:name`) and take it as a prop",
296                            path.display()
297                        ));
298                    }
299                }
300                // router.rs is the one place allowed to spell query-URL shapes
301                // (route definitions, canonical builders, these tests); seo.rs
302                // holds the JSON-LD SearchAction template, whose `{search_term}`
303                // placeholder cannot be built from the Route type.
304                if path.ends_with("router.rs") || path.ends_with("seo.rs") {
305                    continue;
306                }
307                for needle in writers_allowed_only_in_router {
308                    if source.contains(needle.as_str()) {
309                        offenders.push(format!(
310                            "{} hand-builds a query URL (`{needle}`) — build it from \
311                             the Route type (studio_file_url / Link {{ to: Route::… }})",
312                            path.display()
313                        ));
314                    }
315                }
316            }
317        }
318        assert!(
319            offenders.is_empty(),
320            "query-string handling outside the route type:\n{}",
321            offenders.join("\n")
322        );
323    }
324
325    #[test]
326    fn news_tag_survives_roundtrip() {
327        let serialized = roundtrip("/news?tag=releases");
328        assert!(
329            serialized.contains("tag=releases"),
330            "news tag param must survive boot normalization, got {serialized}"
331        );
332    }
333
334    #[test]
335    fn success_session_id_survives_roundtrip() {
336        let serialized = roundtrip("/success?session_id=cs_test_123");
337        assert!(
338            serialized.contains("session_id=cs_test_123"),
339            "Stripe session_id must survive boot normalization, got {serialized}"
340        );
341    }
342
343    #[test]
344    fn registry_oauth_params_survive_roundtrip() {
345        let serialized = roundtrip("/registry?token=t0ken&login=octocat");
346        assert!(
347            serialized.contains("token=t0ken") && serialized.contains("login=octocat"),
348            "registry OAuth params must survive boot normalization, got {serialized}"
349        );
350
351        let serialized = roundtrip("/registry?error=denied");
352        assert!(
353            serialized.contains("error=denied"),
354            "registry OAuth error must survive boot normalization, got {serialized}"
355        );
356    }
357
358    #[test]
359    fn registry_search_action_url_works() {
360        // The JSON-LD SearchAction advertises /registry?q={search_term}.
361        let serialized = roundtrip("/registry?q=parser");
362        assert!(
363            serialized.contains("q=parser"),
364            "registry search query must survive boot normalization, got {serialized}"
365        );
366        let route = Route::from_str("/registry?q=parser").unwrap();
367        assert_eq!(
368            route,
369            Route::Registry {
370                token: None,
371                login: None,
372                error: None,
373                q: Some("parser".to_string())
374            }
375        );
376    }
377
378    #[test]
379    fn percent_encoded_file_roundtrips() {
380        let serialized = roundtrip("/studio?file=my%20notes.logos");
381        assert!(
382            serialized.contains("file=my%20notes.logos"),
383            "encoded file param must survive boot normalization, got {serialized}"
384        );
385        let route = Route::from_str(&serialized).expect("re-serialized URL parses");
386        assert_eq!(serialized, route.to_string(), "serialization is stable");
387    }
388
389    #[test]
390    fn boot_normalization_is_fixpoint() {
391        for url in [
392            "/",
393            "/studio",
394            "/studio?file=examples/logic/prover-demo.logic",
395            "/studio?file=examples/code/basics/hello.logos",
396            "/news",
397            "/news?tag=releases",
398            "/success",
399            "/success?session_id=cs_test_123",
400            "/registry",
401            "/registry?token=t0ken&login=octocat",
402            "/registry?error=denied",
403            "/registry?q=parser",
404            "/registry/package/logicaffeine-base",
405            "/news/some-article",
406            "/workspace/logic",
407        ] {
408            let first = Route::from_str(url)
409                .unwrap_or_else(|e| panic!("{url} failed to parse: {e}"));
410            let s1 = first.to_string();
411            let second = Route::from_str(&s1)
412                .unwrap_or_else(|e| panic!("{s1} (from {url}) failed to re-parse: {e}"));
413            assert_eq!(first, second, "{url}: parse → serialize → parse must be identity");
414            assert_eq!(s1, second.to_string(), "{url}: serialization must be a fixpoint");
415        }
416    }
417}
418
419#[component]
420fn NotFound(route: Vec<String>) -> Element {
421    rsx! {
422        div {
423            style: "min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%); color: #e8e8e8;",
424            h1 { style: "font-size: 48px; margin-bottom: 16px;", "404" }
425            p { style: "color: #888; margin-bottom: 24px;", "Page not found: /{route.join(\"/\")}" }
426            Link {
427                to: Route::Landing {},
428                style: "padding: 12px 24px; background: linear-gradient(135deg, #667eea, #764ba2); border-radius: 8px; color: white; text-decoration: none;",
429                "Go Home"
430            }
431        }
432    }
433}