Files
2026-08-12 13:21:50 +02:00

294 lines
10 KiB
Rust

// 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::<syn::File>(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<Face> {
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<Asset>,
) -> 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<Asset>,
) -> 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);
}
}