silkenweb_css/
lib.rs

1use std::{
2    collections::HashSet,
3    env,
4    ffi::OsStr,
5    fs,
6    path::{Path, PathBuf},
7    str::FromStr,
8};
9
10use anyhow::{anyhow, Context};
11use cssparser::{Parser, ParserInput, Token};
12use derive_more::Into;
13use grass::InputSyntax;
14
15#[cfg_attr(feature = "css-transpile", path = "transpile-enabled.rs")]
16#[cfg_attr(not(feature = "css-transpile"), path = "transpile-disabled.rs")]
17mod transpile;
18
19use thiserror::Error;
20pub use transpile::Version;
21
22pub struct NameMapping {
23    pub plain: String,
24    pub mangled: String,
25}
26
27#[derive(Into)]
28pub struct CssSyntax(InputSyntax);
29
30impl Default for CssSyntax {
31    fn default() -> Self {
32        Self(InputSyntax::Css)
33    }
34}
35
36impl FromStr for CssSyntax {
37    type Err = ();
38
39    fn from_str(syntax: &str) -> Result<Self, Self::Err> {
40        let syntax = match syntax {
41            "css" => InputSyntax::Css,
42            "scss" => InputSyntax::Scss,
43            "sass" => InputSyntax::Sass,
44            _ => return Err(()),
45        };
46
47        Ok(Self(syntax))
48    }
49}
50
51impl CssSyntax {
52    fn from_path(path: impl AsRef<Path>) -> Self {
53        path.as_ref()
54            .extension()
55            .and_then(OsStr::to_str)
56            .and_then(|ext| Self::from_str(ext.to_lowercase().as_str()).ok())
57            .unwrap_or_default()
58    }
59}
60
61#[derive(Debug)]
62pub struct Css {
63    content: String,
64    dependency: Option<String>,
65}
66
67impl Css {
68    pub fn from_content(content: impl Into<String>, syntax: CssSyntax) -> Result<Self, Error> {
69        Ok(Self {
70            content: Self::css_content(content.into(), syntax)?,
71            dependency: None,
72        })
73    }
74
75    pub fn from_path(path: impl AsRef<Path>, syntax: Option<CssSyntax>) -> Result<Self, Error> {
76        let syntax = syntax.unwrap_or_else(|| CssSyntax::from_path(path.as_ref()));
77        const CARGO_MANIFEST_DIR: &str = "CARGO_MANIFEST_DIR";
78
79        let root_dir = env::var(CARGO_MANIFEST_DIR)
80            .with_context(|| format!("Couldn't read '{CARGO_MANIFEST_DIR}' variable"))?;
81        let path = PathBuf::from(root_dir)
82            .join(path)
83            .into_os_string()
84            .into_string()
85            .map_err(|filename| anyhow!("Couldn't convert filename to string: '{filename:?}'"))?;
86
87        Ok(Self {
88            content: Self::css_content(
89                fs::read_to_string(&path)
90                    .with_context(|| format!("Couldn't read file '{path}'"))?,
91                syntax,
92            )?,
93            dependency: Some(path),
94        })
95    }
96
97    fn css_content(source: String, syntax: CssSyntax) -> Result<String, Error> {
98        let syntax = syntax.into();
99
100        let css = if syntax != InputSyntax::Css {
101            grass::from_string(source, &grass::Options::default().input_syntax(syntax))
102                .context("Error parsing CSS")?
103        } else {
104            source
105        };
106
107        Ok(css)
108    }
109
110    pub fn transpile(
111        &mut self,
112        validate: bool,
113        transpile: Option<Transpile>,
114    ) -> Result<Option<Vec<NameMapping>>, TranspileError> {
115        if validate || transpile.is_some() {
116            transpile::transpile(self, validate, transpile)
117        } else {
118            Ok(None)
119        }
120    }
121
122    pub fn dependency(&self) -> Option<&str> {
123        self.dependency.as_deref()
124    }
125
126    pub fn content(&self) -> &str {
127        &self.content
128    }
129
130    pub fn class_names(&self) -> impl Iterator<Item = String> {
131        let mut parser_input = ParserInput::new(&self.content);
132        let mut input = Parser::new(&mut parser_input);
133        let mut classes = HashSet::new();
134        let mut prev_dot = false;
135
136        while let Ok(token) = input.next_including_whitespace_and_comments() {
137            if prev_dot {
138                if let Token::Ident(class) = token {
139                    classes.insert(class.to_string());
140                }
141            }
142
143            prev_dot = matches!(token, Token::Delim('.'));
144        }
145
146        classes.into_iter()
147    }
148
149    pub fn variable_names(&self) -> impl Iterator<Item = String> {
150        let mut parser_input = ParserInput::new(&self.content);
151        let mut input = Parser::new(&mut parser_input);
152        let mut variables = HashSet::new();
153        let mut tokens = Vec::new();
154
155        flattened_tokens(&mut tokens, &mut input);
156
157        for token in tokens {
158            if let Token::Ident(ident) = token {
159                if let Some(var) = ident.strip_prefix("--") {
160                    variables.insert(var.to_string());
161                }
162            }
163        }
164
165        variables.into_iter()
166    }
167}
168
169pub struct Transpile {
170    pub minify: bool,
171    pub pretty: bool,
172    pub modules: bool,
173    pub nesting: bool,
174    pub browsers: Option<Browsers>,
175}
176
177#[derive(Default)]
178pub struct Browsers {
179    pub android: Option<Version>,
180    pub chrome: Option<Version>,
181    pub edge: Option<Version>,
182    pub firefox: Option<Version>,
183    pub ie: Option<Version>,
184    pub ios_saf: Option<Version>,
185    pub opera: Option<Version>,
186    pub safari: Option<Version>,
187    pub samsung: Option<Version>,
188}
189
190#[derive(Error, Debug)]
191#[error(transparent)]
192pub struct Error(#[from] anyhow::Error);
193
194#[derive(Error, Debug)]
195pub enum TranspileError {
196    #[error("Transpilation requires `css-transpile` feature")]
197    Disabled,
198    #[error("Transpile failed: {0}")]
199    Failed(#[from] Error),
200}
201
202impl From<anyhow::Error> for TranspileError {
203    fn from(value: anyhow::Error) -> Self {
204        Self::Failed(value.into())
205    }
206}
207
208fn flattened_tokens<'i>(tokens: &mut Vec<Token<'i>>, input: &mut Parser<'i, '_>) {
209    while let Ok(token) = input.next_including_whitespace_and_comments() {
210        tokens.push(token.clone());
211
212        match token {
213            Token::ParenthesisBlock
214            | Token::CurlyBracketBlock
215            | Token::SquareBracketBlock
216            | Token::Function(_) => {
217                input
218                    .parse_nested_block(|parser| -> Result<(), cssparser::ParseError<()>> {
219                        flattened_tokens(tokens, parser);
220                        Ok(())
221                    })
222                    .unwrap();
223            }
224            _ => (),
225        }
226    }
227}