lightningcss/
css_modules.rs

1//! CSS module exports.
2//!
3//! [CSS modules](https://github.com/css-modules/css-modules) are a way of locally scoping names in a
4//! CSS file. This includes class names, ids, keyframe animation names, and any other places where the
5//! [CustomIdent](super::values::ident::CustomIdent) type is used.
6//!
7//! CSS modules can be enabled using the `css_modules` option when parsing a style sheet. When the
8//! style sheet is printed, hashes will be added to any declared names, and references to those names
9//! will be updated accordingly. A map of the original names to compiled (hashed) names will be returned.
10
11use crate::error::PrinterErrorKind;
12use crate::properties::css_modules::{Composes, Specifier};
13use crate::selector::SelectorList;
14use data_encoding::{Encoding, Specification};
15use lazy_static::lazy_static;
16use pathdiff::diff_paths;
17#[cfg(any(feature = "serde", feature = "nodejs"))]
18use serde::Serialize;
19use smallvec::{smallvec, SmallVec};
20use std::borrow::Cow;
21use std::collections::hash_map::DefaultHasher;
22use std::collections::HashMap;
23use std::fmt::Write;
24use std::hash::{Hash, Hasher};
25use std::path::Path;
26
27/// Configuration for CSS modules.
28#[derive(Clone, Debug)]
29pub struct Config<'i> {
30  /// The name pattern to use when renaming class names and other identifiers.
31  /// Default is `[hash]_[local]`.
32  pub pattern: Pattern<'i>,
33  /// Whether to rename dashed identifiers, e.g. custom properties.
34  pub dashed_idents: bool,
35  /// Whether to scope animation names.
36  /// Default is `true`.
37  pub animation: bool,
38  /// Whether to scope grid names.
39  /// Default is `true`.
40  pub grid: bool,
41  /// Whether to scope custom identifiers
42  /// Default is `true`.
43  pub custom_idents: bool,
44  /// Whether to check for pure CSS modules.
45  pub pure: bool,
46}
47
48impl<'i> Default for Config<'i> {
49  fn default() -> Self {
50    Config {
51      pattern: Default::default(),
52      dashed_idents: Default::default(),
53      animation: true,
54      grid: true,
55      custom_idents: true,
56      pure: false,
57    }
58  }
59}
60
61/// A CSS modules class name pattern.
62#[derive(Clone, Debug)]
63pub struct Pattern<'i> {
64  /// The list of segments in the pattern.
65  pub segments: SmallVec<[Segment<'i>; 2]>,
66}
67
68impl<'i> Default for Pattern<'i> {
69  fn default() -> Self {
70    Pattern {
71      segments: smallvec![Segment::Hash, Segment::Literal("_"), Segment::Local],
72    }
73  }
74}
75
76/// An error that occurred while parsing a CSS modules name pattern.
77#[derive(Debug)]
78pub enum PatternParseError {
79  /// An unknown placeholder segment was encountered at the given index.
80  UnknownPlaceholder(String, usize),
81  /// An opening bracket with no following closing bracket was found at the given index.
82  UnclosedBrackets(usize),
83}
84
85impl std::fmt::Display for PatternParseError {
86  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87    use PatternParseError::*;
88    match self {
89      UnknownPlaceholder(p, i) => write!(
90        f,
91        "Error parsing CSS modules pattern: unknown placeholder \"{}\" at index {}",
92        p, i
93      ),
94      UnclosedBrackets(i) => write!(f, "Error parsing CSS modules pattern: unclosed brackets at index {}", i),
95    }
96  }
97}
98
99impl std::error::Error for PatternParseError {}
100
101impl<'i> Pattern<'i> {
102  /// Parse a pattern from a string.
103  pub fn parse(mut input: &'i str) -> Result<Self, PatternParseError> {
104    let mut segments = SmallVec::new();
105    let mut start_idx: usize = 0;
106    while !input.is_empty() {
107      if input.starts_with('[') {
108        if let Some(end_idx) = input.find(']') {
109          let segment = match &input[0..=end_idx] {
110            "[name]" => Segment::Name,
111            "[local]" => Segment::Local,
112            "[hash]" => Segment::Hash,
113            "[content-hash]" => Segment::ContentHash,
114            s => return Err(PatternParseError::UnknownPlaceholder(s.into(), start_idx)),
115          };
116          segments.push(segment);
117          start_idx += end_idx + 1;
118          input = &input[end_idx + 1..];
119        } else {
120          return Err(PatternParseError::UnclosedBrackets(start_idx));
121        }
122      } else {
123        let end_idx = input.find('[').unwrap_or_else(|| input.len());
124        segments.push(Segment::Literal(&input[0..end_idx]));
125        start_idx += end_idx;
126        input = &input[end_idx..];
127      }
128    }
129
130    Ok(Pattern { segments })
131  }
132
133  /// Whether the pattern contains any `[content-hash]` segments.
134  pub fn has_content_hash(&self) -> bool {
135    self.segments.iter().any(|s| matches!(s, Segment::ContentHash))
136  }
137
138  /// Write the substituted pattern to a destination.
139  pub fn write<W, E>(
140    &self,
141    hash: &str,
142    path: &Path,
143    local: &str,
144    content_hash: &str,
145    mut write: W,
146  ) -> Result<(), E>
147  where
148    W: FnMut(&str) -> Result<(), E>,
149  {
150    for segment in &self.segments {
151      match segment {
152        Segment::Literal(s) => {
153          write(s)?;
154        }
155        Segment::Name => {
156          let stem = path.file_stem().unwrap().to_str().unwrap();
157          if stem.contains('.') {
158            write(&stem.replace('.', "-"))?;
159          } else {
160            write(stem)?;
161          }
162        }
163        Segment::Local => {
164          write(local)?;
165        }
166        Segment::Hash => {
167          write(hash)?;
168        }
169        Segment::ContentHash => {
170          write(content_hash)?;
171        }
172      }
173    }
174    Ok(())
175  }
176
177  #[inline]
178  fn write_to_string(
179    &self,
180    mut res: String,
181    hash: &str,
182    path: &Path,
183    local: &str,
184    content_hash: &str,
185  ) -> Result<String, std::fmt::Error> {
186    self.write(hash, path, local, content_hash, |s| res.write_str(s))?;
187    Ok(res)
188  }
189}
190
191/// A segment in a CSS modules class name pattern.
192///
193/// See [Pattern](Pattern).
194#[derive(Clone, Debug)]
195pub enum Segment<'i> {
196  /// A literal string segment.
197  Literal(&'i str),
198  /// The base file name.
199  Name,
200  /// The original class name.
201  Local,
202  /// A hash of the file name.
203  Hash,
204  /// A hash of the file contents.
205  ContentHash,
206}
207
208/// A referenced name within a CSS module, e.g. via the `composes` property.
209///
210/// See [CssModuleExport](CssModuleExport).
211#[derive(PartialEq, Debug, Clone)]
212#[cfg_attr(any(feature = "serde", feature = "nodejs"), derive(Serialize))]
213#[cfg_attr(
214  any(feature = "serde", feature = "nodejs"),
215  serde(tag = "type", rename_all = "lowercase")
216)]
217pub enum CssModuleReference {
218  /// A local reference.
219  Local {
220    /// The local (compiled) name for the reference.
221    name: String,
222  },
223  /// A global reference.
224  Global {
225    /// The referenced global name.
226    name: String,
227  },
228  /// A reference to an export in a different file.
229  Dependency {
230    /// The name to reference within the dependency.
231    name: String,
232    /// The dependency specifier for the referenced file.
233    specifier: String,
234  },
235}
236
237/// An exported value from a CSS module.
238#[derive(PartialEq, Debug, Clone)]
239#[cfg_attr(any(feature = "serde", feature = "nodejs"), derive(Serialize))]
240#[cfg_attr(any(feature = "serde", feature = "nodejs"), serde(rename_all = "camelCase"))]
241pub struct CssModuleExport {
242  /// The local (compiled) name for this export.
243  pub name: String,
244  /// Other names that are composed by this export.
245  pub composes: Vec<CssModuleReference>,
246  /// Whether the export is referenced in this file.
247  pub is_referenced: bool,
248}
249
250/// A map of exported names to values.
251pub type CssModuleExports = HashMap<String, CssModuleExport>;
252
253/// A map of placeholders to references.
254pub type CssModuleReferences = HashMap<String, CssModuleReference>;
255
256lazy_static! {
257  static ref ENCODER: Encoding = {
258    let mut spec = Specification::new();
259    spec
260      .symbols
261      .push_str("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890_-");
262    spec.encoding().unwrap()
263  };
264}
265
266pub(crate) struct CssModule<'a, 'b, 'c> {
267  pub config: &'a Config<'b>,
268  pub sources: Vec<&'c Path>,
269  pub hashes: Vec<String>,
270  pub content_hashes: &'a Option<Vec<String>>,
271  pub exports_by_source_index: Vec<CssModuleExports>,
272  pub references: &'a mut HashMap<String, CssModuleReference>,
273}
274
275impl<'a, 'b, 'c> CssModule<'a, 'b, 'c> {
276  pub fn new(
277    config: &'a Config<'b>,
278    sources: &'c Vec<String>,
279    project_root: Option<&'c str>,
280    references: &'a mut HashMap<String, CssModuleReference>,
281    content_hashes: &'a Option<Vec<String>>,
282  ) -> Self {
283    let project_root = project_root.map(|p| Path::new(p));
284    let sources: Vec<&Path> = sources.iter().map(|filename| Path::new(filename)).collect();
285    let hashes = sources
286      .iter()
287      .map(|path| {
288        // Make paths relative to project root so hashes are stable.
289        let source = match project_root {
290          Some(project_root) if path.is_absolute() => {
291            diff_paths(path, project_root).map_or(Cow::Borrowed(*path), Cow::Owned)
292          }
293          _ => Cow::Borrowed(*path),
294        };
295        hash(
296          &source.to_string_lossy(),
297          matches!(config.pattern.segments[0], Segment::Hash),
298        )
299      })
300      .collect();
301    Self {
302      config,
303      exports_by_source_index: sources.iter().map(|_| HashMap::new()).collect(),
304      sources,
305      hashes,
306      content_hashes,
307      references,
308    }
309  }
310
311  pub fn add_local(&mut self, exported: &str, local: &str, source_index: u32) {
312    self.exports_by_source_index[source_index as usize]
313      .entry(exported.into())
314      .or_insert_with(|| CssModuleExport {
315        name: self
316          .config
317          .pattern
318          .write_to_string(
319            String::new(),
320            &self.hashes[source_index as usize],
321            &self.sources[source_index as usize],
322            local,
323            if let Some(content_hashes) = &self.content_hashes {
324              &content_hashes[source_index as usize]
325            } else {
326              ""
327            },
328          )
329          .unwrap(),
330        composes: vec![],
331        is_referenced: false,
332      });
333  }
334
335  pub fn add_dashed(&mut self, local: &str, source_index: u32) {
336    self.exports_by_source_index[source_index as usize]
337      .entry(local.into())
338      .or_insert_with(|| CssModuleExport {
339        name: self
340          .config
341          .pattern
342          .write_to_string(
343            "--".into(),
344            &self.hashes[source_index as usize],
345            &self.sources[source_index as usize],
346            &local[2..],
347            if let Some(content_hashes) = &self.content_hashes {
348              &content_hashes[source_index as usize]
349            } else {
350              ""
351            },
352          )
353          .unwrap(),
354        composes: vec![],
355        is_referenced: false,
356      });
357  }
358
359  pub fn reference(&mut self, name: &str, source_index: u32) {
360    match self.exports_by_source_index[source_index as usize].entry(name.into()) {
361      std::collections::hash_map::Entry::Occupied(mut entry) => {
362        entry.get_mut().is_referenced = true;
363      }
364      std::collections::hash_map::Entry::Vacant(entry) => {
365        entry.insert(CssModuleExport {
366          name: self
367            .config
368            .pattern
369            .write_to_string(
370              String::new(),
371              &self.hashes[source_index as usize],
372              &self.sources[source_index as usize],
373              name,
374              if let Some(content_hashes) = &self.content_hashes {
375                &content_hashes[source_index as usize]
376              } else {
377                ""
378              },
379            )
380            .unwrap(),
381          composes: vec![],
382          is_referenced: true,
383        });
384      }
385    }
386  }
387
388  pub fn reference_dashed(&mut self, name: &str, from: &Option<Specifier>, source_index: u32) -> Option<String> {
389    let (reference, key) = match from {
390      Some(Specifier::Global) => return Some(name[2..].into()),
391      Some(Specifier::File(file)) => (
392        CssModuleReference::Dependency {
393          name: name.to_string(),
394          specifier: file.to_string(),
395        },
396        file.as_ref(),
397      ),
398      Some(Specifier::SourceIndex(source_index)) => {
399        return Some(
400          self
401            .config
402            .pattern
403            .write_to_string(
404              String::new(),
405              &self.hashes[*source_index as usize],
406              &self.sources[*source_index as usize],
407              &name[2..],
408              if let Some(content_hashes) = &self.content_hashes {
409                &content_hashes[*source_index as usize]
410              } else {
411                ""
412              },
413            )
414            .unwrap(),
415        )
416      }
417      None => {
418        // Local export. Mark as used.
419        match self.exports_by_source_index[source_index as usize].entry(name.into()) {
420          std::collections::hash_map::Entry::Occupied(mut entry) => {
421            entry.get_mut().is_referenced = true;
422          }
423          std::collections::hash_map::Entry::Vacant(entry) => {
424            entry.insert(CssModuleExport {
425              name: self
426                .config
427                .pattern
428                .write_to_string(
429                  "--".into(),
430                  &self.hashes[source_index as usize],
431                  &self.sources[source_index as usize],
432                  &name[2..],
433                  if let Some(content_hashes) = &self.content_hashes {
434                    &content_hashes[source_index as usize]
435                  } else {
436                    ""
437                  },
438                )
439                .unwrap(),
440              composes: vec![],
441              is_referenced: true,
442            });
443          }
444        }
445        return None;
446      }
447    };
448
449    let hash = hash(
450      &format!("{}_{}_{}", self.hashes[source_index as usize], name, key),
451      false,
452    );
453    let name = format!("--{}", hash);
454
455    self.references.insert(name.clone(), reference);
456    Some(hash)
457  }
458
459  pub fn handle_composes(
460    &mut self,
461    selectors: &SelectorList,
462    composes: &Composes,
463    source_index: u32,
464  ) -> Result<(), PrinterErrorKind> {
465    for sel in &selectors.0 {
466      if sel.len() == 1 {
467        match sel.iter_raw_match_order().next().unwrap() {
468          parcel_selectors::parser::Component::Class(ref id) => {
469            for name in &composes.names {
470              let reference = match &composes.from {
471                None => CssModuleReference::Local {
472                  name: self
473                    .config
474                    .pattern
475                    .write_to_string(
476                      String::new(),
477                      &self.hashes[source_index as usize],
478                      &self.sources[source_index as usize],
479                      name.0.as_ref(),
480                      if let Some(content_hashes) = &self.content_hashes {
481                        &content_hashes[source_index as usize]
482                      } else {
483                        ""
484                      },
485                    )
486                    .unwrap(),
487                },
488                Some(Specifier::SourceIndex(dep_source_index)) => {
489                  if let Some(entry) =
490                    self.exports_by_source_index[*dep_source_index as usize].get(&name.0.as_ref().to_owned())
491                  {
492                    let name = entry.name.clone();
493                    let composes = entry.composes.clone();
494                    let export = self.exports_by_source_index[source_index as usize]
495                      .get_mut(&id.0.as_ref().to_owned())
496                      .unwrap();
497
498                    export.composes.push(CssModuleReference::Local { name });
499                    export.composes.extend(composes);
500                  }
501                  continue;
502                }
503                Some(Specifier::Global) => CssModuleReference::Global {
504                  name: name.0.as_ref().into(),
505                },
506                Some(Specifier::File(file)) => CssModuleReference::Dependency {
507                  name: name.0.to_string(),
508                  specifier: file.to_string(),
509                },
510              };
511
512              let export = self.exports_by_source_index[source_index as usize]
513                .get_mut(&id.0.as_ref().to_owned())
514                .unwrap();
515              if !export.composes.contains(&reference) {
516                export.composes.push(reference);
517              }
518            }
519            continue;
520          }
521          _ => {}
522        }
523      }
524
525      // The composes property can only be used within a simple class selector.
526      return Err(PrinterErrorKind::InvalidComposesSelector);
527    }
528
529    Ok(())
530  }
531}
532
533pub(crate) fn hash(s: &str, at_start: bool) -> String {
534  let mut hasher = DefaultHasher::new();
535  s.hash(&mut hasher);
536  let hash = hasher.finish() as u32;
537
538  let hash = ENCODER.encode(&hash.to_le_bytes());
539  if at_start && matches!(hash.as_bytes()[0], b'0'..=b'9') {
540    format!("_{}", hash)
541  } else {
542    hash
543  }
544}