use alloc::string::String;
use alloc::vec::Vec;
use core::fmt;
use resident_fat::{BlockDevice, DateTime, Error};
use crate::logln;
use crate::storage::Volume;
pub const WWW_DIR: &str = "WWW";
const INDEX: &str = "index.html";
const MAX_DEPTH: usize = 4;
const CONTENT_TYPES: &[(&str, &str)] = &[
("html", "text/html; charset=utf-8"),
("htm", "text/html; charset=utf-8"),
("css", "text/css; charset=utf-8"),
("js", "text/javascript; charset=utf-8"),
("json", "application/json"),
("svg", "image/svg+xml"),
("ico", "image/x-icon"),
("png", "image/png"),
("txt", "text/plain; charset=utf-8"),
];
const FALLBACK_TYPE: &str = "application/octet-stream";
#[derive(Debug)]
pub struct Asset {
pub path: String,
pub body: Vec<u8>,
pub content_type: &'static str,
}
#[derive(Debug, Default)]
pub struct Site {
assets: Vec<Asset>,
}
impl Site {
pub fn get(&self, path: &str) -> Option<&Asset> {
let path = path.strip_prefix('/').unwrap_or(path);
let index;
let wanted = if path.is_empty() || path.ends_with('/') {
index = alloc::format!("{path}{INDEX}");
index.as_str()
} else {
path
};
self.assets
.iter()
.find(|asset| asset.path.eq_ignore_ascii_case(wanted))
}
pub fn len(&self) -> usize {
self.assets.len()
}
pub fn is_empty(&self) -> bool {
self.assets.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = &Asset> {
self.assets.iter()
}
}
pub fn load<D: BlockDevice>(volume: &mut Volume<D>) -> Result<Site, Error<D::Error>> {
load_with(volume, &[])
}
pub fn load_with<D: BlockDevice>(
volume: &mut Volume<D>,
content_types: &[(&str, &'static str)],
) -> Result<Site, Error<D::Error>> {
let mut assets = Vec::new();
match read_all(volume, content_types, &mut assets) {
Ok(()) => {}
Err(Error::NotFound { name })
if assets.is_empty() && name.eq_ignore_ascii_case(WWW_DIR) =>
{
logln!("site: no /{WWW_DIR} on the card; serving no pages");
}
Err(error) => return Err(error),
}
Ok(Site { assets })
}
struct Stamp(DateTime);
impl fmt::Display for Stamp {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let at = &self.0;
if *at == DateTime::EPOCH {
return f.write_str("no timestamp");
}
write!(
f,
"{:04}-{:02}-{:02} {:02}:{:02}",
at.year, at.month, at.day, at.hour, at.minute
)
}
}
fn read_all<D: BlockDevice>(
volume: &mut Volume<D>,
content_types: &[(&str, &'static str)],
out: &mut Vec<Asset>,
) -> Result<(), Error<D::Error>> {
let mut pending: Vec<(String, usize)> = alloc::vec![(String::new(), 0)];
while let Some((directory, depth)) = pending.pop() {
let absolute = if directory.is_empty() {
String::from(WWW_DIR)
} else {
alloc::format!("{WWW_DIR}/{directory}")
};
let mut files: Vec<(String, DateTime)> = Vec::new();
for entry in volume.open_dir(&absolute)?.iter() {
let name = entry.name();
if name == "." || name == ".." {
continue;
}
let path = if directory.is_empty() {
String::from(name)
} else {
alloc::format!("{directory}/{name}")
};
if entry.is_directory() {
if depth + 1 > MAX_DEPTH {
logln!("site: /{WWW_DIR}/{path} is deeper than {MAX_DEPTH}; not walked");
continue;
}
pending.push((path, depth + 1));
} else {
files.push((path, entry.modified()));
}
}
for (path, modified) in files {
let file = volume.open(&alloc::format!("{WWW_DIR}/{path}"))?;
let body = volume.read_all(&file)?;
logln!(
"site: /{WWW_DIR}/{path} ({} bytes, {})",
body.len(),
Stamp(modified)
);
let content_type = content_type_for(&path, content_types);
out.push(Asset {
path,
body,
content_type,
});
}
}
Ok(())
}
fn content_type_for(path: &str, overrides: &[(&str, &'static str)]) -> &'static str {
let name = path.rsplit('/').next().unwrap_or(path);
let Some((stem, extension)) = name.rsplit_once('.') else {
return FALLBACK_TYPE;
};
if stem.is_empty() {
return FALLBACK_TYPE;
}
overrides
.iter()
.chain(CONTENT_TYPES)
.find(|(known, _)| known.eq_ignore_ascii_case(extension))
.map_or(FALLBACK_TYPE, |&(_, content_type)| content_type)
}
#[cfg(test)]
mod tests {
use alloc::vec;
use super::*;
fn site(paths: &[&str]) -> Site {
Site {
assets: paths
.iter()
.map(|path| Asset {
path: String::from(*path),
body: Vec::new(),
content_type: content_type_for(path, &[]),
})
.collect(),
}
}
#[test]
fn root_and_trailing_slash_are_the_index() {
let site = site(&["index.html", "docs/index.html", "app.js"]);
assert_eq!(site.get("/").unwrap().path, "index.html");
assert_eq!(site.get("").unwrap().path, "index.html");
assert_eq!(site.get("/docs/").unwrap().path, "docs/index.html");
assert!(site.get("/docs").is_none());
}
#[test]
fn lookup_is_case_insensitive_and_exact() {
let site = site(&["js/pages/System.js"]);
assert!(site.get("/js/pages/system.js").is_some());
assert!(site.get("/JS/PAGES/SYSTEM.JS").is_some());
assert!(site.get("/js/pages/system").is_none());
assert!(site.get("/js/pages/../pages/system.js").is_none());
assert!(site.get("/pages/system.js").is_none());
}
#[test]
fn an_empty_site_finds_nothing() {
let site = Site::default();
assert!(site.is_empty());
assert!(site.get("/").is_none());
}
#[test]
fn content_types() {
assert_eq!(
content_type_for("index.html", &[]),
"text/html; charset=utf-8"
);
assert_eq!(
content_type_for("a/B.JS", &[]),
"text/javascript; charset=utf-8"
);
assert_eq!(content_type_for("font.woff2", &[]), FALLBACK_TYPE);
assert_eq!(content_type_for("README", &[]), FALLBACK_TYPE);
assert_eq!(content_type_for(".htaccess", &[]), FALLBACK_TYPE);
assert_eq!(content_type_for("v1.2/LICENSE", &[]), FALLBACK_TYPE);
}
#[test]
fn a_boards_table_comes_first() {
let table = vec![
("wasm", "application/wasm"),
("js", "application/javascript"),
];
assert_eq!(content_type_for("app.WASM", &table), "application/wasm");
assert_eq!(content_type_for("app.js", &table), "application/javascript");
assert_eq!(
content_type_for("app.css", &table),
"text/css; charset=utf-8"
);
}
#[test]
fn stamps() {
assert_eq!(alloc::format!("{}", Stamp(DateTime::EPOCH)), "no timestamp");
let mut at = DateTime::EPOCH;
at.year = 2026;
at.month = 9;
at.day = 27;
at.hour = 14;
at.minute = 5;
assert_eq!(alloc::format!("{}", Stamp(at)), "2026-09-27 14:05");
}
}