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