Skip to main content

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