oj_css 0.1.3

CSS pipeline: Lightning CSS transforms, CSS Modules scoping
Documentation
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Raphael Amorim

use std::io;
use std::path::{Path, PathBuf};

use lightningcss::css_modules;
use lightningcss::printer::PrinterOptions;
use lightningcss::stylesheet::{MinifyOptions, ParserOptions, StyleSheet};
use lightningcss::targets::{Browsers, Targets};

#[derive(Debug)]
pub struct CssOutput {
    pub css: String,
    pub exports: Option<Vec<(String, String)>>,
}

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")
}

// If `p.scss`/`p.sass` exists as a real file, return it. Used to treat a
// dotted-basename stylesheet as resolvable.
fn dotted_stylesheet(p: &Path) -> Option<PathBuf> {
    for ext in ["scss", "sass"] {
        let mut s = p.as_os_str().to_owned();
        s.push(".");
        s.push(ext);
        let c = PathBuf::from(s);
        if c.is_file() {
            return Some(c);
        }
    }
    None
}

// Map a grass directory-index probe `.../X/index.scss` (or _index/.sass) back
// to a real `.../X.scss` file when `X` is a dotted-basename stylesheet. The
// mapping is extension-faithful: an `index.sass` probe only matches a real
// `.sass` file, so scss content is never parsed as the indented syntax.
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 mut s = p.parent()?.as_os_str().to_owned();
    s.push(".");
    s.push(ext);
    let c = PathBuf::from(s);
    c.is_file().then_some(c)
}

// grass 0.13 treats a trailing `.segment` in an `@use`/`@import` path as an
// extension, so `@use "../css/variables.module"` never probes
// `variables.module.scss` and fails on the CSS-modules `.module.scss`
// convention. grass does probe the basename as a directory, so present a real
// `name.segment.scss` file as a directory whose index is that file. This makes
// resolution match dart-sass (what Vite uses), which treats the whole basename
// as the stylesheet name.
#[derive(Debug)]
struct DottedFs;

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() || index_target(p).is_some()
    }
    fn read(&self, p: &Path) -> io::Result<Vec<u8>> {
        if p.is_file() {
            return std::fs::read(p);
        }
        match index_target(p) {
            Some(real) => std::fs::read(real),
            None => std::fs::read(p),
        }
    }
}

// grass also mishandles an explicit `.scss`/`.sass` extension on a dotted
// basename (`@use "../css/vars.module.scss"`), probing only CWD-relative and
// never through the load paths. Drop the extension on `@use`/`@forward`/
// `@import` specifiers so every import takes the bare-name path (which DottedFs
// resolves); Sass treats `@use "x"` and `@use "x.scss"` identically. `.css` is
// left alone (it stays a plain CSS import), as are `url(...)` lines.
fn strip_sass_import_ext(source: &str) -> String {
    let mut out = String::with_capacity(source.len());
    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(") {
            out.push_str(
                &line
                    .replace(".scss\"", "\"")
                    .replace(".scss'", "'")
                    .replace(".sass\"", "\"")
                    .replace(".sass'", "'"),
            );
        } else {
            out.push_str(line);
        }
    }
    out
}

pub fn compile_sass(source: &str, load_dir: Option<&Path>) -> Result<String, String> {
    let fs = DottedFs;
    let mut options = grass::Options::default().fs(&fs);
    if let Some(dir) = load_dir {
        options = options.load_path(dir);
    }
    let source = strip_sass_import_ext(source);
    grass::from_string(source, &options).map_err(|e| format!("sass error: {e}"))
}

fn default_targets() -> Targets {
    Targets::from(Browsers {
        chrome: Some(100 << 16),
        edge: Some(100 << 16),
        firefox: Some(100 << 16),
        safari: Some(14 << 16),
        ios_saf: Some(14 << 16),
        ..Browsers::default()
    })
}

pub fn compile_css(url: &str, source: &str, minify: bool) -> Result<CssOutput, String> {
    let is_module = is_css_module(url);
    let options = ParserOptions {
        filename: url.to_string(),
        css_modules: is_module.then(|| css_modules::Config {
            pattern: css_modules::Pattern::parse("[name]_[local]_[hash]").expect("static pattern"),
            ..css_modules::Config::default()
        }),
        ..ParserOptions::default()
    };

    let mut stylesheet = StyleSheet::parse(source, options)
        .map_err(|err| format!("css parse error in {url}: {err}"))?;

    let targets = default_targets();
    stylesheet
        .minify(MinifyOptions {
            targets: targets.clone(),
            ..MinifyOptions::default()
        })
        .map_err(|err| format!("css transform error in {url}: {err}"))?;

    let result = stylesheet
        .to_css(PrinterOptions {
            minify,
            targets,
            ..PrinterOptions::default()
        })
        .map_err(|err| format!("css print error in {url}: {err}"))?;

    let exports = result.exports.map(|map| {
        let mut pairs: Vec<(String, String)> = map
            .into_iter()
            .map(|(name, export)| (name, export.name))
            .collect();
        pairs.sort();
        pairs
    });

    Ok(CssOutput {
        css: result.code,
        exports,
    })
}

#[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 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);
    }

    #[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);
    }

    // grass 0.13 mis-reads a dotted basename (`variables.module`) as an
    // extension and never finds `variables.module.scss`; the DottedFs shim
    // makes the CSS-modules `.module.scss` convention resolve like dart-sass.
    #[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();
        // a plain (non-dotted) sibling must keep working too
        std::fs::write(base.join("css/plain.scss"), "$p: 2px;").unwrap();
        // Both the bare and the explicit-extension forms must resolve, matching
        // dart-sass / Vite (verified against dart-sass 1.51: both give the same
        // output). Excalidraw uses both across its stylesheets.
        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 strip_sass_import_ext_only_touches_import_specifiers() {
        // extension dropped on @use/@forward/@import so grass takes the bare path
        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"
        );
        // a plain CSS @import keeps its .css; url(...) is left alone
        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"
        );
        // a `.scss` string in a normal declaration must not be rewritten
        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() {
        // The target matrix is the compatibility contract of every stylesheet oj
        // emits. Asserted through behaviour: syntax the configured versions
        // support has to survive, and syntax they do not has to be lowered. A
        // matrix that decoded to version 0 would downlevel everything.
        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}");

        // Nesting and logical properties are not supported by the oldest target,
        // so they are lowered.
        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!(logical.contains("left:1px"), "logical props lowered: {logical}");
    }

    #[test]
    fn scoped_class_names_follow_the_name_local_hash_pattern() {
        // The scoped name shows up in devtools, in snapshots and in the exports
        // map, so its shape is part of the contract.
        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");
        // `[name]` is the file stem with its dots flattened.
        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}"
        );
        // The hash is derived from the path, not the contents: editing a
        // stylesheet must not rename its classes, or every edit would invalidate
        // the markup that already references them.
        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 parse_errors_are_reported_not_panicked() {
        assert!(compile_css("/x.css", "!!not-css!!", false).is_err());
    }
}