mrpc/ructe-0.17.0/src/staticfiles.rs

709 lines
23 KiB
Rust
Raw Normal View History

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 `<img>` tag in
/// a template. That can be done like this:
///
/// ```html
/// @use super::statics::image_png;
/// @()
/// <img alt="Something" src="/static/@image_png.name">
/// ```
///
/// 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<u8>,
/// 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<String, String>,
/// Maps public names to rust names (foo-abc123.jpg -> foo_jpg)
names_r: BTreeMap<String, String>,
}
impl StaticFiles {
pub(crate) fn for_template_dir(
outdir: &Path,
base_path: &Path,
) -> Result<Self> {
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<Path>) -> 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<Path>,
) -> 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<Path>,
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<Path>) -> 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<Path>,
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<P>(
&mut self,
path: P,
data: &[u8],
) -> Result<&mut Self>
where
P: AsRef<Path>,
{
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<P>(&mut self, src: P) -> Result<&mut Self>
where
P: AsRef<Path>,
{
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<_>>(),
/// vec!["black_css -> black-r3rltVhW.css".to_string(),
/// "blue_css -> blue-GZGxfXag.css".to_string()],
/// );
/// # Ok(())
/// # }
/// ````
pub fn get_names(&self) -> &BTreeMap<String, String> {
&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",
}
}