1use anyhow::{Context, Result};
2use log::{debug, info, warn};
3use serde::{Deserialize, Serialize};
4use serde_json::{json, Map, Value as JsonValue};
5use std::collections::HashMap;
6use std::path::PathBuf;
7use tokio::fs;
8
9use crate::config::BuildConfig;
10use crate::document::Document;
11use crate::inventory::InventoryFile;
12use crate::template::TemplateEngine;
13use crate::utils;
14
15pub const INVENTORY_FILENAME: &str = "objects.inv";
17
18#[derive(Debug)]
20pub struct HTMLBuilder {
21 pub name: String,
22 pub format: String,
23 pub epilog: String,
24 pub out_suffix: String,
25 pub link_suffix: String,
26 pub searchindex_filename: String,
27 pub allow_parallel: bool,
28 pub copysource: bool,
29 pub use_index: bool,
30 pub embedded: bool,
31 pub search: bool,
32 pub download_support: bool,
33 pub supported_image_types: Vec<String>,
34 pub supported_remote_images: bool,
35 pub supported_data_uri_images: bool,
36
37 pub outdir: PathBuf,
39 pub srcdir: PathBuf,
40 pub confdir: PathBuf,
41 pub static_dir: PathBuf,
42 pub sources_dir: PathBuf,
43 pub downloads_dir: PathBuf,
44 pub images_dir: PathBuf,
45
46 pub config: BuildConfig,
48 pub current_docname: String,
49 pub secnumbers: HashMap<String, Vec<u32>>,
50 pub imgpath: String,
51 pub dlpath: String,
52
53 pub css_files: Vec<CSSFile>,
55 pub js_files: Vec<JSFile>,
56
57 pub template_engine: TemplateEngine,
59
60 pub global_context: Map<String, JsonValue>,
62
63 pub relations: HashMap<String, DocumentRelation>,
65
66 pub domain_indices: Vec<DomainIndex>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct CSSFile {
72 pub filename: String,
73 pub priority: i32,
74 pub media: Option<String>,
75 pub id: Option<String>,
76 pub rel: String,
77 pub type_: String,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct JSFile {
82 pub filename: String,
83 pub priority: i32,
84 pub loading_method: String,
85 pub async_: bool,
86 pub defer: bool,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct DocumentRelation {
91 pub parent: Option<String>,
92 pub prev: Option<String>,
93 pub next: Option<String>,
94}
95
96#[derive(Debug, Clone)]
97pub struct DomainIndex {
98 pub name: String,
99 pub localname: String,
100 pub shortname: Option<String>,
101 pub content: Vec<IndexEntry>,
102 pub collapse: bool,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct IndexEntry {
107 pub name: String,
108 pub subentries: Vec<IndexEntry>,
109 pub uri: String,
110 pub display_name: String,
111}
112
113impl HTMLBuilder {
114 pub fn new(config: BuildConfig, srcdir: PathBuf, outdir: PathBuf) -> Result<Self> {
115 let confdir = srcdir.clone();
116 let static_dir = outdir.join("_static");
117 let sources_dir = outdir.join("_sources");
118 let downloads_dir = outdir.join("_downloads");
119 let images_dir = outdir.join("_images");
120
121 let template_engine = TemplateEngine::new(&config)?;
122
123 Ok(Self {
124 name: "html".to_string(),
125 format: "html".to_string(),
126 epilog: "The HTML pages are in %(outdir)s.".to_string(),
127 out_suffix: ".html".to_string(),
128 link_suffix: ".html".to_string(),
129 searchindex_filename: "searchindex.js".to_string(),
130 allow_parallel: true,
131 copysource: true,
132 use_index: false,
133 embedded: false,
134 search: true,
135 download_support: true,
136 supported_image_types: vec![
137 "image/svg+xml".to_string(),
138 "image/png".to_string(),
139 "image/gif".to_string(),
140 "image/jpeg".to_string(),
141 ],
142 supported_remote_images: true,
143 supported_data_uri_images: true,
144
145 outdir,
146 srcdir,
147 confdir,
148 static_dir,
149 sources_dir,
150 downloads_dir,
151 images_dir,
152
153 config,
154 current_docname: String::new(),
155 secnumbers: HashMap::new(),
156 imgpath: String::new(),
157 dlpath: String::new(),
158
159 css_files: Vec::new(),
160 js_files: Vec::new(),
161
162 template_engine,
163
164 global_context: Map::new(),
165 relations: HashMap::new(),
166 domain_indices: Vec::new(),
167 })
168 }
169
170 pub async fn init(&mut self) -> Result<()> {
172 info!("Initializing HTML builder");
173
174 fs::create_dir_all(&self.outdir).await?;
176 fs::create_dir_all(&self.static_dir).await?;
177 fs::create_dir_all(&self.sources_dir).await?;
178 fs::create_dir_all(&self.downloads_dir).await?;
179 fs::create_dir_all(&self.images_dir).await?;
180
181 self.init_css_files()?;
183 self.init_js_files()?;
184
185 self.init_global_context()?;
187
188 self.use_index = self.config.html_use_index.unwrap_or(true);
190
191 Ok(())
192 }
193
194 fn init_css_files(&mut self) -> Result<()> {
196 self.css_files.clear();
197
198 self.add_css_file("pygments.css", 200, None, None)?;
200
201 let styles = self.config.html_style.clone();
203 for style in &styles {
204 self.add_css_file(style, 200, None, None)?;
205 }
206
207 let css_files = self.config.html_css_files.clone();
209 for css_file in &css_files {
210 self.add_css_file(css_file, 800, None, None)?;
211 }
212
213 Ok(())
214 }
215
216 fn init_js_files(&mut self) -> Result<()> {
218 self.js_files.clear();
219
220 self.add_js_file("documentation_options.js", 200, false, false)?;
222 self.add_js_file("doctools.js", 200, false, false)?;
223 self.add_js_file("sphinx_highlight.js", 200, false, false)?;
224
225 let js_files = self.config.html_js_files.clone();
227 for js_file in &js_files {
228 self.add_js_file(js_file, 800, false, false)?;
229 }
230
231 if self.has_translations() {
233 self.add_js_file("translations.js", 500, false, false)?;
234 }
235
236 Ok(())
237 }
238
239 fn add_css_file(
241 &mut self,
242 filename: &str,
243 priority: i32,
244 media: Option<&str>,
245 id: Option<&str>,
246 ) -> Result<()> {
247 let filename = if !filename.contains("://") {
248 format!("_static/{}", filename)
249 } else {
250 filename.to_string()
251 };
252
253 let css_file = CSSFile {
254 filename,
255 priority,
256 media: media.map(|s| s.to_string()),
257 id: id.map(|s| s.to_string()),
258 rel: "stylesheet".to_string(),
259 type_: "text/css".to_string(),
260 };
261
262 if !self.css_files.contains(&css_file) {
263 self.css_files.push(css_file);
264 }
265
266 Ok(())
267 }
268
269 fn add_js_file(
271 &mut self,
272 filename: &str,
273 priority: i32,
274 async_: bool,
275 defer: bool,
276 ) -> Result<()> {
277 let filename = if !filename.is_empty() && !filename.contains("://") {
278 format!("_static/{}", filename)
279 } else {
280 filename.to_string()
281 };
282
283 let js_file = JSFile {
284 filename,
285 priority,
286 loading_method: "normal".to_string(),
287 async_,
288 defer,
289 };
290
291 if !self.js_files.contains(&js_file) {
292 self.js_files.push(js_file);
293 }
294
295 Ok(())
296 }
297
298 fn has_translations(&self) -> bool {
300 let locale_dir = self.confdir.join("locale");
302 let lang = self.config.language.as_deref().unwrap_or("en");
303 let js_file = locale_dir.join(lang).join("LC_MESSAGES").join("sphinx.js");
304 js_file.exists()
305 }
306
307 fn init_global_context(&mut self) -> Result<()> {
309 use serde_json::json;
310
311 let _now = std::time::SystemTime::now()
312 .duration_since(std::time::UNIX_EPOCH)
313 .unwrap()
314 .as_secs();
315
316 let last_updated = if let Some(fmt) = &self.config.html_last_updated_fmt {
317 Some(utils::format_date(fmt, &self.config.language))
318 } else {
319 None
320 };
321
322 self.global_context = json!({
323 "embedded": self.embedded,
324 "project": self.config.project,
325 "release": self.config.release.as_deref().unwrap_or(""),
326 "version": self.config.version.as_deref().unwrap_or(""),
327 "last_updated": last_updated,
328 "copyright": self.config.copyright.as_deref().unwrap_or(""),
329 "master_doc": self.config.root_doc.as_deref().unwrap_or("index"),
330 "root_doc": self.config.root_doc.as_deref().unwrap_or("index"),
331 "use_opensearch": self.config.html_use_opensearch.unwrap_or(false),
332 "docstitle": self.config.html_title.as_deref().unwrap_or(&self.config.project),
333 "shorttitle": self.config.html_short_title.as_deref().unwrap_or(&self.config.project),
334 "show_copyright": self.config.html_show_copyright.unwrap_or(true),
335 "show_sphinx": self.config.html_show_sphinx.unwrap_or(true),
336 "has_source": self.config.html_copy_source.unwrap_or(true),
337 "show_source": self.config.html_show_sourcelink.unwrap_or(true),
338 "sourcelink_suffix": self.config.html_sourcelink_suffix.as_deref().unwrap_or(".txt"),
339 "file_suffix": &self.out_suffix,
340 "link_suffix": &self.link_suffix,
341 "script_files": &self.js_files,
342 "language": self.config.language.as_deref().unwrap_or("en"),
343 "css_files": &self.css_files,
344 "sphinx_version": env!("CARGO_PKG_VERSION"),
345 "styles": self.config.html_style.clone(),
346 "builder": &self.name,
347 "parents": Vec::<String>::new(),
348 "logo_url": self.config.html_logo.as_deref().unwrap_or(""),
349 "favicon_url": self.config.html_favicon.as_deref().unwrap_or(""),
350 "html5_doctype": true,
351 })
352 .as_object()
353 .unwrap()
354 .clone();
355
356 Ok(())
357 }
358
359 pub async fn write_doc(&mut self, docname: &str, doctree: &Document) -> Result<()> {
361 info!("Writing document: {}", docname);
362
363 self.current_docname = docname.to_string();
364 self.imgpath = self.get_relative_uri(docname, "_images");
365 self.dlpath = self.get_relative_uri(docname, "_downloads");
366
367 let body = format!(
369 "<div class=\"document\">\n{}\n</div>",
370 html_escape::encode_text(&doctree.content.to_string())
371 );
372 let metatags = format!(
373 "<meta name=\"source\" content=\"{}\" />",
374 html_escape::encode_double_quoted_attribute(&doctree.source_path.to_string_lossy())
375 );
376
377 let ctx = self.get_doc_context(docname, &body, &metatags).await?;
379
380 self.handle_page(docname, ctx, "page.html").await?;
382
383 Ok(())
384 }
385
386 async fn get_doc_context(
388 &self,
389 docname: &str,
390 body: &str,
391 metatags: &str,
392 ) -> Result<serde_json::Map<String, serde_json::Value>> {
393 use serde_json::json;
394
395 let mut ctx = self.global_context.clone();
396
397 let relation = self.relations.get(docname);
399 let (prev, next) = if let Some(rel) = relation {
400 (rel.prev.clone(), rel.next.clone())
401 } else {
402 (None, None)
403 };
404
405 let mut parents = Vec::new();
407 let mut current = relation.and_then(|r| r.parent.clone());
408 while let Some(parent_name) = current {
409 if let Some(parent_rel) = self.relations.get(&parent_name) {
410 parents.push(json!({
411 "link": self.get_relative_uri(docname, &parent_name),
412 "title": parent_name, }));
414 current = parent_rel.parent.clone();
415 } else {
416 break;
417 }
418 }
419 parents.reverse();
420
421 let title = docname; let source_suffix = ".rst"; let sourcename = if self.config.html_copy_source.unwrap_or(true) {
425 format!(
426 "{}{}",
427 docname,
428 self.config
429 .html_sourcelink_suffix
430 .as_deref()
431 .unwrap_or(".txt")
432 )
433 } else {
434 String::new()
435 };
436
437 let toc = self.generate_local_toc(docname).await?;
439
440 ctx.insert("parents".to_string(), json!(parents));
441 if let Some(p) = prev {
442 ctx.insert(
443 "prev".to_string(),
444 json!({
445 "link": self.get_relative_uri(docname, &p),
446 "title": p, }),
448 );
449 }
450 if let Some(n) = next {
451 ctx.insert(
452 "next".to_string(),
453 json!({
454 "link": self.get_relative_uri(docname, &n),
455 "title": n, }),
457 );
458 }
459 ctx.insert("title".to_string(), json!(title));
460 ctx.insert("body".to_string(), json!(body));
461 ctx.insert("metatags".to_string(), json!(metatags));
462 ctx.insert("sourcename".to_string(), json!(sourcename));
463 ctx.insert("toc".to_string(), json!(toc));
464 ctx.insert("display_toc".to_string(), json!(true));
465 ctx.insert("page_source_suffix".to_string(), json!(source_suffix));
466
467 Ok(ctx)
468 }
469
470 async fn generate_local_toc(&self, _docname: &str) -> Result<String> {
472 Ok("<div class=\"toc\"></div>".to_string())
474 }
475
476 async fn handle_page(
478 &self,
479 pagename: &str,
480 context: serde_json::Map<String, serde_json::Value>,
481 template_name: &str,
482 ) -> Result<()> {
483 debug!(
484 "Handling page: {} with template: {}",
485 pagename, template_name
486 );
487
488 let output = self.template_engine.render(template_name, &context)?;
490
491 let output_path = self.get_output_path(pagename);
493 utils::ensure_dir(output_path.parent().unwrap()).await?;
494
495 fs::write(&output_path, output)
496 .await
497 .with_context(|| format!("Failed to write page: {}", output_path.display()))?;
498
499 if self.copysource
501 && context
502 .get("sourcename")
503 .and_then(|s| s.as_str())
504 .map(|s| !s.is_empty())
505 .unwrap_or(false)
506 {
507 let sourcename = context["sourcename"].as_str().unwrap();
508 let source_path = self.sources_dir.join(sourcename);
509 utils::ensure_dir(source_path.parent().unwrap()).await?;
510
511 let doc_path = self.srcdir.join(format!("{}.rst", pagename)); if doc_path.exists() {
513 fs::copy(&doc_path, &source_path).await?;
514 }
515 }
516
517 Ok(())
518 }
519
520 fn get_output_path(&self, docname: &str) -> PathBuf {
522 self.outdir.join(format!("{}{}", docname, self.out_suffix))
523 }
524
525 fn get_relative_uri(&self, from: &str, to: &str) -> String {
527 utils::relative_uri(from, to, &self.link_suffix)
528 }
529
530 pub fn get_target_uri(&self, docname: &str) -> String {
532 format!("{}{}", docname, self.link_suffix)
533 }
534
535 pub async fn gen_indices(&mut self) -> Result<()> {
537 info!("Generating indices");
538
539 if self.use_index {
541 self.write_genindex().await?;
542 }
543
544 self.write_domain_indices().await?;
546
547 Ok(())
548 }
549
550 async fn write_genindex(&self) -> Result<()> {
552 info!("Writing general index");
553
554 let genindex_context = serde_json::json!({
556 "genindexentries": [],
557 "genindexcounts": [],
558 "split_index": false,
559 });
560
561 self.handle_page(
562 "genindex",
563 genindex_context.as_object().unwrap().clone(),
564 "genindex.html",
565 )
566 .await?;
567
568 Ok(())
569 }
570
571 async fn write_domain_indices(&self) -> Result<()> {
573 for domain_index in &self.domain_indices {
574 info!("Writing domain index: {}", domain_index.name);
575
576 let index_context = serde_json::json!({
577 "indextitle": domain_index.localname,
578 "content": domain_index.content,
579 "collapse_index": domain_index.collapse,
580 });
581
582 self.handle_page(
583 &domain_index.name,
584 index_context.as_object().unwrap().clone(),
585 "domainindex.html",
586 )
587 .await?;
588 }
589
590 Ok(())
591 }
592
593 pub async fn copy_static_files(&self) -> Result<()> {
595 info!("Copying static files");
596
597 self.copy_theme_static_files().await?;
599
600 for static_path in &self.config.html_static_path {
602 let source_dir = self.confdir.join(static_path);
603 if source_dir.exists() {
604 utils::copy_dir_all(&source_dir, &self.static_dir).await?;
605 }
606 }
607
608 self.create_pygments_style_file().await?;
610
611 if self.has_translations() {
613 self.copy_translation_js().await?;
614 }
615
616 Ok(())
617 }
618
619 async fn copy_theme_static_files(&self) -> Result<()> {
621 Ok(())
623 }
624
625 async fn create_pygments_style_file(&self) -> Result<()> {
627 let css_content = "/* Basic syntax highlighting */\n.highlight { background: #f8f8f8; }\n";
628 let css_path = self.static_dir.join("pygments.css");
629 fs::write(css_path, css_content).await?;
630 Ok(())
631 }
632
633 async fn copy_translation_js(&self) -> Result<()> {
635 let locale_dir = self.confdir.join("locale");
636 let lang = self.config.language.as_deref().unwrap_or("en");
637 let js_file = locale_dir.join(lang).join("LC_MESSAGES").join("sphinx.js");
638
639 if js_file.exists() {
640 let dest = self.static_dir.join("translations.js");
641 fs::copy(js_file, dest).await?;
642 }
643
644 Ok(())
645 }
646
647 pub async fn copy_image_files(&self, images: &HashMap<String, String>) -> Result<()> {
649 info!("Copying {} images", images.len());
650
651 for (src, dest) in images {
652 let src_path = self.srcdir.join(src);
653 let dest_path = self.images_dir.join(dest);
654
655 utils::ensure_dir(dest_path.parent().unwrap()).await?;
656
657 if src_path.exists() {
658 fs::copy(&src_path, &dest_path).await.with_context(|| {
659 format!(
660 "Failed to copy image {} to {}",
661 src_path.display(),
662 dest_path.display()
663 )
664 })?;
665 } else {
666 warn!("Image file not found: {}", src_path.display());
667 }
668 }
669
670 Ok(())
671 }
672
673 pub async fn copy_download_files(&self, downloads: &HashMap<String, String>) -> Result<()> {
675 info!("Copying {} download files", downloads.len());
676
677 for (src, dest) in downloads {
678 let src_path = self.srcdir.join(src);
679 let dest_path = self.downloads_dir.join(dest);
680
681 utils::ensure_dir(dest_path.parent().unwrap()).await?;
682
683 if src_path.exists() {
684 fs::copy(&src_path, &dest_path).await.with_context(|| {
685 format!(
686 "Failed to copy download {} to {}",
687 src_path.display(),
688 dest_path.display()
689 )
690 })?;
691 } else {
692 warn!("Download file not found: {}", src_path.display());
693 }
694 }
695
696 Ok(())
697 }
698
699 pub async fn dump_inventory(&self, env: &crate::environment::BuildEnvironment) -> Result<()> {
701 info!("Dumping object inventory");
702
703 let inventory_path = self.outdir.join(INVENTORY_FILENAME);
704 InventoryFile::dump(&inventory_path, env, self).await?;
705
706 Ok(())
707 }
708
709 pub async fn dump_search_index(
711 &self,
712 _search_index: &crate::search::SearchIndex,
713 ) -> Result<()> {
714 if !self.search {
715 return Ok(());
716 }
717
718 info!("Dumping search index");
719
720 let search_index_path = self.outdir.join(&self.searchindex_filename);
722 let search_data = serde_json::json!({
723 "docnames": [],
724 "filenames": [],
725 "titles": [],
726 "terms": {},
727 "objects": {},
728 "objnames": {},
729 "objtypes": {},
730 });
731
732 fs::write(
733 search_index_path,
734 serde_json::to_string_pretty(&search_data)?,
735 )
736 .await?;
737
738 Ok(())
739 }
740
741 pub async fn write_build_info(&self) -> Result<()> {
743 let build_info = serde_json::json!({
744 "config": {
745 "extensions": [],
746 "templates_path": [],
747 "source_suffix": ".rst",
748 "master_doc": self.config.root_doc.as_deref().unwrap_or("index"),
749 "version": self.config.version.as_deref().unwrap_or(""),
750 "release": self.config.release.as_deref().unwrap_or(""),
751 "project": self.config.project,
752 "copyright": self.config.copyright.as_deref().unwrap_or(""),
753 "language": self.config.language.as_deref().unwrap_or("en"),
754 },
755 "tags": [],
756 "version": env!("CARGO_PKG_VERSION"),
757 });
758
759 let build_info_path = self.outdir.join(".buildinfo");
760 fs::write(build_info_path, serde_json::to_string_pretty(&build_info)?).await?;
761
762 Ok(())
763 }
764
765 pub async fn finish(
767 &mut self,
768 env: &crate::environment::BuildEnvironment,
769 search_index: &crate::search::SearchIndex,
770 ) -> Result<()> {
771 info!("Finishing HTML build");
772
773 self.gen_indices().await?;
775
776 self.copy_static_files().await?;
778
779 self.dump_inventory(env).await?;
781 self.dump_search_index(search_index).await?;
782
783 self.write_build_info().await?;
785
786 Ok(())
787 }
788}
789
790impl PartialEq for CSSFile {
791 fn eq(&self, other: &Self) -> bool {
792 self.filename == other.filename
793 }
794}
795
796impl PartialEq for JSFile {
797 fn eq(&self, other: &Self) -> bool {
798 self.filename == other.filename
799 }
800}