Skip to main content

webui_docs/
content.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Content pipeline: reads markdown files, parses frontmatter, builds page state.
5
6use std::collections::HashMap;
7use std::fs;
8use std::path::Path;
9
10use serde_json::{Map, Value};
11
12use crate::error::{Error, Result};
13use crate::markdown::{render_markdown, Highlighter};
14use crate::state::{load_render_states, merge_page_state, LoadedStates};
15use crate::types::{DocsConfig, PageDescriptor, SidebarItem, SidebarSection};
16
17/// Normalize a config link (e.g. `/guide/intro/` or `/guide/intro`) to a
18/// canonical URL path that includes the site's `base_path` prefix and
19/// **never** has a trailing slash (except for root, which is just the base).
20/// GitHub Pages and most static hosts auto-redirect `/foo` → `/foo/index.html`,
21/// so trailing slashes are unnecessary and pollute internal hrefs.
22fn normalize_link(base: &str, link: &str) -> String {
23    if link.is_empty() {
24        return String::new();
25    }
26    // Tolerate config links written without a leading slash; also avoids a
27    // panic when `link` starts with a multi-byte UTF-8 char (a bare `&link[1..]`
28    // would slice mid-char and abort the build).
29    let stripped = link.strip_prefix('/').unwrap_or(link);
30    if link.contains('#') {
31        // Fragment link — preserve original shape, don't touch the slash.
32        return format!("{base}{stripped}");
33    }
34    let cleaned = stripped.trim_end_matches('/');
35    if cleaned.is_empty() {
36        // Root: `base` already ends with '/'.
37        base.to_string()
38    } else {
39        format!("{base}{cleaned}")
40    }
41}
42
43/// Build a JSON object from key-value pairs without using `json!` (which calls `unwrap`).
44fn json_obj<const N: usize>(entries: [(&str, Value); N]) -> Value {
45    let mut map = Map::with_capacity(N);
46    for (k, v) in entries {
47        map.insert(k.to_string(), v);
48    }
49    Value::Object(map)
50}
51
52/// Parsed frontmatter from a markdown file.
53#[derive(Debug)]
54struct Frontmatter {
55    title: Option<String>,
56    description: Option<String>,
57    layout: Option<String>,
58}
59
60fn parse_frontmatter(content: &str) -> Result<(Frontmatter, &str)> {
61    if !content.starts_with("---\n") && !content.starts_with("---\r\n") {
62        return Ok((
63            Frontmatter {
64                title: None,
65                description: None,
66                layout: None,
67            },
68            content,
69        ));
70    }
71
72    // Skip leading "---" + line ending.
73    let body_start_marker = if content.starts_with("---\r\n") { 5 } else { 4 };
74    let after_open = &content[body_start_marker..];
75
76    // Find the closing "---" on its own line (preceded by `\n` or `\r\n`).
77    let (yaml_end_offset, close_skip) = if let Some(i) = after_open.find("\r\n---") {
78        (i, "\r\n---".len())
79    } else if let Some(i) = after_open.find("\n---") {
80        (i, "\n---".len())
81    } else {
82        (after_open.len(), 0)
83    };
84
85    let yaml_str = &after_open[..yaml_end_offset];
86    let body_offset = body_start_marker + yaml_end_offset + close_skip;
87    let body = if body_offset < content.len() {
88        // Strip the line-ending immediately following the closing "---".
89        let rest = &content[body_offset..];
90        rest.strip_prefix("\r\n")
91            .or_else(|| rest.strip_prefix('\n'))
92            .unwrap_or(rest)
93    } else {
94        ""
95    };
96
97    let yaml: HashMap<String, serde_yaml::Value> = serde_yaml::from_str(yaml_str)
98        .map_err(|e| crate::error::Error::Markdown(format!("Invalid frontmatter YAML: {e}")))?;
99
100    Ok((
101        Frontmatter {
102            title: yaml.get("title").and_then(|v| v.as_str()).map(String::from),
103            description: yaml
104                .get("description")
105                .and_then(|v| v.as_str())
106                .map(String::from),
107            layout: yaml
108                .get("layout")
109                .and_then(|v| v.as_str())
110                .map(String::from),
111        },
112        body,
113    ))
114}
115
116fn extract_h1(content: &str) -> Option<String> {
117    for line in content.lines() {
118        if let Some(heading) = line.strip_prefix("# ") {
119            return Some(heading.trim().to_string());
120        }
121    }
122    None
123}
124
125/// Collect all links from a sidebar item and its children (iterative).
126fn collect_sidebar_links(items: &[SidebarItem]) -> Vec<&str> {
127    let mut links = Vec::new();
128    let mut stack: Vec<&SidebarItem> = items.iter().rev().collect();
129    while let Some(item) = stack.pop() {
130        if !item.link.is_empty() {
131            links.push(item.link.as_str());
132        }
133        for child in item.items.iter().rev() {
134            stack.push(child);
135        }
136    }
137    links
138}
139
140/// Build the page registry by walking content_dir for every `.md` file.
141///
142/// Discovery is filesystem-driven: any markdown file under content_dir becomes
143/// a page. The sidebar/nav config controls navigation, not discovery.
144/// Strip redundant filename if it matches the parent folder name.
145/// E.g., "my-button/my-button" → "my-button"
146///      "my-button/usage" → "my-button/usage" (unchanged)
147fn normalize_path_as_index(path: &str) -> &str {
148    // Find the last slash to split parent/filename
149    let Some(slash_pos) = path.rfind('/') else {
150        return path;
151    };
152
153    let parent = &path[..slash_pos];
154    let filename = &path[slash_pos + 1..];
155
156    // Get the parent folder name (last segment before filename)
157    let folder_name = parent.rsplit('/').next().unwrap_or("");
158
159    // If folder name matches filename, treat as index and strip filename
160    if folder_name == filename {
161        parent
162    } else {
163        path
164    }
165}
166
167fn build_page_registry(content_dir: &Path, base_path: &str) -> Vec<(String, std::path::PathBuf)> {
168    let mut pages = Vec::new();
169    let mut stack: Vec<std::path::PathBuf> = vec![content_dir.to_path_buf()];
170
171    while let Some(dir) = stack.pop() {
172        let Ok(entries) = fs::read_dir(&dir) else {
173            continue;
174        };
175        for entry in entries.flatten() {
176            let path = entry.path();
177            // Skip dotfiles/dotdirs and the build output.
178            let name = entry.file_name();
179            let name_str = name.to_string_lossy();
180            if name_str.starts_with('.') || name_str == "dist" || name_str == "node_modules" {
181                continue;
182            }
183            if path.is_dir() {
184                stack.push(path);
185                continue;
186            }
187            if path.extension().and_then(|s| s.to_str()) != Some("md") {
188                continue;
189            }
190
191            // Compute URL: relative path from content_dir, drop trailing /index.md,
192            // drop .md, prefix with base_path.
193            let rel = match path.strip_prefix(content_dir) {
194                Ok(r) => r,
195                Err(_) => continue,
196            };
197            let rel_str = rel.to_string_lossy().replace('\\', "/");
198            let trimmed = rel_str.trim_end_matches(".md");
199
200            // Strip /index suffix
201            let trimmed = trimmed.strip_suffix("/index").unwrap_or(trimmed);
202
203            // Strip redundant filename if it matches parent folder
204            // e.g., "webui-button/webui-button" → "webui-button"
205            let trimmed = normalize_path_as_index(trimmed);
206
207            // Root index.md => "/"
208            let url_path = if trimmed.is_empty() || trimmed == "index" {
209                if base_path.is_empty() {
210                    "/".to_string()
211                } else {
212                    base_path.trim_end_matches('/').to_string() + "/"
213                }
214            } else {
215                let prefix = base_path.trim_end_matches('/');
216                format!("{prefix}/{trimmed}")
217            };
218            pages.push((url_path, path));
219        }
220    }
221
222    pages.sort_by(|a, b| a.0.cmp(&b.0));
223    pages
224}
225
226fn build_sidebar_state(url_path: &str, config: &DocsConfig) -> serde_json::Value {
227    let base = &config.base_path;
228
229    // Determine active sidebar
230    let active_sidebar = config
231        .sidebar_groups
232        .iter()
233        .find(|(prefix, _)| {
234            let full_prefix = format!("{}{}", base, prefix.strip_prefix('/').unwrap_or(prefix));
235            let trimmed = full_prefix.trim_end_matches('/');
236            url_path == trimmed || url_path.starts_with(&format!("{trimmed}/"))
237        })
238        .map(|(_, s)| s.as_slice())
239        .unwrap_or(&config.sidebar);
240
241    // Convert a SidebarItem to a JSON value (iterative via stack for children)
242    let item_to_value = |item: &SidebarItem| -> Value {
243        let item_path = normalize_link(base, &item.link);
244        let active = !item_path.is_empty() && item_path == url_path;
245        let children: Vec<Value> = item
246            .items
247            .iter()
248            .map(|child| {
249                let child_path = normalize_link(base, &child.link);
250                let child_active = !child_path.is_empty() && child_path == url_path;
251                json_obj([
252                    ("text", Value::String(child.text.clone())),
253                    ("link", Value::String(child_path)),
254                    ("active", Value::Bool(child_active)),
255                    ("hasChildren", Value::Bool(false)),
256                    ("children", Value::Array(vec![])),
257                ])
258            })
259            .collect();
260        json_obj([
261            ("text", Value::String(item.text.clone())),
262            ("link", Value::String(item_path)),
263            ("active", Value::Bool(active)),
264            ("hasChildren", Value::Bool(!children.is_empty())),
265            ("children", Value::Array(children)),
266        ])
267    };
268
269    let sections: Vec<Value> = active_sidebar
270        .iter()
271        .map(|section| {
272            let items: Vec<Value> = section.items.iter().map(item_to_value).collect();
273            json_obj([
274                ("title", Value::String(section.title.clone())),
275                ("items", Value::Array(items)),
276            ])
277        })
278        .collect();
279
280    json_obj([("sections", Value::Array(sections))])
281}
282
283fn find_sidebar_text(url_path: &str, config: &DocsConfig) -> Option<String> {
284    let base = &config.base_path;
285    let all: Vec<&[SidebarSection]> = std::iter::once(config.sidebar.as_slice())
286        .chain(config.sidebar_groups.values().map(|v| v.as_slice()))
287        .collect();
288
289    for sidebar in all {
290        for section in sidebar {
291            // Use iterative stack to search nested items
292            let mut stack: Vec<&SidebarItem> = section.items.iter().collect();
293            while let Some(item) = stack.pop() {
294                if !item.link.is_empty() {
295                    let item_path = normalize_link(base, &item.link);
296                    if item_path == url_path {
297                        return Some(item.text.clone());
298                    }
299                }
300                for child in &item.items {
301                    stack.push(child);
302                }
303            }
304        }
305    }
306    None
307}
308
309/// Process all content files and return page descriptors.
310///
311/// `config_dir` is the directory containing `config.json`; relative paths
312/// declared in the config (such as a custom page's `stateFile`) are resolved
313/// against it.
314///
315/// `head_injection` is the fully-resolved `<head>` snippet (CSS link
316/// tags + `config.head` entries). It MUST be computed before this call
317/// so the descriptor is render-ready.
318#[allow(dead_code)] // Public library API; the binary uses preloaded state for 404 parity.
319pub fn process_content(
320    config: &DocsConfig,
321    config_dir: &Path,
322    highlighter: &Highlighter,
323    head_injection: &str,
324) -> Result<Vec<PageDescriptor>> {
325    let states = load_render_states(config, config_dir)?;
326    process_content_with_states(config, highlighter, head_injection, &states)
327}
328
329pub(crate) fn process_content_with_states(
330    config: &DocsConfig,
331    highlighter: &Highlighter,
332    head_injection: &str,
333    states: &LoadedStates,
334) -> Result<Vec<PageDescriptor>> {
335    let content_dir = Path::new(&config.content_dir);
336    let base_path = &config.base_path;
337
338    let nav_links: Vec<Value> = config
339        .nav
340        .iter()
341        .map(|item| {
342            let (link, section) = if item.link.starts_with("http") {
343                // External links can never match a docs section. Use the URL
344                // itself as a unique sentinel so the equality check below
345                // never matches any page's section slug.
346                (item.link.clone(), item.link.clone())
347            } else {
348                let full = format!("{}{}", base_path, &item.link[1..]);
349                // Derive a section slug from the raw config link, e.g. "/guide/" -> "guide".
350                let slug = item
351                    .link
352                    .trim_start_matches('/')
353                    .split('/')
354                    .next()
355                    .unwrap_or("")
356                    .to_string();
357                (full, slug)
358            };
359            json_obj([
360                ("text", Value::String(item.text.clone())),
361                ("link", Value::String(link)),
362                ("section", Value::String(section)),
363            ])
364        })
365        .collect();
366
367    let mut registry = build_page_registry(content_dir, base_path);
368
369    // Register custom pages
370    for page_link in config.custom_pages.keys() {
371        let normalized = if page_link.ends_with('/') {
372            page_link.clone()
373        } else {
374            format!("{}/", page_link)
375        };
376        let url_path = format!("{}{}", base_path, &normalized[1..]);
377        let src_file = format!("{}index.md", &normalized[1..]);
378        let full_path = content_dir.join(&src_file);
379        if !registry.iter().any(|(p, _)| p == &url_path) {
380            registry.push((url_path, full_path));
381        }
382    }
383
384    use rayon::prelude::*;
385
386    // Per-page processing: parse markdown / load custom HTML, expand
387    // syntax highlighting, build the state JSON. Always runs for every
388    // page on every call — there is no descriptor cache. The dev
389    // server keeps a `BuildCache` only to amortize the syntect
390    // highlighter load.
391    let results: Vec<(usize, PageDescriptor)> = registry
392        .par_iter()
393        .enumerate()
394        .map(
395            |(idx, (url_path, full_path))| -> Result<(usize, PageDescriptor)> {
396                let mut is_home = false;
397                let mut html = String::new();
398                let mut title = config.site.title.clone();
399                let mut description = config.site.description.clone();
400                let mut layout = "doc".to_string();
401
402                // Check custom page override
403                let logical_path = format!("/{}", &url_path[base_path.len()..]);
404                let custom_entry = config
405                    .custom_pages
406                    .get(&logical_path)
407                    .or_else(|| config.custom_pages.get(logical_path.trim_end_matches('/')));
408                let custom_state = states.custom_page_state(&logical_path);
409
410                if let Some(entry) = custom_entry {
411                    html = entry.html().to_string();
412                    layout = entry.layout().to_string();
413                } else if full_path.exists() {
414                    let raw = fs::read_to_string(full_path)
415                        .map_err(|e| crate::error::Error::Io(e.to_string()))?;
416                    let (fm, body) = parse_frontmatter(&raw).map_err(|e| {
417                        crate::error::Error::Markdown(format!("{}: {e}", full_path.display()))
418                    })?;
419
420                    is_home = fm.layout.as_deref() == Some("home");
421                    layout = if is_home {
422                        "home".to_string()
423                    } else {
424                        fm.layout.unwrap_or_else(|| "doc".to_string())
425                    };
426
427                    if !is_home {
428                        // Canonical page URL: every page is written as
429                        // `<dir>/index.html`, so it is served with a trailing
430                        // slash. In-page anchors need this exact path so they
431                        // aren't resolved against `<base href>`.
432                        let page_url = if url_path.ends_with('/') {
433                            url_path.to_string()
434                        } else {
435                            format!("{url_path}/")
436                        };
437                        html = render_markdown(body, highlighter, base_path, &page_url)?;
438                    }
439
440                    if let Some(d) = fm.description {
441                        description = d;
442                    }
443
444                    let h1 = extract_h1(body);
445                    let sidebar_text = find_sidebar_text(url_path, config);
446                    title = fm
447                        .title
448                        .or(h1)
449                        .or(sidebar_text)
450                        .unwrap_or_else(|| config.site.title.clone());
451                }
452
453                let hero_val = if is_home {
454                    config
455                        .hero
456                        .as_ref()
457                        .map(|h| {
458                            json_obj([
459                                (
460                                    "text",
461                                    h.text
462                                        .as_ref()
463                                        .map_or(Value::Null, |t| Value::String(t.clone())),
464                                ),
465                                ("tagline", Value::String(h.tagline.clone())),
466                                (
467                                    "manifesto",
468                                    h.manifesto
469                                        .as_ref()
470                                        .map_or(Value::Null, |m| Value::String(m.clone())),
471                                ),
472                                (
473                                    "actions",
474                                    Value::Array(
475                                        h.actions
476                                            .iter()
477                                            .map(|a| {
478                                                json_obj([
479                                                    ("text", Value::String(a.text.clone())),
480                                                    ("link", Value::String(a.link.clone())),
481                                                    ("brand", Value::Bool(a.brand)),
482                                                ])
483                                            })
484                                            .collect(),
485                                    ),
486                                ),
487                                (
488                                    "features",
489                                    Value::Array(
490                                        h.features
491                                            .iter()
492                                            .map(|f| {
493                                                json_obj([
494                                                    ("icon", Value::String(f.icon.clone())),
495                                                    ("title", Value::String(f.title.clone())),
496                                                    (
497                                                        "description",
498                                                        Value::String(f.description.clone()),
499                                                    ),
500                                                ])
501                                            })
502                                            .collect(),
503                                    ),
504                                ),
505                            ])
506                        })
507                        .unwrap_or(Value::Null)
508                } else {
509                    Value::Null
510                };
511
512                let footer_val = config
513                    .footer
514                    .as_ref()
515                    .map(|f| json_obj([("html", Value::String(f.html.clone()))]))
516                    .unwrap_or(Value::Null);
517
518                // Top-level section slug for the current page, derived from the
519                // first path segment after the base. Used by the nav template to
520                // mark the active link via `?active="{{link.section == page.section}}"`.
521                let page_section = logical_path
522                    .trim_start_matches('/')
523                    .split('/')
524                    .next()
525                    .unwrap_or("")
526                    .to_string();
527
528                let state = json_obj([
529                    (
530                        "site",
531                        json_obj([
532                            ("title", Value::String(config.site.title.clone())),
533                            ("base", Value::String(base_path.to_string())),
534                        ]),
535                    ),
536                    ("navigation", Value::Array(nav_links.clone())),
537                    ("sidebar", build_sidebar_state(url_path, config)),
538                    (
539                        "page",
540                        json_obj([
541                            ("title", Value::String(title.clone())),
542                            ("description", Value::String(description.clone())),
543                            ("content", Value::String(html.clone())),
544                            ("isHome", Value::Bool(is_home)),
545                            ("layout", Value::String(layout)),
546                            ("section", Value::String(page_section)),
547                        ]),
548                    ),
549                    ("hero", hero_val),
550                    ("footer", footer_val),
551                    ("prev", Value::Null),
552                    ("next", Value::Null),
553                    ("pageData", custom_state.cloned().unwrap_or(Value::Null)),
554                    ("headTags", Value::String(head_injection.to_string())),
555                    ("label", Value::String("Copy".to_string())),
556                    ("icon", Value::String("🌙".to_string())),
557                ]);
558
559                // Flatten shared state first, then custom-page state. Component
560                // templates can bind directly to these fields (e.g.
561                // `<for each="item in files">`) while reserved docs keys keep
562                // their canonical values.
563                let state = merge_page_state(state, states.global(), custom_state);
564
565                Ok((
566                    idx,
567                    PageDescriptor {
568                        path: url_path.clone(),
569                        is_home,
570                        state,
571                    },
572                ))
573            },
574        )
575        .collect::<Result<Vec<_>>>()?;
576
577    // Build prev/next links from the sidebar that matches each page's URL.
578    // We resolve the active sidebar per-page by checking sidebar_groups
579    // prefixes, then falling back to the default config.sidebar.
580    let resolve_sidebar = |page_path: &str| -> Vec<SidebarItem> {
581        let active = config
582            .sidebar_groups
583            .iter()
584            .find(|(prefix, _)| {
585                let full_prefix = format!(
586                    "{}{}",
587                    base_path,
588                    prefix.strip_prefix('/').unwrap_or(prefix)
589                );
590                let trimmed = full_prefix.trim_end_matches('/');
591                page_path == trimmed || page_path.starts_with(&format!("{trimmed}/"))
592            })
593            .map(|(_, s)| s.as_slice())
594            .unwrap_or(&config.sidebar);
595        active.iter().flat_map(|s| s.items.clone()).collect()
596    };
597
598    // Restore canonical (registry) order and apply prev/next to each
599    // descriptor. Carrying the registry index explicitly through the
600    // parallel pass avoids depending on `par_iter().collect()`'s
601    // implicit order-preservation contract.
602    let mut pages: Vec<Option<PageDescriptor>> = (0..results.len()).map(|_| None).collect();
603    for (idx, mut desc) in results {
604        let sidebar_items = resolve_sidebar(&desc.path);
605        let flat_links = collect_sidebar_links(&sidebar_items);
606        let all_links: Vec<(String, String)> = flat_links
607            .iter()
608            .map(|link| {
609                let path = normalize_link(base_path, link);
610                let text = find_sidebar_text(&path, config).unwrap_or_default();
611                (path, text)
612            })
613            .collect();
614        if let Some(pos) = all_links.iter().position(|(p, _)| p == &desc.path) {
615            if pos > 0 {
616                let (link, text) = &all_links[pos - 1];
617                desc.state["prev"] = json_obj([
618                    ("link", Value::String(link.clone())),
619                    ("text", Value::String(text.clone())),
620                ]);
621            }
622            if pos + 1 < all_links.len() {
623                let (link, text) = &all_links[pos + 1];
624                desc.state["next"] = json_obj([
625                    ("link", Value::String(link.clone())),
626                    ("text", Value::String(text.clone())),
627                ]);
628            }
629        }
630        pages[idx] = Some(desc);
631    }
632    let pages: Vec<PageDescriptor> = pages
633        .into_iter()
634        .enumerate()
635        .map(|(i, slot)| {
636            slot.ok_or_else(|| Error::Build(format!("missing page at registry index {i}")))
637        })
638        .collect::<Result<Vec<_>>>()?;
639
640    Ok(pages)
641}
642
643#[cfg(test)]
644mod tests {
645    use super::*;
646
647    // --- normalize_link --------------------------------------------------
648
649    #[test]
650    fn normalize_link_empty_returns_empty() {
651        assert_eq!(normalize_link("/webui/", ""), "");
652    }
653
654    #[test]
655    fn normalize_link_root_slash_returns_base() {
656        assert_eq!(normalize_link("/webui/", "/"), "/webui/");
657    }
658
659    #[test]
660    fn normalize_link_trims_trailing_slash() {
661        assert_eq!(
662            normalize_link("/webui/", "/guide/intro/"),
663            "/webui/guide/intro"
664        );
665        assert_eq!(
666            normalize_link("/webui/", "/guide/intro"),
667            "/webui/guide/intro"
668        );
669    }
670
671    #[test]
672    fn normalize_link_preserves_fragment() {
673        assert_eq!(
674            normalize_link("/webui/", "/guide/intro#section"),
675            "/webui/guide/intro#section"
676        );
677    }
678
679    #[test]
680    fn normalize_link_tolerates_missing_leading_slash() {
681        // Was previously a slice-from-1 panic risk on multi-byte chars.
682        assert_eq!(
683            normalize_link("/webui/", "guide/intro"),
684            "/webui/guide/intro"
685        );
686    }
687
688    #[test]
689    fn normalize_link_tolerates_non_ascii_first_char() {
690        // `&link[1..]` would have panicked here on UTF-8 boundary.
691        let out = normalize_link("/webui/", "é-page");
692        assert!(out.ends_with("é-page"), "got {out}");
693    }
694
695    // --- normalize_path_as_index -----------------------------------------
696
697    #[test]
698    fn normalize_path_as_index_strips_duplicate_folder_name() {
699        assert_eq!(
700            normalize_path_as_index("webui-button/webui-button"),
701            "webui-button"
702        );
703    }
704
705    #[test]
706    fn normalize_path_as_index_keeps_different_filename() {
707        assert_eq!(
708            normalize_path_as_index("webui-button/usage"),
709            "webui-button/usage"
710        );
711    }
712
713    #[test]
714    fn normalize_path_as_index_nested_folders() {
715        assert_eq!(
716            normalize_path_as_index("components/webui-button/webui-button"),
717            "components/webui-button"
718        );
719    }
720
721    #[test]
722    fn normalize_path_as_index_no_slash_returns_unchanged() {
723        assert_eq!(normalize_path_as_index("webui-button"), "webui-button");
724    }
725
726    #[test]
727    fn normalize_path_as_index_deep_nesting_with_match() {
728        assert_eq!(
729            normalize_path_as_index("a/b/c/webui-card/webui-card"),
730            "a/b/c/webui-card"
731        );
732    }
733
734    #[test]
735    fn normalize_path_as_index_deep_nesting_without_match() {
736        assert_eq!(
737            normalize_path_as_index("a/b/c/webui-card/examples"),
738            "a/b/c/webui-card/examples"
739        );
740    }
741
742    // --- parse_frontmatter -----------------------------------------------
743
744    #[test]
745    fn parse_frontmatter_no_frontmatter_returns_full_body() {
746        let (fm, body) = parse_frontmatter("# Hello\nworld\n").expect("ok");
747        assert!(fm.title.is_none());
748        assert!(fm.layout.is_none());
749        assert_eq!(body, "# Hello\nworld\n");
750    }
751
752    #[test]
753    fn parse_frontmatter_valid_yaml_extracts_fields() {
754        let raw = "---\ntitle: Hello\ndescription: World\nlayout: home\n---\n# Body\n";
755        let (fm, body) = parse_frontmatter(raw).expect("ok");
756        assert_eq!(fm.title.as_deref(), Some("Hello"));
757        assert_eq!(fm.description.as_deref(), Some("World"));
758        assert_eq!(fm.layout.as_deref(), Some("home"));
759        assert_eq!(body, "# Body\n");
760    }
761
762    #[test]
763    fn parse_frontmatter_handles_crlf_line_endings() {
764        let raw = "---\r\ntitle: CRLF\r\nlayout: doc\r\n---\r\n# Body\r\n";
765        let (fm, body) = parse_frontmatter(raw).expect("ok");
766        assert_eq!(fm.title.as_deref(), Some("CRLF"));
767        assert_eq!(fm.layout.as_deref(), Some("doc"));
768        assert_eq!(body, "# Body\r\n");
769    }
770
771    #[test]
772    fn parse_frontmatter_malformed_yaml_returns_error() {
773        // `: : :` is not valid YAML.
774        let raw = "---\ntitle: : : :\n---\nBody\n";
775        let result = parse_frontmatter(raw);
776        assert!(result.is_err(), "expected error, got {result:?}");
777    }
778}