Manganis CSS Modules Support (#3578)
* feat: type safe css selectors * feat: css modules support --------- Co-authored-by: Jonathan Kelley <jkelleyrtp@gmail.com>
This commit is contained in:
1 parent
bcc1dc020c
commit
e96550c48c
9 files changed
+736
-100
No files matched your search
@@ -1,89 +1,24 @@
|
||||
use crate::{resolve_path, AssetParseError};
|
||||
use macro_string::MacroString;
|
||||
use manganis_core::hash::AssetHash;
|
||||
use proc_macro2::TokenStream as TokenStream2;
|
||||
use quote::{quote, ToTokens, TokenStreamExt};
|
||||
use std::{iter, path::PathBuf};
|
||||
use std::path::PathBuf;
|
||||
use syn::{
|
||||
parse::{Parse, ParseStream},
|
||||
spanned::Spanned as _,
|
||||
Token,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum AssetParseError {
|
||||
AssetDoesntExist { path: PathBuf },
|
||||
InvalidPath { path: PathBuf },
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AssetParseError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
AssetParseError::AssetDoesntExist { path } => {
|
||||
write!(f, "Asset at {} doesn't exist", path.display())
|
||||
}
|
||||
AssetParseError::InvalidPath { path } => {
|
||||
write!(
|
||||
f,
|
||||
"Asset path {} is invalid. Make sure the asset exists within this crate.",
|
||||
path.display()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_path(raw: &str) -> Result<PathBuf, AssetParseError> {
|
||||
// Get the location of the root of the crate which is where all assets are relative to
|
||||
//
|
||||
// IE
|
||||
// /users/dioxus/dev/app/
|
||||
// is the root of
|
||||
// /users/dioxus/dev/app/assets/blah.css
|
||||
let manifest_dir = dunce::canonicalize(
|
||||
std::env::var("CARGO_MANIFEST_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// 1. the input file should be a pathbuf
|
||||
let input = PathBuf::from(raw);
|
||||
|
||||
// 2. absolute path to the asset
|
||||
let Ok(path) = std::path::absolute(manifest_dir.join(raw.trim_start_matches('/'))) else {
|
||||
return Err(AssetParseError::InvalidPath {
|
||||
path: input.clone(),
|
||||
});
|
||||
};
|
||||
|
||||
// 3. Ensure the path exists
|
||||
let Ok(path) = dunce::canonicalize(path) else {
|
||||
return Err(AssetParseError::AssetDoesntExist {
|
||||
path: input.clone(),
|
||||
});
|
||||
};
|
||||
|
||||
// 4. Ensure the path doesn't escape the crate dir
|
||||
//
|
||||
// - Note: since we called canonicalize on both paths, we can safely compare the parent dirs.
|
||||
// On windows, we can only compare the prefix if both paths are canonicalized (not just absolute)
|
||||
// https://github.com/rust-lang/rust/issues/42869
|
||||
if path == manifest_dir || !path.starts_with(manifest_dir) {
|
||||
return Err(AssetParseError::InvalidPath { path });
|
||||
}
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub struct AssetParser {
|
||||
/// The token(s) of the source string, for error reporting
|
||||
path_expr: proc_macro2::TokenStream,
|
||||
pub(crate) path_expr: proc_macro2::TokenStream,
|
||||
|
||||
/// The asset itself
|
||||
asset: Result<PathBuf, AssetParseError>,
|
||||
pub(crate) asset: Result<PathBuf, AssetParseError>,
|
||||
|
||||
/// The source of the trailing options
|
||||
options: TokenStream2,
|
||||
pub(crate) options: TokenStream2,
|
||||
}
|
||||
|
||||
impl Parse for AssetParser {
|
||||
@@ -107,7 +42,7 @@ impl Parse for AssetParser {
|
||||
// But we need to decide the hint first before parsing the options
|
||||
fn parse(input: ParseStream) -> syn::Result<Self> {
|
||||
// And then parse the options
|
||||
let (MacroString(src), path_expr) = input.call(parse_with_tokens)?;
|
||||
let (MacroString(src), path_expr) = input.call(crate::parse_with_tokens)?;
|
||||
let asset = resolve_path(&src);
|
||||
let _comma = input.parse::<Token![,]>();
|
||||
let options = input.parse()?;
|
||||
@@ -190,20 +125,3 @@ impl ToTokens for AssetParser {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `T`, while also collecting the tokens it was parsed from.
|
||||
fn parse_with_tokens<T: Parse>(input: ParseStream) -> syn::Result<(T, proc_macro2::TokenStream)> {
|
||||
let begin = input.cursor();
|
||||
let t: T = input.parse()?;
|
||||
let end = input.cursor();
|
||||
|
||||
let mut cursor = begin;
|
||||
let mut tokens = proc_macro2::TokenStream::new();
|
||||
while cursor != end {
|
||||
let (tt, next) = cursor.token_tree().unwrap();
|
||||
tokens.extend(iter::once(tt));
|
||||
cursor = next;
|
||||
}
|
||||
|
||||
Ok((t, tokens))
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
use crate::{asset::AssetParser, resolve_path};
|
||||
use macro_string::MacroString;
|
||||
use proc_macro2::{Span, TokenStream};
|
||||
use quote::{format_ident, quote, ToTokens, TokenStreamExt};
|
||||
use syn::{
|
||||
parse::{Parse, ParseStream},
|
||||
token::Comma,
|
||||
Ident, Token, Visibility,
|
||||
};
|
||||
|
||||
pub(crate) struct CssModuleParser {
|
||||
/// Whether the ident is const or static.
|
||||
styles_vis: Visibility,
|
||||
styles_ident: Ident,
|
||||
asset_parser: AssetParser,
|
||||
}
|
||||
|
||||
impl Parse for CssModuleParser {
|
||||
fn parse(input: ParseStream) -> syn::Result<Self> {
|
||||
// NEW: macro!(pub? STYLES_IDENT = "/path.css");
|
||||
// pub(x)?
|
||||
let styles_vis = input.parse::<Visibility>()?;
|
||||
|
||||
// Styles Ident
|
||||
let styles_ident = input.parse::<Ident>()?;
|
||||
let _equals = input.parse::<Token![=]>()?;
|
||||
|
||||
// Asset path "/path.css"
|
||||
let (MacroString(src), path_expr) = input.call(crate::parse_with_tokens)?;
|
||||
let asset = resolve_path(&src);
|
||||
|
||||
let _comma = input.parse::<Comma>();
|
||||
|
||||
// Optional options
|
||||
let mut options = input.parse::<TokenStream>()?;
|
||||
if options.is_empty() {
|
||||
options = quote! { manganis::CssModuleAssetOptions::new() }
|
||||
}
|
||||
|
||||
let asset_parser = AssetParser {
|
||||
path_expr,
|
||||
asset,
|
||||
options,
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
styles_vis,
|
||||
styles_ident,
|
||||
asset_parser,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ToTokens for CssModuleParser {
|
||||
fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) {
|
||||
// Use the regular asset parser to generate the linker bridge.
|
||||
let mut linker_tokens = quote! {
|
||||
/// Auto-generated Manganis asset for css modules.
|
||||
#[allow(missing_docs)]
|
||||
const ASSET: manganis::Asset =
|
||||
};
|
||||
self.asset_parser.to_tokens(&mut linker_tokens);
|
||||
|
||||
let path = match self.asset_parser.asset.as_ref() {
|
||||
Ok(path) => path,
|
||||
Err(err) => {
|
||||
let err = err.to_string();
|
||||
tokens.append_all(quote! { compile_error!(#err) });
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Get the file hash
|
||||
let hash = match crate::hash_file_contents(path) {
|
||||
Ok(hash) => hash,
|
||||
Err(err) => {
|
||||
let err = err.to_string();
|
||||
tokens.append_all(quote! { compile_error!(#err) });
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Process css idents
|
||||
let css = std::fs::read_to_string(path).unwrap();
|
||||
let (classes, ids) = manganis_core::collect_css_idents(&css);
|
||||
|
||||
let mut values = Vec::new();
|
||||
|
||||
// Create unique module name based on styles ident.
|
||||
let styles_ident = &self.styles_ident;
|
||||
let mod_name = format_ident!("__{}_module", styles_ident);
|
||||
|
||||
// Generate id struct field tokens.
|
||||
for id in ids.iter() {
|
||||
let as_snake = to_snake_case(id);
|
||||
let ident = Ident::new(&as_snake, Span::call_site());
|
||||
|
||||
values.push(quote! {
|
||||
pub const #ident: #mod_name::__CssIdent = #mod_name::__CssIdent { inner: manganis::macro_helpers::const_serialize::ConstStr::new(#id).push_str(#mod_name::__ASSET_HASH.as_str()).as_str() };
|
||||
});
|
||||
}
|
||||
|
||||
// Generate class struct field tokens.
|
||||
for class in classes.iter() {
|
||||
let as_snake = to_snake_case(class);
|
||||
let as_snake = match ids.contains(class) {
|
||||
false => as_snake,
|
||||
true => format!("{as_snake}_class"),
|
||||
};
|
||||
|
||||
let ident = Ident::new(&as_snake, Span::call_site());
|
||||
values.push(quote! {
|
||||
pub const #ident: #mod_name::__CssIdent = #mod_name::__CssIdent { inner: manganis::macro_helpers::const_serialize::ConstStr::new(#class).push_str(#mod_name::__ASSET_HASH.as_str()).as_str() };
|
||||
});
|
||||
}
|
||||
|
||||
let options = &self.asset_parser.options;
|
||||
let styles_vis = &self.styles_vis;
|
||||
|
||||
// We use a PhantomData to prevent Rust from complaining about an unused lifetime if a css module without any idents is used.
|
||||
tokens.extend(quote! {
|
||||
#[doc(hidden)]
|
||||
#[allow(missing_docs, non_snake_case)]
|
||||
mod #mod_name {
|
||||
#[allow(unused_imports)]
|
||||
use super::manganis::{self, CssModuleAssetOptions};
|
||||
|
||||
#linker_tokens;
|
||||
|
||||
// Get the hash to use when builidng hashed css idents.
|
||||
const __ASSET_OPTIONS: manganis::AssetOptions = #options.into_asset_options();
|
||||
pub(super) const __ASSET_HASH: manganis::macro_helpers::const_serialize::ConstStr = manganis::macro_helpers::hash_asset(&__ASSET_OPTIONS, #hash);
|
||||
|
||||
// Css ident class for deref stylesheet inclusion.
|
||||
pub(super) struct __CssIdent { pub inner: &'static str }
|
||||
|
||||
use std::ops::Deref;
|
||||
use std::sync::OnceLock;
|
||||
use dioxus::document::{document, LinkProps};
|
||||
|
||||
impl Deref for __CssIdent {
|
||||
type Target = str;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
static CELL: OnceLock<()> = OnceLock::new();
|
||||
CELL.get_or_init(move || {
|
||||
let doc = document();
|
||||
doc.create_link(
|
||||
LinkProps::builder()
|
||||
.rel(Some("stylesheet".to_string()))
|
||||
.href(Some(ASSET.to_string()))
|
||||
.build(),
|
||||
);
|
||||
});
|
||||
|
||||
self.inner
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for __CssIdent {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
|
||||
self.deref().fmt(f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto-generated idents struct for CSS modules.
|
||||
#[allow(missing_docs, non_snake_case)]
|
||||
#styles_vis struct #styles_ident {}
|
||||
|
||||
impl #styles_ident {
|
||||
#( #values )*
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert camel and kebab case to snake case.
|
||||
///
|
||||
/// This can fail sometimes, for example `myCss-Class`` is `my_css__class`
|
||||
fn to_snake_case(input: &str) -> String {
|
||||
let mut new = String::new();
|
||||
|
||||
for (i, c) in input.chars().enumerate() {
|
||||
if c.is_uppercase() && i != 0 {
|
||||
new.push('_');
|
||||
}
|
||||
|
||||
new.push(c.to_ascii_lowercase());
|
||||
}
|
||||
|
||||
new.replace('-', "_")
|
||||
}
|
||||
@@ -1,11 +1,22 @@
|
||||
#![doc = include_str!("../README.md")]
|
||||
#![deny(missing_docs)]
|
||||
|
||||
use std::{
|
||||
hash::Hasher,
|
||||
io::Read,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use css_module::CssModuleParser;
|
||||
use proc_macro::TokenStream;
|
||||
use quote::{quote, ToTokens};
|
||||
use syn::parse_macro_input;
|
||||
use syn::{
|
||||
parse::{Parse, ParseStream},
|
||||
parse_macro_input,
|
||||
};
|
||||
|
||||
pub(crate) mod asset;
|
||||
pub(crate) mod css_module;
|
||||
pub(crate) mod linker;
|
||||
|
||||
use linker::generate_link_section;
|
||||
@@ -53,3 +64,236 @@ pub fn asset(input: TokenStream) -> TokenStream {
|
||||
|
||||
quote! { #asset }.into_token_stream().into()
|
||||
}
|
||||
|
||||
/// Generate type-safe and globally-unique CSS identifiers from a CSS module.
|
||||
///
|
||||
/// CSS modules allow you to have unique, scoped and type-safe CSS identifiers. A CSS module is a CSS file with the `.module.css` file extension.
|
||||
/// The `css_module!()` macro allows you to utilize CSS modules in your Rust projects.
|
||||
///
|
||||
/// # Syntax
|
||||
///
|
||||
/// The `css_module!()` macro takes a few items.
|
||||
/// - A styles struct identifier. This is the `struct` you use to access your type-safe CSS identifiers in Rust.
|
||||
/// - The asset string path. This is the absolute path (from the crate root) to your CSS module.
|
||||
/// - An optional `CssModuleAssetOptions` struct to configure the processing of your CSS module.
|
||||
///
|
||||
/// ```rust
|
||||
/// css_module!(StylesIdent = "/my.module.css", CssModuleAssetOptions::new());
|
||||
/// ```
|
||||
///
|
||||
/// The styles struct can be made public by appending `pub` before the identifier.
|
||||
/// Read the [Variable Visibility](#variable-visibility) section for more information.
|
||||
///
|
||||
/// # Generation
|
||||
///
|
||||
/// The `css_module!()` macro does two few things:
|
||||
/// - It generates an asset using the `asset!()` macro and automatically inserts it into the app meta.
|
||||
/// - It generates a struct with snake-case associated constants of your CSS idents.
|
||||
///
|
||||
/// ```rust
|
||||
/// // This macro usage:
|
||||
/// css_module!(Styles = "/mycss.module.css");
|
||||
///
|
||||
/// // Will generate this (simplified):
|
||||
/// struct Styles {}
|
||||
///
|
||||
/// impl Styles {
|
||||
/// // This can be accessed with `Styles::your_ident`
|
||||
/// pub const your_ident: &str = "abc",
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// # CSS Identifier Collection
|
||||
/// The macro will collect all identifiers used in your CSS module, convert them into snake_case, and generate a struct and fields around those identifier names.
|
||||
///
|
||||
/// For example, `#fooBar` will become `foo_bar`.
|
||||
///
|
||||
/// Identifier used only inside of a media query, will not be collected (not yet supported). To get around this, you can use an empty block for the identifier:
|
||||
/// ```css
|
||||
/// /* Empty ident block to ensure collection */
|
||||
/// #foo {}
|
||||
///
|
||||
/// @media ... {
|
||||
/// #foo { ... }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// # Variable Visibility
|
||||
/// If you want your asset or styles constant to be public, you can add the `pub` keyword in front of them.
|
||||
/// Restricted visibility (`pub(super)`, `pub(crate)`, etc) is also supported.
|
||||
/// ```rust
|
||||
/// css_module!(pub Styles = "/mycss.module.css");
|
||||
/// ```
|
||||
///
|
||||
/// # Asset Options
|
||||
/// Similar to the `asset!()` macro, you can pass an optional `CssModuleAssetOptions` to configure a few processing settings.
|
||||
/// ```rust
|
||||
/// use manganis::CssModuleAssetOptions;
|
||||
///
|
||||
/// css_module!(Styles = "/mycss.module.css",
|
||||
/// CssModuleAssetOptions::new()
|
||||
/// .with_minify(true)
|
||||
/// .with_preload(false),
|
||||
/// );
|
||||
/// ```
|
||||
///
|
||||
/// # Examples
|
||||
/// First you need a CSS module:
|
||||
/// ```css
|
||||
/// /* mycss.module.css */
|
||||
///
|
||||
/// #header {
|
||||
/// padding: 50px;
|
||||
/// }
|
||||
///
|
||||
/// .header {
|
||||
/// margin: 20px;
|
||||
/// }
|
||||
///
|
||||
/// .button {
|
||||
/// background-color: #373737;
|
||||
/// }
|
||||
/// ```
|
||||
/// Then you can use the `css_module!()` macro in your Rust project:
|
||||
/// ```rust
|
||||
/// css_module!(Styles = "/mycss.module.css");
|
||||
///
|
||||
/// println!("{}", Styles::header);
|
||||
/// println!("{}", Styles::header_class);
|
||||
/// println!("{}", Styles::button);
|
||||
/// ```
|
||||
#[proc_macro]
|
||||
pub fn css_module(input: TokenStream) -> TokenStream {
|
||||
let style = parse_macro_input!(input as CssModuleParser);
|
||||
|
||||
quote! { #style }.into_token_stream().into()
|
||||
}
|
||||
|
||||
fn resolve_path(raw: &str) -> Result<PathBuf, AssetParseError> {
|
||||
// Get the location of the root of the crate which is where all assets are relative to
|
||||
//
|
||||
// IE
|
||||
// /users/dioxus/dev/app/
|
||||
// is the root of
|
||||
// /users/dioxus/dev/app/assets/blah.css
|
||||
let manifest_dir = dunce::canonicalize(
|
||||
std::env::var("CARGO_MANIFEST_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// 1. the input file should be a pathbuf
|
||||
let input = PathBuf::from(raw);
|
||||
|
||||
// 2. absolute path to the asset
|
||||
let Ok(path) = std::path::absolute(manifest_dir.join(raw.trim_start_matches('/'))) else {
|
||||
return Err(AssetParseError::InvalidPath {
|
||||
path: input.clone(),
|
||||
});
|
||||
};
|
||||
|
||||
// 3. Ensure the path exists
|
||||
let Ok(path) = dunce::canonicalize(path) else {
|
||||
return Err(AssetParseError::AssetDoesntExist {
|
||||
path: input.clone(),
|
||||
});
|
||||
};
|
||||
|
||||
// 4. Ensure the path doesn't escape the crate dir
|
||||
//
|
||||
// - Note: since we called canonicalize on both paths, we can safely compare the parent dirs.
|
||||
// On windows, we can only compare the prefix if both paths are canonicalized (not just absolute)
|
||||
// https://github.com/rust-lang/rust/issues/42869
|
||||
if path == manifest_dir || !path.starts_with(manifest_dir) {
|
||||
return Err(AssetParseError::InvalidPath { path });
|
||||
}
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn hash_file_contents(file_path: &Path) -> Result<u64, AssetParseError> {
|
||||
// Create a hasher
|
||||
let mut hash = std::collections::hash_map::DefaultHasher::new();
|
||||
|
||||
// If this is a folder, hash the folder contents
|
||||
if file_path.is_dir() {
|
||||
let files = std::fs::read_dir(file_path).map_err(|err| AssetParseError::IoError {
|
||||
err,
|
||||
path: file_path.to_path_buf(),
|
||||
})?;
|
||||
for file in files.flatten() {
|
||||
let path = file.path();
|
||||
hash_file_contents(&path)?;
|
||||
}
|
||||
return Ok(hash.finish());
|
||||
}
|
||||
|
||||
// Otherwise, open the file to get its contents
|
||||
let mut file = std::fs::File::open(file_path).map_err(|err| AssetParseError::IoError {
|
||||
err,
|
||||
path: file_path.to_path_buf(),
|
||||
})?;
|
||||
|
||||
// We add a hash to the end of the file so it is invalidated when the bundled version of the file changes
|
||||
// The hash includes the file contents, the options, and the version of manganis. From the macro, we just
|
||||
// know the file contents, so we only include that hash
|
||||
let mut buffer = [0; 8192];
|
||||
loop {
|
||||
let read = file
|
||||
.read(&mut buffer)
|
||||
.map_err(AssetParseError::FailedToReadAsset)?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
hash.write(&buffer[..read]);
|
||||
}
|
||||
|
||||
Ok(hash.finish())
|
||||
}
|
||||
|
||||
/// Parse `T`, while also collecting the tokens it was parsed from.
|
||||
fn parse_with_tokens<T: Parse>(input: ParseStream) -> syn::Result<(T, proc_macro2::TokenStream)> {
|
||||
let begin = input.cursor();
|
||||
let t: T = input.parse()?;
|
||||
let end = input.cursor();
|
||||
|
||||
let mut cursor = begin;
|
||||
let mut tokens = proc_macro2::TokenStream::new();
|
||||
while cursor != end {
|
||||
let (tt, next) = cursor.token_tree().unwrap();
|
||||
tokens.extend(std::iter::once(tt));
|
||||
cursor = next;
|
||||
}
|
||||
|
||||
Ok((t, tokens))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum AssetParseError {
|
||||
AssetDoesntExist { path: PathBuf },
|
||||
IoError { err: std::io::Error, path: PathBuf },
|
||||
InvalidPath { path: PathBuf },
|
||||
FailedToReadAsset(std::io::Error),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AssetParseError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
AssetParseError::AssetDoesntExist { path } => {
|
||||
write!(f, "Asset at {} doesn't exist", path.display())
|
||||
}
|
||||
AssetParseError::IoError { path, err } => {
|
||||
write!(f, "Failed to read file: {}; {}", path.display(), err)
|
||||
}
|
||||
AssetParseError::InvalidPath { path } => {
|
||||
write!(
|
||||
f,
|
||||
"Asset path {} is invalid. Make sure the asset exists within this crate.",
|
||||
path.display()
|
||||
)
|
||||
}
|
||||
AssetParseError::FailedToReadAsset(err) => write!(f, "Failed to read asset: {}", err),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user