sphinx_ultra/
html_builder.rs

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
15/// The filename for the inventory of objects (matches Sphinx)
16pub const INVENTORY_FILENAME: &str = "objects.inv";
17
18/// HTML Builder that mirrors Sphinx's StandaloneHTMLBuilder
19#[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    // Directories
38    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    // Internal state
47    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    // Asset management
54    pub css_files: Vec<CSSFile>,
55    pub js_files: Vec<JSFile>,
56
57    // Template engine
58    pub template_engine: TemplateEngine,
59
60    /// Global template context
61    pub global_context: Map<String, JsonValue>,
62
63    // Relations between documents
64    pub relations: HashMap<String, DocumentRelation>,
65
66    // Domain indices
67    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    /// Initialize the builder (mirrors Sphinx's init method)
171    pub async fn init(&mut self) -> Result<()> {
172        info!("Initializing HTML builder");
173
174        // Create necessary directories
175        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        // Initialize CSS and JS files
182        self.init_css_files()?;
183        self.init_js_files()?;
184
185        // Set up global template context
186        self.init_global_context()?;
187
188        // Configure use_index based on config
189        self.use_index = self.config.html_use_index.unwrap_or(true);
190
191        Ok(())
192    }
193
194    /// Initialize CSS files (mirrors Sphinx's init_css_files)
195    fn init_css_files(&mut self) -> Result<()> {
196        self.css_files.clear();
197
198        // Add pygments CSS
199        self.add_css_file("pygments.css", 200, None, None)?;
200
201        // Add theme stylesheets
202        let styles = self.config.html_style.clone();
203        for style in &styles {
204            self.add_css_file(style, 200, None, None)?;
205        }
206
207        // Add user CSS files
208        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    /// Initialize JS files (mirrors Sphinx's init_js_files)
217    fn init_js_files(&mut self) -> Result<()> {
218        self.js_files.clear();
219
220        // Add core JS files
221        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        // Add user JS files
226        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        // Add translations if available
232        if self.has_translations() {
233            self.add_js_file("translations.js", 500, false, false)?;
234        }
235
236        Ok(())
237    }
238
239    /// Add a CSS file
240    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    /// Add a JS file
270    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    /// Check if translations are available
299    fn has_translations(&self) -> bool {
300        // Check for translation files
301        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    /// Initialize global template context (mirrors Sphinx's prepare_writing)
308    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    /// Write a single document (mirrors Sphinx's write_doc)
360    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        // Render the document to HTML
368        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        // Get document context
378        let ctx = self.get_doc_context(docname, &body, &metatags).await?;
379
380        // Handle the page
381        self.handle_page(docname, ctx, "page.html").await?;
382
383        Ok(())
384    }
385
386    /// Get document context for template (mirrors Sphinx's get_doc_context)
387    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        // Find relations
398        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        // Build parents chain
406        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, // TODO: Get actual title
413                }));
414                current = parent_rel.parent.clone();
415            } else {
416                break;
417            }
418        }
419        parents.reverse();
420
421        // Title and metadata
422        let title = docname; // TODO: Extract actual title from document
423        let source_suffix = ".rst"; // TODO: Detect actual suffix
424        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        // Local TOC
438        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, // TODO: Get actual title
447                }),
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, // TODO: Get actual title
456                }),
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    /// Generate local table of contents
471    async fn generate_local_toc(&self, _docname: &str) -> Result<String> {
472        // TODO: Implement actual TOC generation
473        Ok("<div class=\"toc\"></div>".to_string())
474    }
475
476    /// Handle a page (render and write) - mirrors Sphinx's handle_page
477    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        // Render the template
489        let output = self.template_engine.render(template_name, &context)?;
490
491        // Write to file
492        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        // Copy source file if needed
500        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)); // TODO: Detect actual extension
512            if doc_path.exists() {
513                fs::copy(&doc_path, &source_path).await?;
514            }
515        }
516
517        Ok(())
518    }
519
520    /// Get output path for a document
521    fn get_output_path(&self, docname: &str) -> PathBuf {
522        self.outdir.join(format!("{}{}", docname, self.out_suffix))
523    }
524
525    /// Get relative URI between two documents
526    fn get_relative_uri(&self, from: &str, to: &str) -> String {
527        utils::relative_uri(from, to, &self.link_suffix)
528    }
529
530    /// Get target URI for a document
531    pub fn get_target_uri(&self, docname: &str) -> String {
532        format!("{}{}", docname, self.link_suffix)
533    }
534
535    /// Generate indices (mirrors Sphinx's gen_indices)
536    pub async fn gen_indices(&mut self) -> Result<()> {
537        info!("Generating indices");
538
539        // Generate general index if enabled
540        if self.use_index {
541            self.write_genindex().await?;
542        }
543
544        // Generate domain-specific indices
545        self.write_domain_indices().await?;
546
547        Ok(())
548    }
549
550    /// Write general index
551    async fn write_genindex(&self) -> Result<()> {
552        info!("Writing general index");
553
554        // TODO: Implement actual index generation
555        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    /// Write domain indices
572    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    /// Copy static files (mirrors Sphinx's copy_static_files)
594    pub async fn copy_static_files(&self) -> Result<()> {
595        info!("Copying static files");
596
597        // Copy theme static files
598        self.copy_theme_static_files().await?;
599
600        // Copy user static files
601        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        // Create pygments CSS
609        self.create_pygments_style_file().await?;
610
611        // Copy translations if available
612        if self.has_translations() {
613            self.copy_translation_js().await?;
614        }
615
616        Ok(())
617    }
618
619    /// Copy theme static files
620    async fn copy_theme_static_files(&self) -> Result<()> {
621        // TODO: Implement theme system
622        Ok(())
623    }
624
625    /// Create pygments style file
626    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    /// Copy translation JS file
634    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    /// Copy image files
648    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    /// Copy download files
674    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    /// Dump object inventory (mirrors Sphinx's dump_inventory)
700    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    /// Dump search index
710    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        // TODO: Implement search index dumping
721        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    /// Write build info file
742    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    /// Finish the build process
766    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        // Generate indices
774        self.gen_indices().await?;
775
776        // Copy static files
777        self.copy_static_files().await?;
778
779        // Dump inventory and search index
780        self.dump_inventory(env).await?;
781        self.dump_search_index(search_index).await?;
782
783        // Write build info
784        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}