use std::path::{Path, PathBuf};
mod schema;
pub use schema::*;
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("config parse error in {0}: {1}")]
Parse(PathBuf, String),
#[error("config evaluation error in {0}: {1}")]
Eval(PathBuf, String),
#[error("config schema error in {0}: {1}")]
Schema(PathBuf, String),
}
const CANDIDATES: &[&str] = &["oj.config.ts", "oj.config.mjs", "oj.config.js", "oj.config.json"];
fn define_value(v: &serde_json::Value) -> String {
match v {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
}
}
pub fn rolldown_options(config: &OjConfig) -> Option<&serde_json::Value> {
let build = config.build.as_ref()?;
build.rolldown_options.as_ref().or(build.rollup_options.as_ref())
}
pub fn config_defines(config: &OjConfig) -> Vec<(String, String)> {
config
.define
.as_ref()
.map(|d| d.iter().map(|(k, v)| (k.clone(), define_value(v))).collect())
.unwrap_or_default()
}
pub fn environment_build_bool(config: &OjConfig, env_name: &str, field: &str) -> Option<bool> {
config
.environments
.as_ref()
.and_then(|e| e.get(env_name))
.and_then(|e| e.get("build"))
.and_then(|b| b.get(field))
.and_then(|v| v.as_bool())
}
pub fn resolve_conditions(config: &OjConfig, env_name: &str) -> Vec<String> {
if let Some(c) = config
.environments
.as_ref()
.and_then(|e| e.get(env_name))
.and_then(|e| e.get("resolve"))
.and_then(|r| r.get("conditions"))
.and_then(|c| c.as_array())
{
return c.iter().filter_map(|v| v.as_str().map(String::from)).collect();
}
if let Some(c) = config.resolve.as_ref().and_then(|r| r.conditions.as_ref()) {
return c.clone();
}
let base = if env_name == "ssr" { "node" } else { "browser" };
[base, "import", "module", "default"].map(String::from).to_vec()
}
pub fn resolve_alias(config: &OjConfig, env_name: &str) -> Vec<(String, String)> {
let mut merged: std::collections::BTreeMap<String, String> = config
.resolve
.as_ref()
.and_then(|r| r.alias.as_ref())
.map(|a| a.clone().into_iter().collect())
.unwrap_or_default();
if let Some(env_alias) = config
.environments
.as_ref()
.and_then(|e| e.get(env_name))
.and_then(|e| e.get("resolve"))
.and_then(|r| r.get("alias"))
.and_then(|a| a.as_object())
{
for (find, replacement) in env_alias {
if let Some(s) = replacement.as_str() {
merged.insert(find.clone(), s.to_string());
}
}
}
merged.into_iter().collect()
}
pub fn environment_defines(config: &OjConfig, env_name: &str) -> Vec<(String, String)> {
config
.environments
.as_ref()
.and_then(|envs| envs.get(env_name))
.and_then(|env| env.get("define"))
.and_then(|d| d.as_object())
.map(|d| d.iter().map(|(k, v)| (k.clone(), define_value(v))).collect())
.unwrap_or_default()
}
pub fn load(root: &Path) -> Result<OjConfig, ConfigError> {
load_with(root, "serve", "development")
}
pub fn load_with(root: &Path, command: &str, mode: &str) -> Result<OjConfig, ConfigError> {
let Some(path) = CANDIDATES.iter().map(|c| root.join(c)).find(|p| p.is_file()) else {
return Ok(OjConfig::default());
};
let source = std::fs::read_to_string(&path)
.map_err(|e| ConfigError::Parse(path.clone(), e.to_string()))?;
let json = if path.extension().and_then(|e| e.to_str()) == Some("json") {
source
} else {
evaluate(&path, &source, command, mode)?
};
serde_json::from_str(&json).map_err(|e| ConfigError::Schema(path, e.to_string()))
}
fn evaluate(path: &Path, source: &str, command: &str, mode: &str) -> Result<String, ConfigError> {
let js = strip_types(path, source)?;
let script = to_script(&js);
let rt = rquickjs::Runtime::new()
.map_err(|e| ConfigError::Eval(path.to_path_buf(), e.to_string()))?;
let ctx = rquickjs::Context::full(&rt)
.map_err(|e| ConfigError::Eval(path.to_path_buf(), e.to_string()))?;
ctx.with(|ctx| {
let env_obj: String = std::env::vars()
.map(|(k, v)| format!("{}:{}", serde_json::to_string(&k).unwrap(), serde_json::to_string(&v).unwrap()))
.collect::<Vec<_>>()
.join(",");
let prelude = format!(
"var defineConfig = function (x) {{ return x; }};\n\
var process = {{ env: {{ {env_obj} }} }};\n\
var globalThis = globalThis || this;\n"
);
let env_arg = format!(
"{{ command: {}, mode: {}, isSsrBuild: false, isPreview: false }}",
serde_json::to_string(command).unwrap(),
serde_json::to_string(mode).unwrap()
);
let full = format!(
"{prelude}{script}\n\
var __ojC = globalThis.__ojConfig;\n\
if (typeof __ojC === 'function') __ojC = __ojC({env_arg});\n\
JSON.stringify(__ojC ?? null)"
);
let result: rquickjs::Value = ctx.eval(full).map_err(|e| {
let caught = ctx.catch();
let mut detail =
caught.as_exception().map(|ex| ex.to_string()).unwrap_or_else(|| format!("{e}"));
if detail.contains("is not defined") {
detail.push_str(
"\nnote: oj.config is evaluated in a sandbox without module imports; \
if this file is a plugins array, put it in oj.plugins.mjs instead",
);
}
ConfigError::Eval(path.to_path_buf(), detail)
})?;
result
.get::<String>()
.map_err(|e| ConfigError::Eval(path.to_path_buf(), e.to_string()))
})
}
fn strip_types(path: &Path, source: &str) -> Result<String, ConfigError> {
use oxc_allocator::Allocator;
use oxc_codegen::Codegen;
use oxc_parser::Parser;
use oxc_semantic::SemanticBuilder;
use oxc_span::SourceType;
use oxc_transformer::{TransformOptions, Transformer};
let allocator = Allocator::default();
let source_type = SourceType::from_path(path).unwrap_or_else(|_| SourceType::ts());
let parsed = Parser::new(&allocator, source, source_type).parse();
if parsed.panicked {
return Err(ConfigError::Parse(path.to_path_buf(), "syntax error".into()));
}
let mut program = parsed.program;
let scoping = SemanticBuilder::new().build(&program).semantic.into_scoping();
let ret = Transformer::new(&allocator, path, &TransformOptions::default())
.build_with_scoping(scoping, &mut program);
if !ret.diagnostics.is_empty() {
return Err(ConfigError::Parse(
path.to_path_buf(),
ret.diagnostics.iter().map(|d| d.to_string()).collect::<Vec<_>>().join("; "),
));
}
Ok(Codegen::new().build(&program).code)
}
fn to_script(js: &str) -> String {
let mut out = String::with_capacity(js.len());
for line in js.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with("import ") || trimmed.starts_with("import{") {
continue;
}
if let Some(rest) = trimmed.strip_prefix("export default ") {
out.push_str("globalThis.__ojConfig = ");
out.push_str(rest);
out.push('\n');
continue;
}
out.push_str(line);
out.push('\n');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn eval_config_in(label: &str, src: &str) -> OjConfig {
let dir = std::env::temp_dir().join(format!("oj-cfg-{}-{label}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
std::fs::write(dir.join("oj.config.ts"), src).unwrap();
let cfg = load(&dir).unwrap();
let _ = std::fs::remove_dir_all(&dir);
cfg
}
#[test]
fn no_config_is_default() {
let cfg = load(std::path::Path::new("/nonexistent-oj-root")).unwrap();
assert!(cfg.server.is_none());
}
#[test]
fn evaluates_ts_config_with_types_and_define_config() {
let cfg = eval_config_in("define",
"import { defineConfig } from \"oj\";\n\
export default defineConfig({\n\
server: { port: 3000, proxy: { \"/api\": \"http://localhost:8080\" } },\n\
resolve: { alias: { \"@\": \"./src\" } as Record<string,string> },\n\
});\n",
);
let server = cfg.server.unwrap();
assert_eq!(server.port, Some(3000));
assert_eq!(
server.proxy.unwrap().get("/api").unwrap().target(),
"http://localhost:8080"
);
assert_eq!(cfg.resolve.unwrap().alias.unwrap().get("@").unwrap(), "./src");
}
#[test]
fn function_config_receives_command_and_mode() {
let src = "export default ({ command, mode }) => ({ base: command === \"build\" ? \"/prod/\" : \"/dev/\", define: { __M__: mode } });\n";
let cfg = eval_config_in("fnform", src);
assert_eq!(cfg.base.as_deref(), Some("/dev/"));
let defines: std::collections::BTreeMap<_, _> = config_defines(&cfg).into_iter().collect();
assert_eq!(defines.get("__M__").unwrap(), "development");
let dir = std::env::temp_dir().join(format!("oj-cfg-fnbuild-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("oj.config.js"), src).unwrap();
let cfg = load_with(&dir, "build", "production").unwrap();
assert_eq!(cfg.base.as_deref(), Some("/prod/"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn undefined_reference_config_gives_plugins_hint() {
let err = evaluate(
std::path::Path::new("oj.config.mjs"),
"export default [tailwindcss()];\n",
"serve",
"development",
)
.unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("tailwindcss"), "{msg}");
assert!(msg.contains("oj.plugins.mjs"), "{msg}");
}
#[test]
fn defineconfig_function_form_works() {
let cfg = eval_config_in(
"definefn",
"import { defineConfig } from \"oj\";\nexport default defineConfig(({ mode }) => ({ bundle: mode === \"development\" }));\n",
);
assert_eq!(cfg.bundle, Some(true));
}
#[test]
fn computed_values_and_process_env_work() {
unsafe { std::env::set_var("OJ_TEST_PORT", "4321") };
let cfg = eval_config_in("computed",
"export default { server: { port: Number(process.env.OJ_TEST_PORT), open: 1 > 0 } };\n",
);
let server = cfg.server.unwrap();
assert_eq!(server.port, Some(4321));
assert_eq!(server.open, Some(true));
}
#[test]
fn default_config_resolver_fallbacks() {
let s = |xs: &[&str]| xs.iter().map(|x| x.to_string()).collect::<Vec<_>>();
let cfg = load(std::path::Path::new("/nonexistent-oj-root")).unwrap();
assert!(config_defines(&cfg).is_empty());
assert!(environment_defines(&cfg, "ssr").is_empty());
assert!(resolve_alias(&cfg, "client").is_empty());
assert_eq!(environment_build_bool(&cfg, "client", "minify"), None);
assert_eq!(resolve_conditions(&cfg, "ssr"), s(&["node", "import", "module", "default"]));
assert_eq!(resolve_conditions(&cfg, "client"), s(&["browser", "import", "module", "default"]));
}
#[test]
fn per_environment_resolution_and_precedence() {
let cfg = eval_config_in(
"env-resolvers",
"export default {\n\
define: { __FLAG__: \"true\", __COUNT__: 3 },\n\
resolve: { conditions: [\"custom\"], alias: { \"@\": \"/src\", \"old\": \"/legacy\" } },\n\
environments: {\n\
ssr: {\n\
build: { minify: false },\n\
resolve: { conditions: [\"node-only\"], alias: { \"old\": \"/ssr-legacy\" } },\n\
define: { __SSR__: true },\n\
},\n\
},\n\
};\n",
);
let defines: std::collections::BTreeMap<_, _> = config_defines(&cfg).into_iter().collect();
assert_eq!(defines.get("__FLAG__").unwrap(), "true");
assert_eq!(defines.get("__COUNT__").unwrap(), "3");
assert_eq!(resolve_conditions(&cfg, "ssr"), vec!["node-only".to_string()]);
assert_eq!(resolve_conditions(&cfg, "client"), vec!["custom".to_string()]);
assert_eq!(
resolve_alias(&cfg, "ssr"),
vec![("@".to_string(), "/src".to_string()), ("old".to_string(), "/ssr-legacy".to_string())]
);
assert_eq!(
resolve_alias(&cfg, "client"),
vec![("@".to_string(), "/src".to_string()), ("old".to_string(), "/legacy".to_string())]
);
assert_eq!(environment_build_bool(&cfg, "ssr", "minify"), Some(false));
assert_eq!(environment_build_bool(&cfg, "ssr", "sourcemap"), None);
let ssr_defines: std::collections::BTreeMap<_, _> = environment_defines(&cfg, "ssr").into_iter().collect();
assert_eq!(ssr_defines.get("__SSR__").unwrap(), "true");
assert!(environment_defines(&cfg, "client").is_empty());
}
#[test]
fn json_config_loads_directly() {
let dir = std::env::temp_dir().join(format!("oj-cfg-json-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("oj.config.json"), r#"{"bundle":true,"base":"/app/"}"#).unwrap();
let cfg = load(&dir).unwrap();
assert_eq!(cfg.bundle, Some(true));
assert_eq!(cfg.base.as_deref(), Some("/app/"));
let _ = std::fs::remove_dir_all(&dir);
}
}