Implement web generation for packages

This commit is contained in:
Wildan M 2026-02-15 15:26:08 +07:00
parent 9d672df778
commit 69fa5f1bc0
No known key found for this signature in database
GPG Key ID: 01AC53185C679C79
9 changed files with 744 additions and 21 deletions

1
.gitignore vendored
View File

@ -12,6 +12,7 @@
.devcontainer/
# Cookbook
/repo
/web
/cookbook.toml
source
source.tmp

View File

@ -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")?;
}
}
}
}

View File

@ -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<String>,
web: Option<CliWebConfig>,
}
impl CliConfig {
fn parse_args() -> Result<Self, std::io::Error> {
let mut args = env::args().skip(1);
let repo_dir = args
.next()
.expect("Usage: repo_builder <REPO_DIR> <recipe1> <recipe2> ...");
let repo_dir = PathBuf::from(
args.next()
.expect("Usage: repo_builder <REPO_DIR> <recipe1> <recipe2> ..."),
);
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(())
}

View File

@ -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<bool>,
/// whether to always write stage.files metadata
pub write_filetree: Option<bool>,
}
#[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<CookConfigOpt> for CookConfig {
@ -49,6 +52,7 @@ impl From<CookConfigOpt> 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

View File

@ -2,6 +2,7 @@ pub mod blake3;
pub mod config;
pub mod cook;
pub mod recipe;
pub mod web;
mod progress_bar;

View File

@ -148,6 +148,7 @@ pub struct BuildRecipe {
pub struct PackageRecipe {
pub dependencies: Vec<PackageName>,
pub version: Option<String>,
pub description: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)]

122
src/web.rs Normal file
View File

@ -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<CliWebConfig> {
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<String>, 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<String, BTreeSet<String>> = 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<String, Vec<&(Package, CookRecipe)>> = 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()
}

326
src/web/html.rs Normal file
View File

@ -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<String>,
stage_files: &Option<String>,
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#"<p class="description">{}</p>"#, desc))
.unwrap_or_default();
let repo_url = &config.repo_url;
let deps_html = if package.depends.is_empty() {
String::from("<p>None</p>")
} else {
let items: Vec<String> = package
.depends
.iter()
.map(|dep| format!(r#"<li><a href="{dep}.html">{dep}</a></li>"#))
.collect();
format!("<ul>\n{}\n</ul>", items.join("\n"))
};
let dependents_html = if dependents.is_empty() {
String::from("<p>None</p>")
} else {
let items: Vec<String> = dependents
.iter()
.map(|dep| format!(r#"<li><a href="{dep}.html">{dep}</a></li>"#))
.collect();
format!("<ul>\n{}\n</ul>", 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#"
<table>
<tr><th>Git:</th><td><a href="{git}" target="_blank">{host}</a></td></tr>
<tr><th>Commit:</th><td><a href="{tree_link}" target="_blank">{short_commit}</a></td></tr>
</table>"#
)
}
Some(SourceRecipe::Tar { tar, .. }) => {
let host = get_hostname(tar);
format!(
r#"<table>
<tr><th>Tarball:</th><td><a href="{tar}" target="_blank">{host}</a></td></tr>
</table>"#
)
}
Some(SourceRecipe::SameAs { same_as }) => {
let r = Path::new(same_as).file_name().unwrap().to_string_lossy();
format!(
r#"<table>
<tr><th>Same as:</th><td><a href="{r}.html">{r}</a></td></tr>
</table>"#
)
}
_ => String::from(r#"<p>No source specified.</p>"#),
};
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!("<pre>{stage_files}</pre>"), format!("{}", count))
} else {
(
String::from(r#"<p>No package files defined.</p>"#),
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#"
<table>
<tr><th>Build script:</th><td><a href="{tree_link}" target="_blank">{short_commit}</a></td></tr>
</table>
"#
);
}
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#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{name} - Redox OS Package</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header class="pkg-header">
<div class="container">
<a href="index.html" class="back-link">&larr; Back to packages</a>
<h1>{name} <span class="version">{version}</span></h1>
{desc_html}
<p class="description">{description}</p>
<div class="install-action">
<span class="prompt">$</span>
<code>pkg install {name}</code>
</div>
</div>
</header>
<main class="pkg-content container">
<div class="pkg-main">
<section class="pkg-deps card">
<h2>Dependencies</h2>
{deps_html}
</section>
<section class="pkg-dependents card">
<h2>Dependents</h2>
{dependents_html}
</section>
<section class="pkg-recipe card">
<h2>Package Files</h2>
{files_html}
</section>
</div>
<section class="pkg-meta card">
<table>
<tr><th></th><td><a href="{repo_url}/{target}/{name}.pkgar" target="_blank">Download</a></td></tr>
</table>
<h2>Package Info</h2>
<table>
<tr><th>OS</th><td>{os}</td></tr>
<tr><th>Architecture</th><td>{arch}</td></tr>
<tr><th>Category</th><td><a href="index.html#cat-{category}">{category}</a></td></tr>
<tr><th>Network Size</th><td>{network_size}</td></tr>
<tr><th>Storage Size</th><td>{storage_size}</td></tr>
<tr><th>File count</th><td>{files_count}</td></tr>
<tr><th>Published</th><td title="{published}">{published_short}</td></tr>
<tr><th>Hash</th><td><code class="hash meta-box">{blake3}</code></td></tr>
</table>
<h2>Package Source</h2>
{source_html}
<div style="height:100px"></div>
</section>
</main>
</body>
</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<String, Vec<&(Package, CookRecipe)>>,
index_path: &Path,
config: &crate::web::CliWebConfig,
) {
let mut categories_html = Vec::new();
for (category, pkgs) in grouped_packages {
let cards_html: Vec<String> = pkgs
.iter()
.map(|(pkg, _recipe)| {
let name = &pkg.name;
format!(
r#"
<div class="package-card">
<h3 class="pkg-name"><a href="{name}.html">{name}</a></h3>
<div class="pkg-stats">
<span class="pkg-version">{version}</span>
<span class="pkg-size">{size}</span>
</div>
</div>"#,
name = name,
version = pkg.version,
size = format_size(pkg.network_size)
)
})
.collect();
let category_block = format!(
r#"
<section class="category-section">
<h2 class="category-title" id="cat-{category}">{category}</h2>
<div class="package-grid">
{cards}
</div>
</section>"#,
category = category,
cards = cards_html.join("\n")
);
categories_html.push(category_block);
}
let html = format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Redox Package Repository</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header class="index-header">
<h1>Redox OS Package Repository</h1>
<p class="description">Repository for <code>{target}</code></p>
</header>
<main class="index-content container">
{category_sections}
<footer>
<p>Generated at <code>{commit_time}</code> with build tree <a href="{commit_tree}" target="_blank">{commit_hash}</a></p>
<div style="height:100px"></div>
</footer>
</main>
</body>
</html>"#,
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,
}
}

246
src/web/style.css Normal file
View File

@ -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%;
}