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, PathBuf};
9
10use serde_json::{Map, Value};
11
12use crate::error::{Error, Result};
13use crate::markdown::{render_markdown, Highlighter};
14use crate::metadata::{canonical_url, document_path, document_title, site_origin};
15use crate::state::{
16    load_render_states, merge_page_state, parse_markdown_state, parse_markdown_state_file,
17    LoadedStates, StateDependencies, StateLoader,
18};
19use crate::types::{DocsConfig, NavLink, PageDescriptor, ShowMode, SidebarItem, SidebarSection};
20
21/// Normalize a config link (e.g. `/guide/intro/` or `/guide/intro`) to a
22/// canonical URL path that includes the site's `base_path` prefix and
23/// **never** has a trailing slash (except for root, which is just the base).
24/// GitHub Pages and most static hosts auto-redirect `/foo` → `/foo/index.html`,
25/// so trailing slashes are unnecessary and pollute internal hrefs.
26fn normalize_link(base: &str, link: &str) -> String {
27    if link.is_empty() {
28        return String::new();
29    }
30    // Tolerate config links written without a leading slash; also avoids a
31    // panic when `link` starts with a multi-byte UTF-8 char (a bare `&link[1..]`
32    // would slice mid-char and abort the build).
33    let stripped = link.strip_prefix('/').unwrap_or(link);
34    if link.contains('#') {
35        // Fragment link — preserve original shape, don't touch the slash.
36        return format!("{base}{stripped}");
37    }
38    let cleaned = stripped.trim_end_matches('/');
39    if cleaned.is_empty() {
40        // Root: `base` already ends with '/'.
41        base.to_string()
42    } else {
43        format!("{base}{cleaned}")
44    }
45}
46
47fn config_paths_match(left: &str, right: &str) -> bool {
48    left.trim_end_matches('/') == right.trim_end_matches('/')
49}
50
51/// Build a JSON object from key-value pairs without using `json!` (which calls `unwrap`).
52fn json_obj<const N: usize>(entries: [(&str, Value); N]) -> Value {
53    let mut map = Map::with_capacity(N);
54    for (k, v) in entries {
55        map.insert(k.to_string(), v);
56    }
57    Value::Object(map)
58}
59
60/// Parsed frontmatter from a markdown file.
61#[derive(Debug)]
62struct Frontmatter {
63    title: Option<String>,
64    description: Option<String>,
65    layout: Option<String>,
66    outline: Option<bool>,
67    state: Option<Value>,
68    state_file: Option<String>,
69}
70
71fn parse_frontmatter(content: &str) -> Result<(Frontmatter, &str)> {
72    if !content.starts_with("---\n") && !content.starts_with("---\r\n") {
73        return Ok((
74            Frontmatter {
75                title: None,
76                description: None,
77                layout: None,
78                outline: None,
79                state: None,
80                state_file: None,
81            },
82            content,
83        ));
84    }
85
86    // Skip leading "---" + line ending.
87    let body_start_marker = if content.starts_with("---\r\n") { 5 } else { 4 };
88    let after_open = &content[body_start_marker..];
89
90    // Find the closing "---" on its own line (preceded by `\n` or `\r\n`).
91    let (yaml_end_offset, close_skip) = if let Some(i) = after_open.find("\r\n---") {
92        (i, "\r\n---".len())
93    } else if let Some(i) = after_open.find("\n---") {
94        (i, "\n---".len())
95    } else {
96        (after_open.len(), 0)
97    };
98
99    let yaml_str = &after_open[..yaml_end_offset];
100    let body_offset = body_start_marker + yaml_end_offset + close_skip;
101    let body = if body_offset < content.len() {
102        // Strip the line-ending immediately following the closing "---".
103        let rest = &content[body_offset..];
104        rest.strip_prefix("\r\n")
105            .or_else(|| rest.strip_prefix('\n'))
106            .unwrap_or(rest)
107    } else {
108        ""
109    };
110
111    let yaml: serde_yaml::Mapping = serde_yaml::from_str(yaml_str).map_err(|e| {
112        Error::Markdown(format!(
113            "Invalid frontmatter YAML: {e}\nhelp: Use valid YAML with unique object keys between the --- delimiters."
114        ))
115    })?;
116
117    Ok((
118        Frontmatter {
119            title: yaml.get("title").and_then(|v| v.as_str()).map(String::from),
120            description: yaml
121                .get("description")
122                .and_then(|v| v.as_str())
123                .map(String::from),
124            layout: yaml
125                .get("layout")
126                .and_then(|v| v.as_str())
127                .map(String::from),
128            outline: yaml
129                .get("outline")
130                .map(crate::outline::parse_override)
131                .transpose()?,
132            state: yaml.get("state").map(parse_markdown_state).transpose()?,
133            state_file: yaml
134                .get("stateFile")
135                .map(parse_markdown_state_file)
136                .transpose()?,
137        },
138        body,
139    ))
140}
141
142struct MarkdownSource {
143    frontmatter: Frontmatter,
144    body: String,
145}
146
147fn process_markdown_batches<T>(
148    registry: &[(String, PathBuf)],
149    config: &DocsConfig,
150    dependencies: &mut StateDependencies,
151    mut process: impl FnMut(usize, &[(String, PathBuf)], Vec<Option<MarkdownSource>>) -> Result<Vec<T>>,
152) -> Result<Vec<T>> {
153    let mut loader = StateLoader::new();
154    // Keep source bodies bounded by worker count while sharing the include cache.
155    let batch_size = rayon::current_num_threads();
156    let results = registry.chunks(batch_size).enumerate().try_fold(
157        Vec::with_capacity(registry.len()),
158        |mut results, (batch_index, batch)| {
159            let sources = batch
160                .iter()
161                .map(|(url, source)| {
162                    let logical = format!("/{}", &url[config.base_path.len()..]);
163                    if config
164                        .custom_pages
165                        .keys()
166                        .any(|key| config_paths_match(key, &logical))
167                        || !source.exists()
168                    {
169                        return Ok(None);
170                    }
171                    let raw = fs::read_to_string(source).map_err(|error| {
172                        Error::Io(format!("Cannot read {}: {error}", source.display()))
173                    })?;
174                    let (mut frontmatter, body) = parse_frontmatter(&raw).map_err(|error| {
175                        Error::Markdown(format!("{}: {error}", source.display()))
176                    })?;
177                    frontmatter.state = loader
178                        .load_markdown_state(
179                            frontmatter.state_file.as_deref(),
180                            frontmatter.state.take(),
181                            source,
182                        )
183                        .map_err(|error| {
184                            Error::Markdown(format!("{}: {error}", source.display()))
185                        })?;
186                    Ok(Some(MarkdownSource {
187                        frontmatter,
188                        body: body.to_string(),
189                    }))
190                })
191                .collect::<Result<Vec<_>>>()?;
192            results.extend(process(batch_index * batch_size, batch, sources)?);
193            Ok(results)
194        },
195    );
196    *dependencies = loader.into_dependencies();
197    results
198}
199
200fn extract_h1(content: &str) -> Option<String> {
201    for line in content.lines() {
202        if let Some(heading) = line.strip_prefix("# ") {
203            return Some(heading.trim().to_string());
204        }
205    }
206    None
207}
208
209/// Collect all links from a sidebar item and its children (iterative).
210fn collect_sidebar_links(items: &[SidebarItem]) -> Vec<&str> {
211    let mut links = Vec::new();
212    let mut stack: Vec<&SidebarItem> = items.iter().rev().collect();
213    while let Some(item) = stack.pop() {
214        if !item.link.is_empty() {
215            links.push(item.link.as_str());
216        }
217        for child in item.items.iter().rev() {
218            stack.push(child);
219        }
220    }
221    links
222}
223
224/// Strip a redundant filename when it should resolve to its parent folder.
225///
226/// E.g., "my-button/my-button" → "my-button"
227///       "my-button/usage" → "my-button/usage" (unchanged)
228fn normalize_path_as_index(path: &str) -> &str {
229    // Find the last slash to split parent/filename
230    let Some(slash_pos) = path.rfind('/') else {
231        return path;
232    };
233
234    let parent = &path[..slash_pos];
235    let filename = &path[slash_pos + 1..];
236
237    // Get the parent folder name (last segment before filename)
238    let folder_name = parent.rsplit('/').next().unwrap_or("");
239
240    // If folder name matches filename, treat as index and strip filename
241    if folder_name == filename {
242        parent
243    } else {
244        path
245    }
246}
247
248/// Resolve the public URL path for a markdown file.
249///
250/// `rel_path` is the file's path relative to the content directory, using
251/// forward slashes (e.g. `guide/install.md`). A nav entry that claims this
252/// file via its `source` field wins outright, which lets a page keep a stable
253/// URL when its filename is dictated by an outside convention. Any fragment
254/// (`#`) or query string (`?`) in the override link is stripped before the
255/// route is formed — they are navigation-only markers that cannot appear in a
256/// filesystem path and would break prev/next matching against normalized
257/// sidebar links. Otherwise the URL is derived from the filesystem: `.md` is
258/// dropped, then a trailing `index` — or a filename that repeats its parent
259/// folder — collapses to the folder.
260fn page_url_path(rel_path: &str, base_path: &str, sources: &HashMap<&str, &str>) -> String {
261    let prefix = base_path.trim_end_matches('/');
262
263    if let Some(&link) = sources.get(rel_path) {
264        // Strip any fragment (#) or query (?) — they are navigation-only and
265        // cannot appear in a filesystem-derived route or match sidebar links.
266        let end = link.find(['#', '?']).unwrap_or(link.len());
267        let link = &link[..end];
268        let cleaned = link.trim_matches('/');
269        return if cleaned.is_empty() {
270            format!("{prefix}/")
271        } else {
272            format!("{prefix}/{cleaned}")
273        };
274    }
275
276    let trimmed = rel_path.trim_end_matches(".md");
277    let trimmed = trimmed.strip_suffix("/index").unwrap_or(trimmed);
278    let trimmed = normalize_path_as_index(trimmed);
279
280    if trimmed.is_empty() || trimmed == "index" {
281        format!("{prefix}/")
282    } else {
283        format!("{prefix}/{trimmed}")
284    }
285}
286
287fn markdown_link_base_url(source_path: &Path, content_dir: &Path, base_path: &str) -> String {
288    let parent = source_path
289        .strip_prefix(content_dir)
290        .ok()
291        .and_then(Path::parent);
292    let parent = parent
293        .filter(|path| !path.as_os_str().is_empty())
294        .map(|path| path.to_string_lossy().replace('\\', "/"))
295        .unwrap_or_default();
296
297    let prefix = base_path.trim_end_matches('/');
298    let parent = parent.trim_matches('/');
299    let mut url = String::with_capacity(prefix.len() + parent.len() + 2);
300    url.push_str(prefix);
301    url.push('/');
302    if !parent.is_empty() {
303        url.push_str(parent);
304        url.push('/');
305    }
306    url
307}
308
309/// Map each nav entry that declares a `source` file to its configured link.
310///
311/// Keys are content-relative markdown paths; values are raw config links.
312/// Entries whose `link` starts with `http` are excluded because `source` is
313/// only meaningful for internal routes; pairing it with an external URL would
314/// produce an invalid route in `page_url_path`.
315fn nav_source_overrides(nav: &[NavLink]) -> HashMap<&str, &str> {
316    nav.iter()
317        .filter_map(|item| {
318            let source = item.source.as_deref()?;
319            if item.link.starts_with("http") {
320                return None;
321            }
322            Some((source, item.link.as_str()))
323        })
324        .collect()
325}
326
327/// Build the page registry by walking content_dir for every `.md` file.
328///
329/// Discovery is filesystem-driven: any markdown file under content_dir becomes
330/// a page. The sidebar/nav config controls navigation, not discovery.
331fn build_page_registry(
332    content_dir: &Path,
333    base_path: &str,
334    sources: &HashMap<&str, &str>,
335) -> Vec<(String, std::path::PathBuf)> {
336    let mut pages = Vec::new();
337    let mut stack: Vec<std::path::PathBuf> = vec![content_dir.to_path_buf()];
338
339    while let Some(dir) = stack.pop() {
340        let Ok(entries) = fs::read_dir(&dir) else {
341            continue;
342        };
343        for entry in entries.flatten() {
344            let path = entry.path();
345            // Skip dotfiles/dotdirs and the build output.
346            let name = entry.file_name();
347            let name_str = name.to_string_lossy();
348            if name_str.starts_with('.') || name_str == "dist" || name_str == "node_modules" {
349                continue;
350            }
351            if path.is_dir() {
352                stack.push(path);
353                continue;
354            }
355            if path.extension().and_then(|s| s.to_str()) != Some("md") {
356                continue;
357            }
358
359            let rel = match path.strip_prefix(content_dir) {
360                Ok(r) => r,
361                Err(_) => continue,
362            };
363            let rel_str = rel.to_string_lossy().replace('\\', "/");
364            pages.push((page_url_path(&rel_str, base_path, sources), path));
365        }
366    }
367
368    pages.sort_by(|a, b| a.0.cmp(&b.0));
369    pages
370}
371
372fn build_sidebar_item_state(item: &SidebarItem, base: &str, url_path: &str) -> (Value, bool) {
373    let item_path = normalize_link(base, &item.link);
374    let active = !item_path.is_empty() && item_path == url_path;
375    let mut descendant_active = false;
376    let children: Vec<Value> = item
377        .items
378        .iter()
379        .map(|child| {
380            let child_path = normalize_link(base, &child.link);
381            let child_active = !child_path.is_empty() && child_path == url_path;
382            descendant_active |= child_active;
383            json_obj([
384                ("text", Value::String(child.text.clone())),
385                ("link", Value::String(child_path)),
386                ("active", Value::Bool(child_active)),
387                ("hasChildren", Value::Bool(false)),
388                ("children", Value::Array(vec![])),
389            ])
390        })
391        .collect();
392    let expanded = active || descendant_active;
393    (
394        json_obj([
395            ("text", Value::String(item.text.clone())),
396            ("link", Value::String(item_path)),
397            ("active", Value::Bool(active)),
398            ("expanded", Value::Bool(expanded)),
399            ("hasChildren", Value::Bool(!children.is_empty())),
400            ("children", Value::Array(children)),
401        ]),
402        expanded,
403    )
404}
405
406fn build_sidebar_state(url_path: &str, config: &DocsConfig) -> serde_json::Value {
407    let base = &config.base_path;
408
409    // Determine active sidebar
410    let active_sidebar = config
411        .sidebar_groups
412        .iter()
413        .find(|(prefix, _)| {
414            let full_prefix = format!("{}{}", base, prefix.strip_prefix('/').unwrap_or(prefix));
415            let trimmed = full_prefix.trim_end_matches('/');
416            url_path == trimmed || url_path.starts_with(&format!("{trimmed}/"))
417        })
418        .map(|(_, s)| s.as_slice())
419        .unwrap_or(&config.sidebar);
420
421    let mut current_section = String::new();
422    let sections: Vec<Value> = active_sidebar
423        .iter()
424        .map(|section| {
425            let mut section_active = false;
426            let items: Vec<Value> = section
427                .items
428                .iter()
429                .map(|item| {
430                    let (value, branch_active) = build_sidebar_item_state(item, base, url_path);
431                    section_active |= branch_active;
432                    value
433                })
434                .collect();
435            if section_active {
436                current_section.clone_from(&section.title);
437            }
438            json_obj([
439                ("title", Value::String(section.title.clone())),
440                ("items", Value::Array(items)),
441            ])
442        })
443        .collect();
444
445    json_obj([
446        ("sections", Value::Array(sections)),
447        ("currentSection", Value::String(current_section)),
448    ])
449}
450
451fn find_sidebar_text(url_path: &str, config: &DocsConfig) -> Option<String> {
452    let base = &config.base_path;
453    let all: Vec<&[SidebarSection]> = std::iter::once(config.sidebar.as_slice())
454        .chain(config.sidebar_groups.values().map(|v| v.as_slice()))
455        .collect();
456
457    for sidebar in all {
458        for section in sidebar {
459            // Use iterative stack to search nested items
460            let mut stack: Vec<&SidebarItem> = section.items.iter().collect();
461            while let Some(item) = stack.pop() {
462                if !item.link.is_empty() {
463                    let item_path = normalize_link(base, &item.link);
464                    if item_path == url_path {
465                        return Some(item.text.clone());
466                    }
467                }
468                for child in &item.items {
469                    stack.push(child);
470                }
471            }
472        }
473    }
474    None
475}
476
477/// Process all content files and return page descriptors.
478///
479/// `config_dir` is the directory containing `config.json`; relative paths
480/// declared in the config (such as a custom page's `stateFile`) are resolved
481/// against it.
482///
483/// `head_injection` is the fully-resolved `<head>` snippet (CSS link
484/// tags + `config.head` entries). It MUST be computed before this call
485/// so the descriptor is render-ready.
486#[allow(dead_code)] // Public library API; the binary uses preloaded state for 404 parity.
487pub fn process_content(
488    config: &DocsConfig,
489    config_dir: &Path,
490    highlighter: &Highlighter,
491    head_injection: &str,
492) -> Result<Vec<PageDescriptor>> {
493    let states = load_render_states(config, config_dir)?;
494    process_content_with_states(
495        config,
496        highlighter,
497        head_injection,
498        &states,
499        &mut StateDependencies::default(),
500    )
501}
502
503pub(crate) fn process_content_with_states(
504    config: &DocsConfig,
505    highlighter: &Highlighter,
506    head_injection: &str,
507    states: &LoadedStates,
508    dependencies: &mut StateDependencies,
509) -> Result<Vec<PageDescriptor>> {
510    let content_dir = Path::new(&config.content_dir);
511    let base_path = &config.base_path;
512    let origin = site_origin(config.site.url.as_deref())?;
513
514    let nav_links: Vec<Value> = config
515        .nav
516        .iter()
517        .map(|item| {
518            let full_layout = !item.link.starts_with("http")
519                && config.custom_pages.iter().any(|(path, page)| {
520                    config_paths_match(path, &item.link) && page.layout() == "full"
521                });
522            let (link, section) = if item.link.starts_with("http") {
523                // External links can never match a docs section. Use the URL
524                // itself as a unique sentinel so the equality check below
525                // never matches any page's section slug.
526                (item.link.clone(), item.link.clone())
527            } else {
528                let full = format!("{}{}", base_path, &item.link[1..]);
529                // Derive a section slug from the raw config link, e.g. "/guide/" -> "guide".
530                let slug = item
531                    .link
532                    .trim_start_matches('/')
533                    .split('/')
534                    .next()
535                    .unwrap_or("")
536                    .to_string();
537                (full, slug)
538            };
539            json_obj([
540                ("text", Value::String(item.text.clone())),
541                ("link", Value::String(link)),
542                ("section", Value::String(section)),
543                ("fullLayout", Value::Bool(full_layout)),
544            ])
545        })
546        .collect();
547
548    let mut registry =
549        build_page_registry(content_dir, base_path, &nav_source_overrides(&config.nav));
550
551    // Register custom pages
552    for page_link in config.custom_pages.keys() {
553        let normalized = if page_link.ends_with('/') {
554            page_link.clone()
555        } else {
556            format!("{}/", page_link)
557        };
558        let url_path = format!("{}{}", base_path, &normalized[1..]);
559        let src_file = format!("{}index.md", &normalized[1..]);
560        let full_path = content_dir.join(&src_file);
561        if !registry.iter().any(|(p, _)| p == &url_path) {
562            registry.push((url_path, full_path));
563        }
564    }
565
566    use rayon::prelude::*;
567
568    // Per-page processing: parse markdown / load custom HTML, expand
569    // syntax highlighting, build the state JSON. Always runs for every
570    // page on every call — there is no descriptor cache. The dev
571    // server keeps a `BuildCache` only to amortize the syntect
572    // highlighter load.
573    let results: Vec<(usize, PageDescriptor)> = process_markdown_batches(
574        &registry,
575        config,
576        dependencies,
577        |offset, registry, markdown_sources| {
578            registry
579                .par_iter()
580                .zip(markdown_sources.into_par_iter())
581                .enumerate()
582                .map(
583                    |(idx, ((url_path, full_path), markdown))| -> Result<(usize, PageDescriptor)> {
584                        let mut is_home = false;
585                        let mut html = String::new();
586                        let mut title = config.site.title.clone();
587                        let mut description = config.site.description.clone();
588                        let mut layout = "doc".to_string();
589                        let mut outline = config.outline;
590                        let mut markdown_state = None;
591
592                        // Check custom page override
593                        let logical_path = format!("/{}", &url_path[base_path.len()..]);
594                        let custom_entry = config.custom_pages.get(&logical_path).or_else(|| {
595                            config.custom_pages.get(logical_path.trim_end_matches('/'))
596                        });
597                        let custom_state = states.custom_page_state(&logical_path);
598
599                        if let Some(entry) = custom_entry {
600                            html = entry.html().to_string();
601                            layout = entry.layout().to_string();
602                            outline = entry.outline().unwrap_or(outline);
603                            if let Some(value) = entry.title() {
604                                title = value.to_string();
605                            }
606                            if let Some(value) = entry.description() {
607                                description = value.to_string();
608                            }
609                        } else if let Some(MarkdownSource {
610                            frontmatter: fm,
611                            body,
612                        }) = markdown
613                        {
614                            markdown_state = fm.state;
615                            outline = fm.outline.unwrap_or(outline);
616
617                            is_home = fm.layout.as_deref() == Some("home");
618                            layout = if is_home {
619                                "home".to_string()
620                            } else {
621                                fm.layout.unwrap_or_else(|| "doc".to_string())
622                            };
623                            if !is_home || config.show == ShowMode::Content {
624                                let page_url = if url_path.ends_with('/') {
625                                    url_path.to_string()
626                                } else {
627                                    format!("{url_path}/")
628                                };
629                                let link_base_url =
630                                    markdown_link_base_url(full_path, content_dir, base_path);
631                                html = render_markdown(
632                                    &body,
633                                    highlighter,
634                                    base_path,
635                                    &page_url,
636                                    &link_base_url,
637                                )?;
638                            }
639
640                            if let Some(d) = fm.description {
641                                description = d;
642                            }
643
644                            let h1 = extract_h1(&body);
645                            let sidebar_text = find_sidebar_text(url_path, config);
646                            title = fm
647                                .title
648                                .or(h1)
649                                .or(sidebar_text)
650                                .unwrap_or_else(|| config.site.title.clone());
651                        }
652
653                        let hero_val = if is_home {
654                            config
655                                .hero
656                                .as_ref()
657                                .map(|h| {
658                                    json_obj([
659                                        (
660                                            "text",
661                                            h.text
662                                                .as_ref()
663                                                .map_or(Value::Null, |t| Value::String(t.clone())),
664                                        ),
665                                        ("tagline", Value::String(h.tagline.clone())),
666                                        (
667                                            "manifesto",
668                                            h.manifesto
669                                                .as_ref()
670                                                .map_or(Value::Null, |m| Value::String(m.clone())),
671                                        ),
672                                        (
673                                            "actions",
674                                            Value::Array(
675                                                h.actions
676                                                    .iter()
677                                                    .map(|a| {
678                                                        json_obj([
679                                                            ("text", Value::String(a.text.clone())),
680                                                            ("link", Value::String(a.link.clone())),
681                                                            ("brand", Value::Bool(a.brand)),
682                                                        ])
683                                                    })
684                                                    .collect(),
685                                            ),
686                                        ),
687                                        (
688                                            "features",
689                                            Value::Array(
690                                                h.features
691                                                    .iter()
692                                                    .map(|f| {
693                                                        json_obj([
694                                                            ("icon", Value::String(f.icon.clone())),
695                                                            (
696                                                                "title",
697                                                                Value::String(f.title.clone()),
698                                                            ),
699                                                            (
700                                                                "description",
701                                                                Value::String(
702                                                                    f.description.clone(),
703                                                                ),
704                                                            ),
705                                                        ])
706                                                    })
707                                                    .collect(),
708                                            ),
709                                        ),
710                                    ])
711                                })
712                                .unwrap_or(Value::Null)
713                        } else {
714                            Value::Null
715                        };
716
717                        let footer_val = config
718                            .footer
719                            .as_ref()
720                            .map(|f| json_obj([("html", Value::String(f.html.clone()))]))
721                            .unwrap_or(Value::Null);
722
723                        // Top-level section slug for the current page, derived from the
724                        // first path segment after the base. Used by the nav template to
725                        // mark the active link via `?active="{{link.section == page.section}}"`.
726                        let page_section = logical_path
727                            .trim_start_matches('/')
728                            .split('/')
729                            .next()
730                            .unwrap_or("")
731                            .to_string();
732
733                        let page_path = document_path(url_path);
734                        let canonical = canonical_url(origin, &page_path);
735                        let state = json_obj([
736                            (
737                                "site",
738                                json_obj([
739                                    ("title", Value::String(config.site.title.clone())),
740                                    ("base", Value::String(base_path.to_string())),
741                                    (
742                                        "url",
743                                        origin.map_or(Value::Null, |url| {
744                                            Value::String(url.to_string())
745                                        }),
746                                    ),
747                                ]),
748                            ),
749                            ("navigation", Value::Array(nav_links.clone())),
750                            ("sidebar", build_sidebar_state(url_path, config)),
751                            (
752                                "page",
753                                json_obj([
754                                    ("title", Value::String(title.clone())),
755                                    (
756                                        "documentTitle",
757                                        Value::String(document_title(&title, &config.site.title)),
758                                    ),
759                                    ("description", Value::String(description.clone())),
760                                    ("path", Value::String(page_path)),
761                                    ("canonicalUrl", canonical.map_or(Value::Null, Value::String)),
762                                    ("content", Value::String(html.clone())),
763                                    ("isHome", Value::Bool(is_home)),
764                                    ("layout", Value::String(layout)),
765                                    ("section", Value::String(page_section)),
766                                    ("mobileNavigationOpen", Value::Bool(false)),
767                                    ("outline", Value::Bool(outline)),
768                                ]),
769                            ),
770                            ("hero", hero_val),
771                            ("footer", footer_val),
772                            ("prev", Value::Null),
773                            ("next", Value::Null),
774                            ("pageData", custom_state.cloned().unwrap_or(Value::Null)),
775                            ("headTags", Value::String(head_injection.to_string())),
776                            ("label", Value::String("Copy".to_string())),
777                            ("icon", Value::String("🌙".to_string())),
778                        ]);
779
780                        let state = merge_page_state(
781                            state,
782                            states.global(),
783                            custom_state.or(markdown_state.as_ref()),
784                        );
785
786                        Ok((
787                            offset + idx,
788                            PageDescriptor {
789                                path: url_path.clone(),
790                                is_home,
791                                state,
792                            },
793                        ))
794                    },
795                )
796                .collect::<Result<Vec<_>>>()
797        },
798    )?;
799
800    // Build prev/next links from the sidebar that matches each page's URL.
801    // We resolve the active sidebar per-page by checking sidebar_groups
802    // prefixes, then falling back to the default config.sidebar.
803    let resolve_sidebar = |page_path: &str| -> Vec<SidebarItem> {
804        let active = config
805            .sidebar_groups
806            .iter()
807            .find(|(prefix, _)| {
808                let full_prefix = format!(
809                    "{}{}",
810                    base_path,
811                    prefix.strip_prefix('/').unwrap_or(prefix)
812                );
813                let trimmed = full_prefix.trim_end_matches('/');
814                page_path == trimmed || page_path.starts_with(&format!("{trimmed}/"))
815            })
816            .map(|(_, s)| s.as_slice())
817            .unwrap_or(&config.sidebar);
818        active.iter().flat_map(|s| s.items.clone()).collect()
819    };
820
821    // Restore canonical (registry) order and apply prev/next to each
822    // descriptor. Carrying the registry index explicitly through the
823    // parallel pass avoids depending on `par_iter().collect()`'s
824    // implicit order-preservation contract.
825    let mut pages: Vec<Option<PageDescriptor>> = (0..results.len()).map(|_| None).collect();
826    for (idx, mut desc) in results {
827        let sidebar_items = resolve_sidebar(&desc.path);
828        let flat_links = collect_sidebar_links(&sidebar_items);
829        let all_links: Vec<(String, String)> = flat_links
830            .iter()
831            .map(|link| {
832                let path = normalize_link(base_path, link);
833                let text = find_sidebar_text(&path, config).unwrap_or_default();
834                (path, text)
835            })
836            .collect();
837        if let Some(pos) = all_links.iter().position(|(p, _)| p == &desc.path) {
838            if pos > 0 {
839                let (link, text) = &all_links[pos - 1];
840                desc.state["prev"] = json_obj([
841                    ("link", Value::String(link.clone())),
842                    ("text", Value::String(text.clone())),
843                ]);
844            }
845            if pos + 1 < all_links.len() {
846                let (link, text) = &all_links[pos + 1];
847                desc.state["next"] = json_obj([
848                    ("link", Value::String(link.clone())),
849                    ("text", Value::String(text.clone())),
850                ]);
851            }
852        }
853        pages[idx] = Some(desc);
854    }
855    let pages: Vec<PageDescriptor> = pages
856        .into_iter()
857        .enumerate()
858        .map(|(i, slot)| {
859            slot.ok_or_else(|| Error::Build(format!("missing page at registry index {i}")))
860        })
861        .collect::<Result<Vec<_>>>()?;
862
863    Ok(pages)
864}
865
866#[cfg(test)]
867mod tests {
868    use super::*;
869
870    #[test]
871    fn markdown_batches_bound_resident_bodies_and_share_include_cache(
872    ) -> std::result::Result<(), Box<dyn std::error::Error>> {
873        let root = tempfile::tempdir_in(std::env::current_dir()?)?;
874        let include = root.path().join("shared.json");
875        fs::write(&include, r#"{"example":1}"#)?;
876        let body = "# Page\n".repeat(1024);
877        let mut registry = Vec::with_capacity(5);
878        for index in 0..5 {
879            let source = root.path().join(format!("{index}.md"));
880            fs::write(
881                &source,
882                format!("---\nstateFile: ./shared.json\n---\n{body}"),
883            )?;
884            registry.push((format!("/{index}"), source));
885        }
886        let config: DocsConfig = serde_json::from_str(
887            r#"{"site":{"title":"Test"},"basePath":"/","contentDir":".","nav":[],"sidebar":[]}"#,
888        )?;
889        let pool = rayon::ThreadPoolBuilder::new().num_threads(2).build()?;
890        let mut dependencies = StateDependencies::default();
891        let mut batches = 0;
892        let paths = pool.install(|| {
893            process_markdown_batches(
894                &registry,
895                &config,
896                &mut dependencies,
897                |offset, batch, sources| {
898                    assert!(sources.len() <= 2, "all Markdown bodies were preloaded");
899                    assert_eq!(offset, batches * 2);
900                    assert_eq!(sources.len(), batch.len());
901                    for source in sources.into_iter().flatten() {
902                        assert_eq!(source.body, body);
903                        assert_eq!(
904                            source.frontmatter.state.as_ref().map(|v| &v["example"]),
905                            Some(&Value::from(1))
906                        );
907                    }
908                    if batches == 0 {
909                        fs::write(&include, r#"{"example":2}"#)
910                            .map_err(|error| Error::Io(error.to_string()))?;
911                    }
912                    batches += 1;
913                    Ok(batch.iter().map(|(url, _)| url.clone()).collect())
914                },
915            )
916        })?;
917        assert_eq!(batches, 3);
918        assert_eq!(
919            paths,
920            registry
921                .iter()
922                .map(|(url, _)| url.clone())
923                .collect::<Vec<_>>()
924        );
925        assert_eq!(dependencies.paths.len(), 1);
926        assert_eq!(dependencies.changed_paths(), dependencies.paths);
927        Ok(())
928    }
929
930    // --- normalize_link --------------------------------------------------
931
932    #[test]
933    fn normalize_link_empty_returns_empty() {
934        assert_eq!(normalize_link("/webui/", ""), "");
935    }
936
937    #[test]
938    fn normalize_link_root_slash_returns_base() {
939        assert_eq!(normalize_link("/webui/", "/"), "/webui/");
940    }
941
942    #[test]
943    fn normalize_link_trims_trailing_slash() {
944        assert_eq!(
945            normalize_link("/webui/", "/guide/intro/"),
946            "/webui/guide/intro"
947        );
948        assert_eq!(
949            normalize_link("/webui/", "/guide/intro"),
950            "/webui/guide/intro"
951        );
952    }
953
954    #[test]
955    fn normalize_link_preserves_fragment() {
956        assert_eq!(
957            normalize_link("/webui/", "/guide/intro#section"),
958            "/webui/guide/intro#section"
959        );
960    }
961
962    #[test]
963    fn normalize_link_tolerates_missing_leading_slash() {
964        // Was previously a slice-from-1 panic risk on multi-byte chars.
965        assert_eq!(
966            normalize_link("/webui/", "guide/intro"),
967            "/webui/guide/intro"
968        );
969    }
970
971    #[test]
972    fn normalize_link_tolerates_non_ascii_first_char() {
973        // `&link[1..]` would have panicked here on UTF-8 boundary.
974        let out = normalize_link("/webui/", "é-page");
975        assert!(out.ends_with("é-page"), "got {out}");
976    }
977
978    #[test]
979    fn config_paths_match_with_or_without_trailing_slash() {
980        assert!(config_paths_match("/playground", "/playground/"));
981        assert!(config_paths_match("/playground/", "/playground"));
982        assert!(!config_paths_match("/playground", "/guide/"));
983    }
984
985    #[test]
986    fn sidebar_item_expands_for_active_descendant() {
987        let item = SidebarItem {
988            text: "Template Syntax".to_string(),
989            link: "/guide/concepts/directives".to_string(),
990            items: vec![SidebarItem {
991                text: "Signals".to_string(),
992                link: "/guide/concepts/directives/signals".to_string(),
993                items: Vec::new(),
994            }],
995        };
996
997        let (state, branch_active) =
998            build_sidebar_item_state(&item, "/webui/", "/webui/guide/concepts/directives/signals");
999
1000        assert!(branch_active);
1001        assert_eq!(state["expanded"], Value::Bool(true));
1002        assert_eq!(state["children"][0]["active"], Value::Bool(true));
1003    }
1004
1005    // --- normalize_path_as_index -----------------------------------------
1006
1007    #[test]
1008    fn normalize_path_as_index_strips_duplicate_folder_name() {
1009        assert_eq!(
1010            normalize_path_as_index("webui-button/webui-button"),
1011            "webui-button"
1012        );
1013    }
1014
1015    #[test]
1016    fn normalize_path_as_index_keeps_different_filename() {
1017        assert_eq!(
1018            normalize_path_as_index("webui-button/usage"),
1019            "webui-button/usage"
1020        );
1021    }
1022
1023    #[test]
1024    fn normalize_path_as_index_nested_folders() {
1025        assert_eq!(
1026            normalize_path_as_index("components/webui-button/webui-button"),
1027            "components/webui-button"
1028        );
1029    }
1030
1031    #[test]
1032    fn normalize_path_as_index_no_slash_returns_unchanged() {
1033        assert_eq!(normalize_path_as_index("webui-button"), "webui-button");
1034    }
1035
1036    // --- page_url_path ----------------------------------------------------
1037
1038    fn sources(pairs: &[(&'static str, &'static str)]) -> HashMap<&'static str, &'static str> {
1039        pairs.iter().copied().collect()
1040    }
1041
1042    #[test]
1043    fn page_url_path_derives_url_from_filesystem() {
1044        let none = sources(&[]);
1045        assert_eq!(
1046            page_url_path("guide/install.md", "/webui/", &none),
1047            "/webui/guide/install"
1048        );
1049    }
1050
1051    #[test]
1052    fn page_url_path_collapses_index_and_root() {
1053        let none = sources(&[]);
1054        assert_eq!(
1055            page_url_path("guide/index.md", "/webui/", &none),
1056            "/webui/guide"
1057        );
1058        assert_eq!(page_url_path("index.md", "/webui/", &none), "/webui/");
1059        assert_eq!(page_url_path("index.md", "", &none), "/");
1060    }
1061
1062    #[test]
1063    fn page_url_path_collapses_filename_repeating_its_folder() {
1064        let none = sources(&[]);
1065        assert_eq!(
1066            page_url_path("components/webui-button/webui-button.md", "/webui/", &none),
1067            "/webui/components/webui-button"
1068        );
1069    }
1070
1071    #[test]
1072    fn page_url_path_nav_source_overrides_derived_url() {
1073        // `SKILL.md` is a filename fixed by the agent-skill spec; the nav entry
1074        // keeps the page on `/ai` instead of leaking it as `/ai/SKILL`.
1075        let map = sources(&[("ai/SKILL.md", "/ai")]);
1076        assert_eq!(page_url_path("ai/SKILL.md", "/webui/", &map), "/webui/ai");
1077    }
1078
1079    #[test]
1080    fn page_url_path_nav_source_tolerates_surrounding_slashes() {
1081        let map = sources(&[("ai/SKILL.md", "ai/"), ("b/SKILL.md", "/b")]);
1082        assert_eq!(page_url_path("ai/SKILL.md", "/webui/", &map), "/webui/ai");
1083        assert_eq!(page_url_path("b/SKILL.md", "", &map), "/b");
1084    }
1085
1086    #[test]
1087    fn page_url_path_nav_source_can_target_site_root() {
1088        let map = sources(&[("home/landing.md", "/")]);
1089        assert_eq!(page_url_path("home/landing.md", "/webui/", &map), "/webui/");
1090    }
1091
1092    #[test]
1093    fn page_url_path_ignores_nav_source_for_other_files() {
1094        let map = sources(&[("ai/SKILL.md", "/ai")]);
1095        assert_eq!(
1096            page_url_path("guide/SKILL.md", "/webui/", &map),
1097            "/webui/guide/SKILL"
1098        );
1099    }
1100
1101    #[test]
1102    fn page_url_path_nav_source_strips_fragment_from_override() {
1103        // A nav link like "/ai#rules" must route to "/ai", not "/ai#rules".
1104        let map = sources(&[("ai/SKILL.md", "/ai#rules")]);
1105        assert_eq!(page_url_path("ai/SKILL.md", "/webui/", &map), "/webui/ai");
1106    }
1107
1108    #[test]
1109    fn page_url_path_nav_source_strips_query_from_override() {
1110        // A nav link like "/ai?v=2" must route to "/ai", not "/ai?v=2".
1111        let map = sources(&[("ai/SKILL.md", "/ai?v=2")]);
1112        assert_eq!(page_url_path("ai/SKILL.md", "/webui/", &map), "/webui/ai");
1113    }
1114
1115    #[test]
1116    fn markdown_link_base_uses_source_directory_for_index_and_leaf_pages() {
1117        let content_dir = Path::new("docs");
1118        assert_eq!(
1119            markdown_link_base_url(Path::new("docs/guide/index.md"), content_dir, "/webui/"),
1120            "/webui/guide/"
1121        );
1122        assert_eq!(
1123            markdown_link_base_url(
1124                Path::new("docs/guide/installation.md"),
1125                content_dir,
1126                "/webui/"
1127            ),
1128            "/webui/guide/"
1129        );
1130        assert_eq!(
1131            markdown_link_base_url(
1132                Path::new("docs/guide/topic/index.md"),
1133                content_dir,
1134                "/webui/"
1135            ),
1136            "/webui/guide/topic/"
1137        );
1138    }
1139
1140    // --- nav_source_overrides ---------------------------------------------
1141
1142    fn nav_link(text: &str, link: &str, source: Option<&str>) -> NavLink {
1143        NavLink {
1144            text: text.to_string(),
1145            link: link.to_string(),
1146            source: source.map(String::from),
1147        }
1148    }
1149
1150    #[test]
1151    fn nav_source_overrides_collects_only_entries_declaring_a_source() {
1152        let nav = vec![
1153            nav_link("Guide", "/guide/", None),
1154            nav_link("AI", "/ai", Some("ai/SKILL.md")),
1155            nav_link("GitHub", "https://github.com/microsoft/webui", None),
1156        ];
1157        let map = nav_source_overrides(&nav);
1158        assert_eq!(map.len(), 1);
1159        assert_eq!(map.get("ai/SKILL.md"), Some(&"/ai"));
1160    }
1161
1162    #[test]
1163    fn nav_source_overrides_empty_nav_yields_no_overrides() {
1164        assert!(nav_source_overrides(&[]).is_empty());
1165    }
1166
1167    #[test]
1168    fn nav_source_overrides_excludes_external_link_with_source() {
1169        // Accidentally pairing `source` with an external URL must be silently
1170        // dropped so it never reaches `page_url_path` as a broken route.
1171        let nav = vec![
1172            nav_link("AI", "/ai", Some("ai/SKILL.md")),
1173            nav_link(
1174                "GitHub",
1175                "https://github.com/microsoft/webui",
1176                Some("github.md"),
1177            ),
1178        ];
1179        let map = nav_source_overrides(&nav);
1180        assert_eq!(map.len(), 1);
1181        assert_eq!(map.get("ai/SKILL.md"), Some(&"/ai"));
1182        assert!(!map.contains_key("github.md"));
1183    }
1184
1185    #[test]
1186    fn normalize_path_as_index_deep_nesting_with_match() {
1187        assert_eq!(
1188            normalize_path_as_index("a/b/c/webui-card/webui-card"),
1189            "a/b/c/webui-card"
1190        );
1191    }
1192
1193    #[test]
1194    fn normalize_path_as_index_deep_nesting_without_match() {
1195        assert_eq!(
1196            normalize_path_as_index("a/b/c/webui-card/examples"),
1197            "a/b/c/webui-card/examples"
1198        );
1199    }
1200
1201    // --- parse_frontmatter -----------------------------------------------
1202
1203    #[test]
1204    fn parse_frontmatter_no_frontmatter_returns_full_body() {
1205        let (fm, body) = parse_frontmatter("# Hello\nworld\n").expect("ok");
1206        assert!(fm.title.is_none());
1207        assert!(fm.layout.is_none());
1208        assert!(fm.state.is_none());
1209        assert_eq!(body, "# Hello\nworld\n");
1210    }
1211
1212    #[test]
1213    fn parse_frontmatter_valid_yaml_extracts_fields() {
1214        let raw = "---\ntitle: Hello\ndescription: World\nlayout: home\n---\n# Body\n";
1215        let (fm, body) = parse_frontmatter(raw).expect("ok");
1216        assert_eq!(fm.title.as_deref(), Some("Hello"));
1217        assert_eq!(fm.description.as_deref(), Some("World"));
1218        assert_eq!(fm.layout.as_deref(), Some("home"));
1219        assert_eq!(body, "# Body\n");
1220    }
1221
1222    #[test]
1223    fn parse_frontmatter_handles_crlf_line_endings() {
1224        let raw = "---\r\ntitle: CRLF\r\nlayout: doc\r\n---\r\n# Body\r\n";
1225        let (fm, body) = parse_frontmatter(raw).expect("ok");
1226        assert_eq!(fm.title.as_deref(), Some("CRLF"));
1227        assert_eq!(fm.layout.as_deref(), Some("doc"));
1228        assert_eq!(body, "# Body\r\n");
1229    }
1230
1231    #[test]
1232    fn parse_frontmatter_malformed_yaml_returns_error() {
1233        // `: : :` is not valid YAML.
1234        let raw = "---\ntitle: : : :\n---\nBody\n";
1235        let result = parse_frontmatter(raw);
1236        assert!(result.is_err(), "expected error, got {result:?}");
1237    }
1238
1239    #[test]
1240    fn parse_frontmatter_preserves_json_compatible_state() {
1241        let raw = concat!(
1242            "---\nstate:\n  example:\n    label: 'Save'\n    count: 2\n",
1243            "    enabled: false\n    optional: null\n    ratio: 0.5\n",
1244            "    items: [one, two]\n    '1': string-key\n    site: nested-is-allowed\n",
1245            "---\n# Example\n"
1246        );
1247        let (fm, body) = parse_frontmatter(raw).expect("valid state");
1248        let state = fm.state.expect("local state");
1249        assert_eq!(state["example"]["label"], "Save");
1250        assert_eq!(state["example"]["count"], 2);
1251        assert_eq!(state["example"]["enabled"], false);
1252        assert_eq!(state["example"]["optional"], Value::Null);
1253        assert_eq!(state["example"]["ratio"], 0.5);
1254        assert_eq!(state["example"]["items"][1], "two");
1255        assert_eq!(state["example"]["1"], "string-key");
1256        assert_eq!(state["example"]["site"], "nested-is-allowed");
1257        assert_eq!(body, "# Example\n");
1258    }
1259
1260    #[test]
1261    fn parse_frontmatter_preserves_yaml_scalar_and_alias_semantics() {
1262        let raw = concat!(
1263            "---\ntitle: 42\ndescription: false\nlayout: [doc]\n",
1264            "state:\n  example:\n    word: yes\n    quoted: 'false'\n",
1265            "    maximum: 18446744073709551615\n    minimum: -9223372036854775808\n",
1266            "    explicit: !!str 42\n    base: &base {count: 2}\n",
1267            "    alias: *base\n    merged: {<<: *base}\n",
1268            "---\nBody\n"
1269        );
1270        let (fm, body) = parse_frontmatter(raw).expect("valid YAML scalars");
1271        assert!(fm.title.is_none());
1272        assert!(fm.description.is_none());
1273        assert!(fm.layout.is_none());
1274        let state = fm.state.expect("state");
1275        assert_eq!(state["example"]["word"], "yes");
1276        assert_eq!(state["example"]["quoted"], "false");
1277        assert_eq!(state["example"]["maximum"].as_u64(), Some(u64::MAX));
1278        assert_eq!(state["example"]["minimum"].as_i64(), Some(i64::MIN));
1279        assert_eq!(state["example"]["explicit"], "42");
1280        assert_eq!(state["example"]["alias"]["count"], 2);
1281        assert_eq!(state["example"]["merged"]["<<"]["count"], 2);
1282        assert_eq!(body, "Body\n");
1283    }
1284
1285    #[test]
1286    fn parse_frontmatter_preserves_leading_zero_and_underscored_strings() {
1287        let raw = concat!(
1288            "---\ntitle: 00123\ndescription: 1_000\nlayout: 00\n",
1289            "state: {example: {00123: leading, 1_000: underscored, ",
1290            "leading: 00123, underscored: 1_000}}\n---\nBody\n"
1291        );
1292        let (fm, _) = parse_frontmatter(raw).expect("string scalar classification");
1293        assert_eq!(fm.title.as_deref(), Some("00123"));
1294        assert_eq!(fm.description.as_deref(), Some("1_000"));
1295        assert_eq!(fm.layout.as_deref(), Some("00"));
1296        let state = fm.state.expect("state");
1297        assert_eq!(state["example"]["00123"], "leading");
1298        assert_eq!(state["example"]["1_000"], "underscored");
1299        assert_eq!(state["example"]["leading"], "00123");
1300        assert_eq!(state["example"]["underscored"], "1_000");
1301    }
1302
1303    #[test]
1304    fn parse_frontmatter_keeps_validation_scoped_to_state() {
1305        for metadata in [
1306            "!str value",
1307            ".inf",
1308            "{42: value}",
1309            "{nested: !example value}",
1310            "{nested: [one, .nan]}",
1311        ] {
1312            let raw = format!(
1313                "---\nignored: &metadata {metadata}\nstate: {{example: {{count: 2}}}}\n---\nBody\n"
1314            );
1315            let (fm, _) = parse_frontmatter(&raw).expect(metadata);
1316            assert_eq!(fm.state.expect("state")["example"]["count"], 2);
1317
1318            let raw = format!(
1319                "---\nignored: &metadata {metadata}\nstate: {{example: *metadata}}\n---\nBody\n"
1320            );
1321            let error = parse_frontmatter(&raw).expect_err(metadata).to_string();
1322            assert!(
1323                error.contains("state") && error.contains("help:"),
1324                "{error}"
1325            );
1326        }
1327        let (fm, _) = parse_frontmatter(
1328            "---\ntitle: !str Ignored\ndescription: .inf\n42: ignored\nstate: {count: 2}\n---\nBody\n",
1329        ).expect("ignored non-JSON metadata");
1330        assert_eq!(fm.title.as_deref(), Some("Ignored"));
1331        assert!(fm.description.is_none());
1332        assert_eq!(fm.state.expect("state")["count"], 2);
1333    }
1334
1335    #[test]
1336    fn parse_frontmatter_preserves_text_and_standard_tags() {
1337        let raw = concat!(
1338            "---\ntitle: !!str 00123\nstate: !!map\n",
1339            "  quoted: ' 00123 '\n  explicit: !!str 1_000\n",
1340            "  block: |\n    00123\n    1_000\n",
1341            "  yes: yes\n  exponent: 1e999\n",
1342            "  boolean: !!bool TRUE\n  count: !!int 0x10\n",
1343            "  ratio: !!float 1.5\n  empty: !!null null\n",
1344            "  items: !!seq [one, two]\n---\nBody\n",
1345        );
1346        let (fm, _) = parse_frontmatter(raw).expect("standard YAML core tags");
1347        assert_eq!(fm.title.as_deref(), Some("00123"));
1348        let state = fm.state.expect("state");
1349        assert_eq!(state["quoted"], " 00123 ");
1350        assert_eq!(state["explicit"], "1_000");
1351        assert_eq!(state["block"], "00123\n1_000\n");
1352        assert_eq!(state["yes"], "yes");
1353        assert_eq!(state["exponent"], "1e999");
1354        assert_eq!(state["boolean"], true);
1355        assert_eq!(state["count"], 16);
1356        assert_eq!(state["ratio"], 1.5);
1357        assert!(state["empty"].is_null());
1358        assert_eq!(state["items"][1], "two");
1359    }
1360
1361    #[test]
1362    fn parse_frontmatter_rejects_invalid_state() {
1363        for state in [
1364            "null",
1365            "example",
1366            "42",
1367            "false",
1368            "[]",
1369            "{nested: {1: value}}",
1370            "{nested: {true: value}}",
1371            "{nested: {.nan: value}}",
1372            "{nested: {null: value}}",
1373            "{nested: {1.5: value}}",
1374            "{nested: {? [one, two]: value}}",
1375            "{nested: {value: .inf}}",
1376            "{nested: {value: -.inf}}",
1377            "{nested: {value: !!float .nan}}",
1378            "{nested: {id: 18446744073709551617}}",
1379            "{nested: {id: -9223372036854775809}}",
1380            "{nested: [1, .nan]}",
1381            "{nested: !example value}",
1382            "!example {value: tagged}",
1383            "{nested: !example [one, two]}",
1384            "{nested: !str value}",
1385            "{nested: !int 42}",
1386            "{nested: !time value}",
1387            "{nested: {value: first, value: second}}",
1388            "{value: first}\nstate: {value: second}",
1389            "{broken: [}",
1390        ] {
1391            let raw = format!("---\nstate: {state}\n---\n\n# Invalid");
1392            let message = parse_frontmatter(&raw).expect_err(state).to_string();
1393            assert!(
1394                message.contains("state") || message.contains("YAML"),
1395                "{message}"
1396            );
1397            assert!(message.contains("help:"), "{message}");
1398        }
1399    }
1400
1401    #[test]
1402    fn parse_frontmatter_rejects_reserved_state_keys() {
1403        for key in [
1404            "site",
1405            "navigation",
1406            "sidebar",
1407            "page",
1408            "hero",
1409            "footer",
1410            "prev",
1411            "next",
1412            "pageData",
1413            "regions",
1414            "headTags",
1415            "tokens",
1416            "label",
1417            "icon",
1418        ] {
1419            let raw = format!("---\nstate:\n  {key}: override\n---\n\n# Reserved");
1420            let message = parse_frontmatter(&raw).expect_err(key).to_string();
1421            assert!(
1422                message.contains(key) && message.contains("reserved"),
1423                "{message}"
1424            );
1425            assert!(message.contains("help:"), "{message}");
1426        }
1427    }
1428}