use std::{collections::BTreeMap, collections::HashMap, fs::create_dir_all, path::Path};
use anyhow::Error;
use swc_bundler::{Bundle, Bundler, Load, ModuleData, ModuleRecord, ModuleType};
use swc_common::{FileName, FilePathMapping, GLOBALS, Mark, SourceMap, Span, sync::Lrc};
use swc_ecma_ast::{
Bool, EsVersion, Expr, IdentName, KeyValueProp, Lit, MemberExpr, MemberProp, MetaPropExpr,
MetaPropKind, PropName, Str,
};
use swc_ecma_codegen::{
Emitter,
text_writer::{JsWriter, WriteJs, omit_trailing_semi},
};
use swc_ecma_loader::{
TargetEnv,
resolvers::{lru::CachingResolver, node::NodeModulesResolver},
};
use swc_ecma_minifier::option::{
CompressOptions, ExtraOptions, MangleOptions, MinifyOptions, TopLevelOptions,
};
use swc_ecma_parser::{EsSyntax, Syntax, TsSyntax, parse_file_as_module, parse_file_as_program};
use swc_ecma_transforms_base::{fixer::fixer, helpers::Helpers};
use swc_ecma_transforms_typescript::strip;
use swc_ecma_visit::VisitMutWith as _;
#[must_use]
pub fn syntax_for_extension(extension: Option<&str>) -> Option<Syntax> {
match extension {
Some("ts") => Some(Syntax::Typescript(TsSyntax {
tsx: false,
decorators: true,
dts: false,
no_early_errors: false,
disallow_ambiguous_jsx_like: true,
})),
Some("js" | "mjs" | "cjs") => Some(Syntax::Es(EsSyntax {
jsx: false,
fn_bind: false,
decorators: true,
decorators_before_export: false,
export_default_from: false,
import_attributes: false,
allow_super_outside_method: false,
allow_return_outside_function: false,
auto_accessors: false,
explicit_resource_management: false,
})),
_ => None,
}
}
pub fn bundle(target: &Path, out: &Path, minify: bool) {
let globals = Box::leak(Box::default());
let cm = Lrc::new(SourceMap::new(FilePathMapping::empty()));
let mut bundler = Bundler::new(
globals,
cm.clone(),
Loader { cm: cm.clone() },
CachingResolver::new(
4096,
NodeModulesResolver::new(TargetEnv::Browser, HashMap::default(), true),
),
swc_bundler::Config {
require: false,
disable_inliner: false,
external_modules: vec![],
disable_fixer: minify,
disable_hygiene: minify,
disable_dce: false,
module: ModuleType::Es,
},
Box::new(Hook),
);
let mut entries = BTreeMap::new();
entries.insert("main".to_string(), FileName::Real(target.to_path_buf()));
let mut output_bundles = bundler.bundle(entries.into_iter().collect()).unwrap();
println!("Bundled as {} bundles", output_bundles.len());
if minify {
output_bundles = output_bundles
.into_iter()
.map(|mut bundle| {
GLOBALS.set(globals, || {
bundle.module = swc_ecma_minifier::optimize(
bundle.module.into(),
cm.clone(),
None,
None,
&MinifyOptions {
compress: Some(CompressOptions {
top_level: Some(TopLevelOptions { functions: true }),
..Default::default()
}),
mangle: Some(MangleOptions {
top_level: Some(true),
eval: true,
..Default::default()
}),
..Default::default()
},
&ExtraOptions {
unresolved_mark: Mark::new(),
top_level_mark: Mark::new(),
mangle_name_cache: None,
},
)
.expect_module();
bundle.module.visit_mut_with(&mut fixer(None));
bundle
})
})
.collect();
}
print_bundles(out, &cm, output_bundles, minify);
}
fn print_bundles(out: &Path, cm: &Lrc<SourceMap>, bundles: Vec<Bundle>, minify: bool) {
for bundled in bundles {
let code = {
let mut buf = vec![];
{
let wr = JsWriter::new(cm.clone(), "\n", &mut buf, None);
let mut emitter = Emitter {
cfg: swc_ecma_codegen::Config::default().with_minify(minify),
cm: cm.clone(),
comments: None,
wr: if minify {
Box::new(omit_trailing_semi(wr)) as Box<dyn WriteJs>
} else {
Box::new(wr) as Box<dyn WriteJs>
},
};
emitter.emit_module(&bundled.module).unwrap();
}
String::from_utf8_lossy(&buf).to_string()
};
if let Some(parent) = out.parent() {
create_dir_all(parent).unwrap();
}
std::fs::write(out, &code).unwrap();
println!("Created {} ({}KiB)", out.display(), code.len() / 1024);
}
}
struct Hook;
impl swc_bundler::Hook for Hook {
fn get_import_meta_props(
&self,
span: Span,
module_record: &ModuleRecord,
) -> Result<Vec<KeyValueProp>, Error> {
let file_name = module_record.file_name.to_string();
println!("get_import_meta_props: file_name={file_name}");
Ok(vec![
KeyValueProp {
key: PropName::Ident(IdentName::new("url".into(), span)),
value: Box::new(Expr::Lit(Lit::Str(Str {
span,
raw: None,
value: file_name.into(),
}))),
},
KeyValueProp {
key: PropName::Ident(IdentName::new("main".into(), span)),
value: Box::new(if module_record.is_entry {
Expr::Member(MemberExpr {
span,
obj: Box::new(Expr::MetaProp(MetaPropExpr {
span,
kind: MetaPropKind::ImportMeta,
})),
prop: MemberProp::Ident(IdentName::new("main".into(), span)),
})
} else {
Expr::Lit(Lit::Bool(Bool { span, value: false }))
}),
},
])
}
}
pub struct Loader {
pub cm: Lrc<SourceMap>,
}
impl Load for Loader {
fn load(&self, f: &FileName) -> Result<ModuleData, Error> {
let FileName::Real(path) = f else {
unreachable!()
};
println!("load: loading file {}", path.display());
let extension = path.extension().and_then(|x| x.to_str());
let syntax = syntax_for_extension(extension)
.unwrap_or_else(|| panic!("Invalid file: {}", path.display()));
let fm = self.cm.load_file(path)?;
let module = if matches!(syntax, Syntax::Typescript(..)) {
let program =
parse_file_as_program(&fm, syntax, EsVersion::Es2020, None, &mut Vec::new())
.unwrap();
let unresolved_mark = Mark::new();
let top_level_mark = Mark::new();
let module = program.apply(&mut strip(unresolved_mark, top_level_mark));
module.module()
} else {
None
};
let module = module.unwrap_or_else(|| {
println!("load: module was None");
parse_file_as_module(&fm, syntax, EsVersion::Es2020, None, &mut Vec::new()).unwrap()
});
Ok(ModuleData {
fm,
module,
helpers: Helpers::new(false),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test_log::test]
fn test_syntax_for_extension_typescript() {
let syntax = syntax_for_extension(Some("ts"));
assert!(syntax.is_some());
let syntax = syntax.unwrap();
assert!(matches!(syntax, Syntax::Typescript(_)));
if let Syntax::Typescript(ts) = syntax {
assert!(!ts.tsx, "tsx should be disabled");
assert!(ts.decorators, "decorators should be enabled");
assert!(!ts.dts, "dts should be disabled");
}
}
#[test_log::test]
fn test_syntax_for_extension_javascript() {
let syntax = syntax_for_extension(Some("js"));
assert!(syntax.is_some());
let syntax = syntax.unwrap();
assert!(matches!(syntax, Syntax::Es(_)));
if let Syntax::Es(es) = syntax {
assert!(!es.jsx, "jsx should be disabled");
assert!(es.decorators, "decorators should be enabled");
}
}
#[test_log::test]
fn test_syntax_for_extension_mjs() {
let syntax = syntax_for_extension(Some("mjs"));
assert!(syntax.is_some());
assert!(matches!(syntax.unwrap(), Syntax::Es(_)));
}
#[test_log::test]
fn test_syntax_for_extension_cjs() {
let syntax = syntax_for_extension(Some("cjs"));
assert!(syntax.is_some());
assert!(matches!(syntax.unwrap(), Syntax::Es(_)));
}
#[test_log::test]
fn test_syntax_for_extension_unsupported() {
assert!(syntax_for_extension(Some("tsx")).is_none());
assert!(syntax_for_extension(Some("jsx")).is_none());
assert!(syntax_for_extension(Some("py")).is_none());
assert!(syntax_for_extension(Some("rs")).is_none());
assert!(syntax_for_extension(Some("")).is_none());
}
#[test_log::test]
fn test_syntax_for_extension_none() {
assert!(syntax_for_extension(None).is_none());
}
#[test_log::test]
fn test_syntax_for_extension_typescript_has_correct_config() {
let syntax = syntax_for_extension(Some("ts")).unwrap();
if let Syntax::Typescript(ts) = syntax {
assert!(!ts.tsx, "tsx should be disabled for .ts files");
assert!(ts.decorators, "decorators should be enabled for bundling");
assert!(!ts.dts, "dts should be disabled - not type definitions");
assert!(!ts.no_early_errors, "early errors should be caught");
assert!(
ts.disallow_ambiguous_jsx_like,
"ambiguous JSX-like syntax should be disallowed"
);
} else {
panic!("Expected Typescript syntax");
}
}
#[test_log::test]
fn test_syntax_for_extension_javascript_has_correct_config() {
let syntax = syntax_for_extension(Some("js")).unwrap();
if let Syntax::Es(es) = syntax {
assert!(!es.jsx, "jsx should be disabled for .js files");
assert!(es.decorators, "decorators should be enabled for bundling");
assert!(!es.fn_bind, "function bind syntax should be disabled");
assert!(
!es.decorators_before_export,
"decorators should come after export"
);
assert!(
!es.export_default_from,
"export default from should be disabled"
);
assert!(
!es.import_attributes,
"import attributes should be disabled"
);
assert!(
!es.allow_super_outside_method,
"super outside method should not be allowed"
);
assert!(
!es.allow_return_outside_function,
"return outside function should not be allowed"
);
assert!(!es.auto_accessors, "auto accessors should be disabled");
assert!(
!es.explicit_resource_management,
"explicit resource management should be disabled"
);
} else {
panic!("Expected Es syntax");
}
}
#[test_log::test]
fn test_syntax_for_extension_all_js_variants_produce_es_syntax() {
for ext in ["js", "mjs", "cjs"] {
let syntax = syntax_for_extension(Some(ext));
assert!(syntax.is_some(), "Extension '{ext}' should be supported");
assert!(
matches!(syntax.unwrap(), Syntax::Es(_)),
"Extension '{ext}' should produce Es syntax"
);
}
}
#[test_log::test]
fn test_syntax_for_extension_consistency_across_js_variants() {
let js_syntax = syntax_for_extension(Some("js")).unwrap();
let mjs_syntax = syntax_for_extension(Some("mjs")).unwrap();
let cjs_syntax = syntax_for_extension(Some("cjs")).unwrap();
if let (Syntax::Es(js), Syntax::Es(mjs), Syntax::Es(cjs)) =
(js_syntax, mjs_syntax, cjs_syntax)
{
assert_eq!(js.jsx, mjs.jsx, "jsx should match across variants");
assert_eq!(js.jsx, cjs.jsx, "jsx should match across variants");
assert_eq!(
js.decorators, mjs.decorators,
"decorators should match across variants"
);
assert_eq!(
js.decorators, cjs.decorators,
"decorators should match across variants"
);
assert_eq!(
js.fn_bind, mjs.fn_bind,
"fn_bind should match across variants"
);
assert_eq!(
js.fn_bind, cjs.fn_bind,
"fn_bind should match across variants"
);
} else {
panic!("Expected all variants to produce Es syntax");
}
}
}