use std::{
borrow::Cow,
fs,
path::{Path, PathBuf},
};
use include_dir::{Dir, include_dir};
pub static DIST: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/web/dist");
#[derive(Debug, Clone)]
pub struct AssetResponse {
pub path: String,
pub mime: &'static str,
pub contents: Cow<'static, [u8]>,
}
pub trait WebAssetProvider: Send + Sync + std::fmt::Debug + 'static {
fn load(&self, path: &str) -> Option<AssetResponse>;
}
#[derive(Debug, Default, Clone)]
pub struct EmbeddedAssets;
impl WebAssetProvider for EmbeddedAssets {
fn load(&self, path: &str) -> Option<AssetResponse> {
let normalized = normalize_path(path);
let file = DIST.get_file(normalized)?;
Some(AssetResponse {
path: normalized.to_string(),
mime: mime_from_path(normalized),
contents: Cow::Borrowed(file.contents()),
})
}
}
#[derive(Debug, Clone)]
pub struct FilesystemAssets {
root: PathBuf,
}
impl FilesystemAssets {
pub fn new<P: Into<PathBuf>>(root: P) -> Self {
Self { root: root.into() }
}
pub fn root(&self) -> &Path {
self.root.as_path()
}
}
impl WebAssetProvider for FilesystemAssets {
fn load(&self, path: &str) -> Option<AssetResponse> {
let normalized = normalize_path(path);
let joined = self.root.join(normalized);
let contents = fs::read(joined).ok()?;
Some(AssetResponse {
path: normalized.to_string(),
mime: mime_from_path(normalized),
contents: Cow::Owned(contents),
})
}
}
pub fn embedded_asset(path: &str) -> Option<AssetResponse> {
#[allow(clippy::default_constructed_unit_structs)]
EmbeddedAssets::default().load(path)
}
fn normalize_path(raw: &str) -> &str {
let trimmed = raw.trim_start_matches('/');
if trimmed.is_empty() {
return "index.html";
}
trimmed
}
fn mime_from_path(path: &str) -> &'static str {
match path.rsplit('.').next().map(|ext| ext.to_ascii_lowercase()) {
Some(ref ext) if ext == "html" => "text/html; charset=utf-8",
Some(ref ext) if ext == "css" => "text/css; charset=utf-8",
Some(ref ext) if ext == "js" => "text/javascript; charset=utf-8",
Some(ref ext) if ext == "json" => "application/json; charset=utf-8",
Some(ref ext) if ext == "svg" => "image/svg+xml",
_ => "application/octet-stream",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn loads_index_html() {
let assets = EmbeddedAssets;
let asset = assets.load("/").expect("index.html embedded");
assert_eq!(asset.mime, "text/html; charset=utf-8");
let expected = b"<!doctype html>";
assert!(
asset
.contents
.get(..expected.len())
.is_some_and(|prefix| prefix.eq_ignore_ascii_case(expected))
);
}
#[test]
fn normalizes_root_path() {
let assets = EmbeddedAssets;
let asset = assets.load("").expect("falls back to index");
assert_eq!(asset.path, "index.html");
}
}