use std::io;
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
use lightningcss::css_modules;
use lightningcss::dependencies::{Dependency, DependencyOptions};
use lightningcss::error::{Error as CssError, ParserError};
use lightningcss::printer::PrinterOptions;
use lightningcss::stylesheet::{MinifyOptions, ParserOptions, StyleSheet};
use lightningcss::targets::{Browsers, Targets};
fn report_css_warnings(name: &str, warnings: &RwLock<Vec<CssError<ParserError<'_>>>>) {
let Ok(list) = warnings.read() else {
return;
};
const SHOWN: usize = 5;
for w in list.iter().take(SHOWN) {
eprintln!("oj: css warning in {name}: {w}");
}
if list.len() > SHOWN {
eprintln!("oj: css warning in {name}: {} more", list.len() - SHOWN);
}
}
#[derive(Debug)]
pub struct CssOutput {
pub css: String,
pub exports: Option<Vec<(String, String)>>,
}
#[derive(Debug, Clone, Copy)]
pub struct CssResolve<'a> {
pub root: Option<&'a Path>,
pub public_dir: Option<&'a Path>,
pub alias: &'a [(String, String)],
pub targets: &'a [String],
pub minify: bool,
pub modules: &'a CssModulesOptions,
}
static DEFAULT_MODULES: CssModulesOptions = CssModulesOptions {
locals_convention: None,
generate_scoped_name: None,
global_scope: false,
global_module_paths: Vec::new(),
};
impl Default for CssResolve<'_> {
fn default() -> Self {
CssResolve {
root: None,
public_dir: None,
alias: &[],
targets: &[],
minify: false,
modules: &DEFAULT_MODULES,
}
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct CssModulesOptions {
pub locals_convention: Option<String>,
pub generate_scoped_name: Option<String>,
pub global_scope: bool,
pub global_module_paths: Vec<String>,
}
#[derive(Debug, Default, Clone)]
pub struct CssResolveConfig {
pub root: PathBuf,
pub public_dir: PathBuf,
pub alias: Vec<(String, String)>,
pub targets: Vec<String>,
pub minify: bool,
pub modules: CssModulesOptions,
}
impl CssResolveConfig {
pub fn as_ref(&self) -> CssResolve<'_> {
fn set(p: &Path) -> Option<&Path> {
(!p.as_os_str().is_empty()).then_some(p)
}
CssResolve {
root: set(&self.root),
public_dir: set(&self.public_dir),
alias: &self.alias,
targets: &self.targets,
minify: self.minify,
modules: &self.modules,
}
}
}
impl CssResolve<'_> {
pub fn alias_spec(&self, spec: &str) -> Option<String> {
for (find, replacement) in self.alias {
if find.is_empty() {
continue;
}
let rest = if spec == find {
""
} else {
match spec.strip_prefix(find.as_str()) {
Some(rest) if rest.starts_with('/') => rest,
_ => continue,
}
};
let target = match (replacement.starts_with('.'), self.root) {
(true, Some(root)) => {
let mut p = root.to_path_buf();
for c in Path::new(replacement).components() {
match c {
std::path::Component::ParentDir => {
p.pop();
}
std::path::Component::Normal(s) => p.push(s),
_ => {}
}
}
p.to_string_lossy().into_owned()
}
_ => replacement.clone(),
};
return Some(format!("{target}{rest}"));
}
None
}
pub fn alias_path(&self, spec: &str) -> Option<PathBuf> {
let aliased = self.alias_spec(spec)?;
Path::new(&aliased).is_absolute().then(|| PathBuf::from(aliased))
}
pub fn public_file(&self, spec: &str) -> Option<PathBuf> {
let rel = root_absolute_rel(spec)?;
let p = self.public_dir?.join(rel);
p.is_file().then_some(p)
}
pub fn root_path(&self, spec: &str) -> Option<PathBuf> {
Some(self.root?.join(root_absolute_rel(spec)?))
}
fn dev_url(&self, file: &Path) -> String {
match self.root.and_then(|r| file.strip_prefix(r).ok()) {
Some(rel) => format!("/{}", rel.to_string_lossy().replace('\\', "/")),
None => format!("/@fs{}", file.display()),
}
}
}
fn root_absolute_rel(spec: &str) -> Option<&str> {
let rel = spec.strip_prefix('/')?;
(!rel.is_empty() && !rel.starts_with('/')).then_some(rel)
}
pub fn css_modules_esm(exports: &[(String, String)]) -> String {
let mut out = String::new();
let mut map = serde_json::Map::new();
for (name, scoped) in exports {
if is_legal_identifier(name) {
out.push_str(&format!("export const {name} = {};\n", serde_json::Value::String(scoped.clone())));
}
map.insert(name.clone(), serde_json::Value::String(scoped.clone()));
}
out.push_str(&format!("export default {};\n", serde_json::Value::Object(map)));
out
}
fn is_legal_identifier(name: &str) -> bool {
const FORBIDDEN: &[&str] = &[
"break", "case", "class", "catch", "const", "continue", "debugger", "default", "delete", "do",
"else", "export", "extends", "finally", "for", "function", "if", "import", "in", "instanceof",
"let", "new", "return", "super", "switch", "this", "throw", "try", "typeof", "var", "void",
"while", "with", "yield", "enum", "await", "implements", "package", "protected", "static",
"interface", "private", "public", "arguments", "Infinity", "NaN", "undefined", "null", "true",
"false", "eval", "uneval", "isFinite", "isNaN", "parseFloat", "parseInt", "decodeURI",
"decodeURIComponent", "encodeURI", "encodeURIComponent", "escape", "unescape", "Object",
"Function", "Boolean", "Symbol", "Error", "EvalError", "InternalError", "RangeError",
"ReferenceError", "SyntaxError", "TypeError", "URIError", "Number", "Math", "Date", "String",
"RegExp", "Array", "Int8Array", "Uint8Array", "Uint8ClampedArray", "Int16Array", "Uint16Array",
"Int32Array", "Uint32Array", "Float32Array", "Float64Array", "Map", "Set", "WeakMap", "WeakSet",
"SIMD", "ArrayBuffer", "DataView", "JSON", "Promise", "Generator", "GeneratorFunction",
"Reflect", "Proxy", "Intl",
];
let mut chars = name.chars();
let Some(first) = chars.next() else {
return false;
};
let ident_char = |c: char| c.is_ascii_alphanumeric() || c == '_' || c == '$';
ident_char(first) && !first.is_ascii_digit() && chars.all(ident_char) && !FORBIDDEN.contains(&name)
}
pub fn is_css_module(url: &str) -> bool {
let path = url.split('?').next().unwrap_or(url);
path.rsplit('/')
.next()
.is_some_and(|f| f.contains(".module."))
}
pub fn is_sass(url: &str) -> bool {
let f = url.split('?').next().unwrap_or(url);
f.ends_with(".scss") || f.ends_with(".sass")
}
fn with_ext(p: &Path, ext: &str) -> PathBuf {
let mut s = p.as_os_str().to_owned();
s.push(".");
s.push(ext);
PathBuf::from(s)
}
fn collapse_phantom_dirs(p: &Path) -> Option<PathBuf> {
let comps: Vec<_> = p.components().collect();
let mut out = PathBuf::new();
let mut changed = false;
for (i, comp) in comps.iter().enumerate() {
let candidate = out.join(comp);
let is_last = i + 1 == comps.len();
if !is_last
&& (with_ext(&candidate, "scss").is_file() || with_ext(&candidate, "sass").is_file())
{
changed = true;
continue;
}
out = candidate;
}
changed.then_some(out)
}
fn dotted_stylesheet(p: &Path) -> Option<PathBuf> {
for ext in ["scss", "sass"] {
let c = with_ext(p, ext);
if c.is_file() {
return Some(c);
}
}
if let Some(collapsed) = collapse_phantom_dirs(p) {
for ext in ["scss", "sass"] {
let c = with_ext(&collapsed, ext);
if c.is_file() {
return Some(c);
}
}
}
None
}
fn index_target(p: &Path) -> Option<PathBuf> {
let name = p.file_name()?.to_str()?;
let ext = match name {
"index.scss" | "_index.scss" => "scss",
"index.sass" | "_index.sass" => "sass",
_ => return None,
};
let parent = p.parent()?;
let c = with_ext(parent, ext);
if c.is_file() {
return Some(c);
}
let collapsed = collapse_phantom_dirs(parent)?;
let c = with_ext(&collapsed, ext);
c.is_file().then_some(c)
}
#[derive(Debug)]
struct DottedFs {
resolve: CssResolveConfig,
}
fn package_entry(probe: &Path) -> Option<PathBuf> {
let name = probe.file_name()?.to_str()?;
let want_sass = match name {
"index.scss" | "_index.scss" => false,
"index.sass" | "_index.sass" => true,
_ => return None,
};
let dir = probe.parent()?;
let pkg = std::fs::read_to_string(dir.join("package.json")).ok()?;
let pkg: serde_json::Value = serde_json::from_str(&pkg).ok()?;
let accepts = |p: &Path| {
let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("");
p.is_file() && if want_sass { ext == "sass" } else { ext == "scss" || ext == "css" }
};
for field in ["sass", "style", "main"] {
let Some(rel) = pkg.get(field).and_then(|v| v.as_str()) else {
continue;
};
let p = dir.join(rel);
if accepts(&p) {
return Some(p);
}
let exts: &[&str] = if want_sass { &["sass"] } else { &["scss", "css"] };
for ext in exts {
let c = with_ext(&p, ext);
if c.is_file() {
return Some(c);
}
}
}
None
}
fn probe_target(p: &Path) -> Option<PathBuf> {
index_target(p).or_else(|| package_entry(p))
}
impl grass::Fs for DottedFs {
fn is_dir(&self, p: &Path) -> bool {
p.is_dir() || dotted_stylesheet(p).is_some()
}
fn is_file(&self, p: &Path) -> bool {
p.is_file() || probe_target(p).is_some()
}
fn read(&self, p: &Path) -> io::Result<Vec<u8>> {
let bytes = if p.is_file() {
std::fs::read(p)?
} else {
match probe_target(p) {
Some(real) => std::fs::read(real)?,
None => std::fs::read(p)?,
}
};
match String::from_utf8(bytes) {
Ok(text) => Ok(prepare_sass_imports(&text, &self.resolve.as_ref()).into_bytes()),
Err(e) => Ok(e.into_bytes()),
}
}
}
fn sass_file_exists(base: &Path) -> bool {
if base.is_file() {
return true;
}
let Some(name) = base.file_name().and_then(|n| n.to_str()) else {
return false;
};
let parent = base.parent().unwrap_or(Path::new("."));
for ext in ["scss", "sass", "css"] {
if with_ext(base, ext).is_file() || parent.join(format!("_{name}.{ext}")).is_file() {
return true;
}
}
["index.scss", "_index.scss", "index.sass", "_index.sass"]
.iter()
.any(|i| base.join(i).is_file())
}
fn rewrite_sass_spec(spec: &str, resolve: &CssResolve<'_>) -> Option<String> {
if let Some(p) = resolve.alias_path(spec) {
return Some(p.to_string_lossy().into_owned());
}
let under_root = resolve.root_path(spec)?;
sass_file_exists(&under_root).then(|| under_root.to_string_lossy().into_owned())
}
fn rewrite_sass_line(line: &str, resolve: &CssResolve<'_>) -> String {
let mut out = String::with_capacity(line.len());
let mut rest = line;
while let Some(open) = rest.find(['"', '\'']) {
let quote = rest.as_bytes()[open] as char;
let Some(close) = rest[open + 1..].find(quote) else {
break;
};
let inner = &rest[open + 1..open + 1 + close];
out.push_str(&rest[..=open]);
match rewrite_sass_spec(inner, resolve) {
Some(path) => out.push_str(&path),
None => out.push_str(inner),
}
out.push(quote);
rest = &rest[open + 1 + close + 1..];
}
out.push_str(rest);
out
}
#[cfg(test)]
fn strip_sass_import_ext(source: &str) -> String {
prepare_sass_imports(source, &CssResolve::default())
}
fn prepare_sass_imports(source: &str, resolve: &CssResolve<'_>) -> String {
let mut out = String::with_capacity(source.len());
let has_resolve = resolve.root.is_some() || !resolve.alias.is_empty();
for line in source.split_inclusive('\n') {
let t = line.trim_start();
let is_import = t.starts_with("@use") || t.starts_with("@forward") || t.starts_with("@import");
if is_import && !line.contains("url(") {
let stripped = line
.replace(".scss\"", "\"")
.replace(".scss'", "'")
.replace(".sass\"", "\"")
.replace(".sass'", "'");
let aliased = if has_resolve { rewrite_sass_line(&stripped, resolve) } else { stripped };
out.push_str(&aliased.replace("\"~", "\"").replace("'~", "'"));
} else {
out.push_str(line);
}
}
out
}
pub fn compile_sass(source: &str, load_dir: Option<&Path>) -> Result<String, String> {
compile_sass_with(source, load_dir, None)
}
pub fn compile_sass_with(
source: &str,
load_dir: Option<&Path>,
additional_data: Option<&str>,
) -> Result<String, String> {
compile_sass_opts(
source,
&SassOptions {
load_dir,
additional_data,
load_paths: &[],
resolve: CssResolve::default(),
},
)
}
#[derive(Debug, Default, Clone, Copy)]
pub struct SassOptions<'a> {
pub load_dir: Option<&'a Path>,
pub additional_data: Option<&'a str>,
pub load_paths: &'a [PathBuf],
pub resolve: CssResolve<'a>,
}
pub fn node_modules_load_paths(dir: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
let mut cur = Some(dir);
while let Some(d) = cur {
if d.file_name().is_some_and(|n| n == "node_modules") {
cur = d.parent();
continue;
}
let nm = d.join("node_modules");
if nm.is_dir() {
out.push(nm);
}
cur = d.parent();
}
out
}
pub fn compile_sass_opts(source: &str, opts: &SassOptions<'_>) -> Result<String, String> {
let fs = DottedFs {
resolve: CssResolveConfig {
root: opts.resolve.root.map(Path::to_path_buf).unwrap_or_default(),
public_dir: opts.resolve.public_dir.map(Path::to_path_buf).unwrap_or_default(),
alias: opts.resolve.alias.to_vec(),
targets: opts.resolve.targets.to_vec(),
minify: opts.resolve.minify,
modules: opts.resolve.modules.clone(),
},
};
let mut options = grass::Options::default().fs(&fs);
if let Some(dir) = opts.load_dir {
options = options.load_path(dir);
}
for p in opts.load_paths {
options = options.load_path(p);
}
if let Some(dir) = opts.load_dir {
for nm in node_modules_load_paths(dir) {
options = options.load_path(nm);
}
}
let additional_data = opts.additional_data;
let stripped = prepare_sass_imports(source, &opts.resolve);
let source = match additional_data {
Some(data) if !data.is_empty() => format!("{data}\n{stripped}"),
_ => stripped,
};
grass::from_string(source, &options).map_err(|e| format!("sass error: {e}"))
}
const BASELINE_TARGETS: &[&str] = &["chrome111", "edge111", "firefox114", "safari16.4", "ios16.4"];
pub fn browser_targets(list: &[String]) -> Targets {
let mut b = Browsers::default();
let mut any = false;
for entry in list {
let split = entry
.find(|c: char| c.is_ascii_digit())
.unwrap_or(entry.len());
let (name, version) = entry.split_at(split);
let mut parts = version.split('.').map(|v| v.parse::<u32>().ok());
let (Some(Some(major)), minor) = (parts.next(), parts.next().flatten().unwrap_or(0)) else {
continue;
};
let v = (major << 16) | (minor << 8);
let slot = match name.to_ascii_lowercase().as_str() {
"chrome" => &mut b.chrome,
"edge" => &mut b.edge,
"firefox" => &mut b.firefox,
"ie" => &mut b.ie,
"ios" | "ios_saf" => &mut b.ios_saf,
"opera" => &mut b.opera,
"safari" => &mut b.safari,
"android" => &mut b.android,
"samsung" => &mut b.samsung,
_ => continue,
};
*slot = Some(slot.map_or(v, |cur| cur.min(v)));
any = true;
}
if !any {
if list.iter().any(|t| t == "modules") {
return browser_targets(&["chrome87", "edge88", "firefox78", "safari14"].map(String::from));
}
return default_targets();
}
Targets::from(b)
}
fn default_targets() -> Targets {
browser_targets(&BASELINE_TARGETS.iter().map(|s| s.to_string()).collect::<Vec<_>>())
}
pub fn compile_css(url: &str, source: &str, minify: bool) -> Result<CssOutput, String> {
compile_css_impl(url, source, minify, false, false, &CssResolve::default())
}
pub fn compile_css_with(url: &str, source: &str, resolve: &CssResolve<'_>) -> Result<CssOutput, String> {
compile_css_impl(url, source, resolve.minify, false, false, resolve)
}
pub fn compile_css_rebased(url: &str, source: &str, minify: bool) -> Result<CssOutput, String> {
compile_css_impl(url, source, minify, true, false, &CssResolve::default())
}
pub fn compile_css_rebased_with_map(url: &str, source: &str, minify: bool) -> Result<CssOutput, String> {
compile_css_impl(url, source, minify, true, true, &CssResolve::default())
}
pub fn compile_css_dev(
url: &str,
source: &str,
source_map: bool,
resolve: &CssResolve<'_>,
) -> Result<CssOutput, String> {
compile_css_impl(url, source, false, true, source_map, resolve)
}
fn compile_css_impl(
url: &str,
source: &str,
minify: bool,
rebase: bool,
source_map: bool,
resolve: &CssResolve<'_>,
) -> Result<CssOutput, String> {
compile_css_depth(url, source, minify, rebase, source_map, resolve, 0)
}
fn module_is_scoped(url: &str, resolve: &CssResolve<'_>) -> bool {
if resolve.modules.global_scope {
return false;
}
if resolve.modules.global_module_paths.is_empty() {
return true;
}
let path = url.split(['?', '#']).next().unwrap_or(url);
let abs = module_file_path(url, resolve).map(|p| p.to_string_lossy().into_owned());
!resolve.modules.global_module_paths.iter().any(|src| {
regex::Regex::new(src).is_ok_and(|re| re.is_match(path) || abs.as_deref().is_some_and(|a| re.is_match(a)))
})
}
fn scoped_name_pattern(modules: &CssModulesOptions) -> css_modules::Pattern {
const DEFAULT: &str = "[name]_[local]_[hash]";
let Some(raw) = modules.generate_scoped_name.as_deref().filter(|s| !s.is_empty()) else {
return css_modules::Pattern::parse(DEFAULT).expect("static pattern");
};
let mut out = String::new();
let mut rest = raw;
while let Some(start) = rest.find('[') {
out.push_str(&rest[..start]);
let Some(end) = rest[start..].find(']') else {
out.push_str(&rest[start..]);
rest = "";
break;
};
let token = &rest[start + 1..start + end];
let lower = token.to_ascii_lowercase();
if lower.starts_with("contenthash") || lower.starts_with("content-hash") {
out.push_str("[content-hash]");
} else if lower.starts_with("hash") {
out.push_str("[hash]");
} else if lower == "name" || lower == "local" {
out.push_str(&format!("[{lower}]"));
}
rest = &rest[start + end + 1..];
}
out.push_str(rest);
let parsed = if out.contains("[local]") {
css_modules::Pattern::parse(&out).map_err(|e| format!("{e:?}"))
} else {
Err("no [local] placeholder".to_string())
};
match parsed {
Ok(p) => p,
Err(e) => {
eprintln!("oj: css.modules.generateScopedName {raw:?} is not a supported pattern ({e}); using {DEFAULT}");
css_modules::Pattern::parse(DEFAULT).expect("static pattern")
}
}
}
fn module_file_path(url: &str, resolve: &CssResolve<'_>) -> Option<PathBuf> {
let path = url.split(['?', '#']).next().unwrap_or(url);
let p = Path::new(path);
if p.is_absolute() && p.is_file() {
return Some(p.to_path_buf());
}
let root = resolve.root?;
let rel = path.trim_start_matches('/');
let joined = root.join(rel);
joined.is_file().then_some(joined)
}
fn camel_case(s: &str) -> String {
let mut words: Vec<String> = Vec::new();
let mut cur = String::new();
let mut prev_lower = false;
for c in s.chars() {
if !c.is_alphanumeric() {
if !cur.is_empty() {
words.push(std::mem::take(&mut cur));
}
prev_lower = false;
continue;
}
if c.is_uppercase() && prev_lower && !cur.is_empty() {
words.push(std::mem::take(&mut cur));
}
prev_lower = c.is_lowercase() || c.is_ascii_digit();
cur.push(c);
}
if !cur.is_empty() {
words.push(cur);
}
let mut out = String::new();
for (i, w) in words.iter().enumerate() {
let lower = w.to_lowercase();
if i == 0 {
out.push_str(&lower);
} else {
let mut chars = lower.chars();
if let Some(first) = chars.next() {
out.extend(first.to_uppercase());
out.push_str(chars.as_str());
}
}
}
out
}
fn dashes_camel_case(s: &str) -> String {
let mut out = String::new();
let mut upper_next = false;
for c in s.chars() {
if c == '-' {
upper_next = true;
continue;
}
if upper_next {
out.extend(c.to_uppercase());
upper_next = false;
} else {
out.push(c);
}
}
out
}
fn apply_locals_convention(pairs: Vec<(String, String)>, convention: Option<&str>) -> Vec<(String, String)> {
let (convert, only): (fn(&str) -> String, bool) = match convention {
Some("camelCase") => (camel_case, false),
Some("camelCaseOnly") => (camel_case, true),
Some("dashes") => (dashes_camel_case, false),
Some("dashesOnly") => (dashes_camel_case, true),
_ => return pairs,
};
let mut out: Vec<(String, String)> = Vec::with_capacity(pairs.len() * 2);
for (name, value) in pairs {
let converted = convert(&name);
if !only && converted != name {
out.push((name, value.clone()));
}
if !out.iter().any(|(k, _)| *k == converted) {
out.push((converted, value));
}
}
out.sort();
out
}
fn expand_composes(
map: css_modules::CssModuleExports,
url: &str,
resolve: &CssResolve<'_>,
depth: u8,
) -> Vec<(String, String)> {
use css_modules::CssModuleReference as R;
fn value_of(
map: &css_modules::CssModuleExports,
export: &css_modules::CssModuleExport,
url: &str,
resolve: &CssResolve<'_>,
depth: u8,
seen: &mut Vec<String>,
) -> String {
let mut value = export.name.clone();
for r in &export.composes {
let part = match r {
R::Local { name } => {
match map.values().find(|e| e.name == *name) {
Some(inner) if !seen.contains(name) => {
seen.push(name.clone());
let v = value_of(map, inner, url, resolve, depth, seen);
seen.pop();
v
}
_ => name.clone(),
}
}
R::Global { name } => name.clone(),
R::Dependency { name, specifier } => {
match dependency_export(specifier, name, url, resolve, depth) {
Some(v) => v,
None => {
eprintln!("oj: css module {url}: cannot resolve `composes: {name} from {specifier:?}`");
continue;
}
}
}
};
if !part.is_empty() {
value.push(' ');
value.push_str(&part);
}
}
value
}
let mut pairs: Vec<(String, String)> = map
.iter()
.map(|(name, export)| (name.clone(), value_of(&map, export, url, resolve, depth, &mut Vec::new())))
.collect();
pairs.sort();
pairs
}
fn dependency_export(spec: &str, name: &str, url: &str, resolve: &CssResolve<'_>, depth: u8) -> Option<String> {
if depth > 8 {
return None;
}
let file = module_file_path(url, resolve)?;
let dir = file.parent()?;
let dep = if let Some(p) = resolve.alias_path(spec) {
p
} else if let Some(rest) = spec.strip_prefix('/') {
resolve.root?.join(rest)
} else {
dir.join(spec)
};
let mut dep_norm = PathBuf::new();
for c in dep.components() {
match c {
std::path::Component::ParentDir => {
dep_norm.pop();
}
std::path::Component::CurDir => {}
other => dep_norm.push(other),
}
}
let dep = dep_norm;
let mut source = std::fs::read_to_string(&dep).ok()?;
if is_sass(&dep.to_string_lossy()) {
source = compile_sass(&source, dep.parent()).ok()?;
}
let dep_url = match resolve.root.and_then(|r| dep.strip_prefix(r).ok()) {
Some(rel) => format!("/{}", rel.display()),
None => dep.to_string_lossy().into_owned(),
};
let out = compile_css_depth(&dep_url, &source, false, false, false, resolve, depth + 1).ok()?;
out.exports?.into_iter().find(|(n, _)| n == name).map(|(_, v)| v)
}
fn compile_css_depth(
url: &str,
source: &str,
minify: bool,
rebase: bool,
source_map: bool,
resolve: &CssResolve<'_>,
depth: u8,
) -> Result<CssOutput, String> {
let is_module_file = is_css_module(url);
let is_module = is_module_file && module_is_scoped(url, resolve);
let warnings = Arc::new(RwLock::new(Vec::new()));
let options = ParserOptions {
filename: url.to_string(),
css_modules: is_module.then(|| css_modules::Config {
pattern: scoped_name_pattern(resolve.modules),
..css_modules::Config::default()
}),
error_recovery: true,
warnings: Some(Arc::clone(&warnings)),
..ParserOptions::default()
};
let mut stylesheet = StyleSheet::parse(source, options)
.map_err(|err| format!("css parse error in {url}: {err}"))?;
report_css_warnings(url, &warnings);
let targets = browser_targets(resolve.targets);
stylesheet
.minify(MinifyOptions {
targets: targets.clone(),
..MinifyOptions::default()
})
.map_err(|err| format!("css transform error in {url}: {err}"))?;
let base = if rebase { css_base_dir(url) } else { None };
let mut sm = parcel_sourcemap::SourceMap::new("");
if source_map {
let idx = sm.add_source(url);
let _ = sm.set_source_content(idx as usize, source);
}
let result = stylesheet
.to_css(PrinterOptions {
minify,
targets,
analyze_dependencies: base.as_ref().map(|_| DependencyOptions::default()),
source_map: source_map.then_some(&mut sm),
..PrinterOptions::default()
})
.map_err(|err| format!("css print error in {url}: {err}"))?;
let mut css = result.code;
if source_map {
let json = sm
.to_json(Some("/"))
.map_err(|err| format!("css sourcemap error in {url}: {err}"))?;
css.push_str("\n/*# sourceMappingURL=data:application/json;base64,");
css.push_str(&base64(json.as_bytes()));
css.push_str(" */\n");
}
if let (Some(base), Some(deps)) = (base, result.dependencies) {
for dep in deps {
let (placeholder, orig) = match dep {
Dependency::Url(u) => (u.placeholder, u.url),
Dependency::Import(i) => (i.placeholder, i.url),
};
let replacement = dev_url_of(&orig, &base, resolve);
css = css.replace(&placeholder, &replacement);
}
}
let exports = match result.exports {
Some(map) => Some(apply_locals_convention(
expand_composes(map, url, resolve, depth),
resolve.modules.locals_convention.as_deref(),
)),
None if is_module_file => Some(Vec::new()),
None => None,
};
Ok(CssOutput { css, exports })
}
pub fn inline_imports(source: &str, file: &Path) -> Result<String, String> {
inline_imports_with(source, file, &CssResolve::default())
}
pub fn inline_imports_with(source: &str, file: &Path, resolve: &CssResolve<'_>) -> Result<String, String> {
let mut stack = vec![file.to_path_buf()];
let out = inline_imports_depth(source, file, &mut stack, resolve)?;
Ok(if out.contains("@import") { hoist_imports(&out) } else { out })
}
fn hoist_imports(css: &str) -> String {
let mut imports: Vec<&str> = Vec::new();
let mut body = String::with_capacity(css.len());
let mut rest = css;
while let Some(pos) = rest.find("@import") {
let (before, at) = rest.split_at(pos);
let statement_start = before
.trim_end()
.chars()
.next_back()
.is_none_or(|c| matches!(c, ';' | '}' | '{'));
let end = if statement_start { at.find(';') } else { None };
let Some(end) = end else {
body.push_str(before);
body.push_str("@import");
rest = &at[7..];
continue;
};
body.push_str(before);
imports.push(&at[..=end]);
rest = &at[end + 1..];
}
body.push_str(rest);
if imports.is_empty() {
return body;
}
let mut out = String::with_capacity(css.len());
let mut body_rest = body.as_str();
if let Some(after) = body_rest.trim_start().strip_prefix("@charset") {
if let Some(end) = after.find(';') {
let stmt_end = body_rest.len() - after.len() + end + 1;
out.push_str(&body_rest[..stmt_end]);
out.push('\n');
body_rest = &body_rest[stmt_end..];
}
}
for imp in imports {
out.push_str(imp.trim_start());
out.push('\n');
}
out.push_str(body_rest);
out
}
fn inline_imports_depth(
source: &str,
file: &Path,
stack: &mut Vec<PathBuf>,
resolve: &CssResolve<'_>,
) -> Result<String, String> {
if !source.contains("@import") || stack.len() > 32 {
return Ok(source.to_string());
}
let dir = file.parent().unwrap_or(Path::new("."));
let mut out = String::with_capacity(source.len());
let mut rest = source;
while let Some(pos) = rest.find("@import") {
let (before, at) = rest.split_at(pos);
let statement_start = before
.trim_end()
.chars()
.next_back()
.is_none_or(|c| matches!(c, ';' | '}' | '{' | '/'));
let parsed = if statement_start { parse_plain_import(&at[7..]) } else { None };
let Some((spec, consumed, media)) = parsed else {
out.push_str(before);
out.push_str("@import");
rest = &at[7..];
continue;
};
let stmt_len = 7 + consumed;
let resolved = resolve_css_import_with(&spec, dir, resolve);
let Some(target) = resolved.filter(|t| !stack.iter().any(|s| s == t)) else {
out.push_str(before);
out.push_str(&at[..stmt_len]);
rest = &at[stmt_len..];
continue;
};
let child = std::fs::read_to_string(&target)
.map_err(|e| format!("cannot read @import {spec} ({}): {e}", target.display()))?;
stack.push(target.clone());
let child = inline_imports_depth(&child, &target, stack, resolve)?;
stack.pop();
let child_dir = target.parent().unwrap_or(Path::new("."));
let rebased = rebase_to_dir(&child, &target, child_dir, dir, resolve)?;
out.push_str(before);
match media {
Some(media) => {
out.push_str("@media ");
out.push_str(&media);
out.push_str(" {\n");
out.push_str(&rebased);
out.push_str("\n}\n");
}
None => {
out.push_str(&rebased);
out.push('\n');
}
}
rest = &at[stmt_len..];
}
out.push_str(rest);
Ok(out)
}
fn parse_plain_import(after: &str) -> Option<(String, usize, Option<String>)> {
let trimmed = after.trim_start();
let ws = after.len() - trimmed.len();
let (spec, used) = if let Some(inner) = trimmed.strip_prefix("url(") {
let close = inner.find(')')?;
let raw = inner[..close].trim().trim_matches(|c| c == '"' || c == '\'');
(raw.to_string(), 4 + close + 1)
} else {
let quote = trimmed.chars().next()?;
if quote != '"' && quote != '\'' {
return None;
}
let close = trimmed[1..].find(quote)?;
(trimmed[1..1 + close].to_string(), 1 + close + 1)
};
let tail = &trimmed[used..];
let semi = tail.find(';')?;
if tail[..semi].contains(['{', '}']) {
return None;
}
let cond = tail[..semi].trim();
if cond.contains("layer(") || cond.contains("supports(") || cond.starts_with("layer") {
return None;
}
if spec.is_empty()
|| spec.starts_with("data:")
|| spec.starts_with("//")
|| spec.contains("://")
{
return None;
}
let media = (!cond.is_empty()).then(|| cond.to_string());
Some((spec, ws + used + semi + 1, media))
}
fn first_css_file(base: &Path) -> Option<PathBuf> {
css_file_candidates(base).into_iter().find(|c| c.is_file())
}
fn css_file_candidates(base: &Path) -> Vec<PathBuf> {
let mut v = vec![base.to_path_buf()];
if base.extension().is_none() {
v.push(with_ext(base, "css"));
}
v.push(base.join("index.css"));
v
}
pub fn resolve_css_import(spec: &str, dir: &Path) -> Option<PathBuf> {
resolve_css_import_with(spec, dir, &CssResolve::default())
}
pub fn resolve_css_import_with(spec: &str, dir: &Path, resolve: &CssResolve<'_>) -> Option<PathBuf> {
let spec = spec.split(['?', '#']).next().unwrap_or(spec);
let aliased = resolve.alias_spec(spec);
let spec = match &aliased {
Some(a) if Path::new(a).is_absolute() => return first_css_file(Path::new(a)),
Some(a) => a.as_str(),
None if spec.starts_with('/') => {
if let Some(public) = resolve.public_file(spec) {
return Some(public);
}
if let Some(under_root) = resolve.root_path(spec).and_then(|p| first_css_file(&p)) {
return Some(under_root);
}
return first_css_file(Path::new(spec));
}
None => spec,
};
let spec = spec.strip_prefix('~').unwrap_or(spec);
if spec.is_empty() {
return None;
}
for c in css_file_candidates(&dir.join(spec)) {
if c.is_file() {
return Some(c);
}
}
if spec.starts_with("./") || spec.starts_with("../") {
return None;
}
let (pkg, rest) = split_package_specifier(spec)?;
for nm in node_modules_load_paths(dir) {
let pkg_dir = nm.join(pkg);
if !pkg_dir.is_dir() {
continue;
}
if rest.is_empty() {
if let Ok(text) = std::fs::read_to_string(pkg_dir.join("package.json")) {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) {
for field in ["style", "main"] {
if let Some(entry) = json.get(field).and_then(|v| v.as_str()) {
for c in css_file_candidates(&pkg_dir.join(entry)) {
if c.is_file() && c.extension().is_some_and(|e| e == "css") {
return Some(c);
}
}
}
}
}
}
let idx = pkg_dir.join("index.css");
return idx.is_file().then_some(idx);
}
for c in css_file_candidates(&pkg_dir.join(rest)) {
if c.is_file() {
return Some(c);
}
}
}
None
}
fn split_package_specifier(spec: &str) -> Option<(&str, &str)> {
let mut parts = spec.splitn(if spec.starts_with('@') { 3 } else { 2 }, '/');
let pkg = if spec.starts_with('@') {
let scope = parts.next()?;
let name = parts.next()?;
&spec[..scope.len() + 1 + name.len()]
} else {
parts.next()?
};
let rest = spec.get(pkg.len() + 1..).unwrap_or("");
Some((pkg, rest))
}
fn rebase_to_dir(
css: &str,
file: &Path,
from_dir: &Path,
to_dir: &Path,
resolve: &CssResolve<'_>,
) -> Result<String, String> {
if !(css.contains("url(") || css.contains("@import")) || from_dir == to_dir {
return Ok(css.to_string());
}
let name = file.to_string_lossy().into_owned();
let warnings = Arc::new(RwLock::new(Vec::new()));
let stylesheet = StyleSheet::parse(
css,
ParserOptions {
filename: name.clone(),
error_recovery: true,
warnings: Some(Arc::clone(&warnings)),
..ParserOptions::default()
},
)
.map_err(|err| format!("css parse error in {name}: {err}"))?;
report_css_warnings(&name, &warnings);
let result = stylesheet
.to_css(PrinterOptions {
analyze_dependencies: Some(DependencyOptions::default()),
..PrinterOptions::default()
})
.map_err(|err| format!("css print error in {name}: {err}"))?;
let mut out = result.code;
for dep in result.dependencies.unwrap_or_default() {
let (placeholder, orig) = match dep {
Dependency::Url(u) => (u.placeholder, u.url),
Dependency::Import(i) => (i.placeholder, i.url),
};
let replacement = if rebase_relative(&orig, "/x").is_none() || resolve.alias_spec(&orig).is_some() {
orig
} else {
let (path, suffix) = match orig.find(['?', '#']) {
Some(i) => (&orig[..i], &orig[i..]),
None => (orig.as_str(), ""),
};
format!("{}{}", relative_path(to_dir, &from_dir.join(path)), suffix)
};
out = out.replace(&placeholder, &replacement);
}
Ok(out)
}
fn relative_path(from_dir: &Path, target: &Path) -> String {
let norm = |p: &Path| -> Vec<String> {
let mut segs: Vec<String> = Vec::new();
for c in p.components() {
match c {
std::path::Component::ParentDir => {
segs.pop();
}
std::path::Component::CurDir | std::path::Component::RootDir | std::path::Component::Prefix(_) => {}
std::path::Component::Normal(s) => segs.push(s.to_string_lossy().into_owned()),
}
}
segs
};
let from = norm(from_dir);
let to = norm(target);
let common = from.iter().zip(to.iter()).take_while(|(a, b)| a == b).count();
let mut parts: Vec<String> = std::iter::repeat_n("..".to_string(), from.len() - common).collect();
parts.extend(to[common..].iter().cloned());
let joined = parts.join("/");
if joined.starts_with("..") {
joined
} else {
format!("./{joined}")
}
}
fn base64(bytes: &[u8]) -> String {
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];
let n = (b[0] as u32) << 16 | (b[1] as u32) << 8 | b[2] as u32;
out.push(T[(n >> 18 & 63) as usize] as char);
out.push(T[(n >> 12 & 63) as usize] as char);
out.push(if chunk.len() > 1 { T[(n >> 6 & 63) as usize] as char } else { '=' });
out.push(if chunk.len() > 2 { T[(n & 63) as usize] as char } else { '=' });
}
out
}
fn css_base_dir(url: &str) -> Option<String> {
let path = url.split(['?', '#']).next().unwrap_or(url);
if !path.starts_with('/') {
return None;
}
match path.rfind('/') {
Some(0) => Some("/".to_string()),
Some(i) => Some(path[..i].to_string()),
None => None,
}
}
fn dev_url_of(spec: &str, base_dir: &str, resolve: &CssResolve<'_>) -> String {
let (path, suffix) = match spec.find(['?', '#']) {
Some(i) => (&spec[..i], &spec[i..]),
None => (spec, ""),
};
if let Some(file) = resolve.alias_path(path) {
return format!("{}{suffix}", resolve.dev_url(&file));
}
rebase_relative(spec, base_dir).unwrap_or_else(|| spec.to_string())
}
fn rebase_relative(spec: &str, base_dir: &str) -> Option<String> {
if spec.is_empty()
|| spec.starts_with('/')
|| spec.starts_with('#')
|| spec.starts_with("data:")
|| spec.starts_with("//")
|| spec.contains("://")
{
return None;
}
let (path, suffix) = match spec.find(['?', '#']) {
Some(i) => (&spec[..i], &spec[i..]),
None => (spec, ""),
};
Some(format!("{}{}", posix_join(base_dir, path), suffix))
}
fn posix_join(base_dir: &str, rel: &str) -> String {
let mut segments: Vec<&str> = base_dir.split('/').filter(|s| !s.is_empty()).collect();
for part in rel.split('/') {
match part {
"" | "." => {}
".." => {
segments.pop();
}
other => segments.push(other),
}
}
format!("/{}", segments.join("/"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plain_css_passes_through_and_minifies() {
let out = compile_css("/styles.css", "body {\n color: red;\n}\n", true).unwrap();
assert_eq!(out.css, "body{color:red}");
assert!(out.exports.is_none());
}
#[test]
fn browser_targets_follow_vite_convert_targets() {
let s = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
let b = browser_targets(&s(&["chrome120", "safari16.4", "es2020", "ios15", "node18"]))
.browsers
.unwrap();
assert_eq!(b.chrome, Some(120 << 16));
assert_eq!(b.safari, Some((16 << 16) | (4 << 8)));
assert_eq!(b.ios_saf, Some(15 << 16));
assert_eq!(b.firefox, None);
let b = browser_targets(&s(&["chrome120", "chrome100"])).browsers.unwrap();
assert_eq!(b.chrome, Some(100 << 16));
for list in [s(&["esnext"]), Vec::new()] {
let b = browser_targets(&list).browsers.unwrap();
assert_eq!(b.chrome, Some(111 << 16));
assert_eq!(b.safari, Some((16 << 16) | (4 << 8)));
}
}
#[test]
fn css_target_and_minify_settings_drive_the_build_compile() {
let src = ".a {\n .b { color: red }\n}\n";
let modern = CssResolveConfig {
targets: vec!["chrome120".into(), "safari17.2".into(), "firefox117".into()],
minify: true,
..Default::default()
};
let out = compile_css_with("/a.css", src, &modern.as_ref()).unwrap().css;
assert!(out.contains(".a{") && out.contains(".b{"), "nesting kept for targets that support it: {out}");
assert!(!out.contains(".a .b"), "{out}");
let baseline = CssResolveConfig { minify: true, ..Default::default() };
let out = compile_css_with("/a.css", src, &baseline.as_ref()).unwrap().css;
assert_eq!(out, ".a .b{color:red}", "baseline (safari16.4) lowers nesting");
let unminified = CssResolveConfig { minify: false, ..Default::default() };
let out = compile_css_with("/a.css", src, &unminified.as_ref()).unwrap().css;
assert!(out.contains('\n') && out.contains("color: red"), "cssMinify false keeps whitespace: {out}");
}
#[test]
fn rebases_relative_url_and_import_to_server_root() {
let src = "@import \"./base.css\";\n.a { background: url(./img/bg.png); }";
let out = compile_css_rebased("/src/app.css", src, true).unwrap();
assert!(out.css.contains("/src/base.css"), "import rebased: {}", out.css);
assert!(
out.css.contains("/src/img/bg.png"),
"url rebased: {}",
out.css
);
assert!(!out.css.contains("./"), "no relative refs remain: {}", out.css);
}
#[test]
fn rebase_resolves_parent_segments_and_skips_external_urls() {
let src = ".a { background: url(../assets/x.png); }\n\
.b { background: url(https://cdn.test/y.png); }\n\
.c { background: url(data:image/png;base64,AAAA); }";
let out = compile_css_rebased("/src/ui/card.css", src, true).unwrap();
assert!(out.css.contains("/src/assets/x.png"), "parent rebased: {}", out.css);
assert!(out.css.contains("https://cdn.test/y.png"), "external kept: {}", out.css);
assert!(
out.css.contains("data:image/png;base64,AAAA"),
"data URI kept: {}",
out.css
);
}
#[test]
fn plain_compile_does_not_rebase_urls() {
let src = ".a { background: url(./bg.png); }";
let out = compile_css("/src/app.css", src, true).unwrap();
assert!(
out.css.contains("./bg.png"),
"non-rebased keeps the relative url: {}",
out.css
);
}
#[test]
fn is_css_module_matches_only_the_filename() {
assert!(is_css_module("/src/app.module.css"));
assert!(is_css_module("app.module.scss"));
assert!(is_css_module("/a/b.module.css?used"));
assert!(!is_css_module("/src/styles.css"));
assert!(!is_css_module("/module.styles/app.css"));
}
#[test]
fn is_sass_strips_query_and_checks_extension() {
assert!(is_sass("/src/theme.scss"));
assert!(is_sass("vars.sass"));
assert!(is_sass("/a/theme.scss?inline"));
assert!(!is_sass("/a/theme.css"));
assert!(!is_sass("/a/scss.ts"));
}
#[test]
fn css_modules_scope_and_export_class_names() {
let out = compile_css(
"/src/Counter.module.css",
".button { padding: 1rem; } .button:hover { opacity: 0.9; }",
false,
)
.unwrap();
let exports = out.exports.expect("module exports");
assert_eq!(exports.len(), 1);
let (name, scoped) = &exports[0];
assert_eq!(name, "button");
assert_ne!(scoped, "button", "class must be scoped: {scoped}");
assert!(out.css.contains(scoped.as_str()), "{}", out.css);
}
fn with_modules(modules: CssModulesOptions) -> CssResolveConfig {
CssResolveConfig { modules, minify: true, ..Default::default() }
}
#[test]
fn css_modules_locals_convention_shapes_the_export_map() {
let src = ".my-class { color: red } .foo_bar { color: blue } .Plain { color: green }";
let keys = |conv: &str| {
let cfg = with_modules(CssModulesOptions { locals_convention: Some(conv.into()), ..Default::default() });
compile_css_with("/src/a.module.css", src, &cfg.as_ref())
.unwrap()
.exports
.unwrap()
.into_iter()
.map(|(k, _)| k)
.collect::<Vec<_>>()
};
assert_eq!(keys("camelCase"), ["Plain", "fooBar", "foo_bar", "my-class", "myClass", "plain"]);
assert_eq!(keys("camelCaseOnly"), ["fooBar", "myClass", "plain"]);
assert_eq!(keys("dashes"), ["Plain", "foo_bar", "my-class", "myClass"]);
assert_eq!(keys("dashesOnly"), ["Plain", "foo_bar", "myClass"]);
let cfg = with_modules(CssModulesOptions { locals_convention: Some("camelCase".into()), ..Default::default() });
let out = compile_css_with("/src/a.module.css", src, &cfg.as_ref()).unwrap().exports.unwrap();
let get = |k: &str| out.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone()).unwrap();
assert_eq!(get("my-class"), get("myClass"));
}
#[test]
fn css_modules_generate_scoped_name_pattern_is_honored() {
let cfg = with_modules(CssModulesOptions {
generate_scoped_name: Some("[local]__[hash:base64:5]".into()),
..Default::default()
});
let out = compile_css_with("/src/Btn.module.css", ".button { color: red }", &cfg.as_ref()).unwrap();
let (_, scoped) = &out.exports.unwrap()[0];
assert!(scoped.starts_with("button__"), "{scoped}");
assert!(!scoped.contains("Btn"), "{scoped}");
let cfg = with_modules(CssModulesOptions {
generate_scoped_name: Some("app-[name]-[local]".into()),
..Default::default()
});
let out = compile_css_with("/src/Btn.module.css", ".button { color: red }", &cfg.as_ref()).unwrap();
assert_eq!(out.exports.unwrap()[0].1, "app-Btn-module-button");
let cfg = with_modules(CssModulesOptions { generate_scoped_name: Some("[nope]".into()), ..Default::default() });
let out = compile_css_with("/src/Btn.module.css", ".button { color: red }", &cfg.as_ref()).unwrap();
assert!(out.exports.unwrap()[0].1.starts_with("Btn-module_button_"));
}
#[test]
fn css_modules_global_scope_and_global_module_paths_compile_unscoped() {
let cfg = with_modules(CssModulesOptions { global_scope: true, ..Default::default() });
let out = compile_css_with("/src/a.module.css", ".x { color: red }", &cfg.as_ref()).unwrap();
assert_eq!(out.css, ".x{color:red}");
assert_eq!(out.exports, Some(Vec::new()), "still a module to its importer, with no locals");
let cfg = with_modules(CssModulesOptions {
global_module_paths: vec![r"global\.module\.css$".into()],
..Default::default()
});
let g = compile_css_with("/src/theme.global.module.css", ".x { color: red }", &cfg.as_ref()).unwrap();
assert_eq!(g.css, ".x{color:red}");
assert_eq!(g.exports, Some(Vec::new()));
let scoped = compile_css_with("/src/a.module.css", ".x { color: red }", &cfg.as_ref()).unwrap();
assert_ne!(scoped.css, ".x{color:red}", "non-matching modules stay scoped");
}
#[test]
fn css_modules_composes_locals_globals_and_other_files() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("src")).unwrap();
std::fs::write(dir.path().join("src/base.module.css"), ".base { padding: 1px } .more { composes: base; margin: 0 }").unwrap();
let cfg = CssResolveConfig { root: dir.path().to_path_buf(), ..Default::default() };
std::fs::write(
dir.path().join("src/a.module.css"),
".a { color: red } .b { composes: a; color: blue } .c { composes: g from global; } .d { composes: more from \"./base.module.css\"; }",
)
.unwrap();
let out = compile_css_with(
"/src/a.module.css",
&std::fs::read_to_string(dir.path().join("src/a.module.css")).unwrap(),
&cfg.as_ref(),
)
.unwrap();
let exports = out.exports.unwrap();
let get = |k: &str| exports.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone()).unwrap();
assert_eq!(get("b"), format!("{} {}", get("b").split(' ').next().unwrap(), get("a")));
assert_eq!(get("c").split(' ').nth(1), Some("g"));
let base = compile_css_with(
"/src/base.module.css",
&std::fs::read_to_string(dir.path().join("src/base.module.css")).unwrap(),
&cfg.as_ref(),
)
.unwrap()
.exports
.unwrap();
let base_more = base.iter().find(|(n, _)| n == "more").map(|(_, v)| v.clone()).unwrap();
assert!(base_more.contains(' '), "more composes base transitively: {base_more}");
let d = get("d");
assert!(d.ends_with(&base_more), "d = {d}, expected suffix {base_more}");
}
#[test]
fn css_modules_scoped_name_matches_ssr_loader() {
let out = compile_css("/src/Counter.module.css", ".button { color: red; }", false).unwrap();
let exports = out.exports.expect("module exports");
assert_eq!(
exports,
vec![("button".to_string(), "Counter-module_button_EjW_Uq".to_string())]
);
}
#[test]
fn sass_nesting_and_variables_compile() {
let scss = "$pad: 1rem;\n.card { padding: $pad; .title { font-weight: bold; } }";
let css = compile_sass(scss, None).unwrap();
assert!(css.contains("padding: 1rem"), "variable resolved: {css}");
assert!(css.contains(".card .title"), "nesting flattened: {css}");
}
#[test]
fn sass_then_lightningcss_pipeline() {
let css = compile_sass(".a { .b { color: red } }", None).unwrap();
let out = compile_css("/x.scss", &css, true).unwrap();
assert!(out.css.contains(".a .b{color:red}"), "{}", out.css);
}
#[test]
fn additional_data_is_prepended_before_compiling() {
let scss = ".btn { color: $brand; }";
let css = compile_sass_with(scss, None, Some("$brand: #f00;")).unwrap();
assert!(css.contains("color: #f00"), "injected var resolved: {css}");
assert!(
compile_sass(scss, None).is_err(),
"undeclared variable must fail without additionalData",
);
assert!(compile_sass_with(".a { color: red; }", None, Some("")).is_ok());
}
#[test]
fn sass_resolves_dotted_module_import() {
let base = std::env::temp_dir().join(format!("oj-css-dotmod-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join("comp")).unwrap();
std::fs::create_dir_all(base.join("css")).unwrap();
std::fs::write(base.join("css/variables.module.scss"), "$c: #f00;").unwrap();
std::fs::write(base.join("css/plain.scss"), "$p: 2px;").unwrap();
for spec in ["../css/variables.module", "../css/variables.module.scss"] {
let src = format!(
"@use \"{spec}\" as v;\n@use \"../css/plain\" as p;\n.x {{ color: v.$c; margin: p.$p; }}"
);
let css = compile_sass(&src, Some(&base.join("comp")))
.unwrap_or_else(|e| panic!("`{spec}` should resolve: {e}"));
assert!(css.contains("color: red") || css.contains("#f00"), "{spec}: {css}");
assert!(css.contains("margin: 2px"), "{spec}: {css}");
}
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn sass_resolves_nested_relative_dotted_import() {
let base = std::env::temp_dir().join(format!("oj-css-nested-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join("comp")).unwrap();
std::fs::create_dir_all(base.join("shared")).unwrap();
std::fs::write(base.join("shared/colors.module.scss"), "$c: #0f0;").unwrap();
std::fs::write(
base.join("shared/base.module.scss"),
"@use \"colors.module\" as c;\n.base { color: c.$c; }",
)
.unwrap();
let src = "@use \"../shared/base.module.scss\";\n.x { display: block; }";
let css = compile_sass(src, Some(&base.join("comp")))
.unwrap_or_else(|e| panic!("nested dotted @use should resolve: {e}"));
assert!(css.contains("color: #0f0") || css.contains("color: green"), "{css}");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn sass_resolves_node_modules_packages_like_vite() {
let base = std::env::temp_dir().join(format!("oj-css-nm-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let pkg = base.join("node_modules/@acme/tokens");
std::fs::create_dir_all(pkg.join("src")).unwrap();
std::fs::create_dir_all(base.join("src/deep")).unwrap();
std::fs::write(
pkg.join("package.json"),
r#"{"name":"@acme/tokens","main":"index.js","sass":"src/index.scss"}"#,
)
.unwrap();
std::fs::write(pkg.join("src/index.scss"), "$brand: #123456;\n").unwrap();
std::fs::write(pkg.join("src/_mixins.scss"), "@mixin pad { padding: 4px; }\n").unwrap();
let styled = base.join("node_modules/plain-css");
std::fs::create_dir_all(&styled).unwrap();
std::fs::write(styled.join("package.json"), r#"{"name":"plain-css","style":"dist/x.css"}"#).unwrap();
std::fs::create_dir_all(styled.join("dist")).unwrap();
std::fs::write(styled.join("dist/x.css"), ".plain { color: green; }\n").unwrap();
let dir = base.join("src/deep");
let scss = "@use \"@acme/tokens\" as t;\n@use \"~@acme/tokens/src/mixins\";\n@import \"plain-css\";\n.a { color: t.$brand; @include mixins.pad; }";
let css = compile_sass(scss, Some(&dir)).unwrap();
assert!(css.contains("#123456"), "package sass entry resolved: {css}");
assert!(css.contains("padding: 4px"), "~pkg/path resolved: {css}");
assert!(css.contains(".plain"), "style entry resolved: {css}");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn sass_load_paths_from_config_resolve_bare_imports() {
let base = std::env::temp_dir().join(format!("oj-css-lp-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join("styles")).unwrap();
std::fs::create_dir_all(base.join("src")).unwrap();
std::fs::write(base.join("styles/_theme.scss"), "$accent: #abc;\n").unwrap();
let opts = SassOptions {
load_dir: Some(&base.join("src")),
additional_data: Some("$pad: 2px;"),
load_paths: &[base.join("styles")],
resolve: CssResolve::default(),
};
let css = compile_sass_opts("@use \"theme\";\n.a { color: theme.$accent; padding: $pad; }", &opts).unwrap();
assert!(css.contains("#abc") && css.contains("2px"), "{css}");
assert!(compile_sass("@use \"theme\";", Some(&base.join("src"))).is_err(), "not on the load path without config");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn inlines_relative_and_package_imports_and_rebases_urls() {
let base = std::env::temp_dir().join(format!("oj-css-imp-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join("src/base")).unwrap();
std::fs::create_dir_all(base.join("node_modules/normalize-fake")).unwrap();
std::fs::write(base.join("src/vars.css"), ":root { --x: 1; }\n").unwrap();
std::fs::write(
base.join("src/base/reset.css"),
"@import \"../vars.css\";\n.reset { background: url(./dot.png); }\n",
)
.unwrap();
std::fs::write(base.join("node_modules/normalize-fake/package.json"), r#"{"name":"normalize-fake","style":"n.css"}"#).unwrap();
std::fs::write(base.join("node_modules/normalize-fake/n.css"), ".norm { margin: 0; }\n").unwrap();
std::fs::write(base.join("src/print.css"), ".print { display: none; }\n").unwrap();
let app = base.join("src/app.css");
let src = "@import \"./base/reset.css\";\n@import 'normalize-fake';\n@import url(https://cdn.test/x.css);\n@import \"./print.css\" print;\n@import \"./missing.css\" screen;\n.app { color: red; }\n";
let out = inline_imports(src, &app).unwrap();
assert!(out.contains("--x: 1"), "nested import inlined: {out}");
assert!(out.contains(".reset"), "relative import inlined: {out}");
assert!(out.contains("url(\"./base/dot.png\")") || out.contains("url(./base/dot.png)"), "url rebased to the entry dir: {out}");
assert!(out.contains(".norm"), "package style entry inlined: {out}");
assert!(out.contains("@import url(https://cdn.test/x.css);"), "external import kept: {out}");
assert!(out.contains("@media print {\n.print { display: none; }"), "media import inlined as @media: {out}");
assert!(out.contains("@import \"./missing.css\" screen;"), "unresolvable media import kept: {out}");
let first_rule = out.find('{').unwrap();
assert!(out.rfind("@import").unwrap() < first_rule, "imports hoisted first: {out}");
assert!(!out.contains("@import \"./base/reset.css\""), "inlined import removed: {out}");
assert!(out.contains(".app { color: red; }"), "own rules kept verbatim: {out}");
let compiled = compile_css_rebased("/src/app.css", &out, true).unwrap();
assert!(compiled.css.contains("/src/base/dot.png"), "{}", compiled.css);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn import_inlining_survives_cycles_and_missing_files() {
let base = std::env::temp_dir().join(format!("oj-css-cyc-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
std::fs::write(base.join("a.css"), "@import \"./b.css\";\n.a{}").unwrap();
std::fs::write(base.join("b.css"), "@import \"./a.css\";\n.b{}").unwrap();
let out = inline_imports("@import \"./b.css\";\n.a{}", &base.join("a.css")).unwrap();
assert!(out.contains(".b{}") && out.contains(".a{}"), "{out}");
assert!(out.contains("@import \"./a.css\";"), "the cycle edge stays as written: {out}");
let missing = inline_imports("@import \"./nope.css\";\n.a{}", &base.join("a.css")).unwrap();
assert!(missing.contains("@import \"./nope.css\";"), "unresolvable import left alone: {missing}");
assert_eq!(relative_path(Path::new("/p/src"), Path::new("/p/src/base/dot.png")), "./base/dot.png");
assert_eq!(relative_path(Path::new("/p/src/base"), Path::new("/p/vars.css")), "../../vars.css");
assert_eq!(split_package_specifier("@acme/tokens/src/x.css"), Some(("@acme/tokens", "src/x.css")));
assert_eq!(split_package_specifier("normalize.css"), Some(("normalize.css", "")));
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn dev_sourcemap_is_appended_inline_and_names_the_source() {
let out = compile_css_rebased_with_map("/src/app.css", ".a {\n color: red;\n}\n.b { color: blue; }\n", false).unwrap();
let marker = "/*# sourceMappingURL=data:application/json;base64,";
let at = out.css.find(marker).expect("sourceMappingURL comment");
let b64_json = out.css[at + marker.len()..].trim_end().trim_end_matches("*/").trim();
let decoded = {
let t = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut bits = 0u32; let mut n = 0; let mut bytes = Vec::new();
for c in b64_json.bytes().filter(|c| *c != b'=') {
let v = t.iter().position(|x| *x == c).unwrap() as u32;
bits = bits << 6 | v; n += 6;
if n >= 8 { n -= 8; bytes.push((bits >> n) as u8); bits &= (1 << n) - 1; }
}
String::from_utf8(bytes).unwrap()
};
assert!(decoded.contains("\"sources\":[\"src/app.css\"]") && decoded.contains("\"sourceRoot\":\"/\""), "{decoded}");
assert!(decoded.contains("sourcesContent"), "source embedded: {decoded}");
assert!(decoded.contains("\"mappings\":\"") && !decoded.contains("\"mappings\":\"\""), "non-empty mappings: {decoded}");
assert!(!compile_css_rebased("/src/app.css", ".a { color: red; }", false).unwrap().css.contains("sourceMappingURL"));
}
#[test]
fn strip_sass_import_ext_only_touches_import_specifiers() {
assert_eq!(
strip_sass_import_ext("@use \"../css/vars.module.scss\" as *;\n"),
"@use \"../css/vars.module\" as *;\n"
);
assert_eq!(
strip_sass_import_ext("@forward './theme.scss';\n"),
"@forward './theme';\n"
);
assert_eq!(
strip_sass_import_ext("@import \"reset.css\";\n"),
"@import \"reset.css\";\n"
);
assert_eq!(
strip_sass_import_ext("@import url(\"x.scss\");\n"),
"@import url(\"x.scss\");\n"
);
assert_eq!(
strip_sass_import_ext(".a { content: \"file.scss\"; }\n"),
".a { content: \"file.scss\"; }\n"
);
}
#[test]
fn sass_missing_import_still_errors() {
let base = std::env::temp_dir().join(format!("oj-css-miss-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
let src = "@use \"./nope.module\" as *;\n.x { color: red; }";
assert!(
compile_sass(src, Some(&base)).is_err(),
"a genuinely missing dotted import must still fail, not resolve to nothing"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn autoprefixing_applies_for_targets() {
let out = compile_css("/p.css", ".x { user-select: none; }", true).unwrap();
assert!(
out.css.contains("-webkit-user-select"),
"autoprefixed: {}",
out.css
);
}
#[test]
fn the_browser_matrix_keeps_modern_syntax_and_downlevels_the_rest() {
let out = compile_css(
"/p.css",
".a { width: clamp(1px, 2vw, 3px); color: rgb(0 0 0 / 50%); aspect-ratio: 1/2 }",
true,
)
.unwrap()
.css;
assert!(out.contains("clamp("), "clamp is supported: {out}");
assert!(out.contains("#00000080"), "modern color syntax: {out}");
assert!(out.contains("aspect-ratio"), "aspect-ratio is supported: {out}");
assert!(!out.contains("max(1px"), "clamp must not be lowered: {out}");
let nested = compile_css("/p.css", ".a { .b { color: red } }", true).unwrap().css;
assert_eq!(nested, ".a .b{color:red}");
let logical = compile_css("/p.css", ".a { inset-inline-start: 1px }", true)
.unwrap()
.css;
assert_eq!(logical, ".a{inset-inline-start:1px}");
let legacy = CssResolveConfig { targets: vec!["safari14".into()], minify: true, ..Default::default() };
let lowered = compile_css_with("/p.css", ".a { inset-inline-start: 1px }", &legacy.as_ref())
.unwrap()
.css;
assert!(lowered.contains("left:1px"), "logical props lowered for safari14: {lowered}");
}
#[test]
fn scoped_class_names_follow_the_name_local_hash_pattern() {
let out = compile_css("/src/Counter.module.css", ".button { color: red }", false).unwrap();
let (local, scoped) = out.exports.expect("exports").into_iter().next().unwrap();
assert_eq!(local, "button");
let prefix = "Counter-module_button_";
assert!(
scoped.starts_with(prefix),
"expected [name]_[local]_[hash], got {scoped}"
);
let hash = &scoped[prefix.len()..];
assert!(!hash.is_empty(), "no hash in {scoped}");
assert!(
hash.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'),
"hash is not a plain token: {scoped}"
);
let after_edit = compile_css("/src/Counter.module.css", ".button { color: blue }", false)
.unwrap()
.exports
.expect("exports")
.remove(0)
.1;
assert_eq!(scoped, after_edit, "an edit must not rename a class");
}
#[test]
fn alias_matches_whole_specifier_or_slash_prefix_like_rollup_alias() {
let alias = vec![
("@".to_string(), "./src".to_string()),
("@components".to_string(), "/abs/components".to_string()),
("react".to_string(), "preact/compat".to_string()),
];
let r = CssResolve { root: Some(Path::new("/proj")), public_dir: None, alias: &alias, ..CssResolve::default() };
assert_eq!(r.alias_spec("@/img.png").as_deref(), Some("/proj/src/img.png"));
assert_eq!(r.alias_spec("@").as_deref(), Some("/proj/src"));
assert_eq!(r.alias_spec("@components/btn.css").as_deref(), Some("/abs/components/btn.css"));
assert_eq!(r.alias_spec("@scope/pkg/x.css"), None, "not a `find/` prefix match");
assert_eq!(r.alias_spec("react/x.css").as_deref(), Some("preact/compat/x.css"));
assert!(r.alias_path("react/x.css").is_none());
assert_eq!(r.alias_path("@/a.css"), Some(PathBuf::from("/proj/src/a.css")));
}
#[test]
fn dev_compile_rewrites_aliased_urls_and_keeps_root_absolute_ones() {
let alias = vec![("@".to_string(), "./src".to_string())];
let r = CssResolve { root: Some(Path::new("/proj")), public_dir: None, alias: &alias, ..CssResolve::default() };
let src = ".a { background: url(@/img/bg.png?v=1); }\n\
.b { background: url(/src/x.png); }\n\
.c { background: url(./y.png); }\n\
.d { background: url(/logo.svg#id); }";
let out = compile_css_dev("/src/ui/card.css", src, false, &r).unwrap().css;
assert!(out.contains("url(\"/src/img/bg.png?v=1\")") || out.contains("url(/src/img/bg.png?v=1)"), "aliased url -> served url of the file: {out}");
assert!(out.contains("/src/x.png"), "root-absolute kept: {out}");
assert!(!out.contains("/src/@/"), "alias must not be treated as a relative segment: {out}");
assert!(out.contains("/src/ui/y.png"), "relative still rebased: {out}");
assert!(out.contains("/logo.svg#id"), "public url kept: {out}");
let outside = vec![("~ui".to_string(), "/elsewhere/ui".to_string())];
let r2 = CssResolve { root: Some(Path::new("/proj")), public_dir: None, alias: &outside, ..CssResolve::default() };
let out = compile_css_dev("/src/a.css", ".a { background: url(~ui/i.png) }", false, &r2).unwrap().css;
assert!(out.contains("/@fs/elsewhere/ui/i.png"), "{out}");
}
#[test]
fn imports_resolve_through_alias_root_and_public_dir() {
let base = std::env::temp_dir().join(format!("oj-css-alias-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join("src/ui")).unwrap();
std::fs::create_dir_all(base.join("public")).unwrap();
std::fs::write(base.join("src/vars.css"), ":root { --v: 1; }\n").unwrap();
std::fs::write(base.join("src/ui/theme.css"), ".theme { color: red; background: url(@/img.png); }\n").unwrap();
std::fs::write(base.join("public/vendor.css"), ".vendor { margin: 0; }\n").unwrap();
std::fs::write(base.join("vendor.css"), ".wrong { margin: 1px; }\n").unwrap();
let alias = vec![("@".to_string(), "./src".to_string())];
let public = base.join("public");
let r = CssResolve { root: Some(&base), public_dir: Some(&public), alias: &alias, ..CssResolve::default() };
let dir = base.join("src/ui");
assert_eq!(resolve_css_import_with("@/vars.css", &dir, &r), Some(base.join("src/vars.css")));
assert_eq!(resolve_css_import_with("@/vars", &dir, &r), Some(base.join("src/vars.css")), "extension probed");
assert_eq!(resolve_css_import_with("/src/vars.css", &dir, &r), Some(base.join("src/vars.css")));
assert_eq!(resolve_css_import_with("/vendor.css", &dir, &r), Some(base.join("public/vendor.css")), "public dir wins");
assert_eq!(resolve_css_import_with("/nope.css", &dir, &r), None);
assert_eq!(resolve_css_import("@/vars.css", &dir), None);
assert_eq!(resolve_css_import("/src/vars.css", &dir), None);
let entry = base.join("src/ui/app.css");
let src = "@import \"@/vars.css\";\n@import \"/src/ui/theme.css\";\n@import '/vendor.css';\n.app { color: blue; }\n";
let out = inline_imports_with(src, &entry, &r).unwrap();
assert!(out.contains("--v: 1"), "aliased import inlined: {out}");
assert!(out.contains(".theme"), "root-absolute import inlined: {out}");
assert!(out.contains(".vendor") && !out.contains(".wrong"), "public import inlined: {out}");
assert!(out.contains("url(@/img.png)") || out.contains("url(\"@/img.png\")"), "aliased url inside an inlined file is not rebased as relative: {out}");
assert!(!out.contains("@import"), "{out}");
let compiled = compile_css_dev("/src/ui/app.css", &out, false, &r).unwrap().css;
assert!(compiled.contains("/src/img.png"), "{compiled}");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn sass_resolves_alias_and_root_absolute_imports() {
let base = std::env::temp_dir().join(format!("oj-css-sass-alias-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join("src/styles")).unwrap();
std::fs::create_dir_all(base.join("src/comp")).unwrap();
std::fs::write(base.join("src/styles/_vars.scss"), "$brand: #f00;\n").unwrap();
std::fs::write(base.join("src/styles/mixins.scss"), "@use \"@/styles/vars\";\n@mixin pad { padding: 4px; color: vars.$brand; }\n").unwrap();
std::fs::write(base.join("src/styles/theme.module.scss"), "$t: 2px;\n").unwrap();
let alias = vec![("@".to_string(), "./src".to_string())];
let opts = SassOptions {
load_dir: Some(&base.join("src/comp")),
additional_data: None,
load_paths: &[],
resolve: CssResolve { root: Some(&base), public_dir: None, alias: &alias, ..CssResolve::default() },
};
let src = "@use \"@/styles/vars\" as v;\n@use '@/styles/mixins.scss' as m;\n@use \"/src/styles/theme.module\" as t;\n.x { color: v.$brand; margin: t.$t; @include m.pad; }";
let css = compile_sass_opts(src, &opts).unwrap_or_else(|e| panic!("{e}"));
assert!(css.contains("color: #f00") || css.contains("color: red"), "{css}");
assert!(css.contains("margin: 2px") && css.contains("padding: 4px"), "{css}");
assert!(compile_sass(src, Some(&base.join("src/comp"))).is_err());
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn css_modules_esm_names_legal_identifiers_and_keeps_the_whole_map_default() {
let exports = vec![
("button".to_string(), "m_button_h".to_string()),
("my-class".to_string(), "m_my-class_h".to_string()),
("default".to_string(), "m_default_h".to_string()),
("class".to_string(), "m_class_h".to_string()),
("Map".to_string(), "m_Map_h".to_string()),
("_private".to_string(), "m__private_h".to_string()),
("$x".to_string(), "m_x_h".to_string()),
("1st".to_string(), "m_1st_h".to_string()),
];
let js = css_modules_esm(&exports);
assert!(js.contains("export const button = \"m_button_h\";"), "{js}");
assert!(js.contains("export const _private = ") && js.contains("export const $x = "), "{js}");
for bad in ["my-class", "default", "class", "Map", "1st"] {
assert!(!js.contains(&format!("export const {bad} ")), "{bad} must not be a named export: {js}");
}
assert!(js.contains("export default {"), "{js}");
for key in ["button", "my-class", "default", "class", "Map", "_private", "$x", "1st"] {
assert!(js.contains(&format!("\"{key}\":\"m_")), "{key} in the default map: {js}");
}
assert_eq!(css_modules_esm(&[]), "export default {};\n");
}
#[test]
fn parse_errors_are_recovered_not_panicked() {
let out = compile_css("/x.css", "!!not-css!! {}\n.ok { color: red }", true).unwrap();
assert_eq!(out.css, ".ok{color:red}");
}
#[test]
fn legacy_hacks_do_not_fail_the_stylesheet() {
let src = ".clearfix { *zoom: 1; _height: 1px; color: red; }\n\
.g { filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff', endColorstr='#000'); background: blue; }\n\
.b { color: green; }";
let out = compile_css("/vendor.css", src, true).unwrap();
assert!(out.css.contains(".clearfix{_height:1px;color:red}"), "{}", out.css);
assert!(out.css.contains("progid:DXImageTransform") && out.css.contains("background:#00f"), "{}", out.css);
assert!(out.css.contains(".b{color:green}"), "{}", out.css);
assert!(compile_css_rebased("/src/vendor.css", src, false).is_ok());
}
}