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