use std::{
borrow::Cow,
env,
hash::Hasher,
path::{Path, PathBuf},
};
use anyhow::{Context, anyhow};
use cow_utils::CowUtils;
use itertools::Itertools;
use rayon::prelude::*;
use rspack_core::{
AssetInfo, Compilation, CompilationAsset, Filename, PathData,
rspack_sources::{RawBufferSource, RawStringSource, SourceExt},
};
use rspack_error::{AnyhowResultToRspackResultExt, Result};
use rspack_hash::RspackHash;
use rspack_paths::Utf8PathBuf;
use rspack_util::fx_hash::FxHashMap;
use serde::{Deserialize, Serialize};
use sugar_path::SugarPath;
use crate::{
config::{HtmlChunkSortMode, HtmlInject, HtmlRspackPluginOptions, HtmlScriptLoading},
tag::HtmlPluginTag,
};
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HtmlPluginAssets {
pub public_path: String,
pub js: Vec<String>,
pub css: Vec<String>,
pub favicon: Option<String>,
pub js_integrity: Option<Vec<Option<String>>>,
pub css_integrity: Option<Vec<Option<String>>>,
}
impl HtmlPluginAssets {
pub async fn create_assets<'a>(
config: &HtmlRspackPluginOptions,
compilation: &'a Compilation,
public_path: &str,
output_path: &Utf8PathBuf,
html_file_name: &Filename,
) -> Result<(HtmlPluginAssets, FxHashMap<String, &'a CompilationAsset>)> {
let mut assets: HtmlPluginAssets = HtmlPluginAssets::default();
let mut asset_map = FxHashMap::default();
assets.public_path = public_path.to_string();
let sorted_entry_names: Vec<&String> =
if matches!(config.chunks_sort_mode, HtmlChunkSortMode::Manual)
&& let Some(chunks) = &config.chunks
{
chunks
.iter()
.filter(|&name| {
compilation
.build_chunk_graph_artifact
.entrypoints
.contains_key(name)
})
.collect()
} else {
compilation
.build_chunk_graph_artifact
.entrypoints
.keys()
.filter(|&entry_name| {
let mut included = true;
if let Some(included_chunks) = &config.chunks {
included = included_chunks.iter().any(|c| c.eq(entry_name));
}
if let Some(exclude_chunks) = &config.exclude_chunks {
included = included && !exclude_chunks.iter().any(|c| c.eq(entry_name));
}
included
})
.collect()
};
let included_assets = sorted_entry_names
.iter()
.map(|entry_name| compilation.entrypoint_by_name(entry_name))
.flat_map(|entry| entry.get_files(&compilation.build_chunk_graph_artifact.chunk_by_ukey))
.filter_map(|asset_name| {
let asset = compilation
.assets()
.get(&asset_name)
.expect("should have asset for entrypoint file");
if asset.info.hot_module_replacement.unwrap_or(false)
|| asset.info.development.unwrap_or(false)
{
None
} else {
Some((asset_name.clone(), asset))
}
})
.collect::<Vec<_>>();
for (asset_name, asset) in included_assets {
if let Some(extension) =
Path::new(asset_name.split("?").next().unwrap_or_default()).extension()
{
let mut asset_uri = format!("{}{}", assets.public_path, url_encode_path(&asset_name));
if config.hash.unwrap_or_default()
&& let Some(hash) = compilation.get_hash()
{
asset_uri = append_hash(&asset_uri, hash);
}
let final_path = generate_posix_path(&asset_uri);
if extension.eq_ignore_ascii_case("css") {
if asset_map.insert(final_path.to_string(), asset).is_none() {
assets.css.push(final_path.to_string());
}
} else if extension.eq_ignore_ascii_case("js") || extension.eq_ignore_ascii_case("mjs") {
#[allow(clippy::collapsible_if)]
if asset_map.insert(final_path.to_string(), asset).is_none() {
assets.js.push(final_path.to_string());
}
}
}
}
assets.favicon = if let Some(favicon) = &config.favicon {
let favicon = PathBuf::from(favicon)
.file_name()
.expect("favicon should have file name")
.to_string_lossy()
.to_string();
let favicon_relative_path = PathBuf::from(config.get_relative_path(compilation, &favicon));
let mut favicon_path: PathBuf = PathBuf::from(
config
.get_public_path(
compilation,
favicon_relative_path.to_string_lossy().to_string().as_str(),
)
.await,
);
if favicon_path.to_str().unwrap_or_default().is_empty() {
let fake_html_file_name = compilation
.get_path(
html_file_name,
PathData::default().filename(output_path.as_str()),
)
.await?;
let output_path = compilation.options.output.path.as_std_path();
favicon_path = output_path
.relative(output_path.join(fake_html_file_name).join(".."))
.join(favicon_relative_path);
} else {
favicon_path.push(favicon_relative_path);
}
let mut favicon_link_path = favicon_path.to_string_lossy().to_string();
if config.hash.unwrap_or_default()
&& let Some(hash) = compilation.get_hash()
{
favicon_link_path = append_hash(&favicon_link_path, hash);
}
Some(generate_posix_path(&favicon_link_path).into())
} else {
None
};
Ok((assets, asset_map))
}
}
#[derive(Clone, Debug, Default)]
pub struct HtmlPluginAssetTags {
pub scripts: Vec<HtmlPluginTag>,
pub styles: Vec<HtmlPluginTag>,
pub meta: Vec<HtmlPluginTag>,
}
impl HtmlPluginAssetTags {
pub fn from_assets(config: &HtmlRspackPluginOptions, assets: &HtmlPluginAssets) -> Self {
let mut asset_tags = HtmlPluginAssetTags::default();
asset_tags.scripts.extend(
assets
.js
.par_iter()
.map(|x| HtmlPluginTag::create_script(x.as_str(), &config.script_loading))
.collect::<Vec<_>>(),
);
asset_tags.styles.extend(
assets
.css
.par_iter()
.map(|x| HtmlPluginTag::create_style(x.as_str()))
.collect::<Vec<_>>(),
);
if let Some(base) = &config.base
&& let Some(tag) = HtmlPluginTag::create_base(base)
{
asset_tags.meta.push(tag);
}
if let Some(title) = &config.title {
asset_tags.meta.push(HtmlPluginTag::create_title(title));
}
if let Some(meta) = &config.meta {
asset_tags.meta.extend(HtmlPluginTag::create_meta(meta));
}
if let Some(favicon) = &assets.favicon {
asset_tags.meta.push(HtmlPluginTag::create_favicon(favicon));
}
asset_tags
}
pub fn to_groups(
config: &HtmlRspackPluginOptions,
asset_tags: HtmlPluginAssetTags,
) -> (Vec<HtmlPluginTag>, Vec<HtmlPluginTag>) {
let mut body_tags = vec![];
let mut head_tags = vec![];
head_tags.extend(asset_tags.meta);
for tag in &asset_tags.scripts {
match config.inject {
HtmlInject::Head => head_tags.push(tag.to_owned()),
HtmlInject::Body => body_tags.push(tag.to_owned()),
HtmlInject::False => {
if matches!(config.script_loading, HtmlScriptLoading::Blocking) {
body_tags.push(tag.to_owned());
} else {
head_tags.push(tag.to_owned());
}
}
}
}
head_tags.extend(asset_tags.styles);
(head_tags, body_tags)
}
}
pub fn append_hash(url: &str, hash: &str) -> String {
format!(
"{}{}{}",
url,
if url.contains("?") {
"$$RSPACK_URL_AMP$$"
} else {
"?"
},
hash
)
}
pub fn generate_posix_path(path: &str) -> Cow<'_, str> {
if env::consts::OS == "windows" {
path.cow_replace(&['/', '\\'] as &[char], "/")
} else {
path.into()
}
}
fn url_encode_path(file_path: &str) -> String {
let query_string_start = file_path.find('?');
let url_path = if let Some(query_string_start) = query_string_start {
&file_path[..query_string_start]
} else {
file_path
};
let query_string = if let Some(query_string_start) = query_string_start {
&file_path[query_string_start..]
} else {
""
};
format!(
"{}{}",
url_path
.split('/')
.map(|p| { urlencoding::encode(p) })
.join("/"),
query_string.cow_replace("&", "$$RSPACK_URL_AMP$$")
)
}
pub async fn create_favicon_asset(
favicon: &str,
config: &HtmlRspackPluginOptions,
compilation: &Compilation,
) -> Result<(String, CompilationAsset)> {
let favicon_file_path = PathBuf::from(config.get_relative_path(compilation, favicon))
.file_name()
.expect("Should have favicon file name")
.to_string_lossy()
.to_string();
let resolved_favicon = compilation.options.context.as_path().join(favicon);
compilation
.input_filesystem
.read(&resolved_favicon)
.await
.map_err(|err| anyhow!(err))
.context(format!(
"HtmlRspackPlugin: could not load file `{}` from `{}`",
favicon, &compilation.options.context
))
.map(|content| {
(
favicon_file_path,
CompilationAsset::from(RawBufferSource::from(content).boxed()),
)
})
.to_rspack_result_from_anyhow()
}
pub async fn create_html_asset(
output_file_name: &Filename,
html: &str,
template_file_name: &str,
compilation: &Compilation,
) -> Result<(String, CompilationAsset)> {
let mut hasher = RspackHash::from(&compilation.options.output);
hasher.write(html.as_bytes());
let hash_digest = hasher.digest(&compilation.options.output.hash_digest);
let content_hash = hash_digest.encoded();
let mut asset_info = AssetInfo::default();
let output_path = compilation
.get_path_with_info(
output_file_name,
PathData::default()
.filename(template_file_name)
.content_hash(content_hash),
&mut asset_info,
)
.await?;
Ok((
output_path,
CompilationAsset::new(Some(RawStringSource::from(html).boxed()), asset_info),
))
}