Skip to main content

sphinx_ultra/
html_builder.rs

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
14/// The filename for the inventory of objects (matches Sphinx)
15pub const INVENTORY_FILENAME: &str = "objects.inv";
16
17/// HTML Builder that mirrors Sphinx's StandaloneHTMLBuilder
18#[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    // Directories
37    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    // Internal state
46    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    // Asset management
53    pub css_files: Vec<CSSFile>,
54    pub js_files: Vec<JSFile>,
55
56    // Template engine
57    pub template_engine: TemplateEngine,
58
59    /// Global template context
60    pub global_context: Map<String, JsonValue>,
61
62    // Relations between documents
63    pub relations: HashMap<String, DocumentRelation>,
64
65    // Domain indices
66    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    /// Initialize the builder (mirrors Sphinx's init method)
170    pub async fn init(&mut self) -> Result<()> {
171        info!("Initializing HTML builder");
172
173        // Create necessary directories
174        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        // Initialize CSS and JS files
181        self.init_css_files()?;
182        self.init_js_files()?;
183
184        // Set up global template context
185        self.init_global_context()?;
186
187        // Configure use_index based on config
188        self.use_index = self.config.html_use_index.unwrap_or(true);
189
190        Ok(())
191    }
192
193    /// Initialize CSS files (mirrors Sphinx's init_css_files)
194    fn init_css_files(&mut self) -> Result<()> {
195        self.css_files.clear();
196
197        // Add pygments CSS
198        self.add_css_file("pygments.css", 200, None, None)?;
199
200        // Add theme stylesheets
201        let styles = self.config.html_style.clone();
202        for style in &styles {
203            self.add_css_file(style, 200, None, None)?;
204        }
205
206        // Add user CSS files
207        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    /// Initialize JS files (mirrors Sphinx's init_js_files)
216    fn init_js_files(&mut self) -> Result<()> {
217        self.js_files.clear();
218
219        // Add core JS files
220        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        // Add user JS files
225        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        // Add translations if available
231        if self.has_translations() {
232            self.add_js_file("translations.js", 500, false, false)?;
233        }
234
235        Ok(())
236    }
237
238    /// Add a CSS file
239    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    /// Add a JS file
269    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    /// Check if translations are available
298    fn has_translations(&self) -> bool {
299        // Check for translation files
300        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    /// Initialize global template context (mirrors Sphinx's prepare_writing)
307    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    /// Write a single document (mirrors Sphinx's write_doc)
359    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        // Render the document to HTML
367        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        // Get document context
377        let ctx = self.get_doc_context(docname, &body, &metatags).await?;
378
379        // Handle the page
380        self.handle_page(docname, ctx, "page.html").await?;
381
382        Ok(())
383    }
384
385    /// Get document context for template (mirrors Sphinx's get_doc_context)
386    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        // Find relations
397        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        // Build parents chain
405        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, // TODO: Get actual title
412                }));
413                current = parent_rel.parent.clone();
414            } else {
415                break;
416            }
417        }
418        parents.reverse();
419
420        // Title and metadata
421        let title = docname; // TODO: Extract actual title from document
422        let source_suffix = ".rst"; // TODO: Detect actual suffix
423        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        // Local TOC
437        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, // TODO: Get actual title
446                }),
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, // TODO: Get actual title
455                }),
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    /// Generate local table of contents
470    async fn generate_local_toc(&self, _docname: &str) -> Result<String> {
471        // TODO: Implement actual TOC generation
472        Ok("<div class=\"toc\"></div>".to_string())
473    }
474
475    /// Handle a page (render and write) - mirrors Sphinx's handle_page
476    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        // Render the template
488        let output = self.template_engine.render(template_name, &context)?;
489
490        // Write to file
491        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        // Copy source file if needed
499        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)); // TODO: Detect actual extension
511            if doc_path.exists() {
512                fs::copy(&doc_path, &source_path).await?;
513            }
514        }
515
516        Ok(())
517    }
518
519    /// Get output path for a document
520    fn get_output_path(&self, docname: &str) -> PathBuf {
521        self.outdir.join(format!("{}{}", docname, self.out_suffix))
522    }
523
524    /// Get relative URI between two documents
525    fn get_relative_uri(&self, from: &str, to: &str) -> String {
526        utils::relative_uri(from, to, &self.link_suffix)
527    }
528
529    /// Get target URI for a document
530    pub fn get_target_uri(&self, docname: &str) -> String {
531        format!("{}{}", docname, self.link_suffix)
532    }
533
534    /// Generate indices (mirrors Sphinx's gen_indices)
535    pub async fn gen_indices(&mut self) -> Result<()> {
536        info!("Generating indices");
537
538        // Generate general index if enabled
539        if self.use_index {
540            self.write_genindex().await?;
541        }
542
543        // Generate domain-specific indices
544        self.write_domain_indices().await?;
545
546        Ok(())
547    }
548
549    /// Write general index
550    async fn write_genindex(&self) -> Result<()> {
551        info!("Writing general index");
552
553        // TODO: Implement actual index generation
554        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    /// Write domain indices
571    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    /// Copy static files (mirrors Sphinx's copy_static_files)
593    pub async fn copy_static_files(&self) -> Result<()> {
594        info!("Copying static files");
595
596        // Copy theme static files
597        self.copy_theme_static_files().await?;
598
599        // Copy user static files
600        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        // Create pygments CSS
608        self.create_pygments_style_file().await?;
609
610        // Copy translations if available
611        if self.has_translations() {
612            self.copy_translation_js().await?;
613        }
614
615        Ok(())
616    }
617
618    /// Copy theme static files
619    async fn copy_theme_static_files(&self) -> Result<()> {
620        // TODO: Implement theme system
621        Ok(())
622    }
623
624    /// Create pygments style file
625    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    /// Copy translation JS file
633    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    /// Copy image files
647    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    /// Copy download files
673    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    /// Dump search index
699    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        // TODO: Implement search index dumping
710        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    /// Write build info file
731    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    /// Finish the build process
755    ///
756    /// Object-inventory dumping used to happen here too; it was removed
757    /// along with the dead `BuildEnvironment`-coupled `dump_inventory`
758    /// (M2 wave 4 task 4) and will come back with a decoupled signature.
759    pub async fn finish(&mut self, search_index: &crate::search::SearchIndex) -> Result<()> {
760        info!("Finishing HTML build");
761
762        // Generate indices
763        self.gen_indices().await?;
764
765        // Copy static files
766        self.copy_static_files().await?;
767
768        // Dump search index
769        self.dump_search_index(search_index).await?;
770
771        // Write build info
772        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}