use super::asset_registry::AssetRegistry;
use super::asset_resolver;
#[cfg(target_family = "wasm")]
pub struct SendWrapper<F>(F);
#[cfg(target_family = "wasm")]
impl<F> SendWrapper<F> {
fn new(future: F) -> Self {
Self(future)
}
}
#[cfg(target_family = "wasm")]
impl<F: std::future::Future> std::future::Future for SendWrapper<F> {
type Output = F::Output;
fn poll(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Self::Output> {
unsafe { self.map_unchecked_mut(|s| &mut s.0).poll(cx) }
}
}
#[cfg(target_family = "wasm")]
unsafe impl<F> Send for SendWrapper<F> {}
#[cfg(not(target_family = "wasm"))]
type _SendWrapper<F> = F;
use rolldown_common::{EmittedAsset, ModuleType};
use rolldown_plugin::{
HookTransformArgs, HookTransformOutput, HookTransformReturn, HookUsage, Plugin,
SharedTransformPluginContext,
};
use std::borrow::Cow;
use std::path::{Path, PathBuf};
use std::sync::Arc;
#[derive(Debug)]
pub struct AssetDetectionPlugin {
registry: Arc<AssetRegistry>,
cwd: PathBuf,
extensions: Vec<String>,
runtime: Arc<dyn crate::Runtime>,
}
impl AssetDetectionPlugin {
pub fn new(
registry: Arc<AssetRegistry>,
cwd: impl AsRef<Path>,
extensions: Vec<String>,
runtime: Arc<dyn crate::Runtime>,
) -> Self {
Self {
registry,
cwd: cwd.as_ref().to_path_buf(),
extensions,
runtime,
}
}
}
impl Plugin for AssetDetectionPlugin {
fn name(&self) -> Cow<'static, str> {
Cow::Borrowed("fob:asset-handler")
}
fn register_hook_usage(&self) -> HookUsage {
HookUsage::Transform
}
fn transform(
&self,
ctx: SharedTransformPluginContext,
args: &HookTransformArgs<'_>,
) -> impl std::future::Future<Output = HookTransformReturn> + Send {
let module_id = args.id.to_string();
let code = args.code.to_string();
let module_type = args.module_type.clone();
let registry = self.registry.clone();
let cwd = self.cwd.clone();
let should_process = self.extensions.clone();
let runtime = self.runtime.clone();
#[cfg(target_family = "wasm")]
{
SendWrapper::new(async move {
Self::transform_impl(
module_id,
code,
module_type,
registry,
cwd,
should_process,
runtime,
ctx,
)
.await
})
}
#[cfg(not(target_family = "wasm"))]
{
async move {
Self::transform_impl(
module_id,
code,
module_type,
registry,
cwd,
should_process,
runtime,
ctx,
)
.await
}
}
}
}
impl AssetDetectionPlugin {
#[allow(clippy::too_many_arguments)]
async fn transform_impl(
module_id: String,
code: String,
module_type: ModuleType,
registry: Arc<AssetRegistry>,
cwd: PathBuf,
should_process: Vec<String>,
runtime: Arc<dyn crate::Runtime>,
ctx: SharedTransformPluginContext,
) -> HookTransformReturn {
if !matches!(
module_type,
ModuleType::Js | ModuleType::Jsx | ModuleType::Ts | ModuleType::Tsx
) {
return Ok(None);
}
let assets = match detect_assets(&code, &module_id, &cwd, runtime.as_ref()).await {
Some(assets) if !assets.is_empty() => assets,
_ => {
return Ok(None);
}
};
let mut modified_code = code.clone();
let mut modified = false;
for (specifier, resolved_path) in assets {
if !should_process.iter().any(|ext| specifier.ends_with(ext)) {
continue;
}
registry.register(resolved_path.clone(), module_id.clone(), specifier.clone());
let content = match runtime.read_file(&resolved_path).await {
Ok(c) => c,
Err(e) => {
ctx.warn(rolldown_common::LogWithoutPlugin {
message: format!("Failed to read asset {:?}: {}", resolved_path, e),
..Default::default()
});
continue;
}
};
let reference_id = match ctx.emit_file(
EmittedAsset {
name: resolved_path
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string()),
original_file_name: Some(resolved_path.to_string_lossy().into_owned()),
file_name: None, source: content.into(),
},
None,
None,
) {
Ok(id) => id,
Err(e) => {
ctx.warn(rolldown_common::LogWithoutPlugin {
message: format!("Failed to emit asset {:?}: {}", resolved_path, e),
..Default::default()
});
continue;
}
};
let final_filename = match ctx.get_file_name(&reference_id) {
Ok(name) => name,
Err(e) => {
ctx.warn(rolldown_common::LogWithoutPlugin {
message: format!("Failed to get filename for asset: {}", e),
..Default::default()
});
continue;
}
};
let url_path = format!("/{}", final_filename);
registry.set_url_path(&resolved_path, url_path.clone());
let patterns = vec![
format!("new URL('{}', import.meta.url)", specifier),
format!("new URL(\"{}\", import.meta.url)", specifier),
format!("new URL(`{}`, import.meta.url)", specifier),
];
for pattern in patterns {
if modified_code.contains(&pattern) {
let new_pattern = pattern.replace(&specifier, &final_filename);
modified_code = modified_code.replace(&pattern, &new_pattern);
modified = true;
break;
}
}
}
if modified {
Ok(Some(HookTransformOutput {
code: Some(modified_code),
map: None,
side_effects: None,
module_type: None,
}))
} else {
Ok(None)
}
}
}
async fn detect_assets(
source: &str,
module_id: &str,
cwd: &Path,
runtime: &dyn crate::Runtime,
) -> Option<Vec<(String, PathBuf)>> {
use regex::Regex;
let re =
Regex::new(r#"new\s+URL\s*\(\s*['"`]([^'"`]+)['"`]\s*,\s*import\.meta\.url\s*\)"#).ok()?;
let mut assets = Vec::new();
let module_path = Path::new(module_id);
let matches: Vec<_> = re.captures_iter(source).collect();
for cap in matches.iter() {
if let Some(specifier_match) = cap.get(1) {
let specifier = specifier_match.as_str();
if let Ok(resolved) =
asset_resolver::resolve_asset(specifier, module_path, cwd, runtime).await
{
assets.push((specifier.to_string(), resolved));
}
}
}
if assets.is_empty() {
None
} else {
Some(assets)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Runtime;
use crate::test_utils::TestRuntime;
use std::fs;
use tempfile::TempDir;
#[cfg(not(target_family = "wasm"))]
#[tokio::test]
async fn test_detect_new_url_pattern() {
let code = r#"
const wasmUrl = new URL('./file.wasm', import.meta.url);
const module = await import(wasmUrl.href);
"#;
let temp = TempDir::new().unwrap();
let cwd = temp.path().to_path_buf();
let runtime = TestRuntime::new(cwd.clone());
let asset_file = cwd.join("file.wasm");
fs::write(&asset_file, b"test").unwrap();
let module_id = cwd.join("index.js").display().to_string();
let assets = detect_assets(code, &module_id, &cwd, &runtime).await;
assert!(assets.is_some());
let assets = assets.unwrap();
assert_eq!(assets.len(), 1);
assert_eq!(assets[0].0, "./file.wasm");
}
#[cfg(not(target_family = "wasm"))]
#[tokio::test]
async fn test_detect_relative_parent() {
let code = r#"
const url = new URL('../assets/logo.png', import.meta.url);
"#;
let temp = TempDir::new().unwrap();
let cwd = temp.path().to_path_buf();
let runtime = TestRuntime::new(cwd.clone());
let assets_dir = cwd.join("assets");
fs::create_dir(&assets_dir).unwrap();
let asset_file = assets_dir.join("logo.png");
fs::write(&asset_file, b"test").unwrap();
let src_dir = cwd.join("src");
fs::create_dir(&src_dir).unwrap();
let module_id = src_dir.join("index.js").display().to_string();
let assets = detect_assets(code, &module_id, &cwd, &runtime).await;
assert!(assets.is_some());
let assets = assets.unwrap();
assert_eq!(assets.len(), 1);
assert_eq!(assets[0].0, "../assets/logo.png");
}
#[cfg(not(target_family = "wasm"))]
#[tokio::test]
async fn test_no_false_positives() {
let code = r#"
// Regular URL construction
const url1 = new URL('https://example.com');
// Different second argument
const url2 = new URL('./file', document.baseURI);
// Not a URL constructor
const other = new SomeClass('./file', import.meta.url);
"#;
let temp = TempDir::new().unwrap();
let cwd = temp.path().to_path_buf();
let runtime = TestRuntime::new(cwd.clone());
let module_id = cwd.join("index.js").display().to_string();
let assets = detect_assets(code, &module_id, &cwd, &runtime).await;
assert!(assets.is_none() || assets.unwrap().is_empty());
}
#[cfg(not(target_family = "wasm"))]
#[tokio::test]
async fn test_full_async_chain() {
let code = r#"
const wasmUrl = new URL('./module.wasm', import.meta.url);
const imageUrl = new URL('./logo.png', import.meta.url);
const fontUrl = new URL('./fonts/font.woff2', import.meta.url);
"#;
let temp = TempDir::new().unwrap();
let cwd = temp.path().to_path_buf();
let runtime = TestRuntime::new(cwd.clone());
fs::write(cwd.join("module.wasm"), b"wasm content").unwrap();
fs::write(cwd.join("logo.png"), b"png content").unwrap();
let fonts_dir = cwd.join("fonts");
fs::create_dir(&fonts_dir).unwrap();
fs::write(fonts_dir.join("font.woff2"), b"font content").unwrap();
let module_id = cwd.join("index.js").display().to_string();
let assets = detect_assets(code, &module_id, &cwd, &runtime).await;
assert!(assets.is_some(), "Should detect assets");
let assets = assets.unwrap();
assert_eq!(assets.len(), 3, "Should detect all 3 assets");
let specifiers: Vec<_> = assets.iter().map(|(s, _)| s.as_str()).collect();
assert!(specifiers.contains(&"./module.wasm"));
assert!(specifiers.contains(&"./logo.png"));
assert!(specifiers.contains(&"./fonts/font.woff2"));
for (_specifier, resolved_path) in &assets {
assert!(
resolved_path.is_absolute(),
"Resolved path should be absolute: {}",
resolved_path.display()
);
assert!(
runtime.exists(resolved_path),
"Asset should exist: {}",
resolved_path.display()
);
}
}
}