1use 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}