use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::AtomicU64;
use std::sync::{Arc, OnceLock};
use std::time::Instant;
use console::style;
use rayon::prelude::*;
use serde_json::{Map, Value};
use webui::BuildOptions;
use webui_handler::{Protocol, RenderOptions, WebUIHandler};
use webui_tokens::TokenFile;
use crate::bundler::{
bundle_assets, collect_component_scripts_for_html, discover_component_scripts,
extract_bundle_scripts, inject_module_script_tags, inject_script_tag, module_script_tag,
page_bundle_signature, resolve_config_component_source, resolve_node_modules, BundleOptions,
BundleResult, BundleThread, PageBundleEntry, RootBundleEntry, ScriptSource,
};
use crate::content::process_content_with_states;
use crate::error::{Error, Result};
use crate::markdown::Highlighter;
use crate::regions::RegionSet;
use crate::state::{load_render_states, merge_page_state};
use crate::types::{BuildStats, DocsConfig, PageDescriptor};
webui_handler::define_string_response_writer!(StringWriter, buf);
fn region_layout(page: &PageDescriptor) -> &str {
let layout = page.state["page"]["layout"].as_str().unwrap_or("doc");
if layout == "home" && !page.is_home {
"doc"
} else {
layout
}
}
#[derive(Default)]
pub struct BuildCache {
pub(crate) highlighter: Option<Highlighter>,
pub quiet: bool,
pub skip_clean: bool,
pub dev_mode: bool,
}
impl BuildCache {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn set_dev_rebuild(&mut self) {
self.quiet = true;
self.skip_clean = true;
self.dev_mode = true;
}
}
fn load_theme(theme: &str, config_dir: &Path) -> Result<TokenFile> {
let config_relative = config_dir.join(theme);
let resolved = if config_relative.exists() {
config_relative
.canonicalize()
.map_err(|e| Error::Build(format!("Failed to canonicalize {theme}: {e}")))?
} else {
webui_tokens::resolve_theme_path(theme, config_dir)
.map_err(|e| Error::Build(format!("Failed to resolve theme {theme}: {e}")))?
};
webui_tokens::load_token_file(&resolved)
.map_err(|e| Error::Build(format!("Failed to load theme {}: {e}", resolved.display())))
}
fn inject_theme_tokens(
state: &mut Value,
token_file: &TokenFile,
protocol_tokens: &[String],
) -> Result<()> {
let resolved = webui_tokens::resolve_tokens(protocol_tokens, token_file)
.map_err(|e| Error::Build(format!("Failed to resolve theme tokens: {e}")))?;
webui_tokens::inject_into_state(state, &resolved);
Ok(())
}
struct NotFoundStateInput<'a> {
config: &'a DocsConfig,
base_path: &'a str,
nav: Value,
footer: Value,
content: &'a str,
head_injection: &'a str,
global_state: Option<&'a Value>,
}
fn build_not_found_state(input: NotFoundStateInput<'_>) -> Value {
let state = json_obj([
(
"site",
json_obj([
("title", Value::String(input.config.site.title.clone())),
("base", Value::String(input.base_path.to_string())),
]),
),
("navigation", input.nav),
("sidebar", json_obj([("sections", Value::Array(vec![]))])),
(
"page",
json_obj([
("title", Value::String("Page Not Found".to_string())),
(
"description",
Value::String("The page you're looking for doesn't exist.".to_string()),
),
("content", Value::String(input.content.to_string())),
("isHome", Value::Bool(false)),
("layout", Value::String("doc".to_string())),
]),
),
("hero", Value::Null),
("footer", input.footer),
("prev", Value::Null),
("next", Value::Null),
("pageData", Value::Null),
("headTags", Value::String(input.head_injection.to_string())),
("label", Value::String("Copy".to_string())),
("icon", Value::String("🌙".to_string())),
]);
merge_page_state(state, input.global_state, None)
}
static REBUILD_NONCE: AtomicU64 = AtomicU64::new(0);
fn print_header(cache: &BuildCache, title: &str) {
if cache.quiet {
return;
}
eprintln!(
"\n {} {}",
style("⚡").cyan().bold(),
style(title).cyan().bold()
);
}
fn print_success(cache: &BuildCache, message: &str) {
if cache.quiet {
return;
}
eprintln!(" {} {message}", style("✔").green());
}
fn print_bundle_summary(cache: &BuildCache, result: &BundleResult) {
print_success(
cache,
&format!(
"Bundled {} component script{} into {} root script{} and {} page script group{} in {:.0}ms",
result.component_count,
if result.component_count == 1 { "" } else { "s" },
usize::from(result.root_script.is_some()),
if result.root_script.is_some() {
""
} else {
"s"
},
result.page_entry_count,
if result.page_entry_count == 1 {
""
} else {
"s"
},
result.duration.as_secs_f64() * 1000.0
),
);
}
fn print_finish(cache: &BuildCache, message: &str) {
if cache.quiet {
return;
}
eprintln!("\n {} {message}\n", style("✨").green());
}
fn json_obj<const N: usize>(entries: [(&str, Value); N]) -> Value {
let mut map = Map::with_capacity(N);
for (k, v) in entries {
map.insert(k.to_string(), v);
}
Value::Object(map)
}
pub fn build_docs(
config: &DocsConfig,
config_dir: &Path,
template_dir: &Path,
) -> Result<BuildStats> {
let mut cache = BuildCache::new();
build_docs_with_cache(config, config_dir, template_dir, &mut cache)
}
pub fn build_docs_with_cache(
config: &DocsConfig,
config_dir: &Path,
template_dir: &Path,
cache: &mut BuildCache,
) -> Result<BuildStats> {
let start = Instant::now();
let base_path = &config.base_path;
let out_dir = Path::new(&config.out_dir);
let site_dir = out_dir.to_path_buf();
let custom_css = config
.css
.as_ref()
.and_then(|p| fs::read_to_string(p).ok())
.unwrap_or_default();
let token_file = match &config.theme {
Some(theme) => Some(load_theme(theme, config_dir)?),
None => None,
};
print_header(cache, &config.site.title);
eprintln!();
let base_css_src = template_dir.join("docs.css");
let has_base_css = base_css_src.exists();
let base_css_link = if has_base_css {
format!("<link rel=\"stylesheet\" href=\"{base_path}docs.css\">")
} else {
String::new()
};
let has_theme_css = !custom_css.is_empty();
let theme_css_link = if has_theme_css {
format!("<link rel=\"stylesheet\" href=\"{base_path}theme.css\">")
} else {
String::new()
};
let head_injection = {
let mut parts: Vec<String> = Vec::with_capacity(2 + config.head.len());
if !base_css_link.is_empty() {
parts.push(base_css_link.clone());
}
if !theme_css_link.is_empty() {
parts.push(theme_css_link.clone());
}
for tag in &config.head {
parts.push(tag.to_html());
}
parts.join("\n ")
};
let render_states = load_render_states(config, config_dir)?;
let highlighter = cache.highlighter.take().unwrap_or_default();
let mut pages =
process_content_with_states(config, &highlighter, &head_injection, &render_states)?;
cache.highlighter = Some(highlighter);
let mut component_sources: Vec<String> = Vec::new();
let cwd = std::env::current_dir().unwrap_or_default();
let builtin_components = template_dir.parent().map(|p| p.join("components"));
if let Some(ref bc) = builtin_components {
if bc.exists() {
component_sources.push(bc.to_string_lossy().to_string());
}
}
if let Some(ref user_sources) = config.components {
for source in user_sources {
component_sources.push(resolve_config_component_source(source, &cwd));
}
}
component_sources.push(template_dir.to_string_lossy().to_string());
let template_html = fs::read_to_string(template_dir.join("index.html"))
.map_err(|e| Error::Build(format!("Failed to read template: {e}")))?;
let regions = RegionSet::load(&config.regions, config_dir, template_html)?;
let component_script_index = discover_component_scripts(&component_sources)?;
if out_dir.exists() && !cache.skip_clean {
remove_dir_all_retry(out_dir)
.map_err(|e| Error::Io(format!("Cannot clean output dir: {e}")))?;
}
fs::create_dir_all(&site_dir).map_err(|e| Error::Io(format!("Cannot create site dir: {e}")))?;
if has_base_css {
fs::copy(&base_css_src, site_dir.join("docs.css"))
.map_err(|e| Error::Io(format!("Cannot copy docs.css: {e}")))?;
}
if has_theme_css {
fs::write(site_dir.join("theme.css"), &custom_css)
.map_err(|e| Error::Io(format!("Cannot write theme.css: {e}")))?;
}
let handler = WebUIHandler::with_plugin(|| {
Box::new(webui_handler::plugin::webui::WebUIHydrationPlugin::new())
});
for page in &pages {
let page_dir = site_dir.join(page.path.strip_prefix(base_path).unwrap_or(&page.path));
fs::create_dir_all(&page_dir)
.map_err(|e| Error::Io(format!("Cannot create dir {}: {e}", page_dir.display())))?;
}
let mut explicit_page_scripts: HashMap<String, Vec<ScriptSource>> = HashMap::new();
let mut page_contents_with_scripts: HashMap<String, String> = HashMap::new();
for page in &pages {
let content = page.state["page"]["content"].as_str().unwrap_or("");
if content.contains("<script") && content.contains("bundle") {
let (modified, scripts) = extract_bundle_scripts(content);
if !scripts.is_empty() {
page_contents_with_scripts.insert(page.path.clone(), modified);
explicit_page_scripts
.entry(page.path.clone())
.or_default()
.extend(scripts);
}
}
for script_file in regions.script_files(region_layout(page)) {
explicit_page_scripts
.entry(page.path.clone())
.or_default()
.push(ScriptSource::File(script_file.to_string()));
}
}
for (link, custom_page) in &config.custom_pages {
if let Some(script_path) = custom_page.script_file() {
let normalized = if link.ends_with('/') {
link.clone()
} else {
format!("{link}/")
};
let page_path = format!("{}{}", base_path, &normalized[1..]);
explicit_page_scripts
.entry(page_path)
.or_default()
.push(ScriptSource::File(script_path.to_string()));
}
}
let not_found_content = format!(
"<h1>404 — Page Not Found</h1>\
<p>The page you're looking for doesn't exist or has been moved.</p>\
<p><a href=\"{base_path}\">← Back to Home</a></p>"
);
let template_script_path = template_dir.join("index.ts");
let template_script = if template_script_path.exists() {
Some(
template_script_path
.canonicalize()
.unwrap_or(template_script_path),
)
} else {
None
};
let template_shell = regions.template_shell();
let root_component_scripts =
collect_component_scripts_for_html(&[template_shell.as_str()], &component_script_index)?;
let root_bundle = if template_script.is_some() || !root_component_scripts.is_empty() {
Some(RootBundleEntry {
script_path: template_script,
component_scripts: root_component_scripts,
})
} else {
None
};
let mut page_bundles: Vec<PageBundleEntry> = Vec::new();
let mut page_bundle_ids: HashMap<String, usize> = HashMap::with_capacity(pages.len());
let mut page_bundle_signatures: HashMap<String, usize> = HashMap::new();
for page in &pages {
let content = page_contents_with_scripts
.get(&page.path)
.map(String::as_str)
.unwrap_or_else(|| page.state["page"]["content"].as_str().unwrap_or(""));
let region_fragments = regions.html_fragments(region_layout(page));
let mut html_sources = Vec::with_capacity(region_fragments.len() + 1);
html_sources.push(content);
html_sources.extend(region_fragments);
let mut component_scripts =
collect_component_scripts_for_html(&html_sources, &component_script_index)?;
if let Some(root) = &root_bundle {
component_scripts.retain(|path| !root.component_scripts.contains(path));
}
let explicit_scripts = explicit_page_scripts.remove(&page.path).unwrap_or_default();
if component_scripts.is_empty() && explicit_scripts.is_empty() {
continue;
}
let signature = page_bundle_signature(&component_scripts, &explicit_scripts, config_dir);
if let Some(&id) = page_bundle_signatures.get(&signature) {
page_bundle_ids.insert(page.path.clone(), id);
continue;
}
let id = page_bundles.len();
page_bundle_signatures.insert(signature, id);
page_bundle_ids.insert(page.path.clone(), id);
page_bundles.push(PageBundleEntry {
id,
page_path: page.path.clone(),
component_scripts,
explicit_scripts,
});
}
let not_found_region_fragments = regions.html_fragments("doc");
let mut not_found_sources = Vec::with_capacity(not_found_region_fragments.len() + 1);
not_found_sources.push(not_found_content.as_str());
not_found_sources.extend(not_found_region_fragments);
let mut not_found_component_scripts =
collect_component_scripts_for_html(¬_found_sources, &component_script_index)?;
if let Some(root) = &root_bundle {
not_found_component_scripts.retain(|path| !root.component_scripts.contains(path));
}
let not_found_scripts: Vec<ScriptSource> = regions
.script_files("doc")
.map(|path| ScriptSource::File(path.to_string()))
.collect();
let not_found_bundle_id =
if not_found_component_scripts.is_empty() && not_found_scripts.is_empty() {
None
} else {
let signature =
page_bundle_signature(¬_found_component_scripts, ¬_found_scripts, config_dir);
if let Some(&id) = page_bundle_signatures.get(&signature) {
Some(id)
} else {
let id = page_bundles.len();
page_bundle_signatures.insert(signature, id);
page_bundles.push(PageBundleEntry {
id,
page_path: format!("{base_path}404/"),
component_scripts: not_found_component_scripts,
explicit_scripts: not_found_scripts,
});
Some(id)
}
};
let node_modules = if root_bundle.is_some() || !page_bundles.is_empty() {
Some(resolve_node_modules(config_dir)?)
} else {
None
};
let (projection_source, projection_completer) = webui::projection_manifest_barrier();
let site_dir_for_bundle = site_dir.clone();
let root_bundle_for_bundle = root_bundle.clone();
let page_bundles_for_bundle = page_bundles.clone();
let bundler_config = config.bundler.clone();
let config_dir_for_bundle = config_dir.to_path_buf();
let content_dir_for_bundle = PathBuf::from(&config.content_dir);
let base_path_for_bundle = base_path.to_string();
let dev_mode = cache.dev_mode;
let generated_preloads = Arc::new(OnceLock::new());
let generated_preloads_for_bundle = Arc::clone(&generated_preloads);
let bundle_thread = BundleThread::spawn(move || {
match bundle_assets(&BundleOptions {
site_dir: &site_dir_for_bundle,
base_path: &base_path_for_bundle,
node_modules: node_modules.as_deref(),
root_bundle: root_bundle_for_bundle.as_ref(),
page_bundles: &page_bundles_for_bundle,
bundler_config: bundler_config.as_ref(),
dev_mode,
config_dir: &config_dir_for_bundle,
content_dir: &content_dir_for_bundle,
}) {
Ok(result) => {
if generated_preloads_for_bundle
.set(result.preloads.clone())
.is_err()
{
let message = "Generated preload metadata was published twice".to_string();
projection_completer.complete(Err(message.clone()));
return Err(Error::Build(message));
}
projection_completer.complete(Ok(result.projection.clone()));
Ok(result)
}
Err(error) => {
projection_completer.complete(Err(error.to_string()));
Err(error)
}
}
});
let total_bytes = std::sync::atomic::AtomicUsize::new(0);
let component_css: std::sync::Mutex<HashMap<String, String>> =
std::sync::Mutex::new(HashMap::new());
let page_start = Instant::now();
pages.par_iter_mut().try_for_each(|page| -> Result<()> {
let page_dir = site_dir.join(page.path.strip_prefix(base_path).unwrap_or(&page.path));
let target = page_dir.join("index.html");
let content = page_contents_with_scripts
.get(&page.path)
.map(|s| s.as_str())
.unwrap_or_else(|| page.state["page"]["content"].as_str().unwrap_or(""));
let (protected, pre_blocks) = protect_pre_blocks(content);
let page_html = regions
.render(region_layout(page))
.replace("{{{page.content}}}", &protected);
let nonce = REBUILD_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let page_tmp = std::env::temp_dir().join(format!(
"webui-press-page-{}-{:x}-{nonce:x}",
std::process::id(),
fxhash(&page.path),
));
if page_tmp.exists() {
fs::remove_dir_all(&page_tmp).ok();
}
fs::create_dir_all(&page_tmp)
.map_err(|e| Error::Io(format!("Cannot create page temp: {e}")))?;
fs::write(page_tmp.join("index.html"), &page_html)
.map_err(|e| Error::Io(format!("Cannot write page temp: {e}")))?;
let mut build_result = webui::build(BuildOptions {
app_dir: page_tmp.clone(),
entry: "index.html".to_string(),
plugin: Some(webui::Plugin::WebUI),
components: component_sources.clone(),
theme: token_file.clone(),
projection_manifests: vec![projection_source.clone()],
..BuildOptions::default()
})
.map_err(|e| Error::Build(format!("{}: {e}", page.path)))?;
let preloads = generated_preloads.get().ok_or_else(|| {
Error::Build("Generated preload metadata was not published".to_string())
})?;
let (hrefs, mut preload_warnings) = preloads.resolve(
&build_result.protocol.module_preloads,
page_bundle_ids.get(&page.path).copied(),
);
build_result.protocol.module_preloads = hrefs;
build_result.warnings.append(&mut preload_warnings);
total_bytes.fetch_add(
build_result.protocol_bytes.len(),
std::sync::atomic::Ordering::Relaxed,
);
if !build_result.css_files.is_empty() {
let mut css_map = component_css
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
for (name, content) in build_result.css_files {
css_map.entry(name).or_insert(content);
}
}
let layout = region_layout(page).to_string();
regions.apply_state(&layout, &mut page.state)?;
if let Some(token_file) = token_file.as_ref() {
inject_theme_tokens(&mut page.state, token_file, &build_result.protocol.tokens)?;
}
let protocol = Protocol::new(build_result.protocol);
let mut writer = StringWriter::with_capacity(8192);
handler
.render(
&protocol,
&page.state,
&RenderOptions::new("index.html", &page.path),
&mut writer,
)
.map_err(|e| Error::Render(format!("{}: {e}", page.path)))?;
let html = restore_pre_blocks(&writer.buf, &pre_blocks);
fs::write(&target, &html)
.map_err(|e| Error::Io(format!("Cannot write {}: {e}", page.path)))?;
fs::remove_dir_all(&page_tmp).ok();
Ok(())
})?;
print_success(
cache,
&format!(
"Rendered {} pages in {:.0}ms",
pages.len(),
page_start.elapsed().as_secs_f64() * 1000.0
),
);
let bundle_result = bundle_thread.join()?;
print_bundle_summary(cache, &bundle_result);
let search_path = site_dir.join("search-index.json");
let search_entries: Vec<Value> = pages
.iter()
.filter(|p| !p.is_home)
.map(|p| {
let html = p.state["page"]["content"].as_str().unwrap_or("");
let title = p.state["page"]["title"].as_str().unwrap_or("");
build_search_entry(title, &p.path, html)
})
.collect();
fs::write(
&search_path,
serde_json::to_string(&search_entries)
.map_err(|e| Error::Build(format!("JSON error: {e}")))?,
)
.map_err(|e| Error::Io(e.to_string()))?;
print_success(
cache,
&format!("Indexed {} pages for search", search_entries.len()),
);
let public_dir = Path::new(&config.public_dir);
if public_dir.exists() {
copy_dir(public_dir, &site_dir)?;
}
let nav_val = pages
.first()
.map(|p| p.state["navigation"].clone())
.unwrap_or_default();
let footer_val = config
.footer
.as_ref()
.map(|f| json_obj([("html", Value::String(f.html.clone()))]))
.unwrap_or(Value::Null);
let mut not_found_state = build_not_found_state(NotFoundStateInput {
config,
base_path,
nav: nav_val,
footer: footer_val,
content: ¬_found_content,
head_injection: &head_injection,
global_state: render_states.global(),
});
regions.apply_state("doc", &mut not_found_state)?;
let (protected_not_found, not_found_pre_blocks) = protect_pre_blocks(¬_found_content);
let not_found_html = regions
.render("doc")
.replace("{{{page.content}}}", &protected_not_found);
let nf_tmp = std::env::temp_dir().join(format!(
"webui-press-404-{}-{:x}",
std::process::id(),
REBUILD_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
));
if nf_tmp.exists() {
fs::remove_dir_all(&nf_tmp).ok();
}
fs::create_dir_all(&nf_tmp).map_err(|e| Error::Io(e.to_string()))?;
fs::write(nf_tmp.join("index.html"), ¬_found_html).map_err(|e| Error::Io(e.to_string()))?;
let mut nf_build = webui::build(BuildOptions {
app_dir: nf_tmp.clone(),
entry: "index.html".to_string(),
plugin: Some(webui::Plugin::WebUI),
components: component_sources.clone(),
theme: token_file.clone(),
projection_manifests: vec![projection_source],
..BuildOptions::default()
})
.map_err(|e| Error::Build(format!("404 build failed: {e}")))?;
let preloads = generated_preloads
.get()
.ok_or_else(|| Error::Build("Generated preload metadata was not published".to_string()))?;
let (hrefs, mut preload_warnings) =
preloads.resolve(&nf_build.protocol.module_preloads, not_found_bundle_id);
nf_build.protocol.module_preloads = hrefs;
nf_build.warnings.append(&mut preload_warnings);
if let Some(token_file) = token_file.as_ref() {
inject_theme_tokens(&mut not_found_state, token_file, &nf_build.protocol.tokens)?;
}
{
let mut css_map = component_css
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
for (name, content) in &nf_build.css_files {
css_map
.entry(name.clone())
.or_insert_with(|| content.clone());
}
}
let nf_protocol = Protocol::new(nf_build.protocol);
let mut writer_404 = StringWriter::with_capacity(4096);
handler
.render(
&nf_protocol,
¬_found_state,
&RenderOptions::new("index.html", &format!("{base_path}404/")),
&mut writer_404,
)
.map_err(|e| Error::Render(format!("404: {e}")))?;
let not_found_output = restore_pre_blocks(&writer_404.buf, ¬_found_pre_blocks);
fs::write(site_dir.join("404.html"), not_found_output).map_err(|e| Error::Io(e.to_string()))?;
fs::remove_dir_all(&nf_tmp).ok();
print_success(cache, "Generated 404 page");
let css_map_local = component_css
.into_inner()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut css_names: Vec<&String> = css_map_local.keys().collect();
css_names.sort();
let css_count = css_names.len();
for name in css_names {
fs::write(site_dir.join(name), &css_map_local[name])
.map_err(|e| Error::Io(format!("Cannot write {name}: {e}")))?;
}
if css_count > 0 {
print_success(cache, &format!("Wrote {css_count} component stylesheets"));
}
if bundle_result.root_script.is_some() || !bundle_result.script_map.is_empty() {
let mut linked_count = 0usize;
for page in &pages {
let page_rel_paths = if let Some(bundle_id) = page_bundle_ids.get(&page.path) {
let rel_paths = bundle_result.script_map.get(bundle_id).ok_or_else(|| {
Error::Build(format!(
"Missing bundled script for page {} entry {}",
page.path, bundle_id
))
})?;
if rel_paths.is_empty() {
None
} else {
Some(rel_paths)
}
} else {
None
};
if bundle_result.root_script.is_none() && page_rel_paths.is_none() {
continue;
}
let page_dir = site_dir.join(page.path.strip_prefix(base_path).unwrap_or(&page.path));
let target = page_dir.join("index.html");
let mut html = fs::read_to_string(&target)
.map_err(|e| Error::Io(format!("Cannot read {}: {e}", target.display())))?;
if let Some(rel_path) = bundle_result.root_script.as_ref() {
let tag = module_script_tag(base_path, rel_path);
inject_script_tag(&mut html, &tag);
linked_count += 1;
}
if let Some(rel_paths) = page_rel_paths {
linked_count += inject_module_script_tags(&mut html, base_path, rel_paths);
}
fs::write(&target, &html)
.map_err(|e| Error::Io(format!("Cannot rewrite {}: {e}", page.path)))?;
}
if let Some(bundle_id) = not_found_bundle_id {
let rel_paths = bundle_result.script_map.get(&bundle_id).ok_or_else(|| {
Error::Build(format!(
"Missing bundled script for 404 page entry {bundle_id}"
))
})?;
let target = site_dir.join("404.html");
let mut html = fs::read_to_string(&target)
.map_err(|e| Error::Io(format!("Cannot read {}: {e}", target.display())))?;
if let Some(rel_path) = bundle_result.root_script.as_ref() {
let tag = module_script_tag(base_path, rel_path);
inject_script_tag(&mut html, &tag);
linked_count += 1;
}
linked_count += inject_module_script_tags(&mut html, base_path, rel_paths);
fs::write(&target, &html)
.map_err(|e| Error::Io(format!("Cannot rewrite 404.html: {e}")))?;
} else if let Some(rel_path) = bundle_result.root_script.as_ref() {
let target = site_dir.join("404.html");
let mut html = fs::read_to_string(&target)
.map_err(|e| Error::Io(format!("Cannot read {}: {e}", target.display())))?;
let tag = module_script_tag(base_path, rel_path);
inject_script_tag(&mut html, &tag);
fs::write(&target, &html)
.map_err(|e| Error::Io(format!("Cannot rewrite 404.html: {e}")))?;
linked_count += 1;
}
if linked_count > 0 {
print_success(
cache,
&format!(
"Linked {} script tag{}",
linked_count,
if linked_count == 1 { "" } else { "s" }
),
);
}
}
let elapsed = start.elapsed();
let total_bytes = total_bytes.load(std::sync::atomic::Ordering::Relaxed);
print_finish(cache, &format!("Built in {:.1}s", elapsed.as_secs_f64()));
Ok(BuildStats {
pages: pages.len(),
protocol_bytes: total_bytes,
})
}
fn remove_dir_all_retry(path: &Path) -> std::io::Result<()> {
const MAX_ATTEMPTS: u32 = 5;
let mut delay = std::time::Duration::from_millis(20);
for attempt in 1..=MAX_ATTEMPTS {
match fs::remove_dir_all(path) {
Ok(()) => return Ok(()),
Err(e) if attempt < MAX_ATTEMPTS && is_dir_not_empty(&e) => {
std::thread::sleep(delay);
delay = delay.saturating_mul(2);
}
Err(e) => return Err(e),
}
}
Ok(())
}
fn is_dir_not_empty(e: &std::io::Error) -> bool {
match e.raw_os_error() {
Some(66) => true, Some(39) => true, Some(145) => true, _ => false,
}
}
fn copy_dir(src: &Path, dest: &Path) -> Result<()> {
if !src.exists() {
return Ok(());
}
fs::create_dir_all(dest).map_err(|e| Error::Io(e.to_string()))?;
let mut stack: Vec<(std::path::PathBuf, std::path::PathBuf)> =
vec![(src.to_path_buf(), dest.to_path_buf())];
while let Some((s, d)) = stack.pop() {
for entry in fs::read_dir(&s).map_err(|e| Error::Io(e.to_string()))? {
let entry = entry.map_err(|e| Error::Io(e.to_string()))?;
let dest_path = d.join(entry.file_name());
let ft = entry.file_type().map_err(|e| Error::Io(e.to_string()))?;
if ft.is_dir() {
fs::create_dir_all(&dest_path).map_err(|e| Error::Io(e.to_string()))?;
stack.push((entry.path(), dest_path));
} else {
fs::copy(entry.path(), &dest_path).map_err(|e| Error::Io(e.to_string()))?;
}
}
}
Ok(())
}
pub(crate) fn truncate_utf8(s: &str, max: usize) -> &str {
if s.len() <= max {
return s;
}
let mut end = max;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
&s[..end]
}
#[derive(Default)]
struct SearchText {
content: String,
headings: Vec<SearchHeading>,
}
struct SearchHeading {
text: String,
anchor: String,
level: u8,
}
struct CurrentHeading {
text: String,
anchor: String,
level: u8,
}
struct HtmlTag<'a> {
name: &'a str,
is_end: bool,
}
pub(crate) fn build_search_entry(title: &str, path: &str, html: &str) -> Value {
let search_text = extract_search_text(html);
let content = normalize_search_text(&search_text.content);
let truncated = truncate_utf8(&content, 500);
let headings: Vec<Value> = search_text
.headings
.into_iter()
.map(|heading| {
json_obj([
("text", Value::String(heading.text)),
("anchor", Value::String(heading.anchor)),
(
"level",
Value::Number(serde_json::Number::from(u64::from(heading.level))),
),
])
})
.collect();
json_obj([
("title", Value::String(title.to_string())),
("path", Value::String(path.to_string())),
("content", Value::String(truncated.to_string())),
("headings", Value::Array(headings)),
])
}
fn extract_search_text(html: &str) -> SearchText {
let mut text = SearchText {
content: String::with_capacity(html.len() / 2),
headings: Vec::with_capacity(8),
};
let mut current_heading: Option<CurrentHeading> = None;
let mut skip_header_anchor = 0usize;
let mut cursor = 0;
while cursor < html.len() {
let remaining = &html[cursor..];
let Some(tag_offset) = remaining.find('<') else {
push_search_text(
&mut text,
current_heading.as_mut(),
skip_header_anchor,
remaining,
);
break;
};
let text_end = cursor + tag_offset;
push_search_text(
&mut text,
current_heading.as_mut(),
skip_header_anchor,
&html[cursor..text_end],
);
let tag_start = text_end + 1;
let Some(tag_len) = html[tag_start..].find('>') else {
push_search_text(
&mut text,
current_heading.as_mut(),
skip_header_anchor,
&html[text_end..],
);
break;
};
let tag_end = tag_start + tag_len;
handle_search_tag(
&html[tag_start..tag_end],
&mut text,
&mut current_heading,
&mut skip_header_anchor,
);
cursor = tag_end + 1;
}
text
}
fn handle_search_tag(
raw_tag: &str,
text: &mut SearchText,
current_heading: &mut Option<CurrentHeading>,
skip_header_anchor: &mut usize,
) {
if let Some(tag) = parse_html_tag(raw_tag) {
if tag.is_end {
handle_end_tag(tag.name, text, current_heading, skip_header_anchor);
} else {
handle_start_tag(tag.name, raw_tag, current_heading, skip_header_anchor);
}
}
push_search_space(text, current_heading.as_mut(), *skip_header_anchor);
}
fn handle_start_tag(
name: &str,
raw_tag: &str,
current_heading: &mut Option<CurrentHeading>,
skip_header_anchor: &mut usize,
) {
if let Some(level) = heading_level(name) {
*current_heading = Some(CurrentHeading {
text: String::with_capacity(64),
anchor: attr_value(raw_tag, "id").unwrap_or("").to_string(),
level,
});
} else if name.eq_ignore_ascii_case("a") && raw_tag.contains("header-anchor") {
*skip_header_anchor += 1;
}
}
fn handle_end_tag(
name: &str,
text: &mut SearchText,
current_heading: &mut Option<CurrentHeading>,
skip_header_anchor: &mut usize,
) {
if name.eq_ignore_ascii_case("a") && *skip_header_anchor > 0 {
*skip_header_anchor -= 1;
}
if heading_level(name).is_some() {
if let Some(raw_heading) = current_heading.take() {
let heading_text = normalize_search_text(&raw_heading.text);
if !heading_text.is_empty() {
text.headings.push(SearchHeading {
text: heading_text,
anchor: raw_heading.anchor,
level: raw_heading.level,
});
}
}
}
}
fn parse_html_tag(raw: &str) -> Option<HtmlTag<'_>> {
let mut tag = raw.trim_start();
if tag.starts_with('!') || tag.starts_with('?') {
return None;
}
let is_end = tag.starts_with('/');
if is_end {
tag = tag[1..].trim_start();
}
let mut name_end = 0;
for (idx, byte) in tag.bytes().enumerate() {
if byte.is_ascii_alphanumeric() || byte == b'-' {
name_end = idx + 1;
} else {
break;
}
}
if name_end == 0 {
return None;
}
Some(HtmlTag {
name: &tag[..name_end],
is_end,
})
}
fn heading_level(name: &str) -> Option<u8> {
let bytes = name.as_bytes();
if bytes.len() == 2 && bytes[0].eq_ignore_ascii_case(&b'h') && (b'1'..=b'6').contains(&bytes[1])
{
Some(bytes[1] - b'0')
} else {
None
}
}
fn attr_value<'a>(raw_tag: &'a str, attr_name: &str) -> Option<&'a str> {
let bytes = raw_tag.as_bytes();
let mut cursor = 0;
while cursor < bytes.len() {
while bytes.get(cursor).is_some_and(u8::is_ascii_whitespace) {
cursor += 1;
}
let name_start = cursor;
while bytes.get(cursor).is_some_and(|b| is_attr_name_byte(*b)) {
cursor += 1;
}
if name_start == cursor {
cursor += 1;
continue;
}
let name = &raw_tag[name_start..cursor];
while bytes.get(cursor).is_some_and(u8::is_ascii_whitespace) {
cursor += 1;
}
if bytes.get(cursor) != Some(&b'=') {
continue;
}
cursor += 1;
while bytes.get(cursor).is_some_and(u8::is_ascii_whitespace) {
cursor += 1;
}
let (value_start, value_end) = attr_value_range(raw_tag, cursor);
if name.eq_ignore_ascii_case(attr_name) {
return Some(&raw_tag[value_start..value_end]);
}
cursor = value_end.saturating_add(1);
}
None
}
fn is_attr_name_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b':')
}
fn attr_value_range(raw_tag: &str, cursor: usize) -> (usize, usize) {
let bytes = raw_tag.as_bytes();
match bytes.get(cursor).copied() {
Some(b'"' | b'\'') => {
let quote = bytes[cursor];
let value_start = cursor + 1;
let value_end = raw_tag[value_start..]
.find(char::from(quote))
.map_or(raw_tag.len(), |offset| value_start + offset);
(value_start, value_end)
}
Some(_) => {
let value_start = cursor;
let value_end = raw_tag[value_start..]
.find(char::is_whitespace)
.map_or(raw_tag.len(), |offset| value_start + offset);
(value_start, value_end)
}
None => (raw_tag.len(), raw_tag.len()),
}
}
fn push_search_text(
text: &mut SearchText,
current_heading: Option<&mut CurrentHeading>,
skip_header_anchor: usize,
value: &str,
) {
if skip_header_anchor > 0 {
return;
}
text.content.push_str(value);
if let Some(heading) = current_heading {
heading.text.push_str(value);
}
}
fn push_search_space(
text: &mut SearchText,
current_heading: Option<&mut CurrentHeading>,
skip_header_anchor: usize,
) {
if skip_header_anchor > 0 {
return;
}
text.content.push(' ');
if let Some(heading) = current_heading {
heading.text.push(' ');
}
}
fn normalize_search_text(raw: &str) -> String {
let decoded = html_escape::decode_html_entities(raw);
let mut normalized = String::with_capacity(decoded.len());
let mut first = true;
for token in decoded.split_whitespace() {
if !first {
normalized.push(' ');
}
normalized.push_str(token);
first = false;
}
normalized
}
const PRE_BLOCK_MARKER_PREFIX: &str = "<span data-webui-press-pre-block=\"";
const PRE_BLOCK_MARKER_SUFFIX: &str = "\"></span>";
fn protect_pre_blocks(content: &str) -> (String, Vec<String>) {
use std::fmt::Write as _;
let mut blocks: Vec<String> = Vec::new();
let mut out = String::with_capacity(content.len());
let mut cursor = 0;
while let Some(rel_start) = find_pre_open(&content[cursor..]) {
let start = cursor + rel_start;
if let Some(rel_end) = content[start..].find("</pre>") {
let end = start + rel_end + "</pre>".len();
out.push_str(&content[cursor..start]);
out.push_str(PRE_BLOCK_MARKER_PREFIX);
let _ = write!(&mut out, "{}", blocks.len());
out.push_str(PRE_BLOCK_MARKER_SUFFIX);
blocks.push(content[start..end].to_string());
cursor = end;
} else {
break;
}
}
out.push_str(&content[cursor..]);
(out, blocks)
}
fn find_pre_open(s: &str) -> Option<usize> {
let bytes = s.as_bytes();
let mut i = 0;
while let Some(rel) = s[i..].find("<pre") {
let pos = i + rel;
let after = pos + 4;
match bytes.get(after) {
Some(b'>' | b' ' | b'\t' | b'\n' | b'\r') => return Some(pos),
_ => i = after,
}
}
None
}
fn restore_pre_blocks(html: &str, blocks: &[String]) -> String {
if blocks.is_empty() {
return html.to_string();
}
let extra: usize = blocks.iter().map(|b| b.len()).sum();
let mut out = String::with_capacity(html.len() + extra);
let mut cursor = 0;
while let Some(rel) = html[cursor..].find(PRE_BLOCK_MARKER_PREFIX) {
let p = cursor + rel;
out.push_str(&html[cursor..p]);
let after = p + PRE_BLOCK_MARKER_PREFIX.len();
if let Some(end_rel) = html[after..].find(PRE_BLOCK_MARKER_SUFFIX) {
let num_str = &html[after..after + end_rel];
if let Ok(idx) = num_str.parse::<usize>() {
if let Some(block) = blocks.get(idx) {
out.push_str(block);
cursor = after + end_rel + PRE_BLOCK_MARKER_SUFFIX.len();
continue;
}
}
out.push_str(&html[p..after + end_rel + PRE_BLOCK_MARKER_SUFFIX.len()]);
cursor = after + end_rel + PRE_BLOCK_MARKER_SUFFIX.len();
} else {
out.push_str(&html[p..]);
return out;
}
}
out.push_str(&html[cursor..]);
out
}
fn fxhash(s: &str) -> u64 {
fxhash_bytes(s.as_bytes())
}
fn fxhash_bytes(bytes: &[u8]) -> u64 {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for &b in bytes {
hash ^= u64::from(b);
hash = hash.wrapping_mul(0x100_0000_01b3);
}
hash
}
#[cfg(test)]
mod tests {
use super::*;
type TestResult<T = ()> = std::result::Result<T, Box<dyn std::error::Error>>;
fn test_obj<const N: usize>(entries: [(&str, Value); N]) -> Value {
let mut map = Map::with_capacity(N);
for (key, value) in entries {
map.insert(key.to_string(), value);
}
Value::Object(map)
}
fn string_value(value: &str) -> Value {
Value::String(value.to_string())
}
fn docs_config() -> TestResult<DocsConfig> {
let config = serde_json::from_str(
r#"{
"site": { "title": "Docs" },
"basePath": "/",
"contentDir": ".",
"nav": [],
"sidebar": []
}"#,
)?;
Ok(config)
}
#[test]
fn not_found_state_includes_global_state() -> TestResult {
let config = docs_config()?;
let global = test_obj([("release", test_obj([("version", string_value("v1.2.3"))]))]);
let state = build_not_found_state(NotFoundStateInput {
config: &config,
base_path: "/",
nav: Value::Array(vec![]),
footer: Value::Null,
content: "<h1>404</h1>",
head_injection: "<meta name=\"docs\">",
global_state: Some(&global),
});
assert_eq!(state["release"]["version"], "v1.2.3");
assert_eq!(state["page"]["title"], "Page Not Found");
assert_eq!(state["pageData"], Value::Null);
assert_eq!(state["headTags"], "<meta name=\"docs\">");
Ok(())
}
#[test]
fn custom_home_layout_uses_non_home_regions() {
let state = test_obj([("page", test_obj([("layout", string_value("home"))]))]);
let custom_page = PageDescriptor {
path: "/custom/".to_string(),
is_home: false,
state: state.clone(),
};
let home_page = PageDescriptor {
path: "/".to_string(),
is_home: true,
state,
};
assert_eq!(region_layout(&custom_page), "doc");
assert_eq!(region_layout(&home_page), "home");
}
#[test]
fn truncate_utf8_short_string_unchanged() {
assert_eq!(truncate_utf8("hello", 100), "hello");
}
#[test]
fn truncate_utf8_ascii_at_exact_boundary() {
assert_eq!(truncate_utf8("hello world", 5), "hello");
}
#[test]
fn truncate_utf8_steps_back_off_multibyte_boundary() {
let s = "é-suffix";
let out = truncate_utf8(s, 1);
assert!(s.is_char_boundary(out.len()), "out.len()={}", out.len());
assert_eq!(out, "");
}
#[test]
fn truncate_utf8_keeps_multibyte_when_room_allows() {
let s = "café";
let out = truncate_utf8(s, 5); assert_eq!(out, "café");
}
#[test]
fn protect_pre_blocks_with_attrs() {
let input = r#"<p>before</p><pre class="hljs">code</pre><p>after</p>"#;
let (out, blocks) = protect_pre_blocks(input);
assert_eq!(blocks.len(), 1);
assert!(out.contains(r#"<span data-webui-press-pre-block="0"></span>"#));
assert!(!out.contains("<pre"));
assert_eq!(blocks[0], r#"<pre class="hljs">code</pre>"#);
}
#[test]
fn protect_pre_blocks_bare_open_tag() {
let input = "<pre>code</pre>";
let (out, blocks) = protect_pre_blocks(input);
assert_eq!(blocks.len(), 1, "got out={out:?} blocks={blocks:?}");
assert_eq!(blocks[0], "<pre>code</pre>");
}
#[test]
fn protect_pre_blocks_does_not_match_presentation_tag() {
let input = "<presentation>x</presentation>";
let (_out, blocks) = protect_pre_blocks(input);
assert_eq!(blocks.len(), 0);
}
#[test]
fn protect_then_restore_round_trip() {
let input = "before <pre>a</pre> mid <pre class=\"x\">b</pre> end";
let (protected, blocks) = protect_pre_blocks(input);
let restored = restore_pre_blocks(&protected, &blocks);
assert_eq!(restored, input);
}
#[test]
fn restore_pre_blocks_no_blocks_returns_input() {
assert_eq!(restore_pre_blocks("plain html", &[]), "plain html");
}
#[test]
fn pre_block_placeholder_survives_webui_component_slot_render() -> TestResult {
let input = "<code-block><pre><code>let x = 1;\n</code></pre></code-block>";
let (protected, blocks) = protect_pre_blocks(input);
let page_html = format!("<!DOCTYPE html><html><body>{protected}</body></html>");
let tmp = std::env::temp_dir().join(format!(
"webui-press-slot-test-{}-{:x}",
std::process::id(),
fxhash(input)
));
if tmp.exists() {
fs::remove_dir_all(&tmp)?;
}
fs::create_dir_all(&tmp)?;
fs::write(tmp.join("index.html"), page_html)?;
let components = vec![Path::new(env!("CARGO_MANIFEST_DIR"))
.join("components")
.to_string_lossy()
.into_owned()];
let build_result = webui::build(BuildOptions {
app_dir: tmp.clone(),
entry: "index.html".to_string(),
plugin: Some(webui::Plugin::WebUI),
components,
..BuildOptions::default()
})?;
let mut writer = StringWriter::with_capacity(4096);
let handler = WebUIHandler::with_plugin(|| {
Box::new(webui_handler::plugin::webui::WebUIHydrationPlugin::new())
});
let protocol = Protocol::new(build_result.protocol);
handler.render(
&protocol,
&Value::Object(Map::new()),
&RenderOptions::new("index.html", "/"),
&mut writer,
)?;
fs::remove_dir_all(&tmp)?;
let html = restore_pre_blocks(&writer.buf, &blocks);
assert!(
html.contains("<pre><code>let x = 1;\n</code></pre>"),
"slotted pre block should be restored after WebUI render: {html}"
);
assert!(
!html.contains(PRE_BLOCK_MARKER_PREFIX),
"placeholder marker must not survive output: {html}"
);
Ok(())
}
#[test]
fn build_search_entry_decodes_code_text_and_captures_headings() {
let entry = build_search_entry(
"`<for>` Loop Directive",
"/guide/concepts/directives/for/",
r##"<h1 id="for"><code><for></code> Loop Directive <a class="header-anchor" href="#for">#</a></h1><p>Use <code><for></code> loops.</p><h2 id="syntax">Syntax <a class="header-anchor" href="#syntax">#</a></h2><h3 id="nested">Nested Search <a class="header-anchor" href="#nested">#</a></h3>"##,
);
assert_eq!(entry["title"].as_str(), Some("`<for>` Loop Directive"));
assert_eq!(
entry["content"].as_str(),
Some("<for> Loop Directive Use <for> loops. Syntax Nested Search")
);
let Some(headings) = entry["headings"].as_array() else {
panic!("headings should be an array: {entry:?}");
};
assert_eq!(headings.len(), 3);
assert_eq!(headings[0]["text"].as_str(), Some("<for> Loop Directive"));
assert_eq!(headings[0]["anchor"].as_str(), Some("for"));
assert_eq!(headings[0]["level"].as_u64(), Some(1));
assert_eq!(headings[1]["text"].as_str(), Some("Syntax"));
assert_eq!(headings[1]["anchor"].as_str(), Some("syntax"));
assert_eq!(headings[1]["level"].as_u64(), Some(2));
assert_eq!(headings[2]["text"].as_str(), Some("Nested Search"));
assert_eq!(headings[2]["anchor"].as_str(), Some("nested"));
assert_eq!(headings[2]["level"].as_u64(), Some(3));
}
#[test]
fn fxhash_deterministic() {
assert_eq!(fxhash("abc"), fxhash("abc"));
assert_ne!(fxhash("abc"), fxhash("abd"));
}
}