mod bundle;
mod options;
mod scan;
mod tsconfig;
#[cfg(test)]
mod tests;
use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
use oj_compiler::glob::GlobCall;
use crate::optimize::OptimizeInput;
pub(crate) use options::DepOptions;
pub(crate) use scan::ScanPluginHooks;
fn is_js_type(file: &str) -> bool {
let file = clean_url(file);
[".js", ".jsx", ".ts", ".tsx", ".mjs"]
.iter()
.any(|ext| file.ends_with(ext))
}
fn is_optimizable_entry(file: &str) -> bool {
[".js", ".mjs", ".cjs", ".ts", ".mts", ".cts"]
.iter()
.any(|ext| file.ends_with(ext))
}
fn clean_url(id: &str) -> &str {
id.split(['?', '#']).next().unwrap_or(id)
}
fn is_bare(id: &str) -> bool {
let mut chars = id.chars();
matches!(chars.next(), Some(c) if c.is_ascii_alphanumeric() || c == '_' || c == '@')
&& chars.next().is_some_and(|c| c != ':')
}
fn in_node_modules(id: &str) -> bool {
id.split(['/', '\\']).any(|part| part == "node_modules")
}
fn module_list_contains(list: &[String], id: &str) -> bool {
list.iter().any(|m| {
id == m
|| id
.strip_prefix(m.as_str())
.is_some_and(|rest| rest.starts_with('/'))
})
}
pub(crate) fn npm_package_name(id: &str) -> Option<&str> {
if id.starts_with('@') {
let mut slashes = id.match_indices('/');
slashes.next()?;
return Some(slashes.next().map_or(id, |(i, _)| &id[..i]));
}
id.split('/').next().filter(|s| !s.is_empty())
}
pub(crate) fn entry_name(dep: &str) -> String {
dep.trim_start_matches('@')
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-' {
c
} else {
'_'
}
})
.collect()
}
fn is_dynamic_pattern(id: &str) -> bool {
id.contains(['*', '?', '[', ']', '{', '}'])
}
const FACADE_DEPS: &[&str] = &[
"react",
"react-dom",
"react-dom/client",
"react/jsx-runtime",
"react/jsx-dev-runtime",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Prebuilt {
pub file: String,
pub needs_interop: bool,
pub file_hash: String,
}
pub(crate) type Scanned = Vec<(String, PathBuf)>;
#[derive(Debug, Default)]
pub(crate) struct Plan {
pub entries: BTreeMap<String, PathBuf>,
pub names: BTreeMap<String, String>,
}
pub(crate) struct Prebundle {
root: PathBuf,
real_root: PathBuf,
out_dir: PathBuf,
auto_discover: bool,
entries: Vec<PathBuf>,
include: Vec<String>,
warn_includes: HashSet<String>,
exclude: Vec<String>,
dedupe: HashSet<String>,
facade_deps: HashSet<String>,
needs_interop: HashSet<String>,
alias: tsconfig::Aliases,
options: DepOptions,
keep_existing: bool,
}
impl std::fmt::Debug for Prebundle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Prebundle")
.field("root", &self.root)
.finish_non_exhaustive()
}
}
impl Prebundle {
pub fn new(
root: &Path,
out_dir: &Path,
input: &OptimizeInput,
implicit: impl IntoIterator<Item = String>,
keep_existing: bool,
) -> Prebundle {
let real_root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
let mut include: Vec<String> = Vec::new();
for inc in &input.include {
let ids = if is_dynamic_pattern(inc) {
expand_glob_ids(root, inc)
} else {
vec![inc.clone()]
};
for id in ids {
if !include.contains(&id) {
include.push(id);
}
}
}
let warn_includes = include.iter().cloned().collect();
for inc in implicit {
if !include.contains(&inc) {
include.push(inc);
}
}
let entries = scan_entries(root, input);
let dedupe = input
.dedupe
.iter()
.filter_map(|d| npm_package_name(d).map(str::to_string))
.collect();
let facade_deps = FACADE_DEPS
.iter()
.map(|s| s.to_string())
.chain(input.dedupe.iter().cloned())
.collect();
Prebundle {
root: root.to_path_buf(),
real_root,
out_dir: out_dir.to_path_buf(),
auto_discover: !input.no_discovery.unwrap_or(false),
entries,
include,
warn_includes,
exclude: input.exclude.clone(),
dedupe,
facade_deps,
needs_interop: input.needs_interop.iter().cloned().collect(),
alias: tsconfig::Aliases::load(root, &input.alias),
options: DepOptions::new(input),
keep_existing,
}
}
fn url_of(&self, file: &Path) -> String {
for base in [&self.root, &self.real_root] {
if let Ok(rel) = file.strip_prefix(base) {
if !rel.as_os_str().is_empty() {
return format!("/{}", slash(rel));
}
}
}
format!("/@fs{}", slash(file))
}
pub fn plan(&self, scanned: &Scanned, warn: bool) -> Plan {
let mut candidates: Vec<(String, Option<PathBuf>)> = scanned
.iter()
.map(|(dep, file)| (dep.clone(), Some(file.clone())))
.collect();
for inc in &self.include {
if !candidates.iter().any(|(dep, _)| dep == inc) {
candidates.push((inc.clone(), None));
}
}
let unable = |dep: &str| {
if warn && (self.warn_includes.contains(dep) || dep.contains('>')) {
eprintln!(
"oj: failed to resolve dependency \"{dep}\", present in 'optimizeDeps.include'"
);
}
};
let mut plan = Plan::default();
for (dep, entry) in candidates {
if self.exclude.contains(&dep) {
continue;
}
if dep.contains('>') {
let parts: Vec<&str> = dep
.split('>')
.map(str::trim)
.filter(|s| !s.is_empty())
.collect();
let resolver = oj_resolver::OjResolver::new(&self.root).require_variant();
let mut dir = self.root.clone();
let mut resolved = None;
for part in &parts {
match resolver.resolve(&dir, part) {
Ok(file) => {
dir = file.parent().map(Path::to_path_buf).unwrap_or_default();
resolved = Some(file);
}
Err(_) => {
resolved = None;
break;
}
}
}
let (Some(file), Some(last)) = (resolved, parts.last()) else {
unable(&dep);
continue;
};
let name = entry_name(last);
plan.entries.insert(name.clone(), file);
plan.names.insert(last.to_string(), name);
continue;
}
if dep.starts_with('#') {
continue;
}
let Some(entry) = entry else {
unable(&dep);
continue;
};
if !self.include.contains(&dep) {
let real = std::fs::canonicalize(&entry).unwrap_or_else(|_| entry.clone());
if !in_node_modules(&real.to_string_lossy()) {
continue;
}
}
if matches!(
dep.as_str(),
"@lingui/macro" | "@lingui/core/macro" | "@lingui/react/macro"
) {
continue;
}
if !is_optimizable_entry(&entry.to_string_lossy()) {
continue;
}
let name = entry_name(&dep);
plan.entries.insert(name.clone(), entry);
plan.names.insert(dep, name);
}
plan
}
pub fn provisional(&self, plan: &Plan) -> BTreeMap<String, Prebuilt> {
let mut out = BTreeMap::new();
for (dep, name) in &plan.names {
let cjs = plan
.entries
.get(name)
.is_some_and(|f| bundle::is_common_js(f));
let forced = self.needs_interop.contains(dep);
let meta = if cjs && self.facade_deps.contains(dep) {
Prebuilt {
file: format!("{name}__oj_named.mjs"),
needs_interop: forced,
file_hash: String::new(),
}
} else {
Prebuilt {
file: format!("{name}.mjs"),
needs_interop: cjs || forced,
file_hash: String::new(),
}
};
out.insert(dep.clone(), meta);
}
out
}
}
fn scan_entries(root: &Path, input: &OptimizeInput) -> Vec<PathBuf> {
let absolute = |e: &str| {
let p = Path::new(e);
if p.is_absolute() {
p.to_path_buf()
} else {
root.join(e)
}
};
let listed = !input.entries.is_empty();
let found: Vec<PathBuf> = if listed {
input
.entries
.iter()
.flat_map(|e| {
if is_dynamic_pattern(e) {
glob_entries(root, &[e.as_str()], &input.build_out_dir, true)
} else {
vec![absolute(e)]
}
})
.collect()
} else if !input.build_inputs.is_empty() {
input.build_inputs.iter().map(|e| absolute(e)).collect()
} else {
glob_entries(root, &["**/*.html"], &input.build_out_dir, false)
};
found
.into_iter()
.filter(|f| {
let s = f.to_string_lossy();
(is_js_type(&s) || s.ends_with(".html")) && f.is_file()
})
.collect()
}
fn glob_entries(root: &Path, patterns: &[&str], out_dir: &str, listed: bool) -> Vec<PathBuf> {
let base = oj_compiler::glob::escape_glob_path(root);
let out_dir = if out_dir.is_empty() { "dist" } else { out_dir };
let mut ignore = vec![format!("!{base}/**/{out_dir}/**")];
if !listed {
ignore.push(format!("!{base}/**/__tests__/**"));
ignore.push(format!("!{base}/**/coverage/**"));
}
let absolute = |p: &str| match p.strip_prefix('!') {
Some(neg) => format!("!{base}/{}", neg.trim_start_matches("./")),
None => format!("{base}/{}", p.trim_start_matches("./")),
};
let (in_modules, regular): (Vec<&str>, Vec<&str>) =
patterns.iter().partition(|p| p.contains("node_modules"));
let mut files = Vec::new();
for (group, exhaustive) in [(in_modules, true), (regular, false)] {
if group.is_empty() {
continue;
}
let call = GlobCall {
patterns: group
.iter()
.map(|p| absolute(p))
.chain(ignore.iter().cloned())
.collect(),
exhaustive,
case_sensitive: true,
};
files.extend(call.files());
}
files
}
fn expand_glob_ids(root: &Path, id: &str) -> Vec<String> {
let Some(pkg_name) = npm_package_name(id) else {
return Vec::new();
};
let Some(pkg_dir) = find_package_dir(root, pkg_name) else {
return Vec::new();
};
let Some(pkg_json) = std::fs::read_to_string(pkg_dir.join("package.json"))
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
else {
return Vec::new();
};
let pattern = id[pkg_name.len()..].trim_start_matches('/');
let dir = slash(&pkg_dir);
let dir_glob = oj_compiler::glob::escape_glob_path(&pkg_dir);
let files_under = |pattern: &str| -> Vec<String> {
GlobCall {
patterns: vec![format!("{dir_glob}/{pattern}")],
exhaustive: false,
case_sensitive: true,
}
.files()
.iter()
.filter_map(|f| f.strip_prefix(&pkg_dir).ok().map(slash))
.collect()
};
let join = |sub: &str| format!("{pkg_name}/{sub}");
let mut out = vec![pkg_name.to_string()];
match pkg_json.get("exports") {
Some(serde_json::Value::String(_) | serde_json::Value::Array(_)) => {}
Some(serde_json::Value::Object(exports)) => {
let mut possible = Vec::new();
for (key, value) in exports {
let Some(key) = key.strip_prefix("./") else {
continue;
};
if !key.contains('*') {
if !value.is_null() {
possible.push(key.to_string());
}
continue;
}
let Some(target) = first_export_string(value) else {
continue;
};
let target = target.trim_start_matches("./");
let Ok(target_re) = regex::Regex::new(&format!(
"^{}$",
target
.split('*')
.map(regex::escape)
.collect::<Vec<_>>()
.join("(.*)")
)) else {
continue;
};
for file in files_under(&target.replace('*', "**/*")) {
let Some(caps) = target_re.captures(&file) else {
continue;
};
let parts: Vec<&str> =
caps.iter().skip(1).flatten().map(|m| m.as_str()).collect();
if parts.windows(2).all(|w| w[0] == w[1]) {
if let Some(first) = parts.first() {
possible.push(key.replacen('*', first, 1));
}
}
}
}
let matcher = GlobCall {
patterns: vec![format!("{dir_glob}/{pattern}")],
exhaustive: false,
case_sensitive: true,
}
.matcher();
out.extend(
possible
.iter()
.filter(|p| matcher.is_match(&format!("{dir}/{p}")))
.map(|p| join(p)),
);
}
_ => out.extend(files_under(pattern).iter().map(|f| join(f))),
}
out
}
fn first_export_string(v: &serde_json::Value) -> Option<&str> {
match v {
serde_json::Value::String(s) => Some(s),
serde_json::Value::Array(a) => a.first().and_then(first_export_string),
serde_json::Value::Object(o) => o.values().next().and_then(first_export_string),
_ => None,
}
}
fn find_package_dir(root: &Path, pkg_name: &str) -> Option<PathBuf> {
root.ancestors()
.map(|dir| dir.join("node_modules").join(pkg_name))
.find(|dir| dir.join("package.json").is_file())
}
fn slash(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
fn normalize(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for part in path.components() {
match part {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
out.pop();
}
other => out.push(other),
}
}
out
}