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, parse_markdown_state, LoadedStates};
15use crate::types::{DocsConfig, NavLink, PageDescriptor, ShowMode, 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
43fn config_paths_match(left: &str, right: &str) -> bool {
44    left.trim_end_matches('/') == right.trim_end_matches('/')
45}
46
47/// Build a JSON object from key-value pairs without using `json!` (which calls `unwrap`).
48fn json_obj<const N: usize>(entries: [(&str, Value); N]) -> Value {
49    let mut map = Map::with_capacity(N);
50    for (k, v) in entries {
51        map.insert(k.to_string(), v);
52    }
53    Value::Object(map)
54}
55
56/// Parsed frontmatter from a markdown file.
57#[derive(Debug)]
58struct Frontmatter {
59    title: Option<String>,
60    description: Option<String>,
61    layout: Option<String>,
62    state: Option<Value>,
63}
64
65fn parse_frontmatter(content: &str) -> Result<(Frontmatter, &str)> {
66    if !content.starts_with("---\n") && !content.starts_with("---\r\n") {
67        return Ok((
68            Frontmatter {
69                title: None,
70                description: None,
71                layout: None,
72                state: None,
73            },
74            content,
75        ));
76    }
77
78    // Skip leading "---" + line ending.
79    let body_start_marker = if content.starts_with("---\r\n") { 5 } else { 4 };
80    let after_open = &content[body_start_marker..];
81
82    // Find the closing "---" on its own line (preceded by `\n` or `\r\n`).
83    let (yaml_end_offset, close_skip) = if let Some(i) = after_open.find("\r\n---") {
84        (i, "\r\n---".len())
85    } else if let Some(i) = after_open.find("\n---") {
86        (i, "\n---".len())
87    } else {
88        (after_open.len(), 0)
89    };
90
91    let yaml_str = &after_open[..yaml_end_offset];
92    let body_offset = body_start_marker + yaml_end_offset + close_skip;
93    let body = if body_offset < content.len() {
94        // Strip the line-ending immediately following the closing "---".
95        let rest = &content[body_offset..];
96        rest.strip_prefix("\r\n")
97            .or_else(|| rest.strip_prefix('\n'))
98            .unwrap_or(rest)
99    } else {
100        ""
101    };
102
103    let yaml: serde_yaml::Mapping = serde_yaml::from_str(yaml_str).map_err(|e| {
104        Error::Markdown(format!(
105            "Invalid frontmatter YAML: {e}\nhelp: Use valid YAML with unique object keys between the --- delimiters."
106        ))
107    })?;
108
109    Ok((
110        Frontmatter {
111            title: yaml.get("title").and_then(|v| v.as_str()).map(String::from),
112            description: yaml
113                .get("description")
114                .and_then(|v| v.as_str())
115                .map(String::from),
116            layout: yaml
117                .get("layout")
118                .and_then(|v| v.as_str())
119                .map(String::from),
120            state: yaml.get("state").map(parse_markdown_state).transpose()?,
121        },
122        body,
123    ))
124}
125
126fn extract_h1(content: &str) -> Option<String> {
127    for line in content.lines() {
128        if let Some(heading) = line.strip_prefix("# ") {
129            return Some(heading.trim().to_string());
130        }
131    }
132    None
133}
134
135/// Collect all links from a sidebar item and its children (iterative).
136fn collect_sidebar_links(items: &[SidebarItem]) -> Vec<&str> {
137    let mut links = Vec::new();
138    let mut stack: Vec<&SidebarItem> = items.iter().rev().collect();
139    while let Some(item) = stack.pop() {
140        if !item.link.is_empty() {
141            links.push(item.link.as_str());
142        }
143        for child in item.items.iter().rev() {
144            stack.push(child);
145        }
146    }
147    links
148}
149
150/// Strip a redundant filename when it should resolve to its parent folder.
151///
152/// E.g., "my-button/my-button" → "my-button"
153///       "my-button/usage" → "my-button/usage" (unchanged)
154fn normalize_path_as_index(path: &str) -> &str {
155    // Find the last slash to split parent/filename
156    let Some(slash_pos) = path.rfind('/') else {
157        return path;
158    };
159
160    let parent = &path[..slash_pos];
161    let filename = &path[slash_pos + 1..];
162
163    // Get the parent folder name (last segment before filename)
164    let folder_name = parent.rsplit('/').next().unwrap_or("");
165
166    // If folder name matches filename, treat as index and strip filename
167    if folder_name == filename {
168        parent
169    } else {
170        path
171    }
172}
173
174/// Resolve the public URL path for a markdown file.
175///
176/// `rel_path` is the file's path relative to the content directory, using
177/// forward slashes (e.g. `guide/install.md`). A nav entry that claims this
178/// file via its `source` field wins outright, which lets a page keep a stable
179/// URL when its filename is dictated by an outside convention. Any fragment
180/// (`#`) or query string (`?`) in the override link is stripped before the
181/// route is formed — they are navigation-only markers that cannot appear in a
182/// filesystem path and would break prev/next matching against normalized
183/// sidebar links. Otherwise the URL is derived from the filesystem: `.md` is
184/// dropped, then a trailing `index` — or a filename that repeats its parent
185/// folder — collapses to the folder.
186fn page_url_path(rel_path: &str, base_path: &str, sources: &HashMap<&str, &str>) -> String {
187    let prefix = base_path.trim_end_matches('/');
188
189    if let Some(&link) = sources.get(rel_path) {
190        // Strip any fragment (#) or query (?) — they are navigation-only and
191        // cannot appear in a filesystem-derived route or match sidebar links.
192        let end = link.find(['#', '?']).unwrap_or(link.len());
193        let link = &link[..end];
194        let cleaned = link.trim_matches('/');
195        return if cleaned.is_empty() {
196            format!("{prefix}/")
197        } else {
198            format!("{prefix}/{cleaned}")
199        };
200    }
201
202    let trimmed = rel_path.trim_end_matches(".md");
203    let trimmed = trimmed.strip_suffix("/index").unwrap_or(trimmed);
204    let trimmed = normalize_path_as_index(trimmed);
205
206    if trimmed.is_empty() || trimmed == "index" {
207        format!("{prefix}/")
208    } else {
209        format!("{prefix}/{trimmed}")
210    }
211}
212
213fn markdown_link_base_url(source_path: &Path, content_dir: &Path, base_path: &str) -> String {
214    let parent = source_path
215        .strip_prefix(content_dir)
216        .ok()
217        .and_then(Path::parent);
218    let parent = parent
219        .filter(|path| !path.as_os_str().is_empty())
220        .map(|path| path.to_string_lossy().replace('\\', "/"))
221        .unwrap_or_default();
222
223    let prefix = base_path.trim_end_matches('/');
224    let parent = parent.trim_matches('/');
225    let mut url = String::with_capacity(prefix.len() + parent.len() + 2);
226    url.push_str(prefix);
227    url.push('/');
228    if !parent.is_empty() {
229        url.push_str(parent);
230        url.push('/');
231    }
232    url
233}
234
235/// Map each nav entry that declares a `source` file to its configured link.
236///
237/// Keys are content-relative markdown paths; values are raw config links.
238/// Entries whose `link` starts with `http` are excluded because `source` is
239/// only meaningful for internal routes; pairing it with an external URL would
240/// produce an invalid route in `page_url_path`.
241fn nav_source_overrides(nav: &[NavLink]) -> HashMap<&str, &str> {
242    nav.iter()
243        .filter_map(|item| {
244            let source = item.source.as_deref()?;
245            if item.link.starts_with("http") {
246                return None;
247            }
248            Some((source, item.link.as_str()))
249        })
250        .collect()
251}
252
253/// Build the page registry by walking content_dir for every `.md` file.
254///
255/// Discovery is filesystem-driven: any markdown file under content_dir becomes
256/// a page. The sidebar/nav config controls navigation, not discovery.
257fn build_page_registry(
258    content_dir: &Path,
259    base_path: &str,
260    sources: &HashMap<&str, &str>,
261) -> Vec<(String, std::path::PathBuf)> {
262    let mut pages = Vec::new();
263    let mut stack: Vec<std::path::PathBuf> = vec![content_dir.to_path_buf()];
264
265    while let Some(dir) = stack.pop() {
266        let Ok(entries) = fs::read_dir(&dir) else {
267            continue;
268        };
269        for entry in entries.flatten() {
270            let path = entry.path();
271            // Skip dotfiles/dotdirs and the build output.
272            let name = entry.file_name();
273            let name_str = name.to_string_lossy();
274            if name_str.starts_with('.') || name_str == "dist" || name_str == "node_modules" {
275                continue;
276            }
277            if path.is_dir() {
278                stack.push(path);
279                continue;
280            }
281            if path.extension().and_then(|s| s.to_str()) != Some("md") {
282                continue;
283            }
284
285            let rel = match path.strip_prefix(content_dir) {
286                Ok(r) => r,
287                Err(_) => continue,
288            };
289            let rel_str = rel.to_string_lossy().replace('\\', "/");
290            pages.push((page_url_path(&rel_str, base_path, sources), path));
291        }
292    }
293
294    pages.sort_by(|a, b| a.0.cmp(&b.0));
295    pages
296}
297
298fn build_sidebar_item_state(item: &SidebarItem, base: &str, url_path: &str) -> (Value, bool) {
299    let item_path = normalize_link(base, &item.link);
300    let active = !item_path.is_empty() && item_path == url_path;
301    let mut descendant_active = false;
302    let children: Vec<Value> = item
303        .items
304        .iter()
305        .map(|child| {
306            let child_path = normalize_link(base, &child.link);
307            let child_active = !child_path.is_empty() && child_path == url_path;
308            descendant_active |= child_active;
309            json_obj([
310                ("text", Value::String(child.text.clone())),
311                ("link", Value::String(child_path)),
312                ("active", Value::Bool(child_active)),
313                ("hasChildren", Value::Bool(false)),
314                ("children", Value::Array(vec![])),
315            ])
316        })
317        .collect();
318    let expanded = active || descendant_active;
319    (
320        json_obj([
321            ("text", Value::String(item.text.clone())),
322            ("link", Value::String(item_path)),
323            ("active", Value::Bool(active)),
324            ("expanded", Value::Bool(expanded)),
325            ("hasChildren", Value::Bool(!children.is_empty())),
326            ("children", Value::Array(children)),
327        ]),
328        expanded,
329    )
330}
331
332fn build_sidebar_state(url_path: &str, config: &DocsConfig) -> serde_json::Value {
333    let base = &config.base_path;
334
335    // Determine active sidebar
336    let active_sidebar = config
337        .sidebar_groups
338        .iter()
339        .find(|(prefix, _)| {
340            let full_prefix = format!("{}{}", base, prefix.strip_prefix('/').unwrap_or(prefix));
341            let trimmed = full_prefix.trim_end_matches('/');
342            url_path == trimmed || url_path.starts_with(&format!("{trimmed}/"))
343        })
344        .map(|(_, s)| s.as_slice())
345        .unwrap_or(&config.sidebar);
346
347    let mut current_section = String::new();
348    let sections: Vec<Value> = active_sidebar
349        .iter()
350        .map(|section| {
351            let mut section_active = false;
352            let items: Vec<Value> = section
353                .items
354                .iter()
355                .map(|item| {
356                    let (value, branch_active) = build_sidebar_item_state(item, base, url_path);
357                    section_active |= branch_active;
358                    value
359                })
360                .collect();
361            if section_active {
362                current_section.clone_from(&section.title);
363            }
364            json_obj([
365                ("title", Value::String(section.title.clone())),
366                ("items", Value::Array(items)),
367            ])
368        })
369        .collect();
370
371    json_obj([
372        ("sections", Value::Array(sections)),
373        ("currentSection", Value::String(current_section)),
374    ])
375}
376
377fn find_sidebar_text(url_path: &str, config: &DocsConfig) -> Option<String> {
378    let base = &config.base_path;
379    let all: Vec<&[SidebarSection]> = std::iter::once(config.sidebar.as_slice())
380        .chain(config.sidebar_groups.values().map(|v| v.as_slice()))
381        .collect();
382
383    for sidebar in all {
384        for section in sidebar {
385            // Use iterative stack to search nested items
386            let mut stack: Vec<&SidebarItem> = section.items.iter().collect();
387            while let Some(item) = stack.pop() {
388                if !item.link.is_empty() {
389                    let item_path = normalize_link(base, &item.link);
390                    if item_path == url_path {
391                        return Some(item.text.clone());
392                    }
393                }
394                for child in &item.items {
395                    stack.push(child);
396                }
397            }
398        }
399    }
400    None
401}
402
403/// Process all content files and return page descriptors.
404///
405/// `config_dir` is the directory containing `config.json`; relative paths
406/// declared in the config (such as a custom page's `stateFile`) are resolved
407/// against it.
408///
409/// `head_injection` is the fully-resolved `<head>` snippet (CSS link
410/// tags + `config.head` entries). It MUST be computed before this call
411/// so the descriptor is render-ready.
412#[allow(dead_code)] // Public library API; the binary uses preloaded state for 404 parity.
413pub fn process_content(
414    config: &DocsConfig,
415    config_dir: &Path,
416    highlighter: &Highlighter,
417    head_injection: &str,
418) -> Result<Vec<PageDescriptor>> {
419    let states = load_render_states(config, config_dir)?;
420    process_content_with_states(config, highlighter, head_injection, &states)
421}
422
423pub(crate) fn process_content_with_states(
424    config: &DocsConfig,
425    highlighter: &Highlighter,
426    head_injection: &str,
427    states: &LoadedStates,
428) -> Result<Vec<PageDescriptor>> {
429    let content_dir = Path::new(&config.content_dir);
430    let base_path = &config.base_path;
431
432    let nav_links: Vec<Value> = config
433        .nav
434        .iter()
435        .map(|item| {
436            let full_layout = !item.link.starts_with("http")
437                && config.custom_pages.iter().any(|(path, page)| {
438                    config_paths_match(path, &item.link) && page.layout() == "full"
439                });
440            let (link, section) = if item.link.starts_with("http") {
441                // External links can never match a docs section. Use the URL
442                // itself as a unique sentinel so the equality check below
443                // never matches any page's section slug.
444                (item.link.clone(), item.link.clone())
445            } else {
446                let full = format!("{}{}", base_path, &item.link[1..]);
447                // Derive a section slug from the raw config link, e.g. "/guide/" -> "guide".
448                let slug = item
449                    .link
450                    .trim_start_matches('/')
451                    .split('/')
452                    .next()
453                    .unwrap_or("")
454                    .to_string();
455                (full, slug)
456            };
457            json_obj([
458                ("text", Value::String(item.text.clone())),
459                ("link", Value::String(link)),
460                ("section", Value::String(section)),
461                ("fullLayout", Value::Bool(full_layout)),
462            ])
463        })
464        .collect();
465
466    let mut registry =
467        build_page_registry(content_dir, base_path, &nav_source_overrides(&config.nav));
468
469    // Register custom pages
470    for page_link in config.custom_pages.keys() {
471        let normalized = if page_link.ends_with('/') {
472            page_link.clone()
473        } else {
474            format!("{}/", page_link)
475        };
476        let url_path = format!("{}{}", base_path, &normalized[1..]);
477        let src_file = format!("{}index.md", &normalized[1..]);
478        let full_path = content_dir.join(&src_file);
479        if !registry.iter().any(|(p, _)| p == &url_path) {
480            registry.push((url_path, full_path));
481        }
482    }
483
484    use rayon::prelude::*;
485
486    // Per-page processing: parse markdown / load custom HTML, expand
487    // syntax highlighting, build the state JSON. Always runs for every
488    // page on every call — there is no descriptor cache. The dev
489    // server keeps a `BuildCache` only to amortize the syntect
490    // highlighter load.
491    let results: Vec<(usize, PageDescriptor)> = registry
492        .par_iter()
493        .enumerate()
494        .map(
495            |(idx, (url_path, full_path))| -> Result<(usize, PageDescriptor)> {
496                let mut is_home = false;
497                let mut html = String::new();
498                let mut title = config.site.title.clone();
499                let mut description = config.site.description.clone();
500                let mut layout = "doc".to_string();
501                let mut markdown_state = None;
502
503                // Check custom page override
504                let logical_path = format!("/{}", &url_path[base_path.len()..]);
505                let custom_entry = config
506                    .custom_pages
507                    .get(&logical_path)
508                    .or_else(|| config.custom_pages.get(logical_path.trim_end_matches('/')));
509                let custom_state = states.custom_page_state(&logical_path);
510
511                if let Some(entry) = custom_entry {
512                    html = entry.html().to_string();
513                    layout = entry.layout().to_string();
514                } else if full_path.exists() {
515                    let raw = fs::read_to_string(full_path)
516                        .map_err(|e| crate::error::Error::Io(e.to_string()))?;
517                    let (fm, body) = parse_frontmatter(&raw).map_err(|e| {
518                        crate::error::Error::Markdown(format!("{}: {e}", full_path.display()))
519                    })?;
520                    markdown_state = fm.state;
521
522                    is_home = fm.layout.as_deref() == Some("home");
523                    layout = if is_home {
524                        "home".to_string()
525                    } else {
526                        fm.layout.unwrap_or_else(|| "doc".to_string())
527                    };
528
529                    if !is_home || config.show == ShowMode::Content {
530                        // Canonical page URL: every page is written as
531                        // `<dir>/index.html`, so it is served with a trailing
532                        // slash. In-page anchors need this exact path so they
533                        // aren't resolved against `<base href>`.
534                        let page_url = if url_path.ends_with('/') {
535                            url_path.to_string()
536                        } else {
537                            format!("{url_path}/")
538                        };
539                        let link_base_url =
540                            markdown_link_base_url(full_path, content_dir, base_path);
541                        html = render_markdown(
542                            body,
543                            highlighter,
544                            base_path,
545                            &page_url,
546                            &link_base_url,
547                        )?;
548                    }
549
550                    if let Some(d) = fm.description {
551                        description = d;
552                    }
553
554                    let h1 = extract_h1(body);
555                    let sidebar_text = find_sidebar_text(url_path, config);
556                    title = fm
557                        .title
558                        .or(h1)
559                        .or(sidebar_text)
560                        .unwrap_or_else(|| config.site.title.clone());
561                }
562
563                let hero_val = if is_home {
564                    config
565                        .hero
566                        .as_ref()
567                        .map(|h| {
568                            json_obj([
569                                (
570                                    "text",
571                                    h.text
572                                        .as_ref()
573                                        .map_or(Value::Null, |t| Value::String(t.clone())),
574                                ),
575                                ("tagline", Value::String(h.tagline.clone())),
576                                (
577                                    "manifesto",
578                                    h.manifesto
579                                        .as_ref()
580                                        .map_or(Value::Null, |m| Value::String(m.clone())),
581                                ),
582                                (
583                                    "actions",
584                                    Value::Array(
585                                        h.actions
586                                            .iter()
587                                            .map(|a| {
588                                                json_obj([
589                                                    ("text", Value::String(a.text.clone())),
590                                                    ("link", Value::String(a.link.clone())),
591                                                    ("brand", Value::Bool(a.brand)),
592                                                ])
593                                            })
594                                            .collect(),
595                                    ),
596                                ),
597                                (
598                                    "features",
599                                    Value::Array(
600                                        h.features
601                                            .iter()
602                                            .map(|f| {
603                                                json_obj([
604                                                    ("icon", Value::String(f.icon.clone())),
605                                                    ("title", Value::String(f.title.clone())),
606                                                    (
607                                                        "description",
608                                                        Value::String(f.description.clone()),
609                                                    ),
610                                                ])
611                                            })
612                                            .collect(),
613                                    ),
614                                ),
615                            ])
616                        })
617                        .unwrap_or(Value::Null)
618                } else {
619                    Value::Null
620                };
621
622                let footer_val = config
623                    .footer
624                    .as_ref()
625                    .map(|f| json_obj([("html", Value::String(f.html.clone()))]))
626                    .unwrap_or(Value::Null);
627
628                // Top-level section slug for the current page, derived from the
629                // first path segment after the base. Used by the nav template to
630                // mark the active link via `?active="{{link.section == page.section}}"`.
631                let page_section = logical_path
632                    .trim_start_matches('/')
633                    .split('/')
634                    .next()
635                    .unwrap_or("")
636                    .to_string();
637
638                let state = json_obj([
639                    (
640                        "site",
641                        json_obj([
642                            ("title", Value::String(config.site.title.clone())),
643                            ("base", Value::String(base_path.to_string())),
644                        ]),
645                    ),
646                    ("navigation", Value::Array(nav_links.clone())),
647                    ("sidebar", build_sidebar_state(url_path, config)),
648                    (
649                        "page",
650                        json_obj([
651                            ("title", Value::String(title.clone())),
652                            ("description", Value::String(description.clone())),
653                            ("content", Value::String(html.clone())),
654                            ("isHome", Value::Bool(is_home)),
655                            ("layout", Value::String(layout)),
656                            ("section", Value::String(page_section)),
657                            ("mobileNavigationOpen", Value::Bool(false)),
658                        ]),
659                    ),
660                    ("hero", hero_val),
661                    ("footer", footer_val),
662                    ("prev", Value::Null),
663                    ("next", Value::Null),
664                    ("pageData", custom_state.cloned().unwrap_or(Value::Null)),
665                    ("headTags", Value::String(head_injection.to_string())),
666                    ("label", Value::String("Copy".to_string())),
667                    ("icon", Value::String("🌙".to_string())),
668                ]);
669
670                let state = merge_page_state(
671                    state,
672                    states.global(),
673                    custom_state.or(markdown_state.as_ref()),
674                );
675
676                Ok((
677                    idx,
678                    PageDescriptor {
679                        path: url_path.clone(),
680                        is_home,
681                        state,
682                    },
683                ))
684            },
685        )
686        .collect::<Result<Vec<_>>>()?;
687
688    // Build prev/next links from the sidebar that matches each page's URL.
689    // We resolve the active sidebar per-page by checking sidebar_groups
690    // prefixes, then falling back to the default config.sidebar.
691    let resolve_sidebar = |page_path: &str| -> Vec<SidebarItem> {
692        let active = config
693            .sidebar_groups
694            .iter()
695            .find(|(prefix, _)| {
696                let full_prefix = format!(
697                    "{}{}",
698                    base_path,
699                    prefix.strip_prefix('/').unwrap_or(prefix)
700                );
701                let trimmed = full_prefix.trim_end_matches('/');
702                page_path == trimmed || page_path.starts_with(&format!("{trimmed}/"))
703            })
704            .map(|(_, s)| s.as_slice())
705            .unwrap_or(&config.sidebar);
706        active.iter().flat_map(|s| s.items.clone()).collect()
707    };
708
709    // Restore canonical (registry) order and apply prev/next to each
710    // descriptor. Carrying the registry index explicitly through the
711    // parallel pass avoids depending on `par_iter().collect()`'s
712    // implicit order-preservation contract.
713    let mut pages: Vec<Option<PageDescriptor>> = (0..results.len()).map(|_| None).collect();
714    for (idx, mut desc) in results {
715        let sidebar_items = resolve_sidebar(&desc.path);
716        let flat_links = collect_sidebar_links(&sidebar_items);
717        let all_links: Vec<(String, String)> = flat_links
718            .iter()
719            .map(|link| {
720                let path = normalize_link(base_path, link);
721                let text = find_sidebar_text(&path, config).unwrap_or_default();
722                (path, text)
723            })
724            .collect();
725        if let Some(pos) = all_links.iter().position(|(p, _)| p == &desc.path) {
726            if pos > 0 {
727                let (link, text) = &all_links[pos - 1];
728                desc.state["prev"] = json_obj([
729                    ("link", Value::String(link.clone())),
730                    ("text", Value::String(text.clone())),
731                ]);
732            }
733            if pos + 1 < all_links.len() {
734                let (link, text) = &all_links[pos + 1];
735                desc.state["next"] = json_obj([
736                    ("link", Value::String(link.clone())),
737                    ("text", Value::String(text.clone())),
738                ]);
739            }
740        }
741        pages[idx] = Some(desc);
742    }
743    let pages: Vec<PageDescriptor> = pages
744        .into_iter()
745        .enumerate()
746        .map(|(i, slot)| {
747            slot.ok_or_else(|| Error::Build(format!("missing page at registry index {i}")))
748        })
749        .collect::<Result<Vec<_>>>()?;
750
751    Ok(pages)
752}
753
754#[cfg(test)]
755mod tests {
756    use super::*;
757
758    // --- normalize_link --------------------------------------------------
759
760    #[test]
761    fn normalize_link_empty_returns_empty() {
762        assert_eq!(normalize_link("/webui/", ""), "");
763    }
764
765    #[test]
766    fn normalize_link_root_slash_returns_base() {
767        assert_eq!(normalize_link("/webui/", "/"), "/webui/");
768    }
769
770    #[test]
771    fn normalize_link_trims_trailing_slash() {
772        assert_eq!(
773            normalize_link("/webui/", "/guide/intro/"),
774            "/webui/guide/intro"
775        );
776        assert_eq!(
777            normalize_link("/webui/", "/guide/intro"),
778            "/webui/guide/intro"
779        );
780    }
781
782    #[test]
783    fn normalize_link_preserves_fragment() {
784        assert_eq!(
785            normalize_link("/webui/", "/guide/intro#section"),
786            "/webui/guide/intro#section"
787        );
788    }
789
790    #[test]
791    fn normalize_link_tolerates_missing_leading_slash() {
792        // Was previously a slice-from-1 panic risk on multi-byte chars.
793        assert_eq!(
794            normalize_link("/webui/", "guide/intro"),
795            "/webui/guide/intro"
796        );
797    }
798
799    #[test]
800    fn normalize_link_tolerates_non_ascii_first_char() {
801        // `&link[1..]` would have panicked here on UTF-8 boundary.
802        let out = normalize_link("/webui/", "é-page");
803        assert!(out.ends_with("é-page"), "got {out}");
804    }
805
806    #[test]
807    fn config_paths_match_with_or_without_trailing_slash() {
808        assert!(config_paths_match("/playground", "/playground/"));
809        assert!(config_paths_match("/playground/", "/playground"));
810        assert!(!config_paths_match("/playground", "/guide/"));
811    }
812
813    #[test]
814    fn sidebar_item_expands_for_active_descendant() {
815        let item = SidebarItem {
816            text: "Template Syntax".to_string(),
817            link: "/guide/concepts/directives".to_string(),
818            items: vec![SidebarItem {
819                text: "Signals".to_string(),
820                link: "/guide/concepts/directives/signals".to_string(),
821                items: Vec::new(),
822            }],
823        };
824
825        let (state, branch_active) =
826            build_sidebar_item_state(&item, "/webui/", "/webui/guide/concepts/directives/signals");
827
828        assert!(branch_active);
829        assert_eq!(state["expanded"], Value::Bool(true));
830        assert_eq!(state["children"][0]["active"], Value::Bool(true));
831    }
832
833    // --- normalize_path_as_index -----------------------------------------
834
835    #[test]
836    fn normalize_path_as_index_strips_duplicate_folder_name() {
837        assert_eq!(
838            normalize_path_as_index("webui-button/webui-button"),
839            "webui-button"
840        );
841    }
842
843    #[test]
844    fn normalize_path_as_index_keeps_different_filename() {
845        assert_eq!(
846            normalize_path_as_index("webui-button/usage"),
847            "webui-button/usage"
848        );
849    }
850
851    #[test]
852    fn normalize_path_as_index_nested_folders() {
853        assert_eq!(
854            normalize_path_as_index("components/webui-button/webui-button"),
855            "components/webui-button"
856        );
857    }
858
859    #[test]
860    fn normalize_path_as_index_no_slash_returns_unchanged() {
861        assert_eq!(normalize_path_as_index("webui-button"), "webui-button");
862    }
863
864    // --- page_url_path ----------------------------------------------------
865
866    fn sources(pairs: &[(&'static str, &'static str)]) -> HashMap<&'static str, &'static str> {
867        pairs.iter().copied().collect()
868    }
869
870    #[test]
871    fn page_url_path_derives_url_from_filesystem() {
872        let none = sources(&[]);
873        assert_eq!(
874            page_url_path("guide/install.md", "/webui/", &none),
875            "/webui/guide/install"
876        );
877    }
878
879    #[test]
880    fn page_url_path_collapses_index_and_root() {
881        let none = sources(&[]);
882        assert_eq!(
883            page_url_path("guide/index.md", "/webui/", &none),
884            "/webui/guide"
885        );
886        assert_eq!(page_url_path("index.md", "/webui/", &none), "/webui/");
887        assert_eq!(page_url_path("index.md", "", &none), "/");
888    }
889
890    #[test]
891    fn page_url_path_collapses_filename_repeating_its_folder() {
892        let none = sources(&[]);
893        assert_eq!(
894            page_url_path("components/webui-button/webui-button.md", "/webui/", &none),
895            "/webui/components/webui-button"
896        );
897    }
898
899    #[test]
900    fn page_url_path_nav_source_overrides_derived_url() {
901        // `SKILL.md` is a filename fixed by the agent-skill spec; the nav entry
902        // keeps the page on `/ai` instead of leaking it as `/ai/SKILL`.
903        let map = sources(&[("ai/SKILL.md", "/ai")]);
904        assert_eq!(page_url_path("ai/SKILL.md", "/webui/", &map), "/webui/ai");
905    }
906
907    #[test]
908    fn page_url_path_nav_source_tolerates_surrounding_slashes() {
909        let map = sources(&[("ai/SKILL.md", "ai/"), ("b/SKILL.md", "/b")]);
910        assert_eq!(page_url_path("ai/SKILL.md", "/webui/", &map), "/webui/ai");
911        assert_eq!(page_url_path("b/SKILL.md", "", &map), "/b");
912    }
913
914    #[test]
915    fn page_url_path_nav_source_can_target_site_root() {
916        let map = sources(&[("home/landing.md", "/")]);
917        assert_eq!(page_url_path("home/landing.md", "/webui/", &map), "/webui/");
918    }
919
920    #[test]
921    fn page_url_path_ignores_nav_source_for_other_files() {
922        let map = sources(&[("ai/SKILL.md", "/ai")]);
923        assert_eq!(
924            page_url_path("guide/SKILL.md", "/webui/", &map),
925            "/webui/guide/SKILL"
926        );
927    }
928
929    #[test]
930    fn page_url_path_nav_source_strips_fragment_from_override() {
931        // A nav link like "/ai#rules" must route to "/ai", not "/ai#rules".
932        let map = sources(&[("ai/SKILL.md", "/ai#rules")]);
933        assert_eq!(page_url_path("ai/SKILL.md", "/webui/", &map), "/webui/ai");
934    }
935
936    #[test]
937    fn page_url_path_nav_source_strips_query_from_override() {
938        // A nav link like "/ai?v=2" must route to "/ai", not "/ai?v=2".
939        let map = sources(&[("ai/SKILL.md", "/ai?v=2")]);
940        assert_eq!(page_url_path("ai/SKILL.md", "/webui/", &map), "/webui/ai");
941    }
942
943    #[test]
944    fn markdown_link_base_uses_source_directory_for_index_and_leaf_pages() {
945        let content_dir = Path::new("docs");
946        assert_eq!(
947            markdown_link_base_url(Path::new("docs/guide/index.md"), content_dir, "/webui/"),
948            "/webui/guide/"
949        );
950        assert_eq!(
951            markdown_link_base_url(
952                Path::new("docs/guide/installation.md"),
953                content_dir,
954                "/webui/"
955            ),
956            "/webui/guide/"
957        );
958        assert_eq!(
959            markdown_link_base_url(
960                Path::new("docs/guide/topic/index.md"),
961                content_dir,
962                "/webui/"
963            ),
964            "/webui/guide/topic/"
965        );
966    }
967
968    // --- nav_source_overrides ---------------------------------------------
969
970    fn nav_link(text: &str, link: &str, source: Option<&str>) -> NavLink {
971        NavLink {
972            text: text.to_string(),
973            link: link.to_string(),
974            source: source.map(String::from),
975        }
976    }
977
978    #[test]
979    fn nav_source_overrides_collects_only_entries_declaring_a_source() {
980        let nav = vec![
981            nav_link("Guide", "/guide/", None),
982            nav_link("AI", "/ai", Some("ai/SKILL.md")),
983            nav_link("GitHub", "https://github.com/microsoft/webui", None),
984        ];
985        let map = nav_source_overrides(&nav);
986        assert_eq!(map.len(), 1);
987        assert_eq!(map.get("ai/SKILL.md"), Some(&"/ai"));
988    }
989
990    #[test]
991    fn nav_source_overrides_empty_nav_yields_no_overrides() {
992        assert!(nav_source_overrides(&[]).is_empty());
993    }
994
995    #[test]
996    fn nav_source_overrides_excludes_external_link_with_source() {
997        // Accidentally pairing `source` with an external URL must be silently
998        // dropped so it never reaches `page_url_path` as a broken route.
999        let nav = vec![
1000            nav_link("AI", "/ai", Some("ai/SKILL.md")),
1001            nav_link(
1002                "GitHub",
1003                "https://github.com/microsoft/webui",
1004                Some("github.md"),
1005            ),
1006        ];
1007        let map = nav_source_overrides(&nav);
1008        assert_eq!(map.len(), 1);
1009        assert_eq!(map.get("ai/SKILL.md"), Some(&"/ai"));
1010        assert!(!map.contains_key("github.md"));
1011    }
1012
1013    #[test]
1014    fn normalize_path_as_index_deep_nesting_with_match() {
1015        assert_eq!(
1016            normalize_path_as_index("a/b/c/webui-card/webui-card"),
1017            "a/b/c/webui-card"
1018        );
1019    }
1020
1021    #[test]
1022    fn normalize_path_as_index_deep_nesting_without_match() {
1023        assert_eq!(
1024            normalize_path_as_index("a/b/c/webui-card/examples"),
1025            "a/b/c/webui-card/examples"
1026        );
1027    }
1028
1029    // --- parse_frontmatter -----------------------------------------------
1030
1031    #[test]
1032    fn parse_frontmatter_no_frontmatter_returns_full_body() {
1033        let (fm, body) = parse_frontmatter("# Hello\nworld\n").expect("ok");
1034        assert!(fm.title.is_none());
1035        assert!(fm.layout.is_none());
1036        assert!(fm.state.is_none());
1037        assert_eq!(body, "# Hello\nworld\n");
1038    }
1039
1040    #[test]
1041    fn parse_frontmatter_valid_yaml_extracts_fields() {
1042        let raw = "---\ntitle: Hello\ndescription: World\nlayout: home\n---\n# Body\n";
1043        let (fm, body) = parse_frontmatter(raw).expect("ok");
1044        assert_eq!(fm.title.as_deref(), Some("Hello"));
1045        assert_eq!(fm.description.as_deref(), Some("World"));
1046        assert_eq!(fm.layout.as_deref(), Some("home"));
1047        assert_eq!(body, "# Body\n");
1048    }
1049
1050    #[test]
1051    fn parse_frontmatter_handles_crlf_line_endings() {
1052        let raw = "---\r\ntitle: CRLF\r\nlayout: doc\r\n---\r\n# Body\r\n";
1053        let (fm, body) = parse_frontmatter(raw).expect("ok");
1054        assert_eq!(fm.title.as_deref(), Some("CRLF"));
1055        assert_eq!(fm.layout.as_deref(), Some("doc"));
1056        assert_eq!(body, "# Body\r\n");
1057    }
1058
1059    #[test]
1060    fn parse_frontmatter_malformed_yaml_returns_error() {
1061        // `: : :` is not valid YAML.
1062        let raw = "---\ntitle: : : :\n---\nBody\n";
1063        let result = parse_frontmatter(raw);
1064        assert!(result.is_err(), "expected error, got {result:?}");
1065    }
1066
1067    #[test]
1068    fn parse_frontmatter_preserves_json_compatible_state() {
1069        let raw = concat!(
1070            "---\nstate:\n  example:\n    label: 'Save'\n    count: 2\n",
1071            "    enabled: false\n    optional: null\n    ratio: 0.5\n",
1072            "    items: [one, two]\n    '1': string-key\n    site: nested-is-allowed\n",
1073            "---\n# Example\n"
1074        );
1075        let (fm, body) = parse_frontmatter(raw).expect("valid state");
1076        let state = fm.state.expect("local state");
1077        assert_eq!(state["example"]["label"], "Save");
1078        assert_eq!(state["example"]["count"], 2);
1079        assert_eq!(state["example"]["enabled"], false);
1080        assert_eq!(state["example"]["optional"], Value::Null);
1081        assert_eq!(state["example"]["ratio"], 0.5);
1082        assert_eq!(state["example"]["items"][1], "two");
1083        assert_eq!(state["example"]["1"], "string-key");
1084        assert_eq!(state["example"]["site"], "nested-is-allowed");
1085        assert_eq!(body, "# Example\n");
1086    }
1087
1088    #[test]
1089    fn parse_frontmatter_rejects_invalid_state() {
1090        for state in [
1091            "null",
1092            "example",
1093            "42",
1094            "false",
1095            "[]",
1096            "{nested: {1: value}}",
1097            "{nested: {true: value}}",
1098            "{nested: {.nan: value}}",
1099            "{nested: {value: .inf}}",
1100            "{nested: [1, .nan]}",
1101            "{nested: !example value}",
1102            "!example {value: tagged}",
1103            "{nested: {value: first, value: second}}",
1104            "{value: first}\nstate: {value: second}",
1105            "{broken: [}",
1106        ] {
1107            let raw = format!("---\nstate: {state}\n---\n\n# Invalid");
1108            let message = parse_frontmatter(&raw).expect_err(state).to_string();
1109            assert!(
1110                message.contains("state") || message.contains("YAML"),
1111                "{message}"
1112            );
1113            assert!(message.contains("help:"), "{message}");
1114        }
1115    }
1116
1117    #[test]
1118    fn parse_frontmatter_rejects_reserved_state_keys() {
1119        for key in [
1120            "site",
1121            "navigation",
1122            "sidebar",
1123            "page",
1124            "hero",
1125            "footer",
1126            "prev",
1127            "next",
1128            "pageData",
1129            "regions",
1130            "headTags",
1131            "tokens",
1132            "label",
1133            "icon",
1134        ] {
1135            let raw = format!("---\nstate:\n  {key}: override\n---\n\n# Reserved");
1136            let message = parse_frontmatter(&raw).expect_err(key).to_string();
1137            assert!(
1138                message.contains(key) && message.contains("reserved"),
1139                "{message}"
1140            );
1141            assert!(message.contains("help:"), "{message}");
1142        }
1143    }
1144}