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 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 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 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 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 options.copy_inside = true;
371 fs_extra::copy_items(&[static_path], dist_dir, &options)?;
372
373 Ok(())
374 }
375
376 pub fn write(self, config: Option<&Config>) -> Result<()> {
382 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 let full_path: Utf8PathBuf = if !filename_path.ends_with("index.html")
397 && filename_path.extension() == Some("html")
398 {
399 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}