mod copy_dir;
#[cfg(test)]
mod copy_dir_test;
mod copy_file;
#[cfg(test)]
mod copy_file_test;
mod css;
mod html;
mod icon;
mod inline;
mod js;
mod rust;
mod sass;
mod tailwind_css;
mod tailwind_css_extra;
pub use html::HtmlPipeline;
use crate::{
common::{dist_relative, html_rewrite::Document, path_exists},
config::rt::RtcBuild,
pipelines::{
copy_dir::{CopyDir, CopyDirOutput},
copy_file::{CopyFile, CopyFileOutput},
css::{Css, CssOutput},
icon::{Icon, IconOutput},
inline::{Inline, InlineOutput},
js::{Js, JsOutput},
rust::{RustApp, RustAppOutput},
sass::{Sass, SassOutput},
tailwind_css::{TailwindCss, TailwindCssOutput},
tailwind_css_extra::{TailwindCssExtra, TailwindCssExtraOutput},
},
processing::minify::{minify_css, minify_js},
};
use anyhow::{bail, ensure, Context, Result};
use minify_js::TopLevelMode;
use oxipng::Options;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::{
collections::HashMap,
ffi::OsString,
fmt::{self, Display},
ops::Deref,
path::{Path, PathBuf},
sync::Arc,
};
use tokio::{fs, sync::mpsc, task::JoinHandle};
const ATTR_INLINE: &str = "data-inline";
const ATTR_CONFIG: &str = "data-config";
const ATTR_HREF: &str = "href";
const ATTR_SRC: &str = "src";
const ATTR_TYPE: &str = "type";
const ATTR_REL: &str = "rel";
const ATTR_NO_MINIFY: &str = "data-no-minify";
const ATTR_TARGET_PATH: &str = "data-target-path";
const SNIPPETS_DIR: &str = "snippets";
const TRUNK_ID: &str = "data-trunk-id";
const PNG_OPTIMIZATION_LEVEL: u8 = 6;
#[derive(Debug, Clone)]
pub struct Attr {
pub value: String,
pub need_escape: bool,
}
pub type Attrs = HashMap<String, Attr>;
pub enum TrunkAssetReference {
Link(Attrs),
Script(Attrs),
}
#[allow(clippy::large_enum_variant)]
pub enum TrunkAsset {
Css(Css),
Sass(Sass),
TailwindCss(TailwindCss),
TailwindCssExtra(TailwindCssExtra),
Js(Js),
Icon(Icon),
Inline(Inline),
CopyFile(CopyFile),
CopyDir(CopyDir),
RustApp(RustApp),
}
impl<S: Display> From<S> for Attr {
fn from(value: S) -> Self {
Self {
value: value.to_string(),
need_escape: true,
}
}
}
impl Deref for Attr {
type Target = str;
fn deref(&self) -> &Self::Target {
&self.value
}
}
impl AsRef<str> for Attr {
fn as_ref(&self) -> &str {
self
}
}
impl TrunkAsset {
pub async fn from_html(
cfg: Arc<RtcBuild>,
html_dir: Arc<PathBuf>,
ignore_chan: Option<mpsc::Sender<PathBuf>>,
reference: TrunkAssetReference,
id: usize,
) -> Result<Self> {
match reference {
TrunkAssetReference::Link(attrs) => {
let rel = attrs.get(ATTR_REL).context(
"all <link data-trunk .../> elements must have a `rel` attribute indicating \
the asset type",
)?;
Ok(match rel.value.as_str() {
Sass::TYPE_SASS | Sass::TYPE_SCSS => {
Self::Sass(Sass::new(cfg, html_dir, attrs, id).await?)
}
Icon::TYPE_ICON => Self::Icon(Icon::new(cfg, html_dir, attrs, id).await?),
Inline::TYPE_INLINE => {
Self::Inline(Inline::new(cfg, html_dir, attrs, id).await?)
}
Css::TYPE_CSS => Self::Css(Css::new(cfg, html_dir, attrs, id).await?),
CopyFile::TYPE_COPY_FILE => {
Self::CopyFile(CopyFile::new(cfg, html_dir, attrs, id).await?)
}
CopyDir::TYPE_COPY_DIR => {
Self::CopyDir(CopyDir::new(cfg, html_dir, attrs, id).await?)
}
RustApp::TYPE_RUST_APP => {
Self::RustApp(RustApp::new(cfg, html_dir, ignore_chan, attrs, id).await?)
}
TailwindCss::TYPE_TAILWIND_CSS => {
Self::TailwindCss(TailwindCss::new(cfg, html_dir, attrs, id).await?)
}
TailwindCssExtra::TYPE_TAILWIND_CSS_EXTRA => Self::TailwindCssExtra(
TailwindCssExtra::new(cfg, html_dir, attrs, id).await?,
),
_ => bail!(
r#"unknown <link data-trunk .../> attr value `rel="{}"`; please ensure the value is lowercase and is a supported asset type"#,
rel.value
),
})
}
TrunkAssetReference::Script(attrs) => {
Ok(Self::Js(Js::new(cfg, html_dir, attrs, id).await?))
}
}
}
pub fn spawn(self) -> JoinHandle<Result<TrunkAssetPipelineOutput>> {
match self {
Self::Css(inner) => inner.spawn(),
Self::Sass(inner) => inner.spawn(),
Self::TailwindCss(inner) => inner.spawn(),
Self::TailwindCssExtra(inner) => inner.spawn(),
Self::Js(inner) => inner.spawn(),
Self::Icon(inner) => inner.spawn(),
Self::Inline(inner) => inner.spawn(),
Self::CopyFile(inner) => inner.spawn(),
Self::CopyDir(inner) => inner.spawn(),
Self::RustApp(inner) => inner.spawn(),
}
}
}
pub enum TrunkAssetPipelineOutput {
Css(CssOutput),
Sass(SassOutput),
TailwindCss(TailwindCssOutput),
TailwindCssExtra(TailwindCssExtraOutput),
Js(JsOutput),
Icon(IconOutput),
Inline(InlineOutput),
CopyFile(CopyFileOutput),
CopyDir(CopyDirOutput),
RustApp(RustAppOutput),
None,
}
impl TrunkAssetPipelineOutput {
pub async fn finalize(self, dom: &mut Document) -> Result<()> {
match self {
TrunkAssetPipelineOutput::Css(out) => out.finalize(dom).await,
TrunkAssetPipelineOutput::Sass(out) => out.finalize(dom).await,
TrunkAssetPipelineOutput::TailwindCss(out) => out.finalize(dom).await,
TrunkAssetPipelineOutput::TailwindCssExtra(out) => out.finalize(dom).await,
TrunkAssetPipelineOutput::Js(out) => out.finalize(dom).await,
TrunkAssetPipelineOutput::Icon(out) => out.finalize(dom).await,
TrunkAssetPipelineOutput::Inline(out) => out.finalize(dom).await,
TrunkAssetPipelineOutput::CopyFile(out) => out.finalize(dom).await,
TrunkAssetPipelineOutput::CopyDir(out) => out.finalize(dom).await,
TrunkAssetPipelineOutput::RustApp(out) => out.finalize(dom).await,
TrunkAssetPipelineOutput::None => Ok(()),
}
}
}
pub enum AssetFileType {
Css,
Icon(ImageType),
Js,
Mjs,
Other,
}
pub enum ImageType {
Png,
Other,
}
pub struct AssetFile {
pub path: PathBuf,
pub file_name: OsString,
pub file_stem: OsString,
pub ext: Option<String>,
}
impl AssetFile {
pub async fn new(rel_dir: &Path, mut path: PathBuf) -> Result<Self> {
if !path.is_absolute() {
path = rel_dir.join(path);
}
let path = fs::canonicalize(&path)
.await
.with_context(|| format!("error getting canonical path for {:?}", &path))?;
ensure!(
path_exists(&path).await?,
"target file does not appear to exist on disk {:?}",
&path
);
let file_name = match path.file_name() {
Some(file_name) => file_name.to_owned(),
None => bail!("asset has no file name {:?}", &path),
};
let file_stem = match path.file_stem() {
Some(file_stem) => file_stem.to_owned(),
None => bail!("asset has no file name stem {:?}", &path),
};
let ext = path
.extension()
.map(|ext| ext.to_owned().to_string_lossy().to_string());
Ok(Self {
path,
file_name,
file_stem,
ext,
})
}
pub async fn copy(
&self,
dist: &Path,
to_dir: &Path,
with_hash: bool,
minify: bool,
file_type: AssetFileType,
) -> Result<String> {
let mut bytes = fs::read(&self.path)
.await
.with_context(|| format!("error reading file for copying {:?}", &self.path))?;
bytes = if minify {
match file_type {
AssetFileType::Css => minify_css(bytes),
AssetFileType::Icon(image_type) => match image_type {
ImageType::Png => oxipng::optimize_from_memory(
bytes.as_ref(),
&Options::from_preset(PNG_OPTIMIZATION_LEVEL),
)
.with_context(|| format!("error optimizing PNG {:?}", &self.path))?,
ImageType::Other => bytes,
},
AssetFileType::Js => minify_js(bytes, TopLevelMode::Global),
AssetFileType::Mjs => minify_js(bytes, TopLevelMode::Module),
_ => bytes,
}
} else {
bytes
};
let file_name = if with_hash {
format!(
"{}-{:x}.{}",
&self.file_stem.to_string_lossy(),
seahash::hash(bytes.as_ref()),
&self.ext.as_deref().unwrap_or_default()
)
} else {
self.file_name.to_string_lossy().into_owned()
};
let file_path = to_dir.join(&file_name);
let file_name = dist_relative(dist, &file_path)?;
fs::write(&file_path, bytes)
.await
.with_context(|| format!("error copying file {:?} to {:?}", &self.path, &file_path))?;
Ok(file_name)
}
pub async fn read_to_string(&self) -> Result<String> {
fs::read_to_string(&self.path)
.await
.with_context(|| format!("error reading file {:?} to string", self.path))
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum PipelineStage {
PreBuild,
Build,
PostBuild,
}
fn trunk_id_selector(id: usize) -> String {
format!(r#"link[{}="{}"]"#, TRUNK_ID, id)
}
fn trunk_script_id_selector(id: usize) -> String {
format!(r#"script[{}="{}"]"#, TRUNK_ID, id)
}
struct AttrWriter<'a> {
pub(self) attrs: &'a Attrs,
pub(self) exclude: &'a [&'a str],
}
impl<'a> AttrWriter<'a> {
pub(self) const EXCLUDE_CSS_INLINE: &'static [&'static str] = &[
TRUNK_ID,
ATTR_HREF,
ATTR_REL,
ATTR_INLINE,
ATTR_SRC,
ATTR_TYPE,
ATTR_NO_MINIFY,
ATTR_TARGET_PATH,
];
pub(self) const EXCLUDE_CSS_LINK: &'static [&'static str] = &[
TRUNK_ID,
ATTR_HREF,
ATTR_REL,
ATTR_INLINE,
ATTR_SRC,
ATTR_NO_MINIFY,
ATTR_TARGET_PATH,
];
pub(self) const EXCLUDE_SCRIPT: &'static [&'static str] =
&[ATTR_SRC, ATTR_NO_MINIFY, ATTR_TARGET_PATH];
pub(self) fn new(attrs: &'a Attrs, exclude: &'a [&'a str]) -> Self {
Self { attrs, exclude }
}
}
impl fmt::Display for AttrWriter<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut filtered: Vec<&str> = self
.attrs
.keys()
.map(|x| x.as_str())
.filter(|name| !name.starts_with("data-trunk"))
.filter(|name| !self.exclude.contains(name))
.collect();
filtered.sort();
for name in filtered {
write!(f, " {name}")?;
let attr = &self.attrs[name];
if !attr.is_empty() {
if attr.need_escape {
let encoded = htmlescape::encode_attribute(attr);
write!(f, "=\"{}\"", encoded)?;
} else {
write!(f, "=\"{}\"", attr.value)?;
}
}
}
Ok(())
}
}
fn data_target_path(attrs: &Attrs) -> Result<Option<PathBuf>> {
Ok(attrs
.get(ATTR_TARGET_PATH)
.map(|attr| attr.trim_end_matches('/'))
.map(|val| val.parse())
.transpose()?)
}