Skip to main content

oj_css/
lib.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 Raphael Amorim
3
4use std::path::Path;
5
6use lightningcss::css_modules;
7use lightningcss::printer::PrinterOptions;
8use lightningcss::stylesheet::{MinifyOptions, ParserOptions, StyleSheet};
9use lightningcss::targets::{Browsers, Targets};
10
11#[derive(Debug)]
12pub struct CssOutput {
13    pub css: String,
14    pub exports: Option<Vec<(String, String)>>,
15}
16
17pub fn is_css_module(url: &str) -> bool {
18    url.rsplit('/').next().is_some_and(|f| f.contains(".module."))
19}
20
21pub fn is_sass(url: &str) -> bool {
22    let f = url.split('?').next().unwrap_or(url);
23    f.ends_with(".scss") || f.ends_with(".sass")
24}
25
26pub fn compile_sass(source: &str, load_dir: Option<&Path>) -> Result<String, String> {
27    let mut options = grass::Options::default();
28    if let Some(dir) = load_dir {
29        options = options.load_path(dir);
30    }
31    grass::from_string(source.to_string(), &options).map_err(|e| format!("sass error: {e}"))
32}
33
34fn default_targets() -> Targets {
35    Targets::from(Browsers {
36        chrome: Some(100 << 16),
37        edge: Some(100 << 16),
38        firefox: Some(100 << 16),
39        safari: Some(14 << 16),
40        ios_saf: Some(14 << 16),
41        ..Browsers::default()
42    })
43}
44
45pub fn compile_css(url: &str, source: &str, minify: bool) -> Result<CssOutput, String> {
46    let is_module = is_css_module(url);
47    let options = ParserOptions {
48        filename: url.to_string(),
49        css_modules: is_module.then(|| css_modules::Config {
50            pattern: css_modules::Pattern::parse("[name]_[local]_[hash]")
51                .expect("static pattern"),
52            ..css_modules::Config::default()
53        }),
54        ..ParserOptions::default()
55    };
56
57    let mut stylesheet = StyleSheet::parse(source, options)
58        .map_err(|err| format!("css parse error in {url}: {err}"))?;
59
60    let targets = default_targets();
61    stylesheet
62        .minify(MinifyOptions { targets: targets.clone(), ..MinifyOptions::default() })
63        .map_err(|err| format!("css transform error in {url}: {err}"))?;
64
65    let result = stylesheet
66        .to_css(PrinterOptions { minify, targets, ..PrinterOptions::default() })
67        .map_err(|err| format!("css print error in {url}: {err}"))?;
68
69    let exports = result.exports.map(|map| {
70        let mut pairs: Vec<(String, String)> =
71            map.into_iter().map(|(name, export)| (name, export.name)).collect();
72        pairs.sort();
73        pairs
74    });
75
76    Ok(CssOutput { css: result.code, exports })
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    #[test]
84    fn plain_css_passes_through_and_minifies() {
85        let out = compile_css("/styles.css", "body {\n  color: red;\n}\n", true).unwrap();
86        assert_eq!(out.css, "body{color:red}");
87        assert!(out.exports.is_none());
88    }
89
90    #[test]
91    fn is_css_module_matches_only_the_filename() {
92        assert!(is_css_module("/src/app.module.css"));
93        assert!(is_css_module("app.module.scss"));
94        assert!(is_css_module("/a/b.module.css?used"));
95        assert!(!is_css_module("/src/styles.css"));
96        assert!(!is_css_module("/module.styles/app.css"));
97    }
98
99    #[test]
100    fn is_sass_strips_query_and_checks_extension() {
101        assert!(is_sass("/src/theme.scss"));
102        assert!(is_sass("vars.sass"));
103        assert!(is_sass("/a/theme.scss?inline"));
104        assert!(!is_sass("/a/theme.css"));
105        assert!(!is_sass("/a/scss.ts"));
106    }
107
108    #[test]
109    fn css_modules_scope_and_export_class_names() {
110        let out = compile_css(
111            "/src/Counter.module.css",
112            ".button { padding: 1rem; } .button:hover { opacity: 0.9; }",
113            false,
114        )
115        .unwrap();
116        let exports = out.exports.expect("module exports");
117        assert_eq!(exports.len(), 1);
118        let (name, scoped) = &exports[0];
119        assert_eq!(name, "button");
120        assert_ne!(scoped, "button", "class must be scoped: {scoped}");
121        assert!(out.css.contains(scoped.as_str()), "{}", out.css);
122    }
123
124    #[test]
125    fn sass_nesting_and_variables_compile() {
126        let scss = "$pad: 1rem;\n.card { padding: $pad; .title { font-weight: bold; } }";
127        let css = compile_sass(scss, None).unwrap();
128        assert!(css.contains("padding: 1rem"), "variable resolved: {css}");
129        assert!(css.contains(".card .title"), "nesting flattened: {css}");
130    }
131
132    #[test]
133    fn sass_then_lightningcss_pipeline() {
134        let css = compile_sass(".a { .b { color: red } }", None).unwrap();
135        let out = compile_css("/x.scss", &css, true).unwrap();
136        assert!(out.css.contains(".a .b{color:red}"), "{}", out.css);
137    }
138
139    #[test]
140    fn autoprefixing_applies_for_targets() {
141        let out = compile_css("/p.css", ".x { user-select: none; }", true).unwrap();
142        assert!(out.css.contains("-webkit-user-select"), "autoprefixed: {}", out.css);
143    }
144
145    #[test]
146    fn parse_errors_are_reported_not_panicked() {
147        assert!(compile_css("/x.css", "!!not-css!!", false).is_err());
148    }
149}