oranda/site/
mod.rs

1use std::path::Path;
2
3use axoasset::{Asset, LocalAsset};
4use axoproject::GithubRepo;
5use camino::{Utf8Path, Utf8PathBuf};
6use indexmap::IndexMap;
7use minijinja::context;
8use tracing::instrument;
9
10use crate::config::{AxoprojectLayer, Config, ReleasesSource};
11use crate::data::github::GithubRelease;
12use crate::data::{funding::Funding, workspaces, Context};
13use crate::errors::*;
14
15use crate::data::workspaces::WorkspaceData;
16use crate::site::templates::Templates;
17use crate::site::workspace_index::WorkspaceIndexContext;
18use layout::css;
19pub use layout::javascript;
20use page::Page;
21
22pub mod artifacts;
23pub mod changelog;
24pub mod funding;
25pub mod layout;
26pub mod link;
27pub mod markdown;
28pub mod mdbook;
29pub mod oranda_theme;
30pub mod page;
31pub mod rss;
32pub mod templates;
33mod workspace_index;
34
35#[derive(Debug)]
36pub struct Site {
37    pub workspace_data: Option<WorkspaceData>,
38    pub pages: Vec<Page>,
39}
40
41impl Site {
42    pub fn build_multi(workspace_config: &Config, json_only: bool) -> Result<Vec<Site>> {
43        tracing::info!("Workspace detected, gathering info...");
44        // We assume the root path is wherever oranda-workspace.json is located (current dir)
45        let root_path = Utf8PathBuf::from_path_buf(std::env::current_dir()?).unwrap_or_default();
46
47        let mut workspace_config_path = root_path.clone();
48        workspace_config_path.push("oranda-workspace.json");
49        let mut results = Vec::new();
50        let members =
51            workspaces::from_config(workspace_config, &root_path, &workspace_config_path)?;
52        tracing::info!("Building {} workspace member(s)...", members.len());
53        for member in &members {
54            std::env::set_current_dir(&member.path)?;
55            let mut site = if json_only {
56                Self::build_single_json_only(&member.config, Some(member.slug.to_string()))?
57            } else {
58                Self::build_single(&member.config, Some(member.slug.to_string()))?
59            };
60            site.workspace_data = Some(member.clone());
61            results.push(site);
62            std::env::set_current_dir(&root_path)?;
63        }
64
65        Ok(results)
66    }
67
68    pub fn build_and_write_workspace_index(
69        workspace_config: &Config,
70        member_data: &Vec<WorkspaceData>,
71    ) -> Result<()> {
72        let templates = Templates::new_for_workspace_index(workspace_config)?;
73        if workspace_config.styles.favicon.is_none() {
74            layout::header::place_default_favicon(workspace_config)?;
75        }
76        css::place_css(
77            &workspace_config.build.dist_dir,
78            &workspace_config.styles.oranda_css_version,
79        )?;
80        let context = WorkspaceIndexContext::new(member_data, workspace_config)?;
81        let page = Page::new_from_template(
82            "index.html",
83            &templates,
84            "workspace_index/index.html",
85            &context,
86        )?;
87        let mut dist = Utf8PathBuf::from(&workspace_config.build.dist_dir);
88        let additional_css = &workspace_config.styles.additional_css;
89        if !additional_css.is_empty() {
90            css::write_additional_css(additional_css, &dist)?;
91        }
92        dist.push("index.html");
93        LocalAsset::write_new_all(&page.contents, dist)?;
94        Ok(())
95    }
96
97    #[instrument("workspace_page", fields(prefix = prefix))]
98    pub fn build_single(config: &Config, prefix: Option<String>) -> Result<Site> {
99        Self::clean_dist_dir(&config.build.dist_dir)?;
100        if config.styles.favicon.is_none() {
101            layout::header::place_default_favicon(config)?;
102        }
103        css::place_css(&config.build.dist_dir, &config.styles.oranda_css_version)?;
104        let needs_context = Self::needs_context(config)?;
105        let context = if needs_context {
106            Some(Self::build_context(config)?)
107        } else {
108            None
109        };
110
111        let templates = Templates::new(config, context.as_ref())?;
112
113        let mut pages = vec![];
114
115        if !config.build.additional_pages.is_empty() {
116            let mut additional_pages =
117                Self::build_additional_pages(&config.build.additional_pages, &templates, config)?;
118            pages.append(&mut additional_pages);
119        }
120
121        let mut index = None;
122        Self::print_plan(config);
123
124        if let Some(mut context) = context {
125            if config.components.artifacts_enabled() {
126                if let Some(latest) = context.latest_mut() {
127                    // Give especially nice treatment to the latest release and make
128                    // its scripts easy to view (others get hotlinked and will just download)
129                    latest.artifacts.make_scripts_viewable(config)?;
130
131                    let template_context = artifacts::template_context(&context, config)?;
132                    index = Some(Page::new_from_both(
133                        &config.project.readme_path,
134                        "index.html",
135                        &templates,
136                        "index.html",
137                        context!(artifacts => template_context),
138                        config,
139                    )?);
140                    let artifacts_page = Page::new_from_template(
141                        "artifacts.html",
142                        &templates,
143                        "artifacts.html",
144                        &template_context,
145                    )?;
146                    pages.push(artifacts_page);
147                    if let Some(template_context) = template_context {
148                        artifacts::write_artifacts_json(config, &template_context)?;
149                    }
150                }
151            }
152            if config.components.changelog.is_some() {
153                let mut changelog_pages =
154                    Self::build_changelog_pages(&context, &templates, config)?;
155                pages.append(&mut changelog_pages);
156            }
157            if let Some(funding_cfg) = &config.components.funding {
158                let funding = Funding::new(funding_cfg, &config.styles)?;
159                let context = funding::context(funding_cfg, &funding)?;
160                let page =
161                    Page::new_from_template("funding.html", &templates, "funding.html", &context)?;
162                pages.push(page);
163            }
164        }
165
166        let index = if let Some(index) = index {
167            index
168        } else {
169            Page::new_from_both(
170                &config.project.readme_path,
171                "index.html",
172                &templates,
173                "index.html",
174                context!(),
175                config,
176            )?
177        };
178        pages.push(index);
179        Ok(Site {
180            pages,
181            workspace_data: None,
182        })
183    }
184
185    #[instrument("workspace_page", fields(prefix = prefix))]
186    pub fn build_single_json_only(config: &Config, prefix: Option<String>) -> Result<Site> {
187        Self::clean_dist_dir(&config.build.dist_dir)?;
188        let context = if Self::needs_context(config)? {
189            Some(Self::build_context(config)?)
190        } else {
191            None
192        };
193
194        if let Some(mut context) = context {
195            if config.components.artifacts_enabled() {
196                if let Some(latest) = context.latest_mut() {
197                    latest.artifacts.make_scripts_viewable(config)?;
198                    let template_context = artifacts::template_context(&context, config)?;
199                    if let Some(template_context) = template_context {
200                        artifacts::write_artifacts_json(config, &template_context)?;
201                    }
202                }
203            }
204        }
205
206        Ok(Site {
207            pages: vec![],
208            workspace_data: None,
209        })
210    }
211
212    pub fn get_workspace_config() -> Result<Option<Config>> {
213        let path = Utf8PathBuf::from("./oranda-workspace.json");
214        if path.exists() {
215            let workspace_config = Config::build_workspace_root(&path)?;
216            Ok(Some(workspace_config))
217        } else {
218            Ok(None)
219        }
220    }
221
222    fn needs_context(config: &Config) -> Result<bool> {
223        Ok(config.project.repository.is_some()
224            && (config.components.artifacts_enabled()
225                || config.components.changelog.is_some()
226                || config.components.funding.is_some()
227                || Self::has_repo_and_releases(&config.project.repository)?))
228    }
229
230    fn has_repo_and_releases(repo_config: &Option<String>) -> Result<bool> {
231        if let Some(repo) = repo_config {
232            GithubRelease::repo_has_releases(&GithubRepo::from_url(repo)?)
233        } else {
234            Ok(false)
235        }
236    }
237
238    fn print_plan(config: &Config) {
239        let mut planned_components = Vec::new();
240        if config.components.artifacts_enabled() {
241            planned_components.push("artifacts");
242        }
243        if config.components.changelog.is_some() {
244            planned_components.push("changelog");
245        }
246        if config.components.funding.is_some() {
247            planned_components.push("funding");
248        }
249        if config.components.mdbook.is_some() {
250            planned_components.push("mdbook");
251        }
252
253        let joined = planned_components
254            .iter()
255            .fold(String::new(), |acc, component| {
256                if acc.is_empty() {
257                    component.to_string()
258                } else {
259                    format!("{}, {}", acc, component)
260                }
261            });
262        if !joined.is_empty() {
263            tracing::info!("Building components: {}", joined);
264        }
265    }
266
267    fn build_context(config: &Config) -> Result<Context> {
268        let Some(repo_url) = config.project.repository.as_ref() else {
269            return Context::new_current(&config.project, config.components.artifacts.as_ref());
270        };
271        let maybe_ctx = match config.components.source {
272            Some(ReleasesSource::GitHub) | None => Context::new_github(
273                repo_url,
274                &config.project,
275                config.components.artifacts.as_ref(),
276            ),
277            Some(ReleasesSource::Axodotdev) => Context::new_axodotdev(
278                &config.project.name,
279                repo_url,
280                &config.project,
281                config.components.artifacts.as_ref(),
282            ),
283        };
284
285        match maybe_ctx {
286            Ok(c) => Ok(c),
287            Err(e) => {
288                // We don't want to hard error here, as we can most likely keep on going even
289                // without a well-formed context.
290                eprintln!("{:?}", miette::Report::new(e));
291                Ok(Context::new_current(
292                    &config.project,
293                    config.components.artifacts.as_ref(),
294                )?)
295            }
296        }
297    }
298
299    fn build_additional_pages(
300        files: &IndexMap<String, String>,
301        templates: &Templates,
302        config: &Config,
303    ) -> Result<Vec<Page>> {
304        let mut pages = vec![];
305        for file_path in files.values() {
306            if page::source::is_markdown(file_path) {
307                let additional_page = Page::new_from_markdown(file_path, templates, config, true)?;
308                pages.push(additional_page)
309            } else {
310                let msg = format!(
311                    "File {} in additional pages is not markdown and will be skipped",
312                    file_path
313                );
314                tracing::warn!("{}", &msg);
315            }
316        }
317        Ok(pages)
318    }
319
320    fn build_changelog_pages(
321        context: &Context,
322        templates: &Templates,
323        config: &Config,
324    ) -> Result<Vec<Page>> {
325        let mut pages = vec![];
326        // Recompute the axoproject layer here (unfortunately we don't pass it around)
327        let cur_dir = std::env::current_dir()?;
328        let project = AxoprojectLayer::get_best_workspace(
329            &Utf8PathBuf::from_path_buf(cur_dir).expect("Current directory isn't UTF-8?"),
330        );
331        let index_context = changelog::index_context(context, config, project.as_ref())?;
332        let changelog_page = Page::new_from_template(
333            "changelog.html",
334            templates,
335            "changelog_index.html",
336            &index_context,
337        )?;
338        pages.push(changelog_page);
339        if config
340            .components
341            .changelog
342            .clone()
343            .is_some_and(|c| c.rss_feed)
344        {
345            let changelog_rss = rss::generate_rss_feed(&index_context, config)?;
346            pages.push(Page {
347                contents: changelog_rss.to_string(),
348                filename: "changelog.rss".to_string(),
349            });
350        }
351        if !(context.releases.len() == 1 && context.releases[0].source.is_current_state()) {
352            for release in context.releases.iter() {
353                let single_context = changelog::single_context(release, config, project.as_ref());
354                let page = Page::new_from_template(
355                    &format!("changelog/{}.html", single_context.version_tag),
356                    templates,
357                    "changelog_single.html",
358                    &context!(release => single_context),
359                )?;
360                pages.push(page);
361            }
362        }
363        Ok(pages)
364    }
365
366    pub fn copy_static(dist_dir: &Utf8Path, static_path: &str) -> Result<()> {
367        let mut options = fs_extra::dir::CopyOptions::new();
368        options.overwrite = true;
369        // We want to be able to rename dirs in the copy, this enables it
370        options.copy_inside = true;
371        fs_extra::copy_items(&[static_path], dist_dir, &options)?;
372
373        Ok(())
374    }
375
376    /// Properly writes page data to disk.
377    /// This takes an optional config argument, the presence of which indicates that we're building
378    /// a single site. If the config isn't given, it indicates that we're building a workspace member
379    /// page instead (its config is stored in the `Site` struct itself). If none of these apply,
380    /// that's a bug (for now).
381    pub fn write(self, config: Option<&Config>) -> Result<()> {
382        // Differentiate between workspace page write or single page write by checking if there's a
383        // workspace config set in the struct, or if the (single) page config is manually passed to
384        // the function.
385        let config = if let Some(config) = config {
386            config
387        } else {
388            &self.workspace_data.as_ref().expect("Attempted to build workspace page without workspace config. This is an oranda bug!").config
389        };
390        let dist = Utf8PathBuf::from(&config.build.dist_dir);
391        for page in self.pages {
392            let filename_path = Utf8PathBuf::from(&page.filename);
393            // Prepare to write a "pretty link" for pages that aren't index.html already.
394            // This essentially means that we rewrite the page from "page.html" to
395            // "page/index.html", so that it can be loaded as "mysite.com/page" in the browser.
396            let full_path: Utf8PathBuf = if !filename_path.ends_with("index.html")
397                && filename_path.extension() == Some("html")
398            {
399                // Surely we can't we do anything BUT unwrap here? A file without a name is a mess.
400                let file_stem = filename_path.file_stem().expect("missing file_stem???");
401                let parent = filename_path.parent().unwrap_or("".into());
402                dist.join(parent).join(file_stem).join("index.html")
403            } else {
404                dist.join(filename_path)
405            };
406            LocalAsset::write_new_all(&page.contents, full_path)?;
407        }
408        if let Some(book_cfg) = &config.components.mdbook {
409            mdbook::build_mdbook(
410                self.workspace_data.as_ref(),
411                &dist,
412                book_cfg,
413                &config.styles.theme,
414                &config.styles.syntax_theme,
415            )?;
416        }
417        if let Some(origin_path) = config.styles.favicon.as_ref() {
418            let copy_result_future = Asset::copy(origin_path, &config.build.dist_dir[..]);
419            tokio::runtime::Handle::current().block_on(copy_result_future)?;
420        }
421        if Path::new(&config.build.static_dir).exists() {
422            Self::copy_static(&dist, &config.build.static_dir)?;
423        }
424        javascript::write_os_script(&dist)?;
425
426        let additional_css = &config.styles.additional_css;
427        if !additional_css.is_empty() {
428            css::write_additional_css(additional_css, &dist)?;
429        }
430
431        Ok(())
432    }
433
434    pub fn clean_dist_dir(dist_path: &str) -> Result<()> {
435        if Path::new(dist_path).exists() {
436            std::fs::remove_dir_all(dist_path)?;
437        }
438        match std::fs::create_dir_all(dist_path) {
439            Ok(_) => Ok(()),
440            Err(e) => Err(OrandaError::DistDirCreationError {
441                dist_path: dist_path.to_string(),
442                details: e,
443            }),
444        }
445    }
446}