refactor: bundle fonts (#1569)

This commit is contained in:
Hampus
2026-08-12 13:21:50 +02:00
committed by GitHub
parent 03e6062ede
commit a9e6919713
1918 changed files with 39141 additions and 12841 deletions
+1
View File
@@ -34,4 +34,5 @@ openapiv3 = "2.2.0"
prettyplease = "0.2"
progenitor = { version = "0.14.0", default-features = false }
serde_json = "1"
sha2 = "0.11.0"
syn = "2"
+9
View File
@@ -24,6 +24,11 @@ RUN TAILWIND_OXIDE_VERSION="4.2.1" \
COPY Cargo.lock Cargo.lock
COPY fluxer_admin fluxer_admin
COPY packages/fonts/manifest.json packages/fonts/manifest.json
COPY packages/fonts/NOTICE.md packages/fonts/NOTICE.md
COPY packages/fonts/LICENSE-IBM-PLEX.txt packages/fonts/LICENSE-IBM-PLEX.txt
COPY packages/fonts/files/FluxerSans packages/fonts/files/FluxerSans
COPY packages/fonts/files/FluxerMono packages/fonts/files/FluxerMono
RUN printf '%s\n' \
'[workspace]' \
'members = ["fluxer_admin"]' \
@@ -42,6 +47,10 @@ RUN cargo build --release -p fluxer_admin \
RUN test -s target/release/build/fluxer_admin-*/out/static/app.css \
&& echo "Tailwind CSS compiled successfully"
RUN test "$(ls target/release/build/fluxer_admin-*/out/static/fonts/*.woff2 | wc -l)" -eq 12 \
&& ls target/release/build/fluxer_admin-*/out/static/fonts/fonts.*.css \
&& echo "Latin-core fonts bundled successfully"
FROM debian:bookworm-slim AS runtime
ARG BUILD_VERSION=""
+215
View File
@@ -1,10 +1,17 @@
// 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"));
@@ -15,6 +22,7 @@ fn main() {
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);
}
@@ -46,6 +54,213 @@ fn generate_admin_api(manifest_dir: &Path, out_dir: &Path) {
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");
+72
View File
@@ -0,0 +1,72 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
include!(concat!(env!("OUT_DIR"), "/static/fonts.rs"));
pub fn asset(file_name: &str) -> Option<(&'static str, &'static [u8])> {
ASSETS
.iter()
.find(|(name, _, _)| *name == file_name)
.map(|(_, content_type, bytes)| (*content_type, *bytes))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stylesheet_is_served_and_content_hashed() {
let (content_type, bytes) =
asset(STYLESHEET_FILE_NAME).expect("the generated stylesheet must be servable");
assert_eq!(content_type, "text/css; charset=utf-8");
let css = std::str::from_utf8(bytes).expect("stylesheet must be UTF-8");
assert!(css.contains("font-family: 'Fluxer Sans'"));
assert!(css.contains("font-family: 'Fluxer Mono'"));
assert!(
!css.contains("?v="),
"content hashing replaces cache-bust tokens"
);
assert!(
!css.contains("fluxerstatic"),
"fonts must not be fetched from the static CDN"
);
assert!(
STYLESHEET_FILE_NAME.starts_with("fonts.") && STYLESHEET_FILE_NAME.ends_with(".css"),
"unexpected stylesheet name {STYLESHEET_FILE_NAME}"
);
}
#[test]
fn every_face_the_stylesheet_references_is_served() {
let (_, bytes) = asset(STYLESHEET_FILE_NAME).expect("stylesheet");
let css = std::str::from_utf8(bytes).expect("stylesheet must be UTF-8");
let mut referenced = 0;
for fragment in css.split("url('").skip(1) {
let file_name = fragment.split('\'').next().expect("unterminated url()");
let (content_type, _) = asset(file_name)
.unwrap_or_else(|| panic!("stylesheet references unserved font {file_name}"));
assert_eq!(content_type, "font/woff2");
referenced += 1;
}
assert_eq!(referenced, 16, "expected the 16 bundled Latin-core faces");
}
#[test]
fn ofl_attribution_ships_with_the_binaries() {
let notice = ASSETS
.iter()
.find(|(name, _, _)| name.starts_with("NOTICE.") && name.ends_with(".md"))
.expect("the OFL modification disclosure must ship with the modified fonts");
assert!(!notice.2.is_empty());
assert!(
ASSETS
.iter()
.any(|(name, _, _)| name.starts_with("LICENSE-IBM-PLEX."))
);
}
#[test]
fn unknown_files_are_not_served() {
assert!(asset("fonts.css").is_none());
assert!(asset("../../../etc/passwd").is_none());
}
}
+1
View File
@@ -4,6 +4,7 @@ pub mod acl;
pub mod admin_flags;
pub mod api;
pub mod config;
pub mod fonts;
pub mod middleware;
pub mod oauth2;
pub mod routes;
+32 -5
View File
@@ -30,17 +30,25 @@ pub struct ActionQuery {
}
use axum::{
Json, Router,
extract::{Request, State},
extract::{Path, Request, State},
http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header},
middleware::{Next, from_fn, from_fn_with_state},
response::{Html, IntoResponse, Response},
routing::get,
};
use tower_http::{compression::CompressionLayer, trace::TraceLayer};
use tower_http::{
compression::{
CompressionLayer,
predicate::{DefaultPredicate, NotForContentType, Predicate},
},
trace::TraceLayer,
};
const APP_CSS: &str = include_str!(concat!(env!("OUT_DIR"), "/static/app.css"));
const HTMX_JS: &str = include_str!("../../static/htmx.min.js");
const IMMUTABLE_CACHE_CONTROL: &str = "public, max-age=31536000, immutable";
const STRICT_TRANSPORT_SECURITY_VALUE: &str = "max-age=31536000; includeSubDomains; preload";
const REFERRER_POLICY_VALUE: &str = "strict-origin-when-cross-origin";
const X_FRAME_OPTIONS_VALUE: &str = "DENY";
@@ -76,6 +84,7 @@ pub fn build_router(config: AdminConfig) -> Router {
.route("/_health", get(health))
.route("/robots.txt", get(robots_txt))
.route("/static/app.css", get(app_css))
.route("/static/fonts/{file_name}", get(font_asset))
.route("/static/htmx.min.js", get(htmx_js))
.merge(auth::router())
.merge(protected)
@@ -85,7 +94,10 @@ pub fn build_router(config: AdminConfig) -> Router {
security_headers_middleware,
))
.layer(from_fn(cache_headers_middleware))
.layer(CompressionLayer::new())
.layer(
CompressionLayer::new()
.compress_when(DefaultPredicate::new().and(NotForContentType::const_new("font/"))),
)
.layer(TraceLayer::new_for_http())
.with_state(state)
}
@@ -129,9 +141,9 @@ fn build_admin_csp(config: &AdminConfig) -> String {
[
"default-src 'self'".to_owned(),
"script-src 'self' 'unsafe-inline'".to_owned(),
format!("style-src 'self' 'unsafe-inline' {static_cdn}"),
"style-src 'self' 'unsafe-inline'".to_owned(),
format!("img-src 'self' data: blob: {static_cdn} {media} https://fluxer-reports.ewr1.vultrobjects.com"),
format!("font-src 'self' data: {static_cdn}"),
"font-src 'self'".to_owned(),
"connect-src 'self'".to_owned(),
"object-src 'none'".to_owned(),
"frame-src 'none'".to_owned(),
@@ -193,6 +205,20 @@ async fn app_css() -> impl IntoResponse {
([(header::CONTENT_TYPE, "text/css; charset=utf-8")], APP_CSS)
}
async fn font_asset(Path(file_name): Path<String>) -> Response {
match crate::fonts::asset(&file_name) {
Some((content_type, bytes)) => (
[
(header::CONTENT_TYPE, content_type),
(header::CACHE_CONTROL, IMMUTABLE_CACHE_CONTROL),
],
bytes,
)
.into_response(),
None => StatusCode::NOT_FOUND.into_response(),
}
}
async fn htmx_js() -> impl IntoResponse {
(
[(
@@ -232,6 +258,7 @@ async fn not_found(State(state): State<AppState>) -> impl IntoResponse {
meta charset="utf-8";
meta name="viewport" content="width=device-width, initial-scale=1";
title { "404 - Not Found" }
link rel="stylesheet" href={(base) "/static/fonts/" (crate::fonts::STYLESHEET_FILE_NAME)};
link rel="stylesheet" href={(base) "/static/app.css"};
}
body class="min-h-screen bg-neutral-50 flex items-center justify-center" {
+1 -2
View File
@@ -5,7 +5,7 @@
@theme {
--font-sans: "Fluxer Sans", ui-sans-serif, system-ui, sans-serif;
--font-display: "Bricolage Grotesque", ui-sans-serif, system-ui, sans-serif;
--font-display: "Fluxer Sans", ui-sans-serif, system-ui, sans-serif;
--color-brand-primary: hsl(242 70% 55%);
--color-brand-primary-dark: hsl(242 70% 47%);
--color-brand-primary-rgb: 93 79 192;
@@ -13,7 +13,6 @@
:root {
--brand-primary: hsl(242 70% 55%);
--font-bricolage: "Bricolage Grotesque", ui-sans-serif, system-ui, sans-serif;
--focus-ring-color: hsl(242 70% 55% / 0.45);
--focus-ring-offset: #ffffff;
}
+1 -2
View File
@@ -70,8 +70,7 @@ pub fn admin_layout_ext(
meta http-equiv="refresh" content="3";
}
title { (title) " ~ Fluxer Admin" }
link rel="stylesheet" href={(config.static_cdn_endpoint) "/fonts/ibm-plex.css?v=3"};
link rel="stylesheet" href={(config.static_cdn_endpoint) "/fonts/bricolage.css?v=3"};
link rel="stylesheet" href={(base) "/static/fonts/" (crate::fonts::STYLESHEET_FILE_NAME)};
link rel="stylesheet" href=(cache_busted_asset(base, asset_version, "/static/app.css"));
link rel="icon" type="image/x-icon" href={(config.static_cdn_endpoint) "/web/favicon.ico"};
link rel="apple-touch-icon" href={(config.static_cdn_endpoint) "/web/apple-touch-icon.png"};
+1 -2
View File
@@ -12,8 +12,7 @@ pub fn login_page(config: &AdminConfig, error_message: Option<&str>) -> Markup {
meta charset="UTF-8";
meta name="viewport" content="width=device-width, initial-scale=1.0";
title { "Login ~ Fluxer Admin" }
link rel="stylesheet" href={(config.static_cdn_endpoint) "/fonts/ibm-plex.css?v=3"};
link rel="stylesheet" href={(config.static_cdn_endpoint) "/fonts/bricolage.css?v=3"};
link rel="stylesheet" href={(base) "/static/fonts/" (crate::fonts::STYLESHEET_FILE_NAME)};
link rel="stylesheet" href={(base) "/static/app.css"};
link rel="icon" type="image/x-icon" href={(config.static_cdn_endpoint) "/web/favicon.ico"};
}
+78
View File
@@ -573,6 +573,84 @@ async fn creating_registration_url_swaps_copyable_url_list_fragment() {
assert!(toast.contains("Registration URL created"), "{toast}");
}
#[tokio::test]
async fn fonts_are_served_locally_content_hashed_and_immutable() {
let app = setup().await;
let stylesheet_path = format!(
"/static/fonts/{}",
fluxer_admin::fonts::STYLESHEET_FILE_NAME
);
let (headers, css) = get_with_headers(&app, &stylesheet_path, &[]).await;
assert_eq!(
headers.get(header::CACHE_CONTROL).unwrap(),
"public, max-age=31536000, immutable"
);
for fragment in css.split("url('").skip(1) {
let file_name = fragment.split('\'').next().unwrap();
let response = app
.router
.clone()
.oneshot(
Request::builder()
.uri(format!("/static/fonts/{file_name}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::OK,
"missing font {file_name}"
);
assert_eq!(
response.headers().get(header::CONTENT_TYPE).unwrap(),
"font/woff2"
);
assert_eq!(
response.headers().get(header::CACHE_CONTROL).unwrap(),
"public, max-age=31536000, immutable"
);
}
let response = app
.router
.clone()
.oneshot(
Request::builder()
.uri("/static/fonts/does-not-exist.woff2")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn rendered_heads_never_reference_the_static_cdn_for_fonts() {
let app = setup().await;
let (headers, page) = get_with_headers(&app, "/users", &[]).await;
assert!(
!page.contains("/fonts/ibm-plex.css"),
"the admin layout still links the CDN font stylesheets"
);
assert!(page.contains("/static/fonts/"), "{page}");
let csp = headers
.get(header::CONTENT_SECURITY_POLICY)
.and_then(|value| value.to_str().ok())
.expect("missing CSP");
assert!(csp.contains("font-src 'self';"), "font-src was {csp}");
assert!(
csp.contains("style-src 'self' 'unsafe-inline';"),
"style-src was {csp}"
);
}
struct SearchCase {
path: &'static str,
result_target: &'static str,