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.
159/// Strip redundant filename if it matches the parent folder name.
160/// E.g., "my-button/my-button" → "my-button"
161///      "my-button/usage" → "my-button/usage" (unchanged)
162fn normalize_path_as_index(path: &str) -> &str {
163    // Find the last slash to split parent/filename
164    let Some(slash_pos) = path.rfind('/') else {
165        return path;
166    };
167
168    let parent = &path[..slash_pos];
169    let filename = &path[slash_pos + 1..];
170
171    // Get the parent folder name (last segment before filename)
172    let folder_name = parent.rsplit('/').next().unwrap_or("");
173
174    // If folder name matches filename, treat as index and strip filename
175    if folder_name == filename {
176        parent
177    } else {
178        path
179    }
180}
181
182fn build_page_registry(content_dir: &Path, base_path: &str) -> Vec<(String, std::path::PathBuf)> {
183    let mut pages = Vec::new();
184    let mut stack: Vec<std::path::PathBuf> = vec![content_dir.to_path_buf()];
185
186    while let Some(dir) = stack.pop() {
187        let Ok(entries) = fs::read_dir(&dir) else {
188            continue;
189        };
190        for entry in entries.flatten() {
191            let path = entry.path();
192            // Skip dotfiles/dotdirs and the build output.
193            let name = entry.file_name();
194            let name_str = name.to_string_lossy();
195            if name_str.starts_with('.') || name_str == "dist" || name_str == "node_modules" {
196                continue;
197            }
198            if path.is_dir() {
199                stack.push(path);
200                continue;
201            }
202            if path.extension().and_then(|s| s.to_str()) != Some("md") {
203                continue;
204            }
205
206            // Compute URL: relative path from content_dir, drop trailing /index.md,
207            // drop .md, prefix with base_path.
208            let rel = match path.strip_prefix(content_dir) {
209                Ok(r) => r,
210                Err(_) => continue,
211            };
212            let rel_str = rel.to_string_lossy().replace('\\', "/");
213            let trimmed = rel_str.trim_end_matches(".md");
214
215            // Strip /index suffix
216            let trimmed = trimmed.strip_suffix("/index").unwrap_or(trimmed);
217
218            // Strip redundant filename if it matches parent folder
219            // e.g., "webui-button/webui-button" → "webui-button"
220            let trimmed = normalize_path_as_index(trimmed);
221
222            // Root index.md => "/"
223            let url_path = if trimmed.is_empty() || trimmed == "index" {
224                if base_path.is_empty() {
225                    "/".to_string()
226                } else {
227                    base_path.trim_end_matches('/').to_string() + "/"
228                }
229            } else {
230                let prefix = base_path.trim_end_matches('/');
231                format!("{prefix}/{trimmed}")
232            };
233            pages.push((url_path, path));
234        }
235    }
236
237    pages.sort_by(|a, b| a.0.cmp(&b.0));
238    pages
239}
240
241fn build_sidebar_state(url_path: &str, config: &DocsConfig) -> serde_json::Value {
242    let base = &config.base_path;
243
244    // Determine active sidebar
245    let active_sidebar = config
246        .sidebar_groups
247        .iter()
248        .find(|(prefix, _)| {
249            let full_prefix = format!("{}{}", base, prefix.strip_prefix('/').unwrap_or(prefix));
250            let trimmed = full_prefix.trim_end_matches('/');
251            url_path == trimmed || url_path.starts_with(&format!("{trimmed}/"))
252        })
253        .map(|(_, s)| s.as_slice())
254        .unwrap_or(&config.sidebar);
255
256    // Convert a SidebarItem to a JSON value (iterative via stack for children)
257    let item_to_value = |item: &SidebarItem| -> Value {
258        let item_path = normalize_link(base, &item.link);
259        let active = !item_path.is_empty() && item_path == url_path;
260        let children: Vec<Value> = item
261            .items
262            .iter()
263            .map(|child| {
264                let child_path = normalize_link(base, &child.link);
265                let child_active = !child_path.is_empty() && child_path == url_path;
266                json_obj([
267                    ("text", Value::String(child.text.clone())),
268                    ("link", Value::String(child_path)),
269                    ("active", Value::Bool(child_active)),
270                    ("hasChildren", Value::Bool(false)),
271                    ("children", Value::Array(vec![])),
272                ])
273            })
274            .collect();
275        json_obj([
276            ("text", Value::String(item.text.clone())),
277            ("link", Value::String(item_path)),
278            ("active", Value::Bool(active)),
279            ("hasChildren", Value::Bool(!children.is_empty())),
280            ("children", Value::Array(children)),
281        ])
282    };
283
284    let sections: Vec<Value> = active_sidebar
285        .iter()
286        .map(|section| {
287            let items: Vec<Value> = section.items.iter().map(item_to_value).collect();
288            json_obj([
289                ("title", Value::String(section.title.clone())),
290                ("items", Value::Array(items)),
291            ])
292        })
293        .collect();
294
295    json_obj([("sections", Value::Array(sections))])
296}
297
298fn find_sidebar_text(url_path: &str, config: &DocsConfig) -> Option<String> {
299    let base = &config.base_path;
300    let all: Vec<&[SidebarSection]> = std::iter::once(config.sidebar.as_slice())
301        .chain(config.sidebar_groups.values().map(|v| v.as_slice()))
302        .collect();
303
304    for sidebar in all {
305        for section in sidebar {
306            // Use iterative stack to search nested items
307            let mut stack: Vec<&SidebarItem> = section.items.iter().collect();
308            while let Some(item) = stack.pop() {
309                if !item.link.is_empty() {
310                    let item_path = normalize_link(base, &item.link);
311                    if item_path == url_path {
312                        return Some(item.text.clone());
313                    }
314                }
315                for child in &item.items {
316                    stack.push(child);
317                }
318            }
319        }
320    }
321    None
322}
323
324/// Resolve and load every custom page's `stateFile` once, returning a map keyed
325/// by the custom-page link (e.g. `/playground/`) holding the parsed JSON value.
326/// Inline `state` values are passed through untouched.
327///
328/// State files are cached by canonical filesystem path so that two custom pages
329/// pointing at the same file only read and parse it once.
330fn load_custom_page_states(
331    config: &DocsConfig,
332    config_dir: &Path,
333) -> Result<HashMap<String, Value>> {
334    let mut cache: HashMap<std::path::PathBuf, Value> = HashMap::new();
335    let mut out: HashMap<String, Value> = HashMap::with_capacity(config.custom_pages.len());
336
337    for (link, page) in &config.custom_pages {
338        let inline = page.inline_state();
339        let path = page.state_file();
340
341        if inline.is_some() && path.is_some() {
342            return Err(crate::error::Error::Build(format!(
343                "Custom page {link}: 'state' and 'stateFile' are mutually exclusive — pick one."
344            )));
345        }
346
347        if let Some(value) = inline {
348            out.insert(link.clone(), value.clone());
349            continue;
350        }
351
352        if let Some(rel) = path {
353            let abs = config_dir.join(rel);
354            let key = fs::canonicalize(&abs).unwrap_or_else(|_| abs.clone());
355            let value = if let Some(cached) = cache.get(&key) {
356                cached.clone()
357            } else {
358                let raw = fs::read_to_string(&abs).map_err(|e| {
359                    crate::error::Error::Build(format!(
360                        "Custom page {link}: cannot read stateFile {}: {e}",
361                        abs.display()
362                    ))
363                })?;
364                let parsed: Value = serde_json::from_str(&raw).map_err(|e| {
365                    crate::error::Error::Build(format!(
366                        "Custom page {link}: stateFile {} is not valid JSON: {e}",
367                        abs.display()
368                    ))
369                })?;
370                cache.insert(key, parsed.clone());
371                parsed
372            };
373            out.insert(link.clone(), value);
374        }
375    }
376
377    Ok(out)
378}
379
380/// Process all content files and return page descriptors.
381///
382/// `config_dir` is the directory containing `config.json`; relative paths
383/// declared in the config (such as a custom page's `stateFile`) are resolved
384/// against it.
385///
386/// `head_injection` is the fully-resolved `<head>` snippet (CSS link
387/// tags + `config.head` entries). It MUST be computed before this call
388/// so the descriptor is render-ready.
389pub fn process_content(
390    config: &DocsConfig,
391    config_dir: &Path,
392    highlighter: &Highlighter,
393    head_injection: &str,
394) -> Result<Vec<PageDescriptor>> {
395    let content_dir = Path::new(&config.content_dir);
396    let base_path = &config.base_path;
397
398    // Load custom-page state once before the parallel pipeline.
399    let custom_states = load_custom_page_states(config, config_dir)?;
400
401    let nav_links: Vec<Value> = config
402        .nav
403        .iter()
404        .map(|item| {
405            let (link, section) = if item.link.starts_with("http") {
406                // External links can never match a docs section. Use the URL
407                // itself as a unique sentinel so the equality check below
408                // never matches any page's section slug.
409                (item.link.clone(), item.link.clone())
410            } else {
411                let full = format!("{}{}", base_path, &item.link[1..]);
412                // Derive a section slug from the raw config link, e.g. "/guide/" -> "guide".
413                let slug = item
414                    .link
415                    .trim_start_matches('/')
416                    .split('/')
417                    .next()
418                    .unwrap_or("")
419                    .to_string();
420                (full, slug)
421            };
422            json_obj([
423                ("text", Value::String(item.text.clone())),
424                ("link", Value::String(link)),
425                ("section", Value::String(section)),
426            ])
427        })
428        .collect();
429
430    let mut registry = build_page_registry(content_dir, base_path);
431
432    // Register custom pages
433    for page_link in config.custom_pages.keys() {
434        let normalized = if page_link.ends_with('/') {
435            page_link.clone()
436        } else {
437            format!("{}/", page_link)
438        };
439        let url_path = format!("{}{}", base_path, &normalized[1..]);
440        let src_file = format!("{}index.md", &normalized[1..]);
441        let full_path = content_dir.join(&src_file);
442        if !registry.iter().any(|(p, _)| p == &url_path) {
443            registry.push((url_path, full_path));
444        }
445    }
446
447    use rayon::prelude::*;
448
449    // Per-page processing: parse markdown / load custom HTML, expand
450    // syntax highlighting, build the state JSON. Always runs for every
451    // page on every call — there is no descriptor cache. The dev
452    // server keeps a `BuildCache` only to amortize the syntect
453    // highlighter load.
454    let results: Vec<(usize, PageDescriptor)> = registry
455        .par_iter()
456        .enumerate()
457        .map(
458            |(idx, (url_path, full_path))| -> Result<(usize, PageDescriptor)> {
459                let mut is_home = false;
460                let mut html = String::new();
461                let mut title = config.site.title.clone();
462                let mut description = config.site.description.clone();
463                let mut layout = "doc".to_string();
464
465                // Check custom page override
466                let logical_path = format!("/{}", &url_path[base_path.len()..]);
467                let custom_entry = config
468                    .custom_pages
469                    .get(&logical_path)
470                    .or_else(|| config.custom_pages.get(logical_path.trim_end_matches('/')));
471
472                if let Some(entry) = custom_entry {
473                    html = entry.html().to_string();
474                    layout = entry.layout().to_string();
475                } else if full_path.exists() {
476                    let raw = fs::read_to_string(full_path)
477                        .map_err(|e| crate::error::Error::Io(e.to_string()))?;
478                    let (fm, body) = parse_frontmatter(&raw).map_err(|e| {
479                        crate::error::Error::Markdown(format!("{}: {e}", full_path.display()))
480                    })?;
481
482                    is_home = fm.layout.as_deref() == Some("home");
483                    layout = if is_home {
484                        "home".to_string()
485                    } else {
486                        fm.layout.unwrap_or_else(|| "doc".to_string())
487                    };
488
489                    if !is_home {
490                        html = render_markdown(body, highlighter, base_path)?;
491                    }
492
493                    if let Some(d) = fm.description {
494                        description = d;
495                    }
496
497                    let h1 = extract_h1(body);
498                    let sidebar_text = find_sidebar_text(url_path, config);
499                    title = fm
500                        .title
501                        .or(h1)
502                        .or(sidebar_text)
503                        .unwrap_or_else(|| config.site.title.clone());
504                }
505
506                let hero_val = if is_home {
507                    config
508                        .hero
509                        .as_ref()
510                        .map(|h| {
511                            json_obj([
512                                (
513                                    "text",
514                                    h.text
515                                        .as_ref()
516                                        .map_or(Value::Null, |t| Value::String(t.clone())),
517                                ),
518                                ("tagline", Value::String(h.tagline.clone())),
519                                (
520                                    "manifesto",
521                                    h.manifesto
522                                        .as_ref()
523                                        .map_or(Value::Null, |m| Value::String(m.clone())),
524                                ),
525                                (
526                                    "actions",
527                                    Value::Array(
528                                        h.actions
529                                            .iter()
530                                            .map(|a| {
531                                                json_obj([
532                                                    ("text", Value::String(a.text.clone())),
533                                                    ("link", Value::String(a.link.clone())),
534                                                    ("brand", Value::Bool(a.brand)),
535                                                ])
536                                            })
537                                            .collect(),
538                                    ),
539                                ),
540                                (
541                                    "features",
542                                    Value::Array(
543                                        h.features
544                                            .iter()
545                                            .map(|f| {
546                                                json_obj([
547                                                    ("icon", Value::String(f.icon.clone())),
548                                                    ("title", Value::String(f.title.clone())),
549                                                    (
550                                                        "description",
551                                                        Value::String(f.description.clone()),
552                                                    ),
553                                                ])
554                                            })
555                                            .collect(),
556                                    ),
557                                ),
558                            ])
559                        })
560                        .unwrap_or(Value::Null)
561                } else {
562                    Value::Null
563                };
564
565                let footer_val = config
566                    .footer
567                    .as_ref()
568                    .map(|f| json_obj([("html", Value::String(f.html.clone()))]))
569                    .unwrap_or(Value::Null);
570
571                // Top-level section slug for the current page, derived from the
572                // first path segment after the base. Used by the nav template to
573                // mark the active link via `?active="{{link.section == page.section}}"`.
574                let page_section = logical_path
575                    .trim_start_matches('/')
576                    .split('/')
577                    .next()
578                    .unwrap_or("")
579                    .to_string();
580
581                let state = json_obj([
582                    (
583                        "site",
584                        json_obj([
585                            ("title", Value::String(config.site.title.clone())),
586                            ("base", Value::String(base_path.to_string())),
587                        ]),
588                    ),
589                    ("navigation", Value::Array(nav_links.clone())),
590                    ("sidebar", build_sidebar_state(url_path, config)),
591                    (
592                        "page",
593                        json_obj([
594                            ("title", Value::String(title.clone())),
595                            ("description", Value::String(description.clone())),
596                            ("content", Value::String(html.clone())),
597                            ("isHome", Value::Bool(is_home)),
598                            ("layout", Value::String(layout)),
599                            ("section", Value::String(page_section)),
600                        ]),
601                    ),
602                    ("hero", hero_val),
603                    ("footer", footer_val),
604                    ("prev", Value::Null),
605                    ("next", Value::Null),
606                    (
607                        "pageData",
608                        custom_states
609                            .get(&logical_path)
610                            .or_else(|| custom_states.get(logical_path.trim_end_matches('/')))
611                            .cloned()
612                            .unwrap_or(Value::Null),
613                    ),
614                    ("headTags", Value::String(head_injection.to_string())),
615                    ("label", Value::String("Copy".to_string())),
616                    ("icon", Value::String("🌙".to_string())),
617                ]);
618
619                // Flatten the loaded custom-page state object onto the top-level
620                // page state so component templates can bind directly to its
621                // fields (e.g. `<for each="item in files">`). This is what enables
622                // SSR for components driven by a `stateFile`. Reserved keys are
623                // never overwritten.
624                let state = if let Some(Value::Object(extra)) = custom_states
625                    .get(&logical_path)
626                    .or_else(|| custom_states.get(logical_path.trim_end_matches('/')))
627                {
628                    let mut map = match state {
629                        Value::Object(m) => m,
630                        other => {
631                            let mut m = Map::new();
632                            m.insert("_root".to_string(), other);
633                            m
634                        }
635                    };
636                    for (k, v) in extra {
637                        if !RESERVED_STATE_KEYS.contains(&k.as_str()) && !map.contains_key(k) {
638                            map.insert(k.clone(), v.clone());
639                        }
640                    }
641                    Value::Object(map)
642                } else {
643                    state
644                };
645
646                Ok((
647                    idx,
648                    PageDescriptor {
649                        path: url_path.clone(),
650                        is_home,
651                        state,
652                    },
653                ))
654            },
655        )
656        .collect::<Result<Vec<_>>>()?;
657
658    // Build prev/next links from the sidebar that matches each page's URL.
659    // We resolve the active sidebar per-page by checking sidebar_groups
660    // prefixes, then falling back to the default config.sidebar.
661    let resolve_sidebar = |page_path: &str| -> Vec<SidebarItem> {
662        let active = config
663            .sidebar_groups
664            .iter()
665            .find(|(prefix, _)| {
666                let full_prefix = format!(
667                    "{}{}",
668                    base_path,
669                    prefix.strip_prefix('/').unwrap_or(prefix)
670                );
671                let trimmed = full_prefix.trim_end_matches('/');
672                page_path == trimmed || page_path.starts_with(&format!("{trimmed}/"))
673            })
674            .map(|(_, s)| s.as_slice())
675            .unwrap_or(&config.sidebar);
676        active.iter().flat_map(|s| s.items.clone()).collect()
677    };
678
679    // Restore canonical (registry) order and apply prev/next to each
680    // descriptor. Carrying the registry index explicitly through the
681    // parallel pass avoids depending on `par_iter().collect()`'s
682    // implicit order-preservation contract.
683    let mut pages: Vec<Option<PageDescriptor>> = (0..results.len()).map(|_| None).collect();
684    for (idx, mut desc) in results {
685        let sidebar_items = resolve_sidebar(&desc.path);
686        let flat_links = collect_sidebar_links(&sidebar_items);
687        let all_links: Vec<(String, String)> = flat_links
688            .iter()
689            .map(|link| {
690                let path = normalize_link(base_path, link);
691                let text = find_sidebar_text(&path, config).unwrap_or_default();
692                (path, text)
693            })
694            .collect();
695        if let Some(pos) = all_links.iter().position(|(p, _)| p == &desc.path) {
696            if pos > 0 {
697                let (link, text) = &all_links[pos - 1];
698                desc.state["prev"] = json_obj([
699                    ("link", Value::String(link.clone())),
700                    ("text", Value::String(text.clone())),
701                ]);
702            }
703            if pos + 1 < all_links.len() {
704                let (link, text) = &all_links[pos + 1];
705                desc.state["next"] = json_obj([
706                    ("link", Value::String(link.clone())),
707                    ("text", Value::String(text.clone())),
708                ]);
709            }
710        }
711        pages[idx] = Some(desc);
712    }
713    let pages: Vec<PageDescriptor> = pages
714        .into_iter()
715        .enumerate()
716        .map(|(i, slot)| {
717            slot.ok_or_else(|| Error::Build(format!("missing page at registry index {i}")))
718        })
719        .collect::<Result<Vec<_>>>()?;
720
721    Ok(pages)
722}
723
724#[cfg(test)]
725mod tests {
726    use super::*;
727
728    // --- normalize_link --------------------------------------------------
729
730    #[test]
731    fn normalize_link_empty_returns_empty() {
732        assert_eq!(normalize_link("/webui/", ""), "");
733    }
734
735    #[test]
736    fn normalize_link_root_slash_returns_base() {
737        assert_eq!(normalize_link("/webui/", "/"), "/webui/");
738    }
739
740    #[test]
741    fn normalize_link_trims_trailing_slash() {
742        assert_eq!(
743            normalize_link("/webui/", "/guide/intro/"),
744            "/webui/guide/intro"
745        );
746        assert_eq!(
747            normalize_link("/webui/", "/guide/intro"),
748            "/webui/guide/intro"
749        );
750    }
751
752    #[test]
753    fn normalize_link_preserves_fragment() {
754        assert_eq!(
755            normalize_link("/webui/", "/guide/intro#section"),
756            "/webui/guide/intro#section"
757        );
758    }
759
760    #[test]
761    fn normalize_link_tolerates_missing_leading_slash() {
762        // Was previously a slice-from-1 panic risk on multi-byte chars.
763        assert_eq!(
764            normalize_link("/webui/", "guide/intro"),
765            "/webui/guide/intro"
766        );
767    }
768
769    #[test]
770    fn normalize_link_tolerates_non_ascii_first_char() {
771        // `&link[1..]` would have panicked here on UTF-8 boundary.
772        let out = normalize_link("/webui/", "é-page");
773        assert!(out.ends_with("é-page"), "got {out}");
774    }
775
776    // --- normalize_path_as_index -----------------------------------------
777
778    #[test]
779    fn normalize_path_as_index_strips_duplicate_folder_name() {
780        assert_eq!(
781            normalize_path_as_index("webui-button/webui-button"),
782            "webui-button"
783        );
784    }
785
786    #[test]
787    fn normalize_path_as_index_keeps_different_filename() {
788        assert_eq!(
789            normalize_path_as_index("webui-button/usage"),
790            "webui-button/usage"
791        );
792    }
793
794    #[test]
795    fn normalize_path_as_index_nested_folders() {
796        assert_eq!(
797            normalize_path_as_index("components/webui-button/webui-button"),
798            "components/webui-button"
799        );
800    }
801
802    #[test]
803    fn normalize_path_as_index_no_slash_returns_unchanged() {
804        assert_eq!(normalize_path_as_index("webui-button"), "webui-button");
805    }
806
807    #[test]
808    fn normalize_path_as_index_deep_nesting_with_match() {
809        assert_eq!(
810            normalize_path_as_index("a/b/c/webui-card/webui-card"),
811            "a/b/c/webui-card"
812        );
813    }
814
815    #[test]
816    fn normalize_path_as_index_deep_nesting_without_match() {
817        assert_eq!(
818            normalize_path_as_index("a/b/c/webui-card/examples"),
819            "a/b/c/webui-card/examples"
820        );
821    }
822
823    // --- parse_frontmatter -----------------------------------------------
824
825    #[test]
826    fn parse_frontmatter_no_frontmatter_returns_full_body() {
827        let (fm, body) = parse_frontmatter("# Hello\nworld\n").expect("ok");
828        assert!(fm.title.is_none());
829        assert!(fm.layout.is_none());
830        assert_eq!(body, "# Hello\nworld\n");
831    }
832
833    #[test]
834    fn parse_frontmatter_valid_yaml_extracts_fields() {
835        let raw = "---\ntitle: Hello\ndescription: World\nlayout: home\n---\n# Body\n";
836        let (fm, body) = parse_frontmatter(raw).expect("ok");
837        assert_eq!(fm.title.as_deref(), Some("Hello"));
838        assert_eq!(fm.description.as_deref(), Some("World"));
839        assert_eq!(fm.layout.as_deref(), Some("home"));
840        assert_eq!(body, "# Body\n");
841    }
842
843    #[test]
844    fn parse_frontmatter_handles_crlf_line_endings() {
845        let raw = "---\r\ntitle: CRLF\r\nlayout: doc\r\n---\r\n# Body\r\n";
846        let (fm, body) = parse_frontmatter(raw).expect("ok");
847        assert_eq!(fm.title.as_deref(), Some("CRLF"));
848        assert_eq!(fm.layout.as_deref(), Some("doc"));
849        assert_eq!(body, "# Body\r\n");
850    }
851
852    #[test]
853    fn parse_frontmatter_malformed_yaml_returns_error() {
854        // `: : :` is not valid YAML.
855        let raw = "---\ntitle: : : :\n---\nBody\n";
856        let result = parse_frontmatter(raw);
857        assert!(result.is_err(), "expected error, got {result:?}");
858    }
859}