From 69fa5f1bc081cf08d58a59f0b2e14979cbaedd64 Mon Sep 17 00:00:00 2001 From: Wildan M Date: Sun, 15 Feb 2026 15:26:08 +0700 Subject: [PATCH] Implement web generation for packages --- .gitignore | 1 + src/bin/repo.rs | 36 +++-- src/bin/repo_builder.rs | 22 ++- src/config.rs | 10 ++ src/lib.rs | 1 + src/recipe.rs | 1 + src/web.rs | 122 +++++++++++++++ src/web/html.rs | 326 ++++++++++++++++++++++++++++++++++++++++ src/web/style.css | 246 ++++++++++++++++++++++++++++++ 9 files changed, 744 insertions(+), 21 deletions(-) create mode 100644 src/web.rs create mode 100644 src/web/html.rs create mode 100644 src/web/style.css diff --git a/.gitignore b/.gitignore index fa328e945..36963a53e 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ .devcontainer/ # Cookbook /repo +/web /cookbook.toml source source.tmp diff --git a/src/bin/repo.rs b/src/bin/repo.rs index 6c97fe6a6..854f3d7b7 100644 --- a/src/bin/repo.rs +++ b/src/bin/repo.rs @@ -63,15 +63,17 @@ const REPO_HELP_STR: &str = r#" --repo-binary override recipes config to use repo_binary cook env and their defaults: - CI= set to any value to disable TUI - COOKBOOK_LOGS= whether to capture build logs (default is !CI) - COOKBOOK_OFFLINE=false prevent internet access if possible + CI= set to any value to disable TUI + COOKBOOK_LOGS= whether to capture build logs (default is !CI) + COOKBOOK_OFFLINE=false prevent internet access if possible ignored when command "fetch" is used - COOKBOOK_NONSTOP=false keep running even a recipe build failed - COOKBOOK_VERBOSE=true print success/error on each recipe - COOKBOOK_CLEAN_BUILD=false remove build directory before building - COOKBOOK_CLEAN_TARGET=false remove target directory after building - COOKBOOK_MAKE_JOBS= override build jobs count from nproc + COOKBOOK_NONSTOP=false keep running even a recipe build failed + COOKBOOK_VERBOSE=true print success/error on each recipe + COOKBOOK_CLEAN_BUILD=false remove build directory before building + COOKBOOK_CLEAN_TARGET=false remove target directory after building + COOKBOOK_WRITE_FILETREE=false whether to write stage files tree + COOKBOOK_MAKE_JOBS= override build jobs count from nproc + COOKBOOK_WEB=false whether to generate package web files "#; #[derive(Clone)] @@ -698,16 +700,20 @@ fn handle_cook( package(&recipe, &stage_dirs, &auto_deps, logger) .map_err(|err| anyhow!("failed to package: {:?}", err))?; - if config.cook.clean_target { + if config.cook.clean_target || config.cook.write_filetree { let stage_dirs = get_stage_dirs(&recipe.recipe.optional_packages, &target_dir); for stage_dir in stage_dirs { if stage_dir.is_dir() { - let mut stage_files_buf = String::new(); - tree::walk_file_tree(&stage_dir, "", &mut stage_files_buf) - .context("failed to walk stage files tree")?; - fs::write(stage_dir.with_added_extension("files"), stage_files_buf) - .context("unable to write stage files")?; - fs::remove_dir_all(&stage_dir).context("failed to remove stage dir")?; + if config.cook.write_filetree { + let mut stage_files_buf = String::new(); + tree::walk_file_tree(&stage_dir, "", &mut stage_files_buf) + .context("failed to walk stage files tree")?; + fs::write(stage_dir.with_added_extension("files"), stage_files_buf) + .context("unable to write stage files")?; + } + if config.cook.clean_target { + fs::remove_dir_all(&stage_dir).context("failed to remove stage dir")?; + } } } } diff --git a/src/bin/repo_builder.rs b/src/bin/repo_builder.rs index 707bb0850..aca991ac9 100644 --- a/src/bin/repo_builder.rs +++ b/src/bin/repo_builder.rs @@ -2,6 +2,7 @@ use cookbook::WALK_DEPTH; use cookbook::cook::ident::{get_ident, init_ident}; use cookbook::cook::{fetch, package as cook_package}; use cookbook::recipe::CookRecipe; +use cookbook::web::{CliWebConfig, generate_web}; use pkg::package::{Repository, SourceIdentifier}; use pkg::{Package, PackageName, recipes}; use std::collections::{BTreeMap, BTreeSet, HashMap}; @@ -29,18 +30,22 @@ struct CliConfig { repo_dir: PathBuf, appstream: bool, recipe_list: Vec, + web: Option, } impl CliConfig { fn parse_args() -> Result { let mut args = env::args().skip(1); - let repo_dir = args - .next() - .expect("Usage: repo_builder ..."); + let repo_dir = PathBuf::from( + args.next() + .expect("Usage: repo_builder ..."), + ); + let web = CliWebConfig::parse_args(); Ok(CliConfig { - repo_dir: PathBuf::from(repo_dir), + repo_dir, appstream: env::var("COOKBOOK_APPSTREAM").ok().as_deref() == Some("true"), recipe_list: args.collect(), + web, }) } } @@ -268,12 +273,17 @@ fn publish_packages(config: &CliConfig) -> anyhow::Result<()> { packages.insert(package_name, version_str.to_string()); } - let output = toml::to_string(&Repository { + let repository = Repository { packages, outdated_packages, - })?; + }; + + let output = toml::to_string(&repository)?; let mut output_file = File::create(&repo_toml_path)?; output_file.write_all(output.as_bytes())?; + if let Some(conf) = &config.web { + generate_web(&repository.packages.keys().cloned().collect(), conf); + } Ok(()) } diff --git a/src/config.rs b/src/config.rs index 05f1baa1f..61e402e81 100644 --- a/src/config.rs +++ b/src/config.rs @@ -24,6 +24,8 @@ pub struct CookConfigOpt { /// whether to always clean the target directory after building /// (deletes everything except pkgar files) pub clean_target: Option, + /// whether to always write stage.files metadata + pub write_filetree: Option, } #[derive(Debug, Default, Clone, Deserialize, PartialEq, Serialize)] @@ -36,6 +38,7 @@ pub struct CookConfig { pub verbose: bool, pub clean_build: bool, pub clean_target: bool, + pub write_filetree: bool, } impl From for CookConfig { @@ -49,6 +52,7 @@ impl From for CookConfig { verbose: value.verbose.unwrap(), clean_build: value.clean_build.unwrap(), clean_target: value.clean_target.unwrap(), + write_filetree: value.write_filetree.unwrap(), } } } @@ -106,6 +110,12 @@ pub fn init_config() { if config.cook_opt.clean_target.is_none() { config.cook_opt.clean_target = Some(extract_env("COOKBOOK_CLEAN_TARGET", false)); } + if config.cook_opt.write_filetree.is_none() { + config.cook_opt.write_filetree = Some(extract_env( + "COOKBOOK_WRITE_FILETREE", + config.cook_opt.clean_target.unwrap_or(false) || extract_env("COOKBOOK_WEB", false), + )); + } if config.mirrors.len() == 0 { // The GNU FTP mirror below is automatically inserted for convenience // You can choose other mirrors by setting it on cookbook.toml diff --git a/src/lib.rs b/src/lib.rs index 0c8f7153b..bcb4b2dea 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ pub mod blake3; pub mod config; pub mod cook; pub mod recipe; +pub mod web; mod progress_bar; diff --git a/src/recipe.rs b/src/recipe.rs index 2659f0e88..9ed44548e 100644 --- a/src/recipe.rs +++ b/src/recipe.rs @@ -148,6 +148,7 @@ pub struct BuildRecipe { pub struct PackageRecipe { pub dependencies: Vec, pub version: Option, + pub description: Option, } #[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)] diff --git a/src/web.rs b/src/web.rs new file mode 100644 index 000000000..db19098ed --- /dev/null +++ b/src/web.rs @@ -0,0 +1,122 @@ +use std::{ + collections::{BTreeMap, BTreeSet, HashMap}, + env, fs, + path::{Path, PathBuf}, +}; + +use pkg::{Package, PackageName}; + +use crate::{ + recipe::CookRecipe, + web::html::{generate_html_index, generate_html_pkg}, +}; + +pub mod html; + +#[derive(Clone)] +pub struct CliWebConfig { + /// path relative to cwd dir to generate web files + out_dir: PathBuf, + /// absolute url to repo (not the web) instead of "/repo" + repo_url: String, + /// this repository build url + this_repo: String, +} + +impl CliWebConfig { + pub fn parse_args() -> Option { + if env::var("COOKBOOK_WEB").ok().as_deref() != Some("true") { + return None; + } + let Ok(pwd) = env::current_dir() else { + return None; + }; + + Some(CliWebConfig { + repo_url: env::var("COOKBOOK_WEB_REPO_URL") + .ok() + .unwrap_or("/repo".to_string()), + out_dir: pwd.join( + env::var("COOKBOOK_WEB_OUT_DIR") + .ok() + .unwrap_or("web".to_string()), + ), + // TODO: Hardcoded URL, maybe get this remote-url next time + this_repo: "https://gitlab.redox-os.org/redox-os/redox".to_string(), + }) + } +} + +const CSS: &str = include_str!("./web/style.css"); + +pub fn generate_web(all_packages: &Vec, config: &CliWebConfig) { + let repo_path = &config.out_dir.join(redoxer::target()); + if !repo_path.is_dir() { + fs::create_dir_all(repo_path).unwrap(); + } + + let mut valid_packages = Vec::new(); + let mut dependents_map: HashMap> = HashMap::new(); + + for package_name in all_packages { + let Some(recipe_path) = pkg::recipes::find(package_name) else { + continue; + }; + // TODO: Package::from_path + let Ok(package) = Package::new(&PackageName::new(package_name).unwrap()) else { + continue; + }; + let Ok(recipe) = CookRecipe::from_path(&recipe_path, true, false) else { + continue; + }; + + for dep in &package.depends { + dependents_map + .entry(dep.to_string()) + .or_default() + .insert(package.name.to_string()); + } + + valid_packages.push((package, recipe)); + } + + for (package, recipe) in &valid_packages { + let dependents = dependents_map + .get(package.name.as_str()) + .cloned() + .unwrap_or_default(); + + let stage_files_path = recipe.stage_paths().0.with_added_extension("files"); + let stage_files = fs::read_to_string(stage_files_path).ok(); + + let html_path = repo_path.join(format!("{}.html", package.name.as_str())); + + generate_html_pkg( + &package, + &recipe, + &dependents.into_iter().collect(), + &stage_files, + &html_path, + &config, + ); + } + + let mut grouped_packages: BTreeMap> = BTreeMap::new(); + + for item in &valid_packages { + let category = get_category(&item.1.dir); + grouped_packages.entry(category).or_default().push(item); + } + + let index_path = repo_path.join("index.html"); + let style_path = repo_path.join("style.css"); + generate_html_index(grouped_packages, &index_path, &config); + fs::write(style_path, CSS).expect("Failed to write CSS file"); +} + +pub(crate) fn get_category(dir: &Path) -> String { + let Some(category) = dir.parent().map(|p| p.display().to_string()) else { + return "uncategorized".to_string(); + }; + category["recipes/".len()..].to_string() +} diff --git a/src/web/html.rs b/src/web/html.rs new file mode 100644 index 000000000..997d44efb --- /dev/null +++ b/src/web/html.rs @@ -0,0 +1,326 @@ +use crate::cook::ident; +use crate::recipe::SourceRecipe; +use crate::web::get_category; +use crate::{cook::tree::format_size, recipe::CookRecipe}; +use pkg::Package; +use std::collections::BTreeMap; +use std::{fs, path::Path}; + +pub fn generate_html_pkg( + package: &Package, + recipe: &CookRecipe, + dependents: &Vec, + stage_files: &Option, + html_path: &Path, + config: &crate::web::CliWebConfig, +) { + let name = &package.name; + let version = &package.version; + let target = &package.target; + let category = &get_category(&recipe.dir); + let description = recipe + .recipe + .package + .description + .as_ref() + .map(|p| p.as_str()) + .unwrap_or("-"); + + let desc_html = recipe + .recipe + .package + .description + .as_ref() + .map(|desc| format!(r#"

