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