1use std::collections::HashMap;
7use std::fs;
8use std::path::Path;
9use std::sync::atomic::AtomicU64;
10use std::time::Instant;
11
12use console::style;
13use rayon::prelude::*;
14use serde_json::{Map, Value};
15use webui::BuildOptions;
16use webui_handler::{RenderOptions, ResponseWriter, WebUIHandler};
17use webui_tokens::TokenFile;
18
19use crate::content::process_content;
20use crate::error::{Error, Result};
21use crate::markdown::Highlighter;
22use crate::types::{BuildStats, DocsConfig};
23
24#[derive(Default)]
34pub struct BuildCache {
35 pub(crate) highlighter: Option<Highlighter>,
38 pub quiet: bool,
42 pub skip_clean: bool,
47}
48
49impl BuildCache {
50 #[must_use]
54 pub fn new() -> Self {
55 Self::default()
56 }
57
58 pub fn set_dev_rebuild(&mut self) {
61 self.quiet = true;
62 self.skip_clean = true;
63 }
64}
65
66fn load_theme(theme: &str, config_dir: &Path) -> Result<TokenFile> {
67 let config_relative = config_dir.join(theme);
68 let resolved = if config_relative.exists() {
69 config_relative
70 .canonicalize()
71 .map_err(|e| Error::Build(format!("Failed to canonicalize {theme}: {e}")))?
72 } else {
73 webui_tokens::resolve_theme_path(theme, config_dir)
74 .map_err(|e| Error::Build(format!("Failed to resolve theme {theme}: {e}")))?
75 };
76 webui_tokens::load_token_file(&resolved)
77 .map_err(|e| Error::Build(format!("Failed to load theme {}: {e}", resolved.display())))
78}
79
80fn inject_theme_tokens(
81 state: &mut Value,
82 token_file: &TokenFile,
83 protocol_tokens: &[String],
84) -> Result<()> {
85 let resolved = webui_tokens::resolve_tokens(protocol_tokens, token_file)
86 .map_err(|e| Error::Build(format!("Failed to resolve theme tokens: {e}")))?;
87 webui_tokens::inject_into_state(state, &resolved);
88 Ok(())
89}
90
91static REBUILD_NONCE: AtomicU64 = AtomicU64::new(0);
102
103fn print_header(cache: &BuildCache, title: &str) {
104 if cache.quiet {
105 return;
106 }
107 eprintln!(
108 "\n {} {}",
109 style("⚡").cyan().bold(),
110 style(title).cyan().bold()
111 );
112}
113
114fn print_success(cache: &BuildCache, message: &str) {
115 if cache.quiet {
116 return;
117 }
118 eprintln!(" {} {message}", style("✔").green());
119}
120
121fn print_finish(cache: &BuildCache, message: &str) {
122 if cache.quiet {
123 return;
124 }
125 eprintln!("\n {} {message}\n", style("✨").green());
126}
127
128fn json_obj<const N: usize>(entries: [(&str, Value); N]) -> Value {
130 let mut map = Map::with_capacity(N);
131 for (k, v) in entries {
132 map.insert(k.to_string(), v);
133 }
134 Value::Object(map)
135}
136
137struct StringWriter {
139 buf: String,
140}
141
142impl StringWriter {
143 fn with_capacity(cap: usize) -> Self {
144 Self {
145 buf: String::with_capacity(cap),
146 }
147 }
148}
149
150impl ResponseWriter for StringWriter {
151 fn write(&mut self, content: &str) -> webui_handler::Result<()> {
152 self.buf.push_str(content);
153 Ok(())
154 }
155
156 fn end(&mut self) -> webui_handler::Result<()> {
157 Ok(())
158 }
159}
160
161pub fn build_docs(
171 config: &DocsConfig,
172 config_dir: &Path,
173 template_dir: &Path,
174) -> Result<BuildStats> {
175 let mut cache = BuildCache::new();
176 build_docs_with_cache(config, config_dir, template_dir, &mut cache)
177}
178
179pub fn build_docs_with_cache(
192 config: &DocsConfig,
193 config_dir: &Path,
194 template_dir: &Path,
195 cache: &mut BuildCache,
196) -> Result<BuildStats> {
197 let start = Instant::now();
198 let base_path = &config.base_path;
199 let out_dir = Path::new(&config.out_dir);
200
201 let site_dir = out_dir.to_path_buf();
207
208 let custom_css = config
210 .css
211 .as_ref()
212 .and_then(|p| fs::read_to_string(p).ok())
213 .unwrap_or_default();
214 let token_file = match &config.theme {
215 Some(theme) => Some(load_theme(theme, config_dir)?),
216 None => None,
217 };
218
219 print_header(cache, &config.site.title);
220 eprintln!();
221
222 let base_css_src = template_dir.join("docs.css");
231 let has_base_css = base_css_src.exists();
232 let base_css_link = if has_base_css {
233 format!("<link rel=\"stylesheet\" href=\"{base_path}docs.css\">")
234 } else {
235 String::new()
236 };
237 let has_theme_css = !custom_css.is_empty();
238 let theme_css_link = if has_theme_css {
239 format!("<link rel=\"stylesheet\" href=\"{base_path}theme.css\">")
240 } else {
241 String::new()
242 };
243 let head_injection = {
244 let mut parts: Vec<String> = Vec::with_capacity(2 + config.head.len());
245 if !base_css_link.is_empty() {
246 parts.push(base_css_link.clone());
247 }
248 if !theme_css_link.is_empty() {
249 parts.push(theme_css_link.clone());
250 }
251 for tag in &config.head {
252 parts.push(tag.to_html());
253 }
254 parts.join("\n ")
255 };
256
257 let highlighter = cache.highlighter.take().unwrap_or_default();
261
262 let pages = process_content(config, config_dir, &highlighter, &head_injection)?;
263
264 cache.highlighter = Some(highlighter);
266
267 let mut component_sources: Vec<String> = Vec::new();
269 let builtin_components = template_dir.parent().map(|p| p.join("components"));
271 if let Some(ref bc) = builtin_components {
272 if bc.exists() {
273 component_sources.push(bc.to_string_lossy().to_string());
274 }
275 }
276 if let Some(ref user_dirs) = config.components {
278 for d in user_dirs {
279 let abs = std::env::current_dir().unwrap_or_default().join(d);
280 component_sources.push(abs.to_string_lossy().to_string());
281 }
282 }
283 component_sources.push(template_dir.to_string_lossy().to_string());
286
287 let template_html = fs::read_to_string(template_dir.join("index.html"))
288 .map_err(|e| Error::Build(format!("Failed to read template: {e}")))?;
289
290 if out_dir.exists() && !cache.skip_clean {
295 remove_dir_all_retry(out_dir)
296 .map_err(|e| Error::Io(format!("Cannot clean output dir: {e}")))?;
297 }
298 fs::create_dir_all(&site_dir).map_err(|e| Error::Io(format!("Cannot create site dir: {e}")))?;
299
300 if has_base_css {
306 fs::copy(&base_css_src, site_dir.join("docs.css"))
307 .map_err(|e| Error::Io(format!("Cannot copy docs.css: {e}")))?;
308 }
309 if has_theme_css {
310 fs::write(site_dir.join("theme.css"), &custom_css)
311 .map_err(|e| Error::Io(format!("Cannot write theme.css: {e}")))?;
312 }
313
314 let handler = WebUIHandler::with_plugin(|| {
315 Box::new(webui_handler::plugin::webui::WebUIHydrationPlugin::new())
316 });
317
318 for page in &pages {
321 let page_dir = site_dir.join(page.path.strip_prefix(base_path).unwrap_or(&page.path));
322 fs::create_dir_all(&page_dir)
323 .map_err(|e| Error::Io(format!("Cannot create dir {}: {e}", page_dir.display())))?;
324 }
325
326 let template_dir_owned = template_dir.to_path_buf();
331 let component_sources_clone = component_sources.clone();
332 let site_dir_clone = site_dir.clone();
333 let bundle_handle = std::thread::spawn(move || -> Result<usize> {
334 let node_modules = std::env::current_dir()
335 .unwrap_or_default()
336 .join("node_modules");
337 bundle_components(
338 &template_dir_owned,
339 &component_sources_clone,
340 &site_dir_clone,
341 &node_modules,
342 )
343 });
344
345 let total_bytes = std::sync::atomic::AtomicUsize::new(0);
355 let component_css: std::sync::Mutex<HashMap<String, String>> =
356 std::sync::Mutex::new(HashMap::new());
357
358 pages.par_iter().try_for_each(|page| -> Result<()> {
359 let page_dir = site_dir.join(page.path.strip_prefix(base_path).unwrap_or(&page.path));
360 let target = page_dir.join("index.html");
361
362 let content = page.state["page"]["content"].as_str().unwrap_or("");
363
364 let (protected, pre_blocks) = protect_pre_blocks(content);
366
367 let page_html = template_html.replace("{{{page.content}}}", &protected);
369
370 let nonce = REBUILD_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
379 let page_tmp = std::env::temp_dir().join(format!(
380 "webui-press-page-{}-{:x}-{nonce:x}",
381 std::process::id(),
382 fxhash(&page.path),
383 ));
384 if page_tmp.exists() {
385 fs::remove_dir_all(&page_tmp).ok();
386 }
387 fs::create_dir_all(&page_tmp)
388 .map_err(|e| Error::Io(format!("Cannot create page temp: {e}")))?;
389 fs::write(page_tmp.join("index.html"), &page_html)
390 .map_err(|e| Error::Io(format!("Cannot write page temp: {e}")))?;
391
392 let build_result = webui::build(BuildOptions {
393 app_dir: page_tmp.clone(),
394 entry: "index.html".to_string(),
395 plugin: Some(webui::Plugin::WebUI),
396 components: component_sources.clone(),
397 ..BuildOptions::default()
398 })
399 .map_err(|e| Error::Build(format!("{}: {e}", page.path)))?;
400
401 total_bytes.fetch_add(
402 build_result.protocol_bytes.len(),
403 std::sync::atomic::Ordering::Relaxed,
404 );
405
406 if !build_result.css_files.is_empty() {
411 let mut css_map = component_css
412 .lock()
413 .unwrap_or_else(std::sync::PoisonError::into_inner);
414 for (name, content) in build_result.css_files {
415 css_map.entry(name).or_insert(content);
416 }
417 }
418
419 let mut themed_state;
420 let render_state = if let Some(token_file) = token_file.as_ref() {
421 themed_state = page.state.clone();
422 inject_theme_tokens(&mut themed_state, token_file, &build_result.protocol.tokens)?;
423 &themed_state
424 } else {
425 &page.state
426 };
427
428 let mut writer = StringWriter::with_capacity(8192);
429 handler
430 .render(
431 &build_result.protocol,
432 render_state,
433 &RenderOptions::new("index.html", &page.path),
434 &mut writer,
435 )
436 .map_err(|e| Error::Render(format!("{}: {e}", page.path)))?;
437
438 let html = restore_pre_blocks(&writer.buf, &pre_blocks);
440
441 fs::write(&target, &html)
443 .map_err(|e| Error::Io(format!("Cannot write {}: {e}", page.path)))?;
444
445 fs::remove_dir_all(&page_tmp).ok();
446 Ok(())
447 })?;
448
449 print_success(cache, &format!("Rendered {} pages", pages.len()));
450
451 let search_path = site_dir.join("search-index.json");
454 let search_entries: Vec<Value> = pages
455 .iter()
456 .filter(|p| !p.is_home)
457 .map(|p| {
458 let html = p.state["page"]["content"].as_str().unwrap_or("");
459 let title = p.state["page"]["title"].as_str().unwrap_or("");
460 build_search_entry(title, &p.path, html)
461 })
462 .collect();
463 fs::write(
464 &search_path,
465 serde_json::to_string(&search_entries)
466 .map_err(|e| Error::Build(format!("JSON error: {e}")))?,
467 )
468 .map_err(|e| Error::Io(e.to_string()))?;
469 print_success(
470 cache,
471 &format!("Indexed {} pages for search", search_entries.len()),
472 );
473
474 let public_dir = Path::new(&config.public_dir);
476 if public_dir.exists() {
477 copy_dir(public_dir, &site_dir)?;
478 }
479
480 let nav_val = pages
482 .first()
483 .map(|p| p.state["navigation"].clone())
484 .unwrap_or_default();
485
486 let footer_val = config
487 .footer
488 .as_ref()
489 .map(|f| json_obj([("html", Value::String(f.html.clone()))]))
490 .unwrap_or(Value::Null);
491
492 let not_found_content = format!(
493 "<h1>404 — Page Not Found</h1>\
494 <p>The page you're looking for doesn't exist or has been moved.</p>\
495 <p><a href=\"{base_path}\">← Back to Home</a></p>"
496 );
497 let mut not_found_state = json_obj([
498 (
499 "site",
500 json_obj([
501 ("title", Value::String(config.site.title.clone())),
502 ("base", Value::String(base_path.to_string())),
503 ]),
504 ),
505 ("navigation", nav_val),
506 ("sidebar", json_obj([("sections", Value::Array(vec![]))])),
507 (
508 "page",
509 json_obj([
510 ("title", Value::String("Page Not Found".to_string())),
511 (
512 "description",
513 Value::String("The page you're looking for doesn't exist.".to_string()),
514 ),
515 ("content", Value::String(not_found_content.clone())),
516 ("isHome", Value::Bool(false)),
517 ("layout", Value::String("doc".to_string())),
518 ]),
519 ),
520 ("hero", Value::Null),
521 ("footer", footer_val),
522 ("prev", Value::Null),
523 ("next", Value::Null),
524 ]);
525 not_found_state["headTags"] = Value::String(head_injection);
526 not_found_state["label"] = Value::String("Copy".to_string());
527 not_found_state["icon"] = Value::String("🌙".to_string());
528
529 let not_found_html = template_html.replace("{{{page.content}}}", ¬_found_content);
530 let nf_tmp = std::env::temp_dir().join(format!(
531 "webui-press-404-{}-{:x}",
532 std::process::id(),
533 REBUILD_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
534 ));
535 if nf_tmp.exists() {
536 fs::remove_dir_all(&nf_tmp).ok();
537 }
538 fs::create_dir_all(&nf_tmp).map_err(|e| Error::Io(e.to_string()))?;
539 fs::write(nf_tmp.join("index.html"), ¬_found_html).map_err(|e| Error::Io(e.to_string()))?;
540
541 let nf_build = webui::build(BuildOptions {
542 app_dir: nf_tmp.clone(),
543 entry: "index.html".to_string(),
544 plugin: Some(webui::Plugin::WebUI),
545 components: component_sources.clone(),
546 ..BuildOptions::default()
547 })
548 .map_err(|e| Error::Build(format!("404 build failed: {e}")))?;
549
550 if let Some(token_file) = token_file.as_ref() {
551 inject_theme_tokens(&mut not_found_state, token_file, &nf_build.protocol.tokens)?;
552 }
553
554 {
559 let mut css_map = component_css
560 .lock()
561 .unwrap_or_else(std::sync::PoisonError::into_inner);
562 for (name, content) in &nf_build.css_files {
563 css_map
564 .entry(name.clone())
565 .or_insert_with(|| content.clone());
566 }
567 }
568
569 let mut writer_404 = StringWriter::with_capacity(4096);
570 handler
571 .render(
572 &nf_build.protocol,
573 ¬_found_state,
574 &RenderOptions::new("index.html", &format!("{base_path}404/")),
575 &mut writer_404,
576 )
577 .map_err(|e| Error::Render(format!("404: {e}")))?;
578
579 fs::write(site_dir.join("404.html"), writer_404.buf).map_err(|e| Error::Io(e.to_string()))?;
580 fs::remove_dir_all(&nf_tmp).ok();
581 print_success(cache, "Generated 404 page");
582
583 let css_map_local = component_css
587 .into_inner()
588 .unwrap_or_else(std::sync::PoisonError::into_inner);
589 let mut css_names: Vec<&String> = css_map_local.keys().collect();
590 css_names.sort();
591 let css_count = css_names.len();
592 for name in css_names {
593 fs::write(site_dir.join(name), &css_map_local[name])
594 .map_err(|e| Error::Io(format!("Cannot write {name}: {e}")))?;
595 }
596 if css_count > 0 {
597 print_success(cache, &format!("Wrote {css_count} component stylesheets"));
598 }
599
600 let component_count = bundle_handle
602 .join()
603 .map_err(|_| Error::Build("Bundle thread panicked".to_string()))??;
604 print_success(cache, &format!("Bundled {component_count} components"));
605
606 let elapsed = start.elapsed();
607 let total_bytes = total_bytes.load(std::sync::atomic::Ordering::Relaxed);
608 print_finish(cache, &format!("Built in {:.1}s", elapsed.as_secs_f64()));
609
610 Ok(BuildStats {
611 pages: pages.len(),
612 protocol_bytes: total_bytes,
613 })
614}
615
616fn remove_dir_all_retry(path: &Path) -> std::io::Result<()> {
635 const MAX_ATTEMPTS: u32 = 5;
636 let mut delay = std::time::Duration::from_millis(20);
637 for attempt in 1..=MAX_ATTEMPTS {
638 match fs::remove_dir_all(path) {
639 Ok(()) => return Ok(()),
640 Err(e) if attempt < MAX_ATTEMPTS && is_dir_not_empty(&e) => {
641 std::thread::sleep(delay);
642 delay = delay.saturating_mul(2);
643 }
644 Err(e) => return Err(e),
645 }
646 }
647 Ok(())
650}
651
652fn is_dir_not_empty(e: &std::io::Error) -> bool {
656 match e.raw_os_error() {
657 Some(66) => true, Some(39) => true, Some(145) => true, _ => false,
661 }
662}
663
664fn copy_dir(src: &Path, dest: &Path) -> Result<()> {
666 if !src.exists() {
667 return Ok(());
668 }
669 fs::create_dir_all(dest).map_err(|e| Error::Io(e.to_string()))?;
670 let mut stack: Vec<(std::path::PathBuf, std::path::PathBuf)> =
671 vec![(src.to_path_buf(), dest.to_path_buf())];
672 while let Some((s, d)) = stack.pop() {
673 for entry in fs::read_dir(&s).map_err(|e| Error::Io(e.to_string()))? {
674 let entry = entry.map_err(|e| Error::Io(e.to_string()))?;
675 let dest_path = d.join(entry.file_name());
676 let ft = entry.file_type().map_err(|e| Error::Io(e.to_string()))?;
677 if ft.is_dir() {
678 fs::create_dir_all(&dest_path).map_err(|e| Error::Io(e.to_string()))?;
679 stack.push((entry.path(), dest_path));
680 } else {
681 fs::copy(entry.path(), &dest_path).map_err(|e| Error::Io(e.to_string()))?;
682 }
683 }
684 }
685 Ok(())
686}
687
688pub(crate) fn truncate_utf8(s: &str, max: usize) -> &str {
690 if s.len() <= max {
691 return s;
692 }
693 let mut end = max;
694 while end > 0 && !s.is_char_boundary(end) {
695 end -= 1;
696 }
697 &s[..end]
698}
699
700pub(crate) fn build_search_entry(title: &str, path: &str, html: &str) -> Value {
705 let mut clean = String::with_capacity(html.len() / 2);
706 let mut in_tag = false;
707 for ch in html.chars() {
708 match ch {
709 '<' => in_tag = true,
710 '>' => {
711 in_tag = false;
712 clean.push(' ');
713 }
714 _ if !in_tag => clean.push(ch),
715 _ => {}
716 }
717 }
718 let mut content = String::with_capacity(clean.len());
722 let mut first = true;
723 for tok in clean.split_whitespace() {
724 if !first {
725 content.push(' ');
726 }
727 content.push_str(tok);
728 first = false;
729 }
730 let truncated = truncate_utf8(&content, 500);
731 json_obj([
732 ("title", Value::String(title.to_string())),
733 ("path", Value::String(path.to_string())),
734 ("content", Value::String(truncated.to_string())),
735 ])
736}
737
738fn is_component_ts_file(path: &Path) -> bool {
739 let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
740 return false;
741 };
742 path.extension().is_some_and(|extension| extension == "ts")
743 && !file_name.ends_with(".spec.ts")
744 && !file_name.ends_with(".test.ts")
745 && !file_name.ends_with(".d.ts")
746}
747
748fn collect_ts_files(dir: &Path) -> Vec<std::path::PathBuf> {
750 let mut files = Vec::new();
751 let mut stack = vec![dir.to_path_buf()];
752 while let Some(d) = stack.pop() {
753 let Ok(entries) = fs::read_dir(&d) else {
754 continue;
755 };
756 for entry in entries.flatten() {
757 let path = entry.path();
758 if path.is_dir() {
759 stack.push(path);
760 } else if is_component_ts_file(&path) {
761 files.push(path);
762 }
763 }
764 }
765 files.sort();
766 files
767}
768
769fn bundle_components(
772 template_dir: &Path,
773 component_sources: &[String],
774 site_dir: &Path,
775 node_modules: &Path,
776) -> Result<usize> {
777 let mut ts_files = Vec::new();
778
779 for dir in component_sources {
781 let p = Path::new(dir);
782 if p.exists() {
783 ts_files.extend(collect_ts_files(p));
784 }
785 }
786
787 if let Ok(entries) = fs::read_dir(template_dir) {
789 for entry in entries.flatten() {
790 if entry.path().is_dir() {
791 ts_files.extend(collect_ts_files(&entry.path()));
792 }
793 }
794 }
795
796 if ts_files.is_empty() {
797 return Ok(0);
798 }
799
800 let imports: String = ts_files
802 .iter()
803 .map(|f| format!("import \"{}\";", f.to_string_lossy().replace('\\', "/")))
804 .collect::<Vec<_>>()
805 .join("\n");
806
807 let out_file = site_dir.join("components.js");
808
809 let status = std::process::Command::new("npx")
810 .arg("esbuild")
811 .arg("--bundle")
812 .arg("--format=esm")
813 .arg("--minify")
814 .arg("--loader:.html=text")
815 .arg("--loader:.css=text")
816 .arg(format!("--outfile={}", out_file.to_string_lossy()))
817 .env("NODE_PATH", node_modules)
818 .stdin(std::process::Stdio::piped())
819 .stdout(std::process::Stdio::piped())
820 .stderr(std::process::Stdio::piped())
821 .spawn()
822 .and_then(|mut child| {
823 use std::io::Write;
824 if let Some(ref mut stdin) = child.stdin {
825 let _ = stdin.write_all(imports.as_bytes());
826 }
827 child.wait_with_output()
828 })
829 .map_err(|e| Error::Build(format!("esbuild failed: {e}")))?;
830
831 if !status.status.success() {
832 let stderr = String::from_utf8_lossy(&status.stderr);
833 return Err(Error::Build(format!("esbuild error: {stderr}")));
834 }
835
836 Ok(ts_files.len())
837}
838
839const PRE_BLOCK_MARKER_PREFIX: &str = "<span data-webui-press-pre-block=\"";
840const PRE_BLOCK_MARKER_SUFFIX: &str = "\"></span>";
841
842fn protect_pre_blocks(content: &str) -> (String, Vec<String>) {
847 use std::fmt::Write as _;
848 let mut blocks: Vec<String> = Vec::new();
849 let mut out = String::with_capacity(content.len());
850 let mut cursor = 0;
851 while let Some(rel_start) = find_pre_open(&content[cursor..]) {
852 let start = cursor + rel_start;
853 if let Some(rel_end) = content[start..].find("</pre>") {
854 let end = start + rel_end + "</pre>".len();
855 out.push_str(&content[cursor..start]);
856 out.push_str(PRE_BLOCK_MARKER_PREFIX);
857 let _ = write!(&mut out, "{}", blocks.len());
859 out.push_str(PRE_BLOCK_MARKER_SUFFIX);
860 blocks.push(content[start..end].to_string());
861 cursor = end;
862 } else {
863 break;
864 }
865 }
866 out.push_str(&content[cursor..]);
867 (out, blocks)
868}
869
870fn find_pre_open(s: &str) -> Option<usize> {
873 let bytes = s.as_bytes();
874 let mut i = 0;
875 while let Some(rel) = s[i..].find("<pre") {
876 let pos = i + rel;
877 let after = pos + 4;
878 match bytes.get(after) {
879 Some(b'>' | b' ' | b'\t' | b'\n' | b'\r') => return Some(pos),
880 _ => i = after,
881 }
882 }
883 None
884}
885
886fn restore_pre_blocks(html: &str, blocks: &[String]) -> String {
889 if blocks.is_empty() {
890 return html.to_string();
891 }
892 let extra: usize = blocks.iter().map(|b| b.len()).sum();
893 let mut out = String::with_capacity(html.len() + extra);
894 let mut cursor = 0;
895 while let Some(rel) = html[cursor..].find(PRE_BLOCK_MARKER_PREFIX) {
896 let p = cursor + rel;
897 out.push_str(&html[cursor..p]);
898 let after = p + PRE_BLOCK_MARKER_PREFIX.len();
899 if let Some(end_rel) = html[after..].find(PRE_BLOCK_MARKER_SUFFIX) {
900 let num_str = &html[after..after + end_rel];
901 if let Ok(idx) = num_str.parse::<usize>() {
902 if let Some(block) = blocks.get(idx) {
903 out.push_str(block);
904 cursor = after + end_rel + PRE_BLOCK_MARKER_SUFFIX.len();
905 continue;
906 }
907 }
908 out.push_str(&html[p..after + end_rel + PRE_BLOCK_MARKER_SUFFIX.len()]);
910 cursor = after + end_rel + PRE_BLOCK_MARKER_SUFFIX.len();
911 } else {
912 out.push_str(&html[p..]);
913 return out;
914 }
915 }
916 out.push_str(&html[cursor..]);
917 out
918}
919
920fn fxhash(s: &str) -> u64 {
922 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
923 for b in s.bytes() {
924 hash ^= u64::from(b);
925 hash = hash.wrapping_mul(0x100_0000_01b3);
926 }
927 hash
928}
929
930#[cfg(test)]
931mod tests {
932 use super::*;
933
934 type TestResult = std::result::Result<(), Box<dyn std::error::Error>>;
935
936 #[test]
939 fn truncate_utf8_short_string_unchanged() {
940 assert_eq!(truncate_utf8("hello", 100), "hello");
941 }
942
943 #[test]
944 fn truncate_utf8_ascii_at_exact_boundary() {
945 assert_eq!(truncate_utf8("hello world", 5), "hello");
946 }
947
948 #[test]
949 fn truncate_utf8_steps_back_off_multibyte_boundary() {
950 let s = "é-suffix";
952 let out = truncate_utf8(s, 1);
953 assert!(s.is_char_boundary(out.len()), "out.len()={}", out.len());
954 assert_eq!(out, "");
955 }
956
957 #[test]
958 fn truncate_utf8_keeps_multibyte_when_room_allows() {
959 let s = "café";
960 let out = truncate_utf8(s, 5); assert_eq!(out, "café");
962 }
963
964 #[test]
967 fn protect_pre_blocks_with_attrs() {
968 let input = r#"<p>before</p><pre class="hljs">code</pre><p>after</p>"#;
969 let (out, blocks) = protect_pre_blocks(input);
970 assert_eq!(blocks.len(), 1);
971 assert!(out.contains(r#"<span data-webui-press-pre-block="0"></span>"#));
972 assert!(!out.contains("<pre"));
973 assert_eq!(blocks[0], r#"<pre class="hljs">code</pre>"#);
974 }
975
976 #[test]
977 fn protect_pre_blocks_bare_open_tag() {
978 let input = "<pre>code</pre>";
980 let (out, blocks) = protect_pre_blocks(input);
981 assert_eq!(blocks.len(), 1, "got out={out:?} blocks={blocks:?}");
982 assert_eq!(blocks[0], "<pre>code</pre>");
983 }
984
985 #[test]
986 fn protect_pre_blocks_does_not_match_presentation_tag() {
987 let input = "<presentation>x</presentation>";
989 let (_out, blocks) = protect_pre_blocks(input);
990 assert_eq!(blocks.len(), 0);
991 }
992
993 #[test]
994 fn protect_then_restore_round_trip() {
995 let input = "before <pre>a</pre> mid <pre class=\"x\">b</pre> end";
996 let (protected, blocks) = protect_pre_blocks(input);
997 let restored = restore_pre_blocks(&protected, &blocks);
998 assert_eq!(restored, input);
999 }
1000
1001 #[test]
1002 fn restore_pre_blocks_no_blocks_returns_input() {
1003 assert_eq!(restore_pre_blocks("plain html", &[]), "plain html");
1004 }
1005
1006 #[test]
1007 fn pre_block_placeholder_survives_webui_component_slot_render() -> TestResult {
1008 let input = "<code-block><pre><code>let x = 1;\n</code></pre></code-block>";
1009 let (protected, blocks) = protect_pre_blocks(input);
1010 let page_html = format!("<!DOCTYPE html><html><body>{protected}</body></html>");
1011
1012 let tmp = std::env::temp_dir().join(format!(
1013 "webui-press-slot-test-{}-{:x}",
1014 std::process::id(),
1015 fxhash(input)
1016 ));
1017 if tmp.exists() {
1018 fs::remove_dir_all(&tmp)?;
1019 }
1020 fs::create_dir_all(&tmp)?;
1021 fs::write(tmp.join("index.html"), page_html)?;
1022
1023 let components = vec![Path::new(env!("CARGO_MANIFEST_DIR"))
1024 .join("components")
1025 .to_string_lossy()
1026 .into_owned()];
1027 let build_result = webui::build(BuildOptions {
1028 app_dir: tmp.clone(),
1029 entry: "index.html".to_string(),
1030 plugin: Some(webui::Plugin::WebUI),
1031 components,
1032 ..BuildOptions::default()
1033 })?;
1034
1035 let mut writer = StringWriter::with_capacity(4096);
1036 let handler = WebUIHandler::with_plugin(|| {
1037 Box::new(webui_handler::plugin::webui::WebUIHydrationPlugin::new())
1038 });
1039 handler.render(
1040 &build_result.protocol,
1041 &Value::Object(Map::new()),
1042 &RenderOptions::new("index.html", "/"),
1043 &mut writer,
1044 )?;
1045
1046 fs::remove_dir_all(&tmp)?;
1047
1048 let html = restore_pre_blocks(&writer.buf, &blocks);
1049 assert!(
1050 html.contains("<pre><code>let x = 1;\n</code></pre>"),
1051 "slotted pre block should be restored after WebUI render: {html}"
1052 );
1053 assert!(
1054 !html.contains(PRE_BLOCK_MARKER_PREFIX),
1055 "placeholder marker must not survive output: {html}"
1056 );
1057
1058 Ok(())
1059 }
1060
1061 #[test]
1062 fn collect_ts_files_skips_tests_and_declarations() -> TestResult {
1063 let root = std::env::temp_dir().join(format!(
1064 "webui-press-ts-collect-test-{}-{:x}",
1065 std::process::id(),
1066 fxhash("ts-collect")
1067 ));
1068 if root.exists() {
1069 fs::remove_dir_all(&root)?;
1070 }
1071 fs::create_dir_all(root.join("my-widget"))?;
1072 fs::write(root.join("my-widget/my-widget.ts"), "")?;
1073 fs::write(root.join("my-widget/my-widget.spec.ts"), "")?;
1074 fs::write(root.join("my-widget/my-widget.test.ts"), "")?;
1075 fs::write(root.join("my-widget/my-widget.d.ts"), "")?;
1076
1077 let files = collect_ts_files(&root);
1078
1079 fs::remove_dir_all(&root)?;
1080
1081 assert_eq!(
1082 files,
1083 vec![root.join("my-widget/my-widget.ts")],
1084 "only hydration entry files should be bundled"
1085 );
1086 Ok(())
1087 }
1088
1089 #[test]
1092 fn fxhash_deterministic() {
1093 assert_eq!(fxhash("abc"), fxhash("abc"));
1094 assert_ne!(fxhash("abc"), fxhash("abd"));
1095 }
1096}