{}

"#, desc)) + .unwrap_or_default(); + + let repo_url = &config.repo_url; + + let deps_html = if package.depends.is_empty() { + String::from("

None

") + } else { + let items: Vec = package + .depends + .iter() + .map(|dep| format!(r#"
  • {dep}
  • "#)) + .collect(); + format!("
      \n{}\n
    ", items.join("\n")) + }; + + let dependents_html = if dependents.is_empty() { + String::from("

    None

    ") + } else { + let items: Vec = dependents + .iter() + .map(|dep| format!(r#"
  • {dep}
  • "#)) + .collect(); + format!("
      \n{}\n
    ", items.join("\n")) + }; + + let mut source_html = match &recipe.recipe.source { + Some(SourceRecipe::Git { git, .. }) => { + let host = get_hostname(git); + let tree_link = get_tree_url(git, host, &package.source_identifier, None); + let short_commit = &package.source_identifier[0..7]; + format!( + r#" + + + +
    Git:{host}
    Commit:{short_commit}
    "# + ) + } + Some(SourceRecipe::Tar { tar, .. }) => { + let host = get_hostname(tar); + format!( + r#" + +
    Tarball:{host}
    "# + ) + } + Some(SourceRecipe::SameAs { same_as }) => { + let r = Path::new(same_as).file_name().unwrap().to_string_lossy(); + format!( + r#" + +
    Same as:{r}
    "# + ) + } + _ => String::from(r#"

    No source specified.

    "#), + }; + + let (files_html, files_count) = if let Some(stage_files) = stage_files { + let count = stage_files + .split('\n') + .filter(|p| !p.ends_with('/') && !p.is_empty()) + .count(); + (format!("
    {stage_files}
    "), format!("{}", count)) + } else { + ( + String::from(r#"

    No package files defined.

    "#), + String::from("?"), + ) + }; + + { + let host = get_hostname(&config.this_repo); + let tree_link = get_tree_url( + &config.this_repo, + host, + &package.commit_identifier, + Some(&format!("recipes/{category}/{name}/recipe.toml")), + ); + let short_commit = &package.commit_identifier[0..7]; + source_html += &format!( + r#" + + +
    Build script:{short_commit}
    +"# + ); + } + + let (arch, os) = { + let target_split: Vec<&str> = package.target.split('-').collect(); + ( + target_split + .get(0) + .map(|s| s.to_string()) + .unwrap_or("-".into()), + target_split + .get(2) + .map(|s| s.to_string()) + .unwrap_or("-".into()), + ) + }; + + let html = format!( + r#" + + + + + {name} - Redox OS Package + + + +
    +
    + ← Back to packages +

    {name} {version}

    +{desc_html} +

    {description}

    +
    + $ + pkg install {name} +
    +
    +
    +
    +
    +
    +

    Dependencies

    +{deps_html} +
    +
    +

    Dependents

    +{dependents_html} +
    +
    +

    Package Files

    +{files_html} +
    +
    + +
    + + +
    Download
    +

    Package Info

    + + + + + + + + + +
    OS{os}
    Architecture{arch}
    Category{category}
    Network Size{network_size}
    Storage Size{storage_size}
    File count{files_count}
    Published{published_short}
    Hash{blake3}
    +

    Package Source

    +{source_html} +
    +
    +
    + +"#, + network_size = format_size(package.network_size), + storage_size = format_size(package.storage_size), + published_short = &package.time_identifier[0..10], + published = package.time_identifier, + blake3 = package.blake3, + ); + + fs::write(html_path, html).expect("Failed to write package HTML file"); +} + +pub fn generate_html_index( + grouped_packages: BTreeMap>, + index_path: &Path, + config: &crate::web::CliWebConfig, +) { + let mut categories_html = Vec::new(); + + for (category, pkgs) in grouped_packages { + let cards_html: Vec = pkgs + .iter() + .map(|(pkg, _recipe)| { + let name = &pkg.name; + format!( + r#" +
    +

    {name}

    +
    + {version} + {size} +
    +
    "#, + name = name, + version = pkg.version, + size = format_size(pkg.network_size) + ) + }) + .collect(); + + let category_block = format!( + r#" +
    +

    {category}

    +
    +{cards} +
    +
    "#, + category = category, + cards = cards_html.join("\n") + ); + + categories_html.push(category_block); + } + + let html = format!( + r#" + + + + + Redox Package Repository + + + +
    +

    Redox OS Package Repository

    +

    Repository for {target}

    +
    + +
    +{category_sections} + + +
    + +"#, + target = redoxer::target(), + category_sections = categories_html.join("\n\n"), + commit_time = &ident::get_ident().time, + commit_hash = &ident::get_ident().commit[0..7], + commit_tree = get_tree_url( + &config.this_repo, + get_hostname(&config.this_repo), + &ident::get_ident().commit, + None + ), + ); + + println!("Generating web content to {}", index_path.display()); + fs::write(index_path, html).expect("Failed to write index HTML file"); +} + +fn get_hostname(url: &str) -> &str { + url.split("://") + .nth(1) + .unwrap_or(url) + .split('/') + .next() + .unwrap_or(url) + .split(':') + .next() + .unwrap_or(url) +} + +pub fn get_tree_url(git_url: &str, host: &str, commit: &str, folder: Option<&str>) -> String { + let mut base_url = git_url.trim_end_matches(".git").to_string(); + + if let Some(ssh_path) = base_url.strip_prefix("git@") { + // "git@github.com:user/repo" -> "https://github.com/user/repo" + base_url = format!("https://{}", ssh_path.replace(':', "/")); + } else if base_url.starts_with("git://") { + // "git://github.com/user/repo" -> "https://github.com/user/repo" + base_url = base_url.replacen("git://", "https://", 1); + } + + let base_url = if host == "github.com" { + format!("{}/tree/{}", base_url, commit) + } else if host.contains("gitlab") { + format!("{}/-/tree/{}", base_url, commit) + } else { + return format!("{}?commit={}", base_url, commit); + }; + + match folder { + Some(f) => format!("{base_url}/{f}"), + None => base_url, + } +} diff --git a/src/web/style.css b/src/web/style.css new file mode 100644 index 000000000..585697e01 --- /dev/null +++ b/src/web/style.css @@ -0,0 +1,246 @@ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + background-color: #f9f9fb; + color: #24292e; + line-height: 1.6; +} + +.container { + max-width: 1280px; + margin: 0 auto; + padding: 0 20px; +} + +.category-section { + margin-bottom: 50px; +} + +.category-title { + font-size: 1.5rem; + color: #24292e; + border-bottom: 2px solid #e1e4e8; + padding-bottom: 10px; + margin-bottom: 20px; + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; +} + +.package-grid { + display: block; + display: flex; + flex-wrap: wrap; + margin: -10px; +} + +.package-card { + background-color: #ffffff; + border: 1px solid #e1e4e8; + border-radius: 6px; + padding: 15px; + margin: 10px; + + display: inline-block; + width: 30%; + vertical-align: top; + + display: flex; + flex: 0 1 280px; + flex-direction: column; + justify-content: space-between; +} + +.package-card .pkg-name { + margin-bottom: 15px; + font-size: 1.25rem; +} + +.package-card .pkg-name a { + border: none; +} + +.package-card .pkg-name a:hover { + text-decoration: underline; +} + +.package-card .pkg-stats { + display: block; + display: flex; + justify-content: space-between; + align-items: center; + color: #6a737d; + font-size: 0.9rem; + border-top: 1px solid #e1e4e8; + padding-top: 10px; +} + +.package-card .pkg-version { + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + background-color: #f3f4f6; + padding: 3px 6px; + border-radius: 4px; + color: #24292e; +} + +.package-card .pkg-size { + font-weight: 500; +} + +a { + color: #24292e; + text-decoration: none; + border-bottom: 1px solid #e1e4e8; +} + +a:hover { + color: #000000; + border-bottom: 1px solid #24292e; +} + +h1, h2, h3 { + font-weight: 600; + margin: 1rem 0; +} + +code { + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; + background-color: #f3f4f6; + padding: 0.2em 0.4em; + border-radius: 3px; + font-size: 0.9em; +} + +.card { + background-color: #ffffff; + border: 1px solid #e1e4e8; + border-radius: 6px; + padding: 20px; + margin-bottom: 20px; +} + +.pkg-header, .index-header { + background-color: #ffffff; + border-bottom: 1px solid #d1d5da; + padding: 40px 0; + margin-bottom: 40px; + text-align: center; +} + +.pkg-header h1 { + font-size: 2.5rem; + margin-bottom: 0.5rem; +} + +.pkg-header .version { + color: #6a737d; + font-size: 1.5rem; + font-weight: 400; +} + +.pkg-header .description { + font-size: 1.2rem; + color: #586069; + max-width: 600px; + margin: 0 auto 1.5rem auto; +} + +.back-link { + display: inline-block; + margin-bottom: 20px; + color: #6a737d; + border: none; + font-size: 0.9rem; +} + +.back-link:hover { + color: #24292e; + border: none; +} + +.install-action { + display: inline-block; + background-color: #f3f4f6; + border: 1px solid #d1d5da; + border-radius: 6px; + padding: 12px 20px; + font-family: ui-monospace, SFMono-Regular, monospace; + font-size: 1.1rem; + color: #24292e; +} + +.install-action .prompt { + color: #6a737d; + margin-right: 12px; +} + +.install-action code { + background-color: transparent; + padding: 0; + font-size: 1.1rem; + user-select: all; +} + +.pkg-content { + display: flex; + flex-wrap: wrap; + justify-content: space-between; +} + +.pkg-main, .pkg-meta { + width: 100%; +} + +@media (min-width: 768px) { + .pkg-main { + width: 60%; + } + .pkg-meta { + width: 35%; + } +} + +.meta-box { + overflow-x: auto; + display: block; + max-width: 150px; + user-select: all; + padding: 8px; + white-space: nowrap; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, td { + padding: 10px 0; + text-align: left; + border-bottom: 1px solid #e1e4e8; +} + +th { + color: #6a737d; + font-weight: 500; +} + +.pkg-meta table th { + width: 40%; + padding-right: 10px; +} + +.pkg-deps ul, .pkg-dependents ul { + list-style-type: none; + display: flex; + flex-wrap: wrap; +} + +.pkg-deps li, .pkg-dependents li { + padding: 8px 0; + border-bottom: 1px solid #e1e4e8; + width: 50%; +}