1pub 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#[component(lazy)]
423pub fn Guide() -> Element {
424 let mut active_section = use_signal(|| "introduction".to_string());
425
426 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 #[allow(unused_variables)]
436 let section_ids: Vec<String> = SECTIONS.iter().map(|s| s.id.to_string()).collect();
437
438 #[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 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 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 let mut options = web_sys::IntersectionObserverInit::new();
483 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 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 for section_id in §ion_ids_for_effect {
500 if let Some(element) = document.get_element_by_id(section_id) {
501 observer.observe(&element);
502 }
503 }
504
505 callback.forget();
507 });
508 }
509
510 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 MainNav {
535 active: ActivePage::Guide,
536 subtitle: Some("Programmer's Guide"),
537 }
538
539 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 div { class: "guide-layout",
553 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 main { class: "guide-content",
564 for section in SECTIONS.iter() {
565 {
566 let show_divider = section.part != current_part && section.number > 1;
568 current_part = section.part.to_string();
569
570 rsx! {
571 if show_divider {
573 div { class: "guide-part-divider",
574 h2 { "{section.part}" }
575 }
576 }
577
578 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 div {
590 dangerous_inner_html: render_markdown(section.content)
591 }
592
593 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#[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
680fn 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 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 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 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 if trimmed.starts_with('|') && trimmed.ends_with('|') {
735 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 if in_table && !trimmed.starts_with('|') {
770 html.push_str("</tbody></table></div>");
771 in_table = false;
772 }
773
774 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 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 if in_list {
798 html.push_str("</ul>");
799 in_list = false;
800 }
801
802 html.push_str(&format!("<p>{}</p>", inline_markdown(trimmed)));
804 }
805
806 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
820fn 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('&', "&").replace('<', "<").replace('>', ">")
836}
837
838fn inline_markdown(text: &str) -> String {
840 let mut result = text.to_string();
841
842 result = result.replace('&', "&");
844 result = result.replace('<', "<");
845 result = result.replace('>', ">");
846
847 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 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 #[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 #[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 #[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("&& a < b"), "{html}");
902 assert!(!html.contains("powershell"), "language tag must not render: {html}");
903 }
904
905 #[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}