Skip to main content

logicaffeine_web/ui/pages/guide/
mod.rs

1//! Programmer's Guide page.
2//!
3//! A beautiful, interactive guide to the LOGOS programming language with:
4//! - 22 sections from work/PROGRAMMERS_LANGUAGE_STARTER.md
5//! - Sticky sidebar navigation
6//! - Interactive code examples with Run/Copy/Reset
7//! - Dual mode: Logic (FOL output) and Imperative (WASM execution)
8
9pub mod content;
10
11use dioxus::prelude::*;
12#[cfg(all(feature = "split", target_arch = "wasm32"))]
13use dioxus::wasm_split;
14use crate::ui::router::Route;
15use crate::ui::components::guide_code_block::GuideCodeBlock;
16use crate::ui::components::guide_sidebar::{GuideSidebar, SectionInfo};
17use crate::ui::components::main_nav::{MainNav, ActivePage};
18use crate::ui::components::footer::Footer;
19use crate::ui::seo::{JsonLdMultiple, PageHead, organization_schema, tech_article_schema, breadcrumb_schema, BreadcrumbItem, pages as seo_pages};
20use content::SECTIONS;
21
22const GUIDE_STYLE: &str = r#"
23.guide-page {
24    min-height: 100vh;
25    color: var(--text-primary);
26    background:
27        radial-gradient(1200px 600px at 50% -120px, rgba(167,139,250,0.14), transparent 60%),
28        radial-gradient(900px 500px at 15% 30%, rgba(96,165,250,0.14), transparent 60%),
29        radial-gradient(800px 450px at 90% 45%, rgba(34,197,94,0.08), transparent 62%),
30        linear-gradient(180deg, #070a12, #0b1022 55%, #070a12);
31    font-family: var(--font-sans);
32}
33
34/* Navigation - now handled by MainNav component */
35
36/* Hero */
37.guide-hero {
38    max-width: 1280px;
39    margin: 0 auto;
40    padding: 60px var(--spacing-xl) 40px;
41}
42
43.guide-hero h1 {
44    font-size: var(--font-display-lg);
45    font-weight: 900;
46    letter-spacing: -1.5px;
47    line-height: 1.1;
48    background: linear-gradient(180deg, #ffffff 0%, rgba(229,231,235,0.78) 65%, rgba(229,231,235,0.62) 100%);
49    -webkit-background-clip: text;
50    -webkit-text-fill-color: transparent;
51    margin: 0 0 var(--spacing-lg);
52}
53
54.guide-hero p {
55    font-size: var(--font-body-lg);
56    color: var(--text-secondary);
57    max-width: 600px;
58    line-height: 1.6;
59    margin: 0;
60}
61
62.guide-hero-badge {
63    display: inline-flex;
64    align-items: center;
65    gap: var(--spacing-sm);
66    padding: var(--spacing-sm) 14px;
67    border-radius: var(--radius-full);
68    background: rgba(255,255,255,0.06);
69    border: 1px solid rgba(255,255,255,0.10);
70    font-size: var(--font-caption-md);
71    font-weight: 600;
72    color: var(--text-primary);
73    margin-bottom: var(--spacing-xl);
74}
75
76.guide-hero-badge .dot {
77    width: 8px;
78    height: 8px;
79    border-radius: 50%;
80    background: var(--color-success);
81    box-shadow: 0 0 0 4px rgba(34,197,94,0.15);
82}
83
84/* Layout */
85.guide-layout {
86    max-width: 1280px;
87    margin: 0 auto;
88    display: flex;
89    gap: 48px;
90    padding: 0 var(--spacing-xl) 80px;
91}
92
93/* Main content */
94.guide-content {
95    flex: 1;
96    min-width: 0;
97    max-width: 800px;
98    padding-right: var(--spacing-lg);
99}
100
101@media (max-width: 1024px) {
102    .guide-content {
103        padding-right: 0;
104    }
105}
106
107/* Section styling */
108.guide-section {
109    margin-bottom: 64px;
110    scroll-margin-top: 100px;
111}
112
113.guide-section h2 {
114    font-size: var(--font-display-md);
115    font-weight: 800;
116    letter-spacing: -0.8px;
117    line-height: 1.2;
118    background: linear-gradient(180deg, #ffffff 0%, rgba(229,231,235,0.85) 100%);
119    -webkit-background-clip: text;
120    -webkit-text-fill-color: transparent;
121    margin: 0 0 var(--spacing-xl);
122    padding-bottom: var(--spacing-lg);
123    border-bottom: 1px solid rgba(255,255,255,0.08);
124}
125
126.guide-section h3 {
127    font-size: var(--font-heading-sm);
128    font-weight: 700;
129    color: var(--text-primary);
130    margin: var(--spacing-xxl) 0 var(--spacing-lg);
131}
132
133.guide-section p {
134    color: var(--text-secondary);
135    font-size: var(--font-body-sm);
136    line-height: 1.75;
137    margin: 0 0 var(--spacing-lg);
138}
139
140.guide-section ul,
141.guide-section ol {
142    color: var(--text-secondary);
143    font-size: var(--font-body-sm);
144    line-height: 1.75;
145    padding-left: var(--spacing-xl);
146    margin: 0 0 var(--spacing-lg);
147}
148
149.guide-section li {
150    margin-bottom: var(--spacing-sm);
151}
152
153.guide-section code {
154    font-family: var(--font-mono);
155    background: rgba(255,255,255,0.08);
156    padding: 3px 7px;
157    border-radius: var(--radius-sm);
158    font-size: 0.9em;
159    color: var(--color-accent-purple);
160}
161
162/* Fenced code blocks (install commands etc.) — copyable, one command per block */
163.guide-section .guide-code-block {
164    position: relative;
165    margin: 0 0 var(--spacing-lg);
166}
167
168.guide-section .guide-code-block pre {
169    font-family: var(--font-mono);
170    background: rgba(255,255,255,0.05);
171    border: 1px solid rgba(255,255,255,0.08);
172    border-radius: var(--radius-lg);
173    padding: var(--spacing-lg);
174    padding-right: 72px;
175    overflow-x: auto;
176    font-size: 0.9em;
177    line-height: 1.6;
178    margin: 0;
179}
180
181.guide-section .guide-code-block code {
182    background: none;
183    padding: 0;
184    color: var(--text-primary);
185}
186
187.guide-section .guide-code-copy {
188    position: absolute;
189    top: 10px;
190    right: 10px;
191    font-size: 12px;
192    color: var(--text-secondary);
193    background: rgba(255,255,255,0.06);
194    border: 1px solid rgba(255,255,255,0.1);
195    border-radius: var(--radius-sm);
196    padding: 3px 10px;
197    cursor: pointer;
198}
199
200.guide-section .guide-code-copy:hover {
201    color: var(--text-primary);
202    background: rgba(255,255,255,0.12);
203}
204
205.guide-section strong {
206    color: var(--text-primary);
207    font-weight: 600;
208}
209
210/* Tables - wrapped in scrollable container */
211.guide-section .table-wrapper {
212    width: 100%;
213    overflow-x: auto;
214    margin: var(--spacing-xl) 0;
215    border-radius: var(--radius-lg);
216    border: 1px solid rgba(255,255,255,0.08);
217    -webkit-overflow-scrolling: touch;
218}
219
220.guide-section table {
221    width: 100%;
222    min-width: 400px;
223    border-collapse: collapse;
224    font-size: var(--font-body-md);
225}
226
227.guide-section th {
228    text-align: left;
229    padding: 14px var(--spacing-lg);
230    background: rgba(255,255,255,0.05);
231    color: var(--text-primary);
232    font-weight: 600;
233    border-bottom: 1px solid rgba(255,255,255,0.08);
234    white-space: nowrap;
235}
236
237.guide-section td {
238    padding: var(--spacing-md) var(--spacing-lg);
239    color: var(--text-secondary);
240    border-bottom: 1px solid rgba(255,255,255,0.05);
241}
242
243.guide-section tr:last-child td {
244    border-bottom: none;
245}
246
247.guide-section tr:hover td {
248    background: rgba(255,255,255,0.02);
249}
250
251/* Collapsible examples container */
252.guide-examples-collapsible {
253    margin-top: var(--spacing-xl);
254    border: 1px solid rgba(255,255,255,0.08);
255    border-radius: var(--radius-lg);
256    overflow: hidden;
257}
258
259.guide-examples-header {
260    display: flex;
261    align-items: center;
262    justify-content: space-between;
263    padding: var(--spacing-lg) var(--spacing-xl);
264    background: rgba(255,255,255,0.03);
265    cursor: pointer;
266    user-select: none;
267    transition: background 0.15s ease;
268}
269
270.guide-examples-header:hover {
271    background: rgba(255,255,255,0.05);
272}
273
274.guide-examples-header-left {
275    display: flex;
276    align-items: center;
277    gap: var(--spacing-md);
278}
279
280.guide-examples-title {
281    font-size: var(--font-body-md);
282    font-weight: 600;
283    color: var(--text-primary);
284}
285
286.guide-examples-count {
287    font-size: var(--font-caption-md);
288    color: var(--text-tertiary);
289    background: rgba(255,255,255,0.06);
290    padding: 4px 10px;
291    border-radius: var(--radius-full);
292}
293
294.guide-examples-chevron {
295    font-size: 12px;
296    color: var(--text-tertiary);
297    transition: transform 0.2s ease;
298}
299
300.guide-examples-collapsible.expanded .guide-examples-chevron {
301    transform: rotate(90deg);
302}
303
304.guide-examples-content {
305    max-height: 0;
306    overflow: hidden;
307    transition: max-height 0.3s ease;
308}
309
310.guide-examples-collapsible.expanded .guide-examples-content {
311    max-height: 10000px;
312}
313
314.guide-examples-inner {
315    padding: var(--spacing-lg);
316    display: flex;
317    flex-direction: column;
318    gap: var(--spacing-lg);
319}
320
321/* Part dividers */
322.guide-part-divider {
323    margin: 80px 0 48px;
324    padding: var(--spacing-xl) 0;
325    border-top: 1px solid rgba(255,255,255,0.08);
326}
327
328.guide-part-divider h2 {
329    font-size: var(--font-body-md);
330    font-weight: 700;
331    text-transform: uppercase;
332    letter-spacing: 1.5px;
333    color: var(--text-tertiary);
334    margin: 0;
335    background: none;
336    -webkit-text-fill-color: currentColor;
337    border-bottom: none;
338    padding-bottom: 0;
339}
340
341/* Section number */
342.section-number {
343    display: inline-block;
344    font-size: var(--font-body-md);
345    font-weight: 700;
346    color: var(--color-accent-purple);
347    margin-right: var(--spacing-sm);
348    opacity: 0.8;
349}
350
351/* Examples container */
352.guide-examples {
353    margin-top: var(--spacing-xl);
354    display: flex;
355    flex-direction: column;
356    gap: var(--spacing-lg);
357}
358
359/* Responsive */
360@media (max-width: 1024px) {
361    .guide-layout {
362        flex-direction: column;
363    }
364
365    .guide-hero h1 {
366        font-size: var(--font-display-md);
367    }
368
369    .guide-hero {
370        padding: 40px var(--spacing-xl) var(--spacing-xxl);
371    }
372}
373
374@media (max-width: 640px) {
375    .guide-hero h1 {
376        font-size: var(--font-heading-lg);
377    }
378
379    .guide-hero p {
380        font-size: var(--font-body-md);
381    }
382
383    .guide-section h2 {
384        font-size: var(--font-heading-lg);
385    }
386}
387
388.guide-community-cta {
389    text-align: center;
390    padding: var(--spacing-xxl) var(--spacing-xl);
391    margin-top: var(--spacing-xxl);
392    border-top: 1px solid rgba(255,255,255,0.06);
393}
394
395.guide-community-cta p {
396    font-size: var(--font-body-lg);
397    color: var(--text-secondary);
398    margin-bottom: var(--spacing-md);
399}
400
401.guide-community-cta a {
402    display: inline-block;
403    padding: 8px 20px;
404    border-radius: var(--radius-full);
405    background: rgba(167,139,250,0.06);
406    border: 1px solid rgba(167,139,250,0.3);
407    color: var(--color-accent-purple);
408    font-weight: 600;
409    font-size: var(--font-body-md);
410    text-decoration: none;
411    transition: background 0.2s, border-color 0.2s;
412}
413
414.guide-community-cta a:hover {
415    background: rgba(167,139,250,0.14);
416    border-color: rgba(167,139,250,0.5);
417}
418"#;
419
420// `lazy`: with the `split` feature + dx `--wasm-split`, this page's body (and the
421// guide content strings only it references) moves into the lazily-fetched chunk.
422#[component(lazy)]
423pub fn Guide() -> Element {
424    let mut active_section = use_signal(|| "introduction".to_string());
425
426    // Build section info for sidebar
427    let sections_info: Vec<SectionInfo> = SECTIONS.iter().map(|s| SectionInfo {
428        id: s.id.to_string(),
429        number: s.number,
430        title: s.title.to_string(),
431        part: s.part.to_string(),
432    }).collect();
433
434    // Collect all section IDs for intersection observer
435    #[allow(unused_variables)]
436    let section_ids: Vec<String> = SECTIONS.iter().map(|s| s.id.to_string()).collect();
437
438    // Set up scroll tracking with IntersectionObserver
439    #[cfg(target_arch = "wasm32")]
440    {
441        use wasm_bindgen::prelude::*;
442        use wasm_bindgen::JsCast;
443
444        let section_ids_for_effect = section_ids.clone();
445
446        use_effect(move || {
447            let window = match web_sys::window() {
448                Some(w) => w,
449                None => return,
450            };
451            let document = match window.document() {
452                Some(d) => d,
453                None => return,
454            };
455
456            // Use RefCell to allow mutation from within Fn closure
457            use std::cell::RefCell;
458            use std::rc::Rc;
459
460            let active_section_clone = Rc::new(RefCell::new(active_section.clone()));
461            let active_section_for_closure = active_section_clone.clone();
462
463            let callback = Closure::<dyn Fn(js_sys::Array, web_sys::IntersectionObserver)>::new(
464                move |entries: js_sys::Array, _observer: web_sys::IntersectionObserver| {
465                    // Simple approach: when a section crosses the threshold line,
466                    // it becomes active
467                    for i in 0..entries.length() {
468                        if let Ok(entry) = entries.get(i).dyn_into::<web_sys::IntersectionObserverEntry>() {
469                            if entry.is_intersecting() {
470                                let target = entry.target();
471                                let id = target.id();
472                                if !id.is_empty() {
473                                    active_section_for_closure.borrow_mut().set(id);
474                                }
475                            }
476                        }
477                    }
478                },
479            );
480
481            // Create IntersectionObserver options
482            let mut options = web_sys::IntersectionObserverInit::new();
483            // Root margin creates a thin "tripwire" near the top of the screen
484            options.root_margin("-100px 0px -90% 0px");
485            let thresholds = js_sys::Array::new();
486            thresholds.push(&JsValue::from(0.0));
487            options.threshold(&thresholds);
488
489            // Create the observer
490            let observer = match web_sys::IntersectionObserver::new_with_options(
491                callback.as_ref().unchecked_ref(),
492                &options,
493            ) {
494                Ok(obs) => obs,
495                Err(_) => return,
496            };
497
498            // Observe all sections
499            for section_id in &section_ids_for_effect {
500                if let Some(element) = document.get_element_by_id(section_id) {
501                    observer.observe(&element);
502                }
503            }
504
505            // Keep callback alive
506            callback.forget();
507        });
508    }
509
510    // Track current part for dividers
511    let mut current_part = String::new();
512
513    let breadcrumbs = vec![
514        BreadcrumbItem { name: "Home", path: "/" },
515        BreadcrumbItem { name: "Syntax Guide", path: "/guide" },
516    ];
517    let schemas = vec![
518        organization_schema(),
519        tech_article_schema("LOGOS Syntax Guide", "Comprehensive guide to LOGOS syntax, features, and First-Order Logic concepts with interactive examples.", "/guide"),
520        breadcrumb_schema(&breadcrumbs),
521    ];
522
523    rsx! {
524        PageHead {
525            title: seo_pages::GUIDE.title,
526            description: seo_pages::GUIDE.description,
527            canonical_path: seo_pages::GUIDE.canonical_path,
528        }
529        style { "{GUIDE_STYLE}" }
530        JsonLdMultiple { schemas }
531
532        div { class: "guide-page",
533            // Navigation
534            MainNav {
535                active: ActivePage::Guide,
536                subtitle: Some("Programmer's Guide"),
537            }
538
539            // Hero
540            header { class: "guide-hero",
541                div { class: "guide-hero-badge",
542                    div { class: "dot" }
543                    span { "Interactive Guide" }
544                }
545                h1 { "LOGOS Syntax Guide" }
546                p {
547                    "Write English. Get Logic. Run Code. A comprehensive guide to programming in LOGOS, from basics to advanced features."
548                }
549            }
550
551            // Main layout
552            div { class: "guide-layout",
553                // Sidebar
554                GuideSidebar {
555                    sections: sections_info,
556                    active_section: active_section.read().clone(),
557                    on_section_click: move |id: String| {
558                        active_section.set(id);
559                    },
560                }
561
562                // Content
563                main { class: "guide-content",
564                    for section in SECTIONS.iter() {
565                        {
566                            // Check if we need a part divider
567                            let show_divider = section.part != current_part && section.number > 1;
568                            current_part = section.part.to_string();
569
570                            rsx! {
571                                // Part divider
572                                if show_divider {
573                                    div { class: "guide-part-divider",
574                                        h2 { "{section.part}" }
575                                    }
576                                }
577
578                                // Section
579                                section {
580                                    id: "{section.id}",
581                                    class: "guide-section",
582
583                                    h2 {
584                                        span { class: "section-number", "{section.number}." }
585                                        "{section.title}"
586                                    }
587
588                                    // Render content as HTML
589                                    div {
590                                        dangerous_inner_html: render_markdown(section.content)
591                                    }
592
593                                    // Render code examples - collapsible if many
594                                    if !section.examples.is_empty() {
595                                        {
596                                            let example_count = section.examples.len();
597                                            let should_collapse = example_count > 3;
598
599                                            if should_collapse {
600                                                rsx! {
601                                                    CollapsibleExamples {
602                                                        section_id: section.id.to_string(),
603                                                        examples: section.examples,
604                                                    }
605                                                }
606                                            } else {
607                                                rsx! {
608                                                    div { class: "guide-examples",
609                                                        for example in section.examples.iter() {
610                                                            GuideCodeBlock {
611                                                                id: example.id.to_string(),
612                                                                label: example.label.to_string(),
613                                                                mode: example.mode,
614                                                                initial_code: example.code.to_string(),
615                                                            }
616                                                        }
617                                                    }
618                                                }
619                                            }
620                                        }
621                                    }
622                                }
623                            }
624                        }
625                        div { class: "guide-community-cta",
626                            p { "Have questions or feedback? Join our Discord community." }
627                            a {
628                                href: "https://discord.gg/pwnjnXvUHH",
629                                target: "_blank",
630                                rel: "noopener noreferrer",
631                                "Join Discord →"
632                            }
633                        }
634                    }
635                }
636            }
637
638            Footer {}
639        }
640    }
641}
642
643/// Collapsible examples component for sections with many examples
644#[component]
645fn CollapsibleExamples(section_id: String, examples: &'static [content::CodeExample]) -> Element {
646    let mut expanded = use_signal(|| false);
647    let example_count = examples.len();
648
649    rsx! {
650        div {
651            class: if *expanded.read() { "guide-examples-collapsible expanded" } else { "guide-examples-collapsible" },
652
653            div {
654                class: "guide-examples-header",
655                onclick: move |_| expanded.toggle(),
656
657                div { class: "guide-examples-header-left",
658                    span { class: "guide-examples-title", "Code Examples" }
659                    span { class: "guide-examples-count", "{example_count} examples" }
660                }
661                span { class: "guide-examples-chevron", "▶" }
662            }
663
664            div { class: "guide-examples-content",
665                div { class: "guide-examples-inner",
666                    for example in examples.iter() {
667                        GuideCodeBlock {
668                            id: example.id.to_string(),
669                            label: example.label.to_string(),
670                            mode: example.mode,
671                            initial_code: example.code.to_string(),
672                        }
673                    }
674                }
675            }
676        }
677    }
678}
679
680/// Simple markdown to HTML converter
681/// Handles: fenced code blocks, headers, paragraphs, lists, tables, inline code, bold
682fn render_markdown(content: &str) -> String {
683    let mut html = String::new();
684    let mut in_list = false;
685    let mut in_table = false;
686    let mut in_table_header = false;
687    let mut in_code_block = false;
688    let mut code_buf = String::new();
689
690    for line in content.lines() {
691        let trimmed = line.trim();
692
693        // Fenced code blocks — raw lines, indentation and blank lines preserved
694        if in_code_block {
695            if trimmed.starts_with("```") {
696                html.push_str(&code_block_html(&code_buf));
697                code_buf.clear();
698                in_code_block = false;
699            } else {
700                code_buf.push_str(line);
701                code_buf.push('\n');
702            }
703            continue;
704        }
705        if trimmed.starts_with("```") {
706            if in_list { html.push_str("</ul>"); in_list = false; }
707            if in_table { html.push_str("</tbody></table></div>"); in_table = false; }
708            in_code_block = true;
709            continue;
710        }
711
712        // Skip empty lines
713        if trimmed.is_empty() {
714            if in_list {
715                html.push_str("</ul>");
716                in_list = false;
717            }
718            if in_table {
719                html.push_str("</tbody></table></div>");
720                in_table = false;
721            }
722            continue;
723        }
724
725        // Headers
726        if trimmed.starts_with("### ") {
727            if in_list { html.push_str("</ul>"); in_list = false; }
728            if in_table { html.push_str("</tbody></table></div>"); in_table = false; }
729            html.push_str(&format!("<h3>{}</h3>", inline_markdown(&trimmed[4..])));
730            continue;
731        }
732
733        // Table row
734        if trimmed.starts_with('|') && trimmed.ends_with('|') {
735            // Check if this is a separator row (|---|---|)
736            if trimmed.contains("---") {
737                in_table_header = false;
738                continue;
739            }
740
741            if !in_table {
742                html.push_str("<div class=\"table-wrapper\"><table><thead>");
743                in_table = true;
744                in_table_header = true;
745            }
746
747            let cells: Vec<&str> = trimmed[1..trimmed.len()-1]
748                .split('|')
749                .map(|s| s.trim())
750                .collect();
751
752            if in_table_header {
753                html.push_str("<tr>");
754                for cell in &cells {
755                    html.push_str(&format!("<th>{}</th>", inline_markdown(cell)));
756                }
757                html.push_str("</tr></thead><tbody>");
758            } else {
759                html.push_str("<tr>");
760                for cell in &cells {
761                    html.push_str(&format!("<td>{}</td>", inline_markdown(cell)));
762                }
763                html.push_str("</tr>");
764            }
765            continue;
766        }
767
768        // Close table if not a table row
769        if in_table && !trimmed.starts_with('|') {
770            html.push_str("</tbody></table></div>");
771            in_table = false;
772        }
773
774        // List items
775        if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
776            if !in_list {
777                html.push_str("<ul>");
778                in_list = true;
779            }
780            html.push_str(&format!("<li>{}</li>", inline_markdown(&trimmed[2..])));
781            continue;
782        }
783
784        // Numbered list
785        if trimmed.chars().next().map_or(false, |c| c.is_ascii_digit()) {
786            if let Some(dot_pos) = trimmed.find(". ") {
787                if !in_list {
788                    html.push_str("<ul>");
789                    in_list = true;
790                }
791                html.push_str(&format!("<li>{}</li>", inline_markdown(&trimmed[dot_pos + 2..])));
792                continue;
793            }
794        }
795
796        // Close list if not a list item
797        if in_list {
798            html.push_str("</ul>");
799            in_list = false;
800        }
801
802        // Paragraph
803        html.push_str(&format!("<p>{}</p>", inline_markdown(trimmed)));
804    }
805
806    // Close any open tags
807    if in_list {
808        html.push_str("</ul>");
809    }
810    if in_table {
811        html.push_str("</tbody></table></div>");
812    }
813    if in_code_block {
814        html.push_str(&code_block_html(&code_buf));
815    }
816
817    html
818}
819
820/// A fenced code block as copy-friendly HTML: a Copy button (clipboard API, inline
821/// handler — the block arrives via `dangerous_inner_html`, so it can't carry a
822/// Dioxus listener) over an escaped `<pre><code>`.
823fn code_block_html(code: &str) -> String {
824    format!(
825        "<div class=\"guide-code-block\">\
826         <button class=\"guide-code-copy\" \
827         onclick=\"navigator.clipboard.writeText(this.nextElementSibling.textContent);\
828         this.textContent='Copied!';setTimeout(()=>{{this.textContent='Copy'}},1200)\">Copy</button>\
829         <pre><code>{}</code></pre></div>",
830        html_escape(code.trim_end_matches('\n'))
831    )
832}
833
834fn html_escape(s: &str) -> String {
835    s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")
836}
837
838/// Process inline markdown: **bold**, `code`, [links]
839fn inline_markdown(text: &str) -> String {
840    let mut result = text.to_string();
841
842    // Escape HTML entities
843    result = result.replace('&', "&amp;");
844    result = result.replace('<', "&lt;");
845    result = result.replace('>', "&gt;");
846
847    // Bold: **text**
848    while let Some(start) = result.find("**") {
849        if let Some(end) = result[start + 2..].find("**") {
850            let before = &result[..start];
851            let inner = &result[start + 2..start + 2 + end];
852            let after = &result[start + 2 + end + 2..];
853            result = format!("{}<strong>{}</strong>{}", before, inner, after);
854        } else {
855            break;
856        }
857    }
858
859    // Inline code: `code`
860    while let Some(start) = result.find('`') {
861        if let Some(end) = result[start + 1..].find('`') {
862            let before = &result[..start];
863            let inner = &result[start + 1..start + 1 + end];
864            let after = &result[start + 1 + end + 1..];
865            result = format!("{}<code>{}</code>{}", before, inner, after);
866        } else {
867            break;
868        }
869    }
870
871    result
872}
873
874#[cfg(test)]
875mod render_markdown_tests {
876    use super::{render_markdown, SECTIONS};
877
878    /// A fenced block becomes one copyable `<pre><code>` unit — never a paragraph
879    /// of mangled inline code (the install-command regression).
880    #[test]
881    fn fenced_block_renders_as_pre_code_with_copy_button() {
882        let md = "Install it:\n\n```bash\ncurl -fsSL https://logicaffeine.com/install.sh | sh\n```\n\nDone.";
883        let html = render_markdown(md);
884        assert!(html.contains("<pre><code>curl -fsSL https://logicaffeine.com/install.sh | sh</code></pre>"), "{html}");
885        assert!(html.contains("guide-code-copy"), "{html}");
886        assert!(!html.contains("```"), "fence markers must never leak into the page: {html}");
887    }
888
889    /// Blank lines and indentation inside a fence are content, not structure.
890    #[test]
891    fn fenced_block_preserves_blank_lines_and_indentation() {
892        let md = "```logos\n## Main\n    Show 1.\n\n    Show 2.\n```";
893        let html = render_markdown(md);
894        assert!(html.contains("## Main\n    Show 1.\n\n    Show 2."), "{html}");
895    }
896
897    /// Code is escaped, and the fence's language tag is not rendered as text.
898    #[test]
899    fn fenced_block_escapes_html_and_drops_language_tag() {
900        let html = render_markdown("```powershell\nirm https://x | iex && a < b\n```");
901        assert!(html.contains("&amp;&amp; a &lt; b"), "{html}");
902        assert!(!html.contains("powershell"), "language tag must not render: {html}");
903    }
904
905    /// The whole shipped guide renders with zero leaked fence markers.
906    #[test]
907    fn no_section_leaks_fence_markers() {
908        for section in SECTIONS {
909            let html = render_markdown(section.content);
910            assert!(!html.contains("```"), "section '{}' leaks fence markers", section.id);
911        }
912    }
913}