// SPDX-License-Identifier: AGPL-3.0-or-later use sha2::{Digest, Sha256}; use std::env; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; const BUNDLED_FAMILIES: &[&str] = &["FluxerSans", "FluxerMono"]; const BUNDLED_WEIGHTS: &[u64] = &[400, 500, 600, 700]; const EXPECTED_FACE_COUNT: usize = 16; fn main() { let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR missing")); let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR missing")); println!("cargo:rerun-if-changed=src/styles/app.css"); println!("cargo:rerun-if-changed=src/"); println!("cargo:rerun-if-changed=openapi-admin.json"); generate_admin_api(&manifest_dir, &out_dir); build_fonts(&manifest_dir, &out_dir); build_tailwind(&manifest_dir, &out_dir); } fn generate_admin_api(manifest_dir: &Path, out_dir: &Path) { let spec_path = manifest_dir.join("openapi-admin.json"); if !spec_path.exists() { eprintln!("cargo:warning=openapi-admin.json not found, skipping API generation"); return; } let json_str = fs::read_to_string(&spec_path).expect("failed to read openapi-admin.json"); let spec: openapiv3::OpenAPI = serde_json::from_str(&json_str).expect("failed to parse openapi-admin.json"); let mut settings = progenitor::GenerationSettings::new(); settings.with_interface(progenitor::InterfaceStyle::Positional); let mut generator = progenitor::Generator::new(&settings); let tokens = generator .generate_tokens(&spec) .expect("failed to generate admin API client"); let content = prettyplease::unparse( &syn::parse2::(tokens).expect("failed to parse generated tokens"), ); let output_path = out_dir.join("admin_api_generated.rs"); fs::write(&output_path, content).expect("failed to write generated API code"); } struct Face { css_family: String, weight: u64, style: String, source: String, } struct Asset { name: String, content_type: &'static str, } fn build_fonts(manifest_dir: &Path, out_dir: &Path) { println!("cargo:rerun-if-changed=../packages/fonts/manifest.json"); println!("cargo:rerun-if-changed=../packages/fonts/files/FluxerSans"); println!("cargo:rerun-if-changed=../packages/fonts/files/FluxerMono"); println!("cargo:rerun-if-changed=../packages/fonts/NOTICE.md"); println!("cargo:rerun-if-changed=../packages/fonts/LICENSE-IBM-PLEX.txt"); let package_dir = manifest_dir.join("../packages/fonts"); let fonts_dir = out_dir.join("static").join("fonts"); let _ = fs::remove_dir_all(&fonts_dir); fs::create_dir_all(&fonts_dir).expect("failed to create generated font dir"); let mut assets = Vec::new(); let notice = emit_asset( &fonts_dir, &package_dir.join("NOTICE.md"), "text/plain; charset=utf-8", &mut assets, ); let plex_license = emit_asset( &fonts_dir, &package_dir.join("LICENSE-IBM-PLEX.txt"), "text/plain; charset=utf-8", &mut assets, ); let faces = select_faces(&package_dir); assert_eq!( faces.len(), EXPECTED_FACE_COUNT, "packages/fonts no longer offers the {} Latin-core faces fluxer_admin renders; \ reconcile BUNDLED_FAMILIES/BUNDLED_WEIGHTS with the manifest", EXPECTED_FACE_COUNT ); let mut stylesheet = String::from("/* SPDX-License-Identifier: AGPL-3.0-or-later */\n"); stylesheet.push_str( "/* @generated by fluxer_admin/build.rs from packages/fonts. Do not edit by hand. */\n\n", ); stylesheet.push_str(&format!( "/*\n\ \x20* IBM Plex is licensed under the SIL Open Font License 1.1.\n\ \x20* The IBM Plex faces below are Modified Versions renamed to \"Fluxer Sans\", so OFL\n\ \x20* clause 3 requires the disclosure to travel with them. It is served beside them:\n\ \x20* ./{notice}\n\ \x20* ./{plex_license}\n\ \x20*\n\ \x20* Every url() below is relative, so it resolves against this stylesheet's own\n\ \x20* directory. That keeps the sheet correct under any FLUXER_ADMIN_BASE_PATH without\n\ \x20* the build having to know the runtime base path.\n\ \x20*/\n" )); for face in &faces { let source = package_dir.join("files").join(&face.source); let file = emit_asset(&fonts_dir, &source, "font/woff2", &mut assets); stylesheet.push_str(&format!( "@font-face {{\n\ \tfont-family: '{}';\n\ \tsrc: url('{file}') format('woff2');\n\ \tfont-weight: {};\n\ \tfont-style: {};\n\ \tfont-display: swap;\n\ }}\n", face.css_family, face.weight, face.style )); } let stylesheet_name = write_hashed( &fonts_dir, "fonts.css", stylesheet.as_bytes(), "text/css; charset=utf-8", &mut assets, ); write_font_asset_table(out_dir, &stylesheet_name, &assets); } fn select_faces(package_dir: &Path) -> Vec { let manifest_path = package_dir.join("manifest.json"); let raw = fs::read_to_string(&manifest_path).unwrap_or_else(|err| { panic!( "failed to read {}: {err}. packages/fonts is generated by \ `python3 tools/fonts/build_fonts.py`.", manifest_path.display() ) }); let manifest: serde_json::Value = serde_json::from_str(&raw).expect("failed to parse packages/fonts/manifest.json"); let families = manifest["families"] .as_array() .expect("packages/fonts/manifest.json has no families array"); let mut faces = Vec::new(); for wanted in BUNDLED_FAMILIES { let family = families .iter() .find(|family| family["id"].as_str() == Some(wanted)) .unwrap_or_else(|| panic!("packages/fonts/manifest.json has no family {wanted}")); assert_eq!( family["latinCore"].as_bool(), Some(true), "{wanted} is no longer a Latin-core family; it would need unicode-range gating" ); let css_family = family["cssFamily"] .as_str() .unwrap_or_else(|| panic!("{wanted} has no cssFamily")) .to_owned(); for face in family["faces"] .as_array() .unwrap_or_else(|| panic!("{wanted} has no faces array")) { let weight = face["weight"].as_u64().expect("face has no weight"); if !BUNDLED_WEIGHTS.contains(&weight) { continue; } assert!( face["unicodeRange"].is_null(), "{wanted} face {} carries a unicode-range; Latin-core faces must not", face["file"] ); faces.push(Face { css_family: css_family.clone(), weight, style: face["style"] .as_str() .expect("face has no style") .to_owned(), source: face["file"].as_str().expect("face has no file").to_owned(), }); } } faces } fn emit_asset( fonts_dir: &Path, source: &Path, content_type: &'static str, assets: &mut Vec, ) -> String { let bytes = fs::read(source).unwrap_or_else(|err| panic!("failed to read {}: {err}", source.display())); let file_name = source .file_name() .and_then(|name| name.to_str()) .unwrap_or_else(|| panic!("{} has no file name", source.display())); write_hashed(fonts_dir, file_name, &bytes, content_type, assets) } fn write_hashed( fonts_dir: &Path, file_name: &str, bytes: &[u8], content_type: &'static str, assets: &mut Vec, ) -> String { let (stem, extension) = file_name .rsplit_once('.') .unwrap_or_else(|| panic!("{file_name} has no extension to hash around")); let digest = Sha256::digest(bytes); let hash: String = digest .iter() .take(8) .map(|byte| format!("{byte:02x}")) .collect(); let name = format!("{stem}.{hash}.{extension}"); fs::write(fonts_dir.join(&name), bytes) .unwrap_or_else(|err| panic!("failed to write {name}: {err}")); assets.push(Asset { name: name.clone(), content_type, }); name } fn write_font_asset_table(out_dir: &Path, stylesheet_name: &str, assets: &[Asset]) { let mut generated = String::from( "// @generated by fluxer_admin/build.rs from packages/fonts. Do not edit by hand.\n\n", ); generated.push_str(&format!( "/// File name of the content-hashed `@font-face` stylesheet, relative to `/static/fonts/`.\npub const STYLESHEET_FILE_NAME: &str = {stylesheet_name:?};\n\n" )); generated.push_str("/// `(file name, content type, bytes)` for everything served under `/static/fonts/`.\npub static ASSETS: &[(&str, &str, &[u8])] = &[\n"); for asset in assets { generated.push_str(&format!( " ({:?}, {:?}, include_bytes!(concat!(env!(\"OUT_DIR\"), \"/static/fonts/{}\"))),\n", asset.name, asset.content_type, asset.name )); } generated.push_str("];\n"); fs::write(out_dir.join("static").join("fonts.rs"), generated) .expect("failed to write generated font asset table"); } fn build_tailwind(manifest_dir: &Path, out_dir: &Path) { let output_dir = out_dir.join("static"); fs::create_dir_all(&output_dir).expect("failed to create generated static dir"); let input = manifest_dir.join("src/styles/app.css"); let output = output_dir.join("app.css"); let candidates = [ manifest_dir.join("node_modules/.bin/tailwindcss"), manifest_dir.join("../node_modules/.bin/tailwindcss"), ]; let Some(cli) = candidates.iter().find(|c| c.exists()) else { panic!( "tailwindcss CLI not found. Run `pnpm install` in fluxer_admin/.\n\ Searched: {:?}", candidates ); }; let status = Command::new(cli) .arg("-i") .arg(&input) .arg("-o") .arg(&output) .arg("--minify") .arg("--cwd") .arg(manifest_dir) .status() .expect("failed to run tailwindcss"); if !status.success() { panic!("tailwindcss failed with status {}", status); } }