use std::io;
use std::path::Path;
use pulldown_cmark::{html, Options, Parser};
use crate::web::ThemeCss;
const SECTIONS: &[(&str, &[&str])] = &[
("Getting Started", &["index", "dsl", "components", "cli"]),
(
"Targets",
&[
"gpui",
"native",
"tui",
"embedded",
"lvgl",
"lite",
"aurorality",
"webext",
"webext-policy",
],
),
(
"Reference",
&[
"view-ir-contract",
"plugin-surface",
"polyglot",
"production",
"observability",
"timing-profile",
"ide-extensions",
"runtime",
"react-native-adaptation",
"tauri-compatibility",
],
),
];
pub(crate) fn generate_docs(
src_dir: &Path,
out_dir: &Path,
theme: &ThemeCss,
site_name: &str,
) -> io::Result<()> {
if !src_dir.is_dir() {
return Ok(());
}
let mut page_map: std::collections::HashMap<String, String> = std::collections::HashMap::new();
let mut bodies: Vec<(String, String, String)> = Vec::new();
for entry in std::fs::read_dir(src_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.map(String::from)
.unwrap_or_default();
let content = std::fs::read_to_string(&path)?;
let (title, body_html) = render_md(&content);
let out_stem = if stem.eq_ignore_ascii_case("README") {
"index".to_string()
} else {
stem.clone()
};
page_map.insert(out_stem.clone(), title.clone());
bodies.push((out_stem, title, body_html));
}
std::fs::create_dir_all(out_dir)?;
for (out_stem, title, body_html) in &bodies {
let nav = render_nav(&page_map, out_stem);
let html = render_shell(body_html, title, &nav, theme, site_name);
std::fs::write(out_dir.join(format!("{out_stem}.html")), html)?;
}
if page_map.is_empty() {
return Ok(());
}
let idx: Vec<serde_json::Value> = page_map
.iter()
.map(|(stem, title)| serde_json::json!({"path": format!("{stem}.html"), "title": title}))
.collect();
std::fs::write(
out_dir.join("docs-search-index.json"),
serde_json::to_string_pretty(&serde_json::json!(idx))?,
)?;
Ok(())
}
fn render_md(md: &str) -> (String, String) {
let mut opts = Options::empty();
opts.insert(Options::ENABLE_TABLES);
opts.insert(Options::ENABLE_STRIKETHROUGH);
opts.insert(Options::ENABLE_HEADING_ATTRIBUTES);
opts.insert(Options::ENABLE_GFM);
let mut title = String::new();
for line in md.lines() {
if title.is_empty() && line.starts_with("# ") {
title = line[2..].trim().to_string();
}
}
let mut body = String::new();
html::push_html(&mut body, Parser::new_ext(md, opts));
if title.is_empty() {
title = "Documentation".into();
}
(title, body)
}
fn render_nav(page_map: &std::collections::HashMap<String, String>, current: &str) -> String {
let mut sections_html = String::new();
for (group_label, stems) in SECTIONS {
let items: Vec<String> = stems
.iter()
.filter(|s| page_map.contains_key(**s))
.map(|s| {
let title = page_map.get(*s).map(String::as_str).unwrap_or(s);
let active = if *s == current {
" class=\"active\""
} else {
""
};
format!("<li><a href=\"{s}.html\"{active}>{}</a></li>", esc(title))
})
.collect();
if items.is_empty() {
continue;
}
let expanded = stems.contains(¤t);
let open = if expanded { " open" } else { "" };
sections_html.push_str(&format!(
"<details class=\"nav-section\"{open}>\
<summary>{}</summary>\
<ul>{}</ul>\
</details>",
esc(group_label),
items.join("")
));
}
let assigned: std::collections::HashSet<&str> = SECTIONS
.iter()
.flat_map(|(_, stems)| stems.iter().copied())
.collect();
let other_items: Vec<String> = page_map
.keys()
.filter(|s| !assigned.contains(s.as_str()))
.map(|s| {
let title = page_map.get(s.as_str()).map(String::as_str).unwrap_or(s);
let active = if s.as_str() == current {
" class=\"active\""
} else {
""
};
format!("<li><a href=\"{s}.html\"{active}>{}</a></li>", esc(title))
})
.collect();
if !other_items.is_empty() {
sections_html.push_str(&format!(
"<details class=\"nav-section\"><summary>Other</summary><ul>{}</ul></details>",
other_items.join("")
));
}
format!(
"<nav aria-label=\"Documentation\">\
<div class=\"doc-search\"><input type=\"text\" id=\"doc-search-input\" placeholder=\"Search docs…\" autocomplete=\"off\">\
<div id=\"doc-search-results\" class=\"doc-search-results\"></div></div>\
{sections_html}</nav>"
)
}
fn render_shell(body: &str, title: &str, nav: &str, theme: &ThemeCss, site_name: &str) -> String {
let ttl = esc(title);
let site = esc(site_name);
let a = esc(&theme.accent);
let s = esc(&theme.surface);
let t = esc(&theme.text);
let m = esc(&theme.muted);
let b = esc(&theme.border);
format!(
r##"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{ttl} — {site}</title>
<style>
*{{box-sizing:border-box}}
body{{margin:0;min-height:100vh;background:{s};color:{t};font-family:Inter,system-ui,sans-serif;-webkit-font-smoothing:antialiased;line-height:1.6}}
a{{color:color-mix(in srgb,{t} 88%,transparent);text-decoration:none}}
a:hover{{color:{t};text-decoration:underline;text-underline-offset:3px}}
.doc-shell{{display:grid;grid-template-columns:minmax(220px,280px) 1fr;min-height:100vh}}
aside{{position:sticky;top:0;height:100vh;overflow-y:auto;padding:1.25rem;border-right:1px solid {b};background:color-mix(in srgb,{s} 92%,white 8%)}}
.brand{{font-weight:700;font-size:.95rem;color:{t};display:block;margin-bottom:1rem}}
.brand:hover{{opacity:.85;text-decoration:none}}
/* Search */
.doc-search{{margin-bottom:1rem;position:relative}}
.doc-search input{{width:100%;padding:.5rem .75rem;border:1px solid {b};border-radius:6px;background:color-mix(in srgb,{t} 6%,{s});color:{t};font-size:.85rem;outline:none;transition:border-color .15s}}
.doc-search input:focus{{border-color:{a}}}
.doc-search input::placeholder{{color:{m};opacity:.7}}
.doc-search-results{{position:absolute;z-index:10;left:0;right:0;max-height:260px;overflow-y:auto;border:1px solid {b};border-radius:6px;background:{s};display:none;box-shadow:0 4px 16px rgba(0,0,0,.35)}}
.doc-search-results.visible{{display:block}}
.doc-search-results a{{display:block;padding:.5rem .75rem;font-size:.85rem;color:{m};border-bottom:1px solid color-mix(in srgb,{b} 30%,transparent)}}
.doc-search-results a:last-child{{border-bottom:none}}
.doc-search-results a:hover,.doc-search-results a.focused{{background:color-mix(in srgb,{a} 14%,transparent);color:{t};text-decoration:none}}
.doc-search-results .no-match{{padding:.75rem;color:{m};font-size:.8rem;font-style:italic}}
/* Sidebar sections */
.nav-section{{margin-bottom:.25rem}}
.nav-section summary{{cursor:pointer;font-size:.7rem;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:{m};padding:.45rem .5rem;border-radius:5px;list-style:none;display:flex;align-items:center;gap:.4rem;transition:color .12s,background .12s;user-select:none}}
.nav-section summary::-webkit-details-marker{{display:none}}
.nav-section summary::before{{content:"▸";font-size:.6rem;transition:transform .15s;opacity:.5}}
.nav-section[open] summary::before{{transform:rotate(90deg)}}
.nav-section summary:hover{{color:{t};background:color-mix(in srgb,{t} 5%,transparent)}}
.nav-section ul{{list-style:none;padding:0 0 0 .35rem;margin:.15rem 0 .5rem}}
.nav-section li{{margin:.15rem 0}}
.nav-section a{{display:block;padding:.25rem .5rem;border-radius:4px;font-size:.85rem;color:{m};transition:background .12s,color .12s}}
.nav-section a.active{{color:{a};font-weight:600;background:color-mix(in srgb,{a} 12%,transparent)}}
.nav-section a:hover{{color:{t};background:color-mix(in srgb,{t} 6%,transparent);text-decoration:none}}
/* Content */
article{{max-width:45rem;margin:0 auto;padding:1.5rem 2rem 3rem}}
article h1,h2,h3{{color:{t}}}
article h1{{font-size:1.75rem}}
article h2{{font-size:1.3rem;border-bottom:1px solid {b};padding-bottom:.35rem}}
article p,li{{color:{m}}}
article code{{font-family:monospace;font-size:.85em;background:color-mix(in srgb,{t} 10%,transparent);padding:.12rem .35rem;border-radius:4px}}
article pre{{background:color-mix(in srgb,{t} 6%,{s});border:1px solid {b};border-radius:8px;padding:1rem;overflow-x:auto;font-size:.82rem}}
article pre code{{background:none;padding:0}}
article blockquote{{margin:1rem 0;padding:.5rem 1rem;border-left:3px solid {a};background:color-mix(in srgb,{a} 8%,transparent);border-radius:0 8px 8px 0}}
article table{{width:100%;border-collapse:collapse;margin:1rem 0;font-size:.875rem}}
article th,td{{padding:.5rem .75rem;border:1px solid {b}}}
article th{{background:color-mix(in srgb,{t} 6%,transparent);color:{t}}}
@media(max-width:640px){{
.doc-shell{{grid-template-columns:1fr}}
aside{{position:static;height:auto;border-right:none;border-bottom:1px solid {b}}}
.doc-search-results{{right:1rem}}
}}
</style>
</head>
<body>
<div class="doc-shell">
<aside><a class="brand" href="../index.html">{site}</a>{nav}</aside>
<article>{body}</article>
</div>
<script>
(function(){{
var idx=[];
fetch('../docs/docs-search-index.json').then(function(r){{return r.json()}}).then(function(d){{idx=d}}).catch(function(){{}});
var input=document.getElementById('doc-search-input');
var results=document.getElementById('doc-search-results');
var focusIdx=-1;
if(!input||!results)return;
function fuzzy(q,s){{
q=q.toLowerCase();s=s.toLowerCase();
if(s.indexOf(q)!==-1)return 1;
var qi=0;
for(var si=0;si<s.length&&qi<q.length;si++){{
if(s[si]===q[qi])qi++;
}}
return qi===q.length?0.5:0;
}}
function showAll(){{
var sections=document.querySelectorAll('.nav-section');
for(var i=0;i<sections.length;i++){{
var lis=sections[i].querySelectorAll('li');
for(var j=0;j<lis.length;j++)lis[j].style.display='';
}}
}}
function render(q){{
if(!q.trim()){{
results.className='doc-search-results';
results.innerHTML='';
showAll();
return;
}}
var scored=[];
for(var i=0;i<idx.length;i++){{
var s=fuzzy(q,idx[i].title);
if(s>0)scored.push({{item:idx[i],score:s}});
}}
scored.sort(function(a,b){{return b.score-a.score}});
/* filter sidebar */
var matchPaths=scored.map(function(s){{return s.item.path}});
var sections=document.querySelectorAll('.nav-section');
for(var i=0;i<sections.length;i++){{
var lis=sections[i].querySelectorAll('li');
var anyVisible=false;
for(var j=0;j<lis.length;j++){{
var a=lis[j].querySelector('a');
var href=a?a.getAttribute('href'):'';
var show=matchPaths.indexOf(href)!==-1;
lis[j].style.display=show?'':'none';
if(show)anyVisible=true;
}}
sections[i].open=anyVisible;
}}
if(scored.length===0){{
results.innerHTML='<div class="no-match">No results</div>';
}}else{{
results.innerHTML=scored.map(function(s){{
return '<a href="'+s.item.path+'">'+s.item.title+'</a>';
}}).join('');
}}
results.className='doc-search-results visible';
focusIdx=-1;
}}
input.addEventListener('input',function(){{render(this.value)}});
input.addEventListener('keydown',function(e){{
var links=results.querySelectorAll('a');
if(e.key==='ArrowDown'){{e.preventDefault();focusIdx=Math.min(focusIdx+1,links.length-1);updateFocus(links)}}
else if(e.key==='ArrowUp'){{e.preventDefault();focusIdx=Math.max(focusIdx-1,-1);updateFocus(links)}}
else if(e.key==='Enter'&&focusIdx>=0&&links[focusIdx]){{e.preventDefault();window.location.href=links[focusIdx].href}}
else if(e.key==='Escape'){{input.value='';render('');input.blur()}}
}});
function updateFocus(links){{
for(var i=0;i<links.length;i++)links[i].className=i===focusIdx?'focused':'';
}}
document.addEventListener('click',function(e){{
if(!e.target.closest('.doc-search')){{render('')}}
}});
}})();
</script>
</body>
</html>"##
)
}
fn esc(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}