1use 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#[derive(Clone, Routable, Debug, PartialEq)]
54pub enum Route {
55 #[route("/")]
57 Landing {},
58
59 #[route("/pricing")]
61 Pricing {},
62
63 #[route("/privacy")]
65 Privacy {},
66
67 #[route("/terms")]
69 Terms {},
70
71 #[route("/roadmap")]
73 Roadmap {},
74
75 #[route("/guide")]
77 Guide {},
78
79 #[route("/crates")]
81 Crates {},
82
83 #[route("/success?:session_id")]
88 Success {
89 session_id: Option<String>,
91 },
92
93 #[route("/studio?:file")]
99 Studio {
100 file: Option<String>,
102 },
103
104 #[route("/learn")]
108 Learn {},
109
110 #[route("/profile")]
112 Profile {},
113
114 #[route("/workspace/:subject")]
116 Workspace {
117 subject: String,
119 },
120
121 #[route("/registry?:token&:login&:error&:q")]
127 Registry {
128 token: Option<String>,
130 login: Option<String>,
132 error: Option<String>,
134 q: Option<String>,
136 },
137
138 #[route("/registry/package/:name")]
140 PackageDetail {
141 name: String,
143 },
144
145 #[route("/benchmarks")]
147 Benchmarks {},
148
149 #[route("/news?:tag")]
153 News {
154 tag: Option<String>,
156 },
157
158 #[route("/news/:slug")]
160 NewsArticle {
161 slug: String,
163 },
164
165 #[route("/:..route")]
167 NotFound {
168 route: Vec<String>,
170 },
171}
172
173pub 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#[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#[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 #[test]
262 fn query_scraping_is_forbidden() {
263 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 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 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}