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