use super::Result; use itertools::Itertools; use std::ascii::escape_default; use std::collections::BTreeMap; use std::fmt::{self, Display}; use std::fs::{read_dir, File}; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; /// Handler for static files. /// /// Apart from handling templates for dynamic content, ructe also /// helps with constants for static content. /// /// Most sites that need HTML templates also needs some static resources. /// Maybe one or several CSS files, some javascript, and / or pictures. /// A good way to reduce network round-trips is to use a far expires /// header to tell the browser it can cache those files and don't need /// to check if they have changed. /// But what if the files do change? /// Then pretty much the only way to make sure the browser gets the /// updated file is to change the URL to the file as well. /// /// Ructe can create content-dependent file names for static files. /// If you have an `image.png`, ructe may call it `image-SomeHash.png` /// where `SomeHash` is 8 url-safe base64 characters encoding 48 bits /// of a md5 sum of the file. /// /// Each static file will be available as a /// [`StaticFile`](templates/statics/index.html) struct instance in /// your `templates::statics` module. /// Also, the const `STATICS` array in the same module will contain a /// reference to each of those instances. /// /// Actually serving the file is a job for a web framework like /// [iron](https://github.com/iron/iron), /// [nickel](https://github.com/nickel-org/nickel.rs) or /// [rocket](https://rocket.rs/), but ructe helps by packing the file /// contents into a constant struct that you can access from rust /// code. /// /// # Overview /// /// This section describes how to set up your project to serve /// static content using ructe. /// /// To do this, the first step is to add a line in `build.rs` telling /// ructe to find and transpile your static files: /// /// ```no_run /// # use ructe::{Ructe, RucteError}; /// # fn main() -> Result<(), RucteError> { /// let mut ructe = Ructe::from_env()?; /// ructe.statics()?.add_files("static")?; /// # Ok(()) /// # } /// ``` /// /// Then you need to link to the encoded file. /// For an image, you probably want to link it from an `` tag in /// a template. That can be done like this: /// /// ```html /// @use super::statics::image_png; /// @() /// Something /// ``` /// /// So, what has happened here? /// First, assuming the `static` directory in your /// `$CARGO_MANIFEST_DIR` contained a file name `image.png`, your /// `templates::statics` module (which is reachable as `super::statics` /// from inside a template) will contain a /// `pub static image_png: StaticFile` which can be imported and used /// in both templates and rust code. /// A `StaticFile` has a field named `name` which is a `&'static str` /// containing the name with the generated hash, `image-SomeHash.png`. /// /// The next step is that a browser actually sends a request for /// `/static/image-SomeHash.png` and your server needs to deliver it. /// Here, things depend on your web framework, so we start with some /// pseudo code. /// Full examples for [warp], [gotham], [nickel], and [iron] is /// available [in the ructe repository]. /// /// [warp]: https://crates.rs/crates/warp /// [gotham]: https://crates.rs/crates/gotham /// [nickel]: https://crates.rs/crates/nickel /// [iron]: https://crates.rs/crates/iron /// [in the ructe repository]: https://github.com/kaj/ructe/tree/master/examples /// /// ```ignore /// /// A hypothetical web framework calls this each /static/... request, /// /// with the name component of the URL as the name argument. /// fn serve_static(name: &str) -> Response { /// if let Some(data) = StaticFile::get(name) { /// Response::Ok(data.content) /// } else { /// Response::NotFound /// } /// } /// ``` /// /// The `StaticFile::get` function returns the `&'static StaticFile` /// for a given file name if the file exists. /// This is a reference to the same struct that we used by the name /// `image_png` in the template. /// Besides the `name` field (which will be equal to the argument, or /// `get` would not have returned this `StaticFile`), there is a /// `content: &'static [u8]` field which contains the actual file /// data. /// /// # Content-types /// /// How to get the content type of static files. /// /// Ructe has support for making the content-type of each static /// file availiable using the /// [mime](https://crates.io/crates/mime) crate. /// Since mime version 0.3.0 was a breaking change of how the /// `mime::Mime` type was implemented, and both Nickel and Iron /// currently require the old version (0.2.x), ructe provides /// support for both mime 0.2.x and mime 0.3.x with separate /// feature flags. /// /// # Mime 0.2.x /// /// To use the mime 0.2.x support, enable the `mime02` feature and /// add mime 0.2.x as a dependency: /// /// ```toml /// [build-dependencies] /// ructe = { version = "^0.3.2", features = ["mime02"] } /// /// [dependencies] /// mime = "~0.2" /// ``` /// /// A `Mime` as implemented in `mime` version 0.2.x cannot be /// created statically, so instead a `StaticFile` provides /// `pub fn mime(&self) -> Mime`. /// /// ``` /// # // Test and doc even without the feature, so mock functionality. /// # pub mod templates { pub mod statics { /// # pub struct FakeFile; /// # impl FakeFile { pub fn mime(&self) -> &'static str { "image/png" } } /// # pub static image_png: FakeFile = FakeFile; /// # }} /// use templates::statics::image_png; /// /// # fn main() { /// assert_eq!(format!("Type is {}", image_png.mime()), /// "Type is image/png"); /// # } /// ``` /// /// # Mime 0.3.x /// /// To use the mime 0.3.x support, enable the `mime3` feature and /// add mime 0.3.x as a dependency: /// /// ```toml /// [build-dependencies] /// ructe = { version = "^0.3.2", features = ["mime03"] } /// /// [dependencies] /// mime = "~0.3" /// ``` /// /// From version 0.3, the `mime` crates supports creating const /// static `Mime` objects, so with this feature, a `StaticFile` /// simply has a `pub mime: &'static Mime` field. /// /// ``` /// # // Test and doc even without the feature, so mock functionality. /// # pub mod templates { pub mod statics { /// # pub struct FakeFile { pub mime: &'static str } /// # pub static image_png: FakeFile = FakeFile { mime: "image/png", }; /// # }} /// use templates::statics::image_png; /// /// # fn main() { /// assert_eq!(format!("Type is {}", image_png.mime), /// "Type is image/png"); /// # } /// ``` pub struct StaticFiles { /// Rust source file `statics.rs` beeing written. src: Vec, /// Path for writing the file `statics.rs`. src_path: PathBuf, /// Base path for finding static files with relative paths base_path: PathBuf, /// Maps rust names to public names (foo_jpg -> foo-abc123.jpg) names: BTreeMap, /// Maps public names to rust names (foo-abc123.jpg -> foo_jpg) names_r: BTreeMap, } impl StaticFiles { pub(crate) fn for_template_dir( outdir: &Path, base_path: &Path, ) -> Result { let mut src = Vec::with_capacity(512); if cfg!(feature = "mime03") { src.write_all(b"use mime::Mime;\n\n")?; } if cfg!(feature = "tide013") { src.write_all(b"use tide::http::mime::{self, Mime};\n\n")?; } else if cfg!(feature = "http-types") { src.write_all(b"use http_types::mime::{self, Mime};\n\n")?; } src.write_all( b"/// A static file has a name (so its url can be recognized) and the /// actual file contents. /// /// The name includes a short (48 bits as 8 base64 characters) hash of /// the content, to enable long-time caching of static resourses in /// the clients. #[allow(dead_code)] pub struct StaticFile { pub content: &'static [u8], pub name: &'static str, ")?; if cfg!(feature = "mime02") { src.write_all(b" _mime: &'static str,\n")?; } if cfg!(feature = "mime03") { src.write_all(b" pub mime: &'static Mime,\n")?; } if cfg!(feature = "http-types") { src.write_all(b" pub mime: &'static Mime,\n")?; } src.write_all( b"} #[allow(dead_code)] impl StaticFile { /// Get a single `StaticFile` by name, if it exists. #[must_use] pub fn get(name: &str) -> Option<&'static Self> { if let Ok(pos) = STATICS.binary_search_by_key(&name, |s| s.name) { Some(STATICS[pos]) } else {None} } } ", )?; if cfg!(feature = "mime02") { src.write_all( b"use mime::Mime; impl StaticFile { /// Get the mime type of this static file. /// /// Currently, this method parses a (static) string every time. /// A future release of `mime` may support statically created /// `Mime` structs, which will make this nicer. #[allow(unused)] pub fn mime(&self) -> Mime { self._mime.parse().unwrap() } } ", )?; } Ok(StaticFiles { src, src_path: outdir.join("statics.rs"), base_path: base_path.into(), names: BTreeMap::new(), names_r: BTreeMap::new(), }) } // Should the return type be some kind of cow path? fn path_for(&self, path: impl AsRef) -> PathBuf { let path = path.as_ref(); if path.is_relative() { self.base_path.join(path) } else { path.into() } } /// Add all files from a specific directory, `indir`, as static files. pub fn add_files( &mut self, indir: impl AsRef, ) -> Result<&mut Self> { let indir = self.path_for(indir); println!("cargo:rerun-if-changed={}", indir.display()); for entry in read_dir(indir)? { let entry = entry?; if entry.file_type()?.is_file() { self.add_file(&entry.path())?; } } Ok(self) } /// Add all files from a specific directory, `indir`, as static files. /// /// The `to` string is used as a directory path of the resulting /// urls, the file names are taken as is, without adding any hash. /// This is usefull for resources used by preexisting javascript /// packages, where it might be hard to change the used urls. /// /// Note that some way of changing the url when the content /// changes is still needed if you serve the files with far /// expire, and using this method makes that your responsibility /// rather than ructes. /// Either the file may have hashed names as is, or you may use /// the version number of a 3:rd party package as part of the `to` /// parameter. /// /// The `to` parameter may be an empty string. /// In that case, no extra slash is added. pub fn add_files_as( &mut self, indir: impl AsRef, to: &str, ) -> Result<&mut Self> { for entry in read_dir(self.path_for(indir))? { let entry = entry?; let file_type = entry.file_type()?; let to = if to.is_empty() { entry.file_name().to_string_lossy().to_string() } else { format!("{}/{}", to, entry.file_name().to_string_lossy()) }; if file_type.is_file() { self.add_file_as(&entry.path(), &to)?; } else if file_type.is_dir() { self.add_files_as(&entry.path(), &to)?; } } Ok(self) } /// Add one specific file as a static file. /// /// Create a name to use in the url like `name-hash.ext` where /// name and ext are the name and extension from `path` and has is /// a few url-friendly bytes from a hash of the file content. /// pub fn add_file(&mut self, path: impl AsRef) -> Result<&mut Self> { let path = self.path_for(path); if let Some((name, ext)) = name_and_ext(&path) { println!("cargo:rerun-if-changed={}", path.display()); let mut input = File::open(&path)?; let mut buf = Vec::new(); input.read_to_end(&mut buf)?; let rust_name = format!("{name}_{ext}"); let url_name = format!("{name}-{}.{ext}", checksum_slug(&buf)); self.add_static( &path, &rust_name, &url_name, &FileContent(&path), ext, )?; } Ok(self) } /// Add one specific file as a static file. /// /// Use `url_name` in the url without adding any hash characters. pub fn add_file_as( &mut self, path: impl AsRef, url_name: &str, ) -> Result<&mut Self> { let path = &self.path_for(path); let ext = name_and_ext(path).map_or("", |(_, e)| e); println!("cargo:rerun-if-changed={}", path.display()); self.add_static(path, url_name, url_name, &FileContent(path), ext)?; Ok(self) } /// Add a resource by its name and content, without reading an actual file. /// /// The `path` parameter is used only to create a file name, the actual /// content of the static file will be the `data` parameter. /// A hash will be added to the file name, just as for /// file-sourced statics. /// /// # Examples /// /// With the folloing code in `build.rs`: /// ```` /// # use ructe::{Result, Ructe, StaticFiles}; /// # use std::fs::create_dir_all; /// # use std::path::PathBuf; /// # use std::vec::Vec; /// # fn main() -> Result<()> { /// # let p = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target").join("test-tmp").join("add-file"); /// # create_dir_all(&p); /// # let mut ructe = Ructe::new(p)?; /// let mut statics = ructe.statics()?; /// statics.add_file_data("black.css", b"body{color:black}\n"); /// # Ok(()) /// # } /// ```` /// /// A `StaticFile` named `black_css` will be defined in the /// `templates::statics` module of your crate: /// /// ```` /// # mod statics { /// # use ructe::templates::StaticFile; /// # pub static black_css: StaticFile = StaticFile { /// # content: b"body{color:black}\n", /// # name: "black-r3rltVhW.css", /// # #[cfg(feature = "mime03")] /// # mime: &mime::TEXT_CSS, /// # }; /// # } /// assert_eq!(statics::black_css.name, "black-r3rltVhW.css"); /// ```` pub fn add_file_data

( &mut self, path: P, data: &[u8], ) -> Result<&mut Self> where P: AsRef, { let path = &self.path_for(path); if let Some((name, ext)) = name_and_ext(path) { let rust_name = format!("{name}_{ext}"); let url_name = format!("{name}-{}.{ext}", checksum_slug(data)); self.add_static( path, &rust_name, &url_name, &ByteString(data), ext, )?; } Ok(self) } /// Compile a sass file and add the resulting css. /// /// If `src` is `"somefile.sass"`, then that file will be copiled /// with [rsass] (using the `Comressed` output style). /// The result will be added as if if was an existing /// `"somefile.css"` file. /// /// While handling the scss input, rsass is extended with a /// `static_name` sass function that takes a file name as given to /// [`add_file()`][Self::add_file] (or simliar) and returns the /// `name-hash.ext` filename that ructe creates for it. /// Note that only files that are added to the `StaticFiles` /// _before_ the call to `add_sass_files` are supported by the /// `static_name` function. /// /// This method is only available when ructe is built with the /// "sass" feature. #[cfg(feature = "sass")] pub fn add_sass_file

(&mut self, src: P) -> Result<&mut Self> where P: AsRef, { use rsass::css::CssString; use rsass::input::CargoContext; use rsass::output::{Format, Style}; use rsass::sass::{CallError, FormalArgs}; use rsass::value::Quotes; use rsass::*; use std::sync::Arc; let format = Format { style: Style::Compressed, precision: 4, }; let src = self.path_for(src); let (context, scss) = CargoContext::for_path(&src).map_err(rsass::Error::from)?; let mut context = context.with_format(format); let existing_statics = self.get_names().clone(); context.get_scope().define_function( "static_name".into(), sass::Function::builtin( "", &"static_name".into(), FormalArgs::new(vec![("name".into(), None)]), Arc::new(move |s| { let name: String = s.get("name".into())?; let rname = name.replace('-', "_").replace('.', "_"); existing_statics .iter() .find(|(n, _v)| *n == &rname) .map(|(_n, v)| { CssString::new(v.into(), Quotes::Double).into() }) .ok_or_else(|| { CallError::msg(format!( "Static file {name:?} not found", )) }) }), ), ); let css = context.transform(scss)?; self.add_file_data(&src.with_extension("css"), &css) } fn add_static( &mut self, path: &Path, rust_name: &str, url_name: &str, content: &impl Display, suffix: &str, ) -> Result<&mut Self> { let mut rust_name = rust_name.replace(|c: char| !c.is_alphanumeric(), "_"); if rust_name .as_bytes() .first() .map(|c| c.is_ascii_digit()) .unwrap_or(true) { rust_name.insert(0, 'n'); } writeln!( self.src, "\n/// From {path:?}\ \n#[allow(non_upper_case_globals)]\ \npub static {rust_name}: StaticFile = StaticFile {{\ \n content: {content},\ \n name: \"{url_name}\",\ \n{mime}\ }};", path = path, rust_name = rust_name, url_name = url_name, content = content, mime = mime_arg(suffix), )?; self.names.insert(rust_name.clone(), url_name.into()); self.names_r.insert(url_name.into(), rust_name); Ok(self) } /// Get a mapping of names, from without hash to with. /// /// ```` /// # use ructe::{Result, Ructe, StaticFiles}; /// # use std::fs::create_dir_all; /// # use std::path::PathBuf; /// # use std::vec::Vec; /// # fn main() -> Result<()> { /// # let p = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target").join("test-tmp").join("get-names"); /// # create_dir_all(&p); /// # let mut ructe = Ructe::new(p)?; /// let mut statics = ructe.statics()?; /// statics.add_file_data("black.css", b"body{color:black}\n"); /// statics.add_file_data("blue.css", b"body{color:blue}\n"); /// assert_eq!( /// statics.get_names().iter() /// .map(|(a, b)| format!("{} -> {}", a, b)) /// .collect::>(), /// vec!["black_css -> black-r3rltVhW.css".to_string(), /// "blue_css -> blue-GZGxfXag.css".to_string()], /// ); /// # Ok(()) /// # } /// ```` pub fn get_names(&self) -> &BTreeMap { &self.names } } impl Drop for StaticFiles { /// Write the ending of the statics source code, declaring the /// `STATICS` variable. fn drop(&mut self) { // Ignore a possible write failure, rather than a panic in drop. let _ = writeln!( self.src, "\npub static STATICS: &[&StaticFile] \ = &[{}];", self.names_r .iter() .map(|s| format!("&{}", s.1)) .format(", "), ); let _ = super::write_if_changed(&self.src_path, &self.src); } } struct FileContent<'a>(&'a Path); impl<'a> Display for FileContent<'a> { fn fmt(&self, out: &mut fmt::Formatter) -> fmt::Result { write!(out, "include_bytes!({:?})", self.0) } } struct ByteString<'a>(&'a [u8]); impl<'a> Display for ByteString<'a> { fn fmt(&self, out: &mut fmt::Formatter) -> fmt::Result { out.write_str("b\"")?; for byte in self.0 { escape_default(*byte).fmt(out)?; } out.write_str("\"") } } fn name_and_ext(path: &Path) -> Option<(&str, &str)> { if let (Some(name), Some(ext)) = (path.file_name(), path.extension()) { if let (Some(name), Some(ext)) = (name.to_str(), ext.to_str()) { return Some((&name[..name.len() - ext.len() - 1], ext)); } } None } #[cfg(any( all(feature = "mime03", feature = "http-types"), all(feature = "mime02", feature = "http-types"), all(feature = "mime02", feature = "mime03"), ))] compile_error!( r#"Only one of these features "http-types", "mime02" or "mime03" must be enabled at a time."# ); /// A short and url-safe checksum string from string data. fn checksum_slug(data: &[u8]) -> String { use base64::prelude::{Engine, BASE64_URL_SAFE_NO_PAD}; BASE64_URL_SAFE_NO_PAD.encode(&md5::compute(data)[..6]) } #[cfg(not(feature = "mime02"))] #[cfg(not(feature = "mime03"))] #[cfg(not(feature = "http-types"))] fn mime_arg(_: &str) -> String { "".to_string() } #[cfg(feature = "mime02")] fn mime_arg(suffix: &str) -> String { format!(" _mime: {:?},\n", mime_from_suffix(suffix)) } #[cfg(feature = "mime02")] fn mime_from_suffix(suffix: &str) -> &'static str { match suffix.to_lowercase().as_ref() { "bmp" => "image/bmp", "css" => "text/css", "eot" => "application/vnd.ms-fontobject", "gif" => "image/gif", "jpg" | "jpeg" => "image/jpeg", "js" | "jsonp" => "application/javascript", "json" => "application/json", "png" => "image/png", "svg" => "image/svg+xml", "woff" => "font/woff", "woff2" => "font/woff2", _ => "application/octet-stream", } } #[cfg(any(feature = "mime03", feature = "http-types"))] fn mime_arg(suffix: &str) -> String { format!(" mime: &mime::{},\n", mime_from_suffix(suffix)) } #[cfg(feature = "mime03")] fn mime_from_suffix(suffix: &str) -> &'static str { // This is limited to the constants that is defined in mime 0.3. match suffix.to_lowercase().as_ref() { "bmp" => "IMAGE_BMP", "css" => "TEXT_CSS", "gif" => "IMAGE_GIF", "jpg" | "jpeg" => "IMAGE_JPEG", "js" | "jsonp" => "TEXT_JAVASCRIPT", "json" => "APPLICATION_JSON", "png" => "IMAGE_PNG", "svg" => "IMAGE_SVG", "woff" => "FONT_WOFF", "woff2" => "FONT_WOFF", _ => "APPLICATION_OCTET_STREAM", } } #[cfg(feature = "http-types")] fn mime_from_suffix(suffix: &str) -> &'static str { match suffix.to_lowercase().as_ref() { "css" => "CSS", "html" | "htm" => "CSS", "ico" => "ICO", "jpg" | "jpeg" => "JPEG", "js" | "jsonp" => "JAVASCRIPT", "json" => "JSON", "png" => "PNG", "svg" => "SVG", "txt" => "PLAIN", "wasm" => "WASM", "xml" => "XML", _ => "mime::BYTE_STREAM", } }