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