1pub mod content;
11
12use crate::progress::UserProgress;
13use crate::ui::components::code_editor::Language;
14use crate::ui::components::exercise_block::ExerciseBlock;
15use crate::ui::components::footer::Footer;
16use crate::ui::components::guide_code_block::GuideCodeBlock;
17use crate::ui::components::guide_sidebar::{GuideSidebar, SectionInfo};
18use crate::ui::components::guide_snippet::GuideSnippet;
19use crate::ui::components::main_nav::{ActivePage, MainNav};
20use crate::ui::components::misconception_box::MisconceptionBox;
21use crate::ui::components::recall_block::RecallBlock;
22use crate::ui::inline_markup::inline_markup_html;
23use crate::ui::markdown_blocks::{blocks, Block};
24use crate::ui::seo::{
25 breadcrumb_schema, organization_schema, pages as seo_pages, tech_article_schema,
26 BreadcrumbItem, JsonLdMultiple, PageHead,
27};
28use content::SECTIONS;
29use dioxus::prelude::*;
30#[cfg(all(feature = "split", target_arch = "wasm32"))]
31use dioxus::wasm_split;
32
33pub fn today_string() -> String {
35 #[cfg(target_arch = "wasm32")]
36 {
37 use wasm_bindgen::prelude::*;
38
39 #[wasm_bindgen]
40 extern "C" {
41 #[wasm_bindgen(js_namespace = Date, js_name = now)]
42 fn date_now() -> f64;
43 }
44
45 let ms = date_now() as i64;
46 let days_since_epoch = ms / 86400000;
47 let year = 1970 + (days_since_epoch / 365) as i32;
48 let day_of_year = (days_since_epoch % 365) as i32;
49 let month = (day_of_year / 30).min(11) + 1;
50 let day = (day_of_year % 30) + 1;
51 format!("{:04}-{:02}-{:02}", year, month, day)
52 }
53
54 #[cfg(not(target_arch = "wasm32"))]
55 {
56 "2025-01-01".to_string()
57 }
58}
59
60pub fn theorem_verdict(r: &logicaffeine_compile::TheoremCompileResult) -> Result<String, String> {
63 if let Some(e) = &r.error {
64 return Err(e.clone());
65 }
66 let mut out = if r.verified {
67 format!("Proved: {} — kernel-certified.", r.name)
68 } else {
69 format!("Not proved: {}.", r.name)
70 };
71 if let Some(goal) = &r.goal_string {
72 out.push_str(&format!("\nGoal: {goal}"));
73 }
74 Ok(out)
75}
76
77#[cfg(not(target_arch = "wasm32"))]
85pub fn page_verdict(source: &str) -> Result<usize, String> {
86 use logicaffeine_compile::testrun::{run_tests, TestConfig, Verdict};
87 let report = run_tests(source, &TestConfig::default())?;
88 if report.outcomes.is_empty() {
89 return Err(
90 "the page has no `## Test` — a test is how a page proves what it does".to_string(),
91 );
92 }
93 let failed: Vec<String> = report
94 .outcomes
95 .iter()
96 .filter(|o| o.verdict != Verdict::Passed)
97 .map(|o| format!("`{}`: {}", o.name, o.failures.join("; ")))
98 .collect();
99 if failed.is_empty() {
100 Ok(report.passed)
101 } else {
102 Err(failed.join("\n"))
103 }
104}
105
106pub fn search_sections(query: &str) -> Vec<&'static content::Section> {
110 let q = query.trim().to_lowercase();
111 if q.is_empty() {
112 return Vec::new();
113 }
114 SECTIONS
115 .iter()
116 .filter(|s| {
117 s.title.to_lowercase().contains(&q)
118 || s.content.to_lowercase().contains(&q)
119 || s.hook.to_lowercase().contains(&q)
120 || s.objectives.iter().any(|o| o.to_lowercase().contains(&q))
121 || s.misconceptions.iter().any(|m| {
122 m.expectation.to_lowercase().contains(&q)
123 || m.reality.to_lowercase().contains(&q)
124 })
125 || s.recap.iter().any(|r| {
126 r.question.to_lowercase().contains(&q) || r.answer.to_lowercase().contains(&q)
127 })
128 || s.examples.iter().any(|e| {
129 e.label.to_lowercase().contains(&q) || e.code.to_lowercase().contains(&q)
130 })
131 })
132 .collect()
133}
134
135const GUIDE_STYLE: &str = r#"
136.guide-page {
137 min-height: 100vh;
138 color: var(--text-primary);
139 background:
140 radial-gradient(1200px 600px at 50% -120px, rgba(167,139,250,0.14), transparent 60%),
141 radial-gradient(900px 500px at 15% 30%, rgba(96,165,250,0.14), transparent 60%),
142 radial-gradient(800px 450px at 90% 45%, rgba(34,197,94,0.08), transparent 62%),
143 linear-gradient(180deg, #070a12, #0b1022 55%, #070a12);
144 font-family: var(--font-sans);
145}
146
147/* Navigation - now handled by MainNav component */
148
149/* Hero */
150.guide-hero {
151 max-width: 1280px;
152 margin: 0 auto;
153 padding: 60px var(--spacing-xl) 40px;
154}
155
156.guide-hero h1 {
157 font-size: var(--font-display-lg);
158 font-weight: 900;
159 letter-spacing: -1.5px;
160 line-height: 1.1;
161 background: linear-gradient(180deg, #ffffff 0%, rgba(229,231,235,0.78) 65%, rgba(229,231,235,0.62) 100%);
162 -webkit-background-clip: text;
163 -webkit-text-fill-color: transparent;
164 margin: 0 0 var(--spacing-lg);
165}
166
167.guide-hero p {
168 font-size: var(--font-body-lg);
169 color: var(--text-secondary);
170 max-width: 600px;
171 line-height: 1.6;
172 margin: 0;
173}
174
175.guide-hero-badge {
176 display: inline-flex;
177 align-items: center;
178 gap: var(--spacing-sm);
179 padding: var(--spacing-sm) 14px;
180 border-radius: var(--radius-full);
181 background: rgba(255,255,255,0.06);
182 border: 1px solid rgba(255,255,255,0.10);
183 font-size: var(--font-caption-md);
184 font-weight: 600;
185 color: var(--text-primary);
186 margin-bottom: var(--spacing-xl);
187}
188
189.guide-hero-badge .dot {
190 width: 8px;
191 height: 8px;
192 border-radius: 50%;
193 background: var(--color-success);
194 box-shadow: 0 0 0 4px rgba(34,197,94,0.15);
195}
196
197/* Layout */
198.guide-layout {
199 max-width: 1280px;
200 margin: 0 auto;
201 display: flex;
202 gap: 48px;
203 padding: 0 var(--spacing-xl) 80px;
204}
205
206/* Main content */
207.guide-content {
208 flex: 1;
209 min-width: 0;
210 max-width: 800px;
211 padding-right: var(--spacing-lg);
212}
213
214@media (max-width: 1024px) {
215 .guide-content {
216 padding-right: 0;
217 }
218}
219
220/* Section styling */
221.guide-section {
222 margin-bottom: 64px;
223 scroll-margin-top: 100px;
224}
225
226.guide-section h2 {
227 font-size: var(--font-display-md);
228 font-weight: 800;
229 letter-spacing: -0.8px;
230 line-height: 1.2;
231 background: linear-gradient(180deg, #ffffff 0%, rgba(229,231,235,0.85) 100%);
232 -webkit-background-clip: text;
233 -webkit-text-fill-color: transparent;
234 margin: 0 0 var(--spacing-xl);
235 padding-bottom: var(--spacing-lg);
236 border-bottom: 1px solid rgba(255,255,255,0.08);
237}
238
239.guide-section h3 {
240 font-size: var(--font-heading-sm);
241 font-weight: 700;
242 color: var(--text-primary);
243 margin: var(--spacing-xxl) 0 var(--spacing-lg);
244}
245
246.guide-section p {
247 color: var(--text-secondary);
248 font-size: var(--font-body-sm);
249 line-height: 1.75;
250 margin: 0 0 var(--spacing-lg);
251}
252
253.guide-section ul,
254.guide-section ol {
255 color: var(--text-secondary);
256 font-size: var(--font-body-sm);
257 line-height: 1.75;
258 padding-left: var(--spacing-xl);
259 margin: 0 0 var(--spacing-lg);
260}
261
262.guide-section li {
263 margin-bottom: var(--spacing-sm);
264}
265
266.guide-section hr {
267 border: 0;
268 border-top: 1px solid rgba(255,255,255,0.08);
269 margin: var(--spacing-xxl) 0;
270}
271
272.guide-section code {
273 font-family: var(--font-mono);
274 background: rgba(255,255,255,0.08);
275 padding: 3px 7px;
276 border-radius: var(--radius-sm);
277 font-size: 0.9em;
278 color: var(--color-accent-purple);
279}
280
281/* Plain fences (install commands, config) — copyable, one command per block. A class of their
282 own, apart from the live example component's. */
283.guide-section .guide-fence {
284 position: relative;
285 margin: 0 0 var(--spacing-lg);
286}
287
288.guide-section .guide-fence pre {
289 font-family: var(--font-mono);
290 background: rgba(255,255,255,0.05);
291 border: 1px solid rgba(255,255,255,0.08);
292 border-radius: var(--radius-lg);
293 padding: var(--spacing-lg);
294 padding-right: 72px;
295 overflow-x: auto;
296 font-size: 0.9em;
297 line-height: 1.6;
298 margin: 0;
299}
300
301.guide-section .guide-fence code {
302 background: none;
303 padding: 0;
304 color: var(--text-primary);
305}
306
307.guide-section .guide-code-copy {
308 position: absolute;
309 top: 10px;
310 right: 10px;
311 font-size: 12px;
312 color: var(--text-secondary);
313 background: rgba(255,255,255,0.06);
314 border: 1px solid rgba(255,255,255,0.1);
315 border-radius: var(--radius-sm);
316 padding: 3px 10px;
317 cursor: pointer;
318}
319
320.guide-section .guide-code-copy:hover {
321 color: var(--text-primary);
322 background: rgba(255,255,255,0.12);
323}
324
325/* LOGOS quoted in the prose: the shared editor, read-only, on a command block's surface */
326.guide-section .guide-snippet {
327 position: relative;
328 margin: 0 0 var(--spacing-lg);
329 background: rgba(255,255,255,0.05);
330 border: 1px solid rgba(255,255,255,0.08);
331 border-radius: var(--radius-lg);
332}
333
334.guide-section .guide-snippet .code-editor-highlight,
335.guide-section .guide-snippet .code-editor-textarea {
336 padding-right: 72px;
337}
338
339/* Above the editor's text area, which lies over the whole snippet */
340.guide-section .guide-snippet .guide-code-copy {
341 z-index: 3;
342}
343
344.guide-section strong {
345 color: var(--text-primary);
346 font-weight: 600;
347}
348
349/* Tables - wrapped in scrollable container */
350.guide-section .table-wrapper {
351 width: 100%;
352 overflow-x: auto;
353 margin: var(--spacing-xl) 0;
354 border-radius: var(--radius-lg);
355 border: 1px solid rgba(255,255,255,0.08);
356 -webkit-overflow-scrolling: touch;
357}
358
359.guide-section table {
360 width: 100%;
361 min-width: 400px;
362 border-collapse: collapse;
363 font-size: var(--font-body-md);
364}
365
366.guide-section th {
367 text-align: left;
368 padding: 14px var(--spacing-lg);
369 background: rgba(255,255,255,0.05);
370 color: var(--text-primary);
371 font-weight: 600;
372 border-bottom: 1px solid rgba(255,255,255,0.08);
373 white-space: nowrap;
374}
375
376.guide-section td {
377 padding: var(--spacing-md) var(--spacing-lg);
378 color: var(--text-secondary);
379 border-bottom: 1px solid rgba(255,255,255,0.05);
380}
381
382.guide-section tr:last-child td {
383 border-bottom: none;
384}
385
386.guide-section tr:hover td {
387 background: rgba(255,255,255,0.02);
388}
389
390/* Collapsible examples container */
391.guide-examples-collapsible {
392 margin-top: var(--spacing-xl);
393 border: 1px solid rgba(255,255,255,0.08);
394 border-radius: var(--radius-lg);
395 overflow: hidden;
396}
397
398.guide-examples-header {
399 display: flex;
400 align-items: center;
401 justify-content: space-between;
402 padding: var(--spacing-lg) var(--spacing-xl);
403 background: rgba(255,255,255,0.03);
404 cursor: pointer;
405 user-select: none;
406 transition: background 0.15s ease;
407}
408
409.guide-examples-header:hover {
410 background: rgba(255,255,255,0.05);
411}
412
413.guide-examples-header-left {
414 display: flex;
415 align-items: center;
416 gap: var(--spacing-md);
417}
418
419.guide-examples-title {
420 font-size: var(--font-body-md);
421 font-weight: 600;
422 color: var(--text-primary);
423}
424
425.guide-examples-count {
426 font-size: var(--font-caption-md);
427 color: var(--text-tertiary);
428 background: rgba(255,255,255,0.06);
429 padding: 4px 10px;
430 border-radius: var(--radius-full);
431}
432
433.guide-examples-chevron {
434 font-size: 12px;
435 color: var(--text-tertiary);
436 transition: transform 0.2s ease;
437}
438
439.guide-examples-collapsible.expanded .guide-examples-chevron {
440 transform: rotate(90deg);
441}
442
443.guide-examples-content {
444 max-height: 0;
445 overflow: hidden;
446 transition: max-height 0.3s ease;
447}
448
449.guide-examples-collapsible.expanded .guide-examples-content {
450 max-height: 10000px;
451}
452
453.guide-examples-inner {
454 padding: var(--spacing-lg);
455 display: flex;
456 flex-direction: column;
457 gap: var(--spacing-lg);
458}
459
460/* Part dividers */
461.guide-part-divider {
462 margin: 80px 0 48px;
463 padding: var(--spacing-xl) 0;
464 border-top: 1px solid rgba(255,255,255,0.08);
465}
466
467.guide-part-divider h2 {
468 font-size: var(--font-body-md);
469 font-weight: 700;
470 text-transform: uppercase;
471 letter-spacing: 1.5px;
472 color: var(--text-tertiary);
473 margin: 0;
474 background: none;
475 -webkit-text-fill-color: currentColor;
476 border-bottom: none;
477 padding-bottom: 0;
478}
479
480/* Section number */
481.section-number {
482 display: inline-block;
483 font-size: var(--font-body-md);
484 font-weight: 700;
485 color: var(--color-accent-purple);
486 margin-right: var(--spacing-sm);
487 opacity: 0.8;
488}
489
490/* Examples container */
491.guide-examples {
492 margin-top: var(--spacing-xl);
493 display: flex;
494 flex-direction: column;
495 gap: var(--spacing-lg);
496}
497
498/* Responsive */
499@media (max-width: 1024px) {
500 .guide-layout {
501 flex-direction: column;
502 }
503
504 .guide-hero h1 {
505 font-size: var(--font-display-md);
506 }
507
508 .guide-hero {
509 padding: 40px var(--spacing-xl) var(--spacing-xxl);
510 }
511}
512
513@media (max-width: 640px) {
514 .guide-hero h1 {
515 font-size: var(--font-heading-lg);
516 }
517
518 .guide-hero p {
519 font-size: var(--font-body-md);
520 }
521
522 .guide-section h2 {
523 font-size: var(--font-heading-lg);
524 }
525}
526
527.guide-community-cta {
528 text-align: center;
529 padding: var(--spacing-xxl) var(--spacing-xl);
530 margin-top: var(--spacing-xxl);
531 border-top: 1px solid rgba(255,255,255,0.06);
532}
533
534.guide-community-cta p {
535 font-size: var(--font-body-lg);
536 color: var(--text-secondary);
537 margin-bottom: var(--spacing-md);
538}
539
540.guide-community-cta a {
541 display: inline-block;
542 padding: 8px 20px;
543 border-radius: var(--radius-full);
544 background: rgba(167,139,250,0.06);
545 border: 1px solid rgba(167,139,250,0.3);
546 color: var(--color-accent-purple);
547 font-weight: 600;
548 font-size: var(--font-body-md);
549 text-decoration: none;
550 transition: background 0.2s, border-color 0.2s;
551}
552
553.guide-community-cta a:hover {
554 background: rgba(167,139,250,0.14);
555 border-color: rgba(167,139,250,0.5);
556}
557
558/* The hook: the headache before the aspirin */
559.guide-hook {
560 font-size: var(--font-body-md);
561 color: var(--text-primary);
562 line-height: 1.7;
563 padding: var(--spacing-lg) var(--spacing-xl);
564 margin: 0 0 var(--spacing-xl);
565 border-left: 3px solid var(--color-accent-purple);
566 border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
567 background: rgba(167,139,250,0.07);
568}
569
570/* "After this section you can" */
571.guide-objectives {
572 margin: 0 0 var(--spacing-xl);
573 padding: var(--spacing-lg) var(--spacing-xl);
574 border: 1px solid rgba(255,255,255,0.08);
575 border-radius: var(--radius-md);
576 background: rgba(255,255,255,0.03);
577}
578
579.guide-objectives-title {
580 font-size: 11px;
581 font-weight: 700;
582 text-transform: uppercase;
583 letter-spacing: 0.6px;
584 color: rgba(229,231,235,0.55);
585 margin-bottom: var(--spacing-sm);
586}
587
588.guide-objectives ul {
589 margin: 0;
590 padding-left: var(--spacing-xl);
591}
592
593.guide-objectives li {
594 color: var(--text-secondary);
595 font-size: var(--font-body-sm);
596 margin-bottom: 4px;
597}
598
599/* Blockquote callouts */
600.guide-section blockquote {
601 margin: 0 0 var(--spacing-lg);
602 padding: var(--spacing-md) var(--spacing-lg);
603 border-left: 3px solid rgba(255,255,255,0.2);
604 border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
605 background: rgba(255,255,255,0.03);
606 color: var(--text-secondary);
607 font-size: var(--font-body-sm);
608 line-height: 1.7;
609}
610
611.guide-section blockquote.guide-mental-model {
612 border-left-color: #22c55e;
613 background: rgba(34,197,94,0.06);
614}
615
616.guide-section blockquote.guide-look-back {
617 border-left-color: #60a5fa;
618 background: rgba(96,165,250,0.06);
619}
620"#;
621
622#[component(lazy)]
625pub fn Guide() -> Element {
626 let mut active_section = use_signal(|| "introduction".to_string());
627 let mut search_query = use_signal(String::new);
628 let mut collapsed_groups = use_signal(std::collections::HashSet::<String>::new);
631 let mut completed = use_signal(Vec::<String>::new);
632 let progress = use_context_provider(|| Signal::new(UserProgress::load()));
635
636 #[cfg(target_arch = "wasm32")]
638 {
639 use gloo_storage::Storage;
640 use_effect(move || {
641 if let Ok(saved) = gloo_storage::LocalStorage::get::<Vec<String>>("guide-completed") {
642 completed.set(saved);
643 }
644 });
645 }
646 let toggle_completed = move |id: String| {
647 let mut list = completed.read().clone();
648 if let Some(pos) = list.iter().position(|x| *x == id) {
649 list.remove(pos);
650 } else {
651 list.push(id);
652 }
653 #[cfg(target_arch = "wasm32")]
654 {
655 use gloo_storage::Storage;
656 let _ = gloo_storage::LocalStorage::set("guide-completed", &list);
657 }
658 completed.set(list);
659 };
660
661 let sections_info: Vec<SectionInfo> = {
664 let prog = progress.read();
665 SECTIONS
666 .iter()
667 .map(|s| {
668 let exercise_total = s.exercises.len();
669 let exercise_passed = s
670 .exercises
671 .iter()
672 .filter(|e| {
673 prog.exercises
674 .get(&crate::guide_review::exercise_key(e.id))
675 .is_some_and(|p| p.correct_count > 0)
676 })
677 .count();
678 SectionInfo {
679 id: s.id.to_string(),
680 number: s.number,
681 title: s.title.to_string(),
682 part: s.part.to_string(),
683 exercise_total,
684 exercise_passed,
685 }
686 })
687 .collect()
688 };
689
690 #[allow(unused_variables)]
692 let section_ids: Vec<String> = SECTIONS.iter().map(|s| s.id.to_string()).collect();
693
694 #[cfg(target_arch = "wasm32")]
696 {
697 use wasm_bindgen::prelude::*;
698 use wasm_bindgen::JsCast;
699
700 let section_ids_for_effect = section_ids.clone();
701
702 use_effect(move || {
703 let window = match web_sys::window() {
704 Some(w) => w,
705 None => return,
706 };
707 let document = match window.document() {
708 Some(d) => d,
709 None => return,
710 };
711
712 use std::cell::RefCell;
714 use std::rc::Rc;
715
716 let active_section_clone = Rc::new(RefCell::new(active_section.clone()));
717 let active_section_for_closure = active_section_clone.clone();
718
719 let callback = Closure::<dyn Fn(js_sys::Array, web_sys::IntersectionObserver)>::new(
720 move |entries: js_sys::Array, _observer: web_sys::IntersectionObserver| {
721 for i in 0..entries.length() {
724 if let Ok(entry) = entries
725 .get(i)
726 .dyn_into::<web_sys::IntersectionObserverEntry>()
727 {
728 if entry.is_intersecting() {
729 let target = entry.target();
730 let id = target.id();
731 if !id.is_empty() {
732 active_section_for_closure.borrow_mut().set(id);
733 }
734 }
735 }
736 }
737 },
738 );
739
740 let mut options = web_sys::IntersectionObserverInit::new();
742 options.root_margin("-100px 0px -90% 0px");
744 let thresholds = js_sys::Array::new();
745 thresholds.push(&JsValue::from(0.0));
746 options.threshold(&thresholds);
747
748 let observer = match web_sys::IntersectionObserver::new_with_options(
750 callback.as_ref().unchecked_ref(),
751 &options,
752 ) {
753 Ok(obs) => obs,
754 Err(_) => return,
755 };
756
757 for section_id in §ion_ids_for_effect {
759 if let Some(element) = document.get_element_by_id(section_id) {
760 observer.observe(&element);
761 }
762 }
763
764 callback.forget();
766 });
767 }
768
769 let mut current_part = String::new();
771
772 let breadcrumbs = vec![
773 BreadcrumbItem {
774 name: "Home",
775 path: "/",
776 },
777 BreadcrumbItem {
778 name: "Syntax Guide",
779 path: "/guide",
780 },
781 ];
782 let schemas = vec![
783 organization_schema(),
784 tech_article_schema("LOGOS Syntax Guide", "Comprehensive guide to LOGOS syntax, features, and First-Order Logic concepts with interactive examples.", "/guide"),
785 breadcrumb_schema(&breadcrumbs),
786 ];
787
788 rsx! {
789 PageHead {
790 title: seo_pages::GUIDE.title,
791 description: seo_pages::GUIDE.description,
792 canonical_path: seo_pages::GUIDE.canonical_path,
793 }
794 style { "{GUIDE_STYLE}" }
795 JsonLdMultiple { schemas }
796
797 div { class: "guide-page",
798 MainNav {
800 active: ActivePage::Guide,
801 subtitle: Some("Programmer's Guide"),
802 }
803
804 header { class: "guide-hero",
806 div { class: "guide-hero-badge",
807 div { class: "dot" }
808 span { "Interactive Guide" }
809 }
810 h1 { "LOGOS Syntax Guide" }
811 p {
812 "Write English. Get Logic. Run Code. A comprehensive guide to programming in LOGOS, from basics to advanced features."
813 }
814
815 div {
817 style: "max-width: 560px; margin: 18px auto 0; position: relative;",
818 input {
819 class: "guide-search-input",
820 style: "width: 100%; padding: 10px 16px; border-radius: 10px; border: 1px solid rgba(255,255,255,0.15); background: rgba(0,0,0,0.35); color: rgba(229,231,235,0.95); font-size: 14px; outline: none;",
821 placeholder: "Search the guide — sections, prose, code…",
822 value: "{search_query}",
823 oninput: move |evt| search_query.set(evt.value()),
824 }
825 {
826 let q = search_query.read().clone();
827 let hits = search_sections(&q);
828 if !hits.is_empty() {
829 rsx! {
830 div {
831 style: "position: absolute; z-index: 30; left: 0; right: 0; margin-top: 6px; border-radius: 10px; border: 1px solid rgba(255,255,255,0.12); background: #0b1022; box-shadow: 0 12px 40px rgba(0,0,0,0.5); text-align: left; max-height: 300px; overflow-y: auto;",
832 for hit in hits.into_iter().take(8) {
833 a {
834 style: "display: block; padding: 9px 14px; color: rgba(229,231,235,0.9); text-decoration: none; font-size: 13px; border-bottom: 1px solid rgba(255,255,255,0.05);",
835 href: "#{hit.id}",
836 onclick: move |_| search_query.set(String::new()),
837 span { style: "opacity: 0.55; margin-right: 8px;", "{hit.number}." }
838 "{hit.title}"
839 }
840 }
841 }
842 }
843 } else {
844 rsx! {}
845 }
846 }
847 }
848 }
849
850 div { class: "guide-layout",
852 GuideSidebar {
854 sections: sections_info,
855 active_section: active_section.read().clone(),
856 on_section_click: move |id: String| {
857 active_section.set(id);
858 },
859 completed: completed.read().clone(),
860 on_toggle_complete: toggle_completed,
861 }
862
863 main { class: "guide-content",
865 div {
866 style: "display: flex; justify-content: flex-end; margin-bottom: 4px;",
867 button {
868 class: "guide-expand-all",
869 style: "padding: 6px 12px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.15); background: rgba(255,255,255,0.05); color: rgba(229,231,235,0.75); font-size: 12px; cursor: pointer;",
870 onclick: move |_| {
871 if collapsed_groups.read().is_empty() {
872 let all: std::collections::HashSet<String> = SECTIONS
873 .iter()
874 .filter(|s| s.examples.len() > 3)
875 .map(|s| s.id.to_string())
876 .collect();
877 collapsed_groups.set(all);
878 } else {
879 collapsed_groups.set(std::collections::HashSet::new());
880 }
881 },
882 if collapsed_groups.read().is_empty() { "Collapse example groups" } else { "Expand all examples" }
883 }
884 }
885 for section in SECTIONS.iter() {
886 {
887 let show_divider = section.part != current_part && section.number > 1;
889 current_part = section.part.to_string();
890
891 rsx! {
892 if show_divider {
894 div { class: "guide-part-divider",
895 h2 { "{section.part}" }
896 }
897 }
898
899 section {
901 id: "{section.id}",
902 class: "guide-section",
903
904 h2 {
905 span { class: "section-number", "{section.number}." }
906 "{section.title}"
907 }
908
909 if !section.hook.is_empty() {
912 div { class: "guide-hook", "{section.hook}" }
913 }
914
915 if !section.objectives.is_empty() {
918 div { class: "guide-objectives",
919 div { class: "guide-objectives-title", "After this section you can" }
920 ul {
921 for objective in section.objectives.iter() {
922 li { "{objective}" }
923 }
924 }
925 }
926 }
927
928 for block in prose_blocks(section.content) {
930 {prose_block(block)}
931 }
932
933 for m in section.misconceptions.iter() {
936 MisconceptionBox {
937 id: m.id.to_string(),
938 expectation: m.expectation.to_string(),
939 reality: m.reality.to_string(),
940 evidence: m.evidence.to_string(),
941 }
942 }
943
944 if !section.examples.is_empty() {
946 {
947 let example_count = section.examples.len();
948 let should_collapse = example_count > 3;
949
950 if should_collapse {
951 rsx! {
952 CollapsibleExamples {
953 section_id: section.id.to_string(),
954 examples: section.examples,
955 is_open: !collapsed_groups.read().contains(section.id),
956 on_toggle: move |id: String| {
957 let mut set = collapsed_groups.read().clone();
958 if !set.remove(&id) {
959 set.insert(id);
960 }
961 collapsed_groups.set(set);
962 },
963 }
964 }
965 } else {
966 rsx! {
967 div { class: "guide-examples",
968 for example in section.examples.iter() {
969 GuideCodeBlock {
970 id: example.id.to_string(),
971 label: example.label.to_string(),
972 mode: example.mode,
973 runnability: example.runnability,
974 expected_output: example.expected_output.map(|s| s.to_string()),
975 predict: example.predict_first,
976 initial_code: example.code.to_string(),
977 }
978 }
979 }
980 }
981 }
982 }
983 }
984
985 for ex in section.exercises.iter() {
987 ExerciseBlock {
988 id: ex.id.to_string(),
989 prompt: ex.prompt.to_string(),
990 mode: ex.mode,
991 expected: ex.expected.to_string(),
992 starter: ex.starter.to_string(),
993 kind: ex.kind,
994 concepts: ex.concepts.iter().map(|c| c.to_string()).collect::<Vec<_>>(),
995 hints: ex.hints.iter().map(|h| h.to_string()).collect::<Vec<_>>(),
996 }
997 }
998
999 if !section.recap.is_empty() {
1002 RecallBlock {
1003 cards: section.recap.iter().map(|r| {
1004 (r.id.to_string(), r.question.to_string(), r.answer.to_string())
1005 }).collect::<Vec<_>>(),
1006 }
1007 }
1008 }
1009 }
1010 }
1011 div { class: "guide-community-cta",
1012 p { "Have questions or feedback? Join our Discord community." }
1013 a {
1014 href: "https://discord.gg/pwnjnXvUHH",
1015 target: "_blank",
1016 rel: "noopener noreferrer",
1017 "Join Discord →"
1018 }
1019 }
1020 }
1021 }
1022 }
1023
1024 Footer {}
1025 }
1026 }
1027}
1028
1029#[component]
1032fn CollapsibleExamples(
1033 section_id: String,
1034 examples: &'static [content::CodeExample],
1035 is_open: bool,
1036 on_toggle: EventHandler<String>,
1037) -> Element {
1038 let example_count = examples.len();
1039
1040 rsx! {
1041 div {
1042 class: if is_open { "guide-examples-collapsible expanded" } else { "guide-examples-collapsible" },
1043
1044 div {
1045 class: "guide-examples-header",
1046 onclick: {
1047 let id = section_id.clone();
1048 move |_| on_toggle.call(id.clone())
1049 },
1050
1051 div { class: "guide-examples-header-left",
1052 span { class: "guide-examples-title", "Code Examples" }
1053 span { class: "guide-examples-count", "{example_count} examples" }
1054 }
1055 span { class: "guide-examples-chevron", "▶" }
1056 }
1057
1058 div { class: "guide-examples-content",
1059 div { class: "guide-examples-inner",
1060 for example in examples.iter() {
1061 GuideCodeBlock {
1062 id: example.id.to_string(),
1063 label: example.label.to_string(),
1064 mode: example.mode,
1065 runnability: example.runnability,
1066 expected_output: example.expected_output.map(|s| s.to_string()),
1067 predict: example.predict_first,
1068 initial_code: example.code.to_string(),
1069 }
1070 }
1071 }
1072 }
1073 }
1074 }
1075}
1076
1077fn render_markdown(content: &str) -> String {
1081 prose_blocks(content)
1082 .into_iter()
1083 .map(|block| match block {
1084 ProseBlock::Html(html) => html,
1085 ProseBlock::Code { code, .. } => code_block_html(&code),
1086 })
1087 .collect()
1088}
1089
1090fn prose_html(block: &Block) -> String {
1094 match block {
1095 Block::Heading { level, text } => {
1096 format!("<h{level}>{}</h{level}>", inline_markup_html(text))
1097 }
1098 Block::Paragraph(text) => format!("<p>{}</p>", inline_markup_html(text)),
1099 Block::List { ordered, items } => {
1100 let tag = if *ordered { "ol" } else { "ul" };
1101 let items: String = items
1102 .iter()
1103 .map(|item| format!("<li>{}</li>", inline_markup_html(item)))
1104 .collect();
1105 format!("<{tag}>{items}</{tag}>")
1106 }
1107 Block::Quote(paragraphs) => {
1108 let opener = paragraphs.first().map_or("", String::as_str);
1109 let class = if opener.contains("**Mental model:**") {
1110 " class=\"guide-callout guide-mental-model\""
1111 } else if opener.contains("**Look back:**") {
1112 " class=\"guide-callout guide-look-back\""
1113 } else {
1114 ""
1115 };
1116 let body: Vec<String> = paragraphs.iter().map(|p| inline_markup_html(p)).collect();
1117 format!(
1118 "<blockquote{class}>{}</blockquote>",
1119 body.join("<br/><br/>")
1120 )
1121 }
1122 Block::Table { header, rows } => {
1123 let row = |cells: &[String], tag: &str| -> String {
1124 let cells: String = cells
1125 .iter()
1126 .map(|cell| format!("<{tag}>{}</{tag}>", inline_markup_html(cell)))
1127 .collect();
1128 format!("<tr>{cells}</tr>")
1129 };
1130 let head = header.as_ref().map_or(String::new(), |h| {
1131 format!("<thead>{}</thead>", row(h, "th"))
1132 });
1133 let body: String = rows.iter().map(|r| row(r, "td")).collect();
1134 format!("<div class=\"table-wrapper\"><table>{head}<tbody>{body}</tbody></table></div>")
1135 }
1136 Block::Fence { code, .. } => code_block_html(code),
1137 Block::Rule => "<hr/>".to_string(),
1138 }
1139}
1140
1141pub fn render_markdown_for_test(content: &str) -> String {
1145 render_markdown(content)
1146}
1147
1148#[derive(Clone, Debug, PartialEq)]
1150pub enum ProseBlock {
1151 Html(String),
1152 Code {
1154 tag: String,
1155 code: String,
1156 },
1157}
1158
1159#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1161pub enum Fence {
1162 Logos,
1164 Logic,
1166 Plain,
1168}
1169
1170impl Fence {
1171 pub fn language(self) -> Option<Language> {
1173 match self {
1174 Fence::Logos => Some(Language::Logos),
1175 Fence::Logic => Some(Language::Logic),
1176 Fence::Plain => None,
1177 }
1178 }
1179}
1180
1181pub fn fence(tag: &str) -> Option<Fence> {
1183 match tag {
1184 "logos" => Some(Fence::Logos),
1185 "logic" => Some(Fence::Logic),
1186 "bash" | "powershell" | "toml" => Some(Fence::Plain),
1187 _ => None,
1188 }
1189}
1190
1191pub fn prose_blocks(content: &str) -> Vec<ProseBlock> {
1195 let mut out = Vec::new();
1196 let mut html = String::new();
1197 for block in blocks(content) {
1198 match block {
1199 Block::Fence { tag, code } => {
1200 if !html.is_empty() {
1201 out.push(ProseBlock::Html(std::mem::take(&mut html)));
1202 }
1203 out.push(ProseBlock::Code { tag, code });
1204 }
1205 block => html.push_str(&prose_html(&block)),
1206 }
1207 }
1208 if !html.is_empty() {
1209 out.push(ProseBlock::Html(html));
1210 }
1211 out
1212}
1213
1214fn prose_block(block: ProseBlock) -> Element {
1217 match block {
1218 ProseBlock::Html(html) => rsx! {
1219 div { dangerous_inner_html: html }
1220 },
1221 ProseBlock::Code { tag, code } => match fence(&tag).and_then(Fence::language) {
1222 Some(language) => rsx! {
1223 GuideSnippet { code, language }
1224 },
1225 None => rsx! {
1226 div { dangerous_inner_html: code_block_html(&code) }
1227 },
1228 },
1229 }
1230}
1231
1232fn code_block_html(code: &str) -> String {
1236 format!(
1237 "<div class=\"guide-fence\">\
1238 <button class=\"guide-code-copy\" \
1239 onclick=\"navigator.clipboard.writeText(this.nextElementSibling.textContent);\
1240 this.textContent='Copied!';setTimeout(()=>{{this.textContent='Copy'}},1200)\">Copy</button>\
1241 <pre><code>{}</code></pre></div>",
1242 html_escape(code.trim_end_matches('\n'))
1243 )
1244}
1245
1246fn html_escape(s: &str) -> String {
1247 s.replace('&', "&")
1248 .replace('<', "<")
1249 .replace('>', ">")
1250}
1251
1252#[cfg(test)]
1253#[path = "../../../../../../crates/logicaffeine_tests/unit/logicaffeine_web/ui/pages/guide/render_markdown_tests.rs"]
1254mod render_markdown_tests;
1255
1256#[cfg(test)]
1257#[path = "../../../../../../crates/logicaffeine_tests/unit/logicaffeine_web/ui/pages/guide/prose_block_tests.rs"]
1258mod prose_block_tests;