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