Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/vertigo-macro/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ pkg-version = "1"
proc-macro-error = "1.0"
proc-macro2 = "1.0"
quote = "1.0"
regex = "1.11"
rstml = "0.12"
syn = { version = "2.0", features = ["full"] }

Expand Down
4 changes: 4 additions & 0 deletions crates/vertigo-macro/src/css_parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ impl CssParser {

let css_output = parser.children.join("\n");

if let Err(err) = crate::trace_tailwind::collect_tailwind_classes(&css_output, true) {
emit_error!(call_site, "Failed to collect Tailwind classes: {}", err);
}

let params = parser.params.into_hashmap();

if params.is_empty() {
Expand Down
41 changes: 27 additions & 14 deletions crates/vertigo-macro/src/trace_tailwind/macro_impl.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use proc_macro::TokenStream;
use proc_macro_error::emit_error;
use proc_macro2::TokenStream as TokenStream2;
use quote::{ToTokens, quote};
use quote::quote;
use std::error::Error;
use std::io::Write;
use syn::spanned::Spanned;
Expand All @@ -24,23 +24,36 @@
if let Expr::Lit(expr_lit) = &input
&& let Lit::Str(input_lit) = &expr_lit.lit
{
let input_str = input_lit.to_token_stream().to_string();
let input_str = input_str.trim_matches('"');
// Only collect tailwind classes during build
if std::env::var("VERTIGO_BUNDLE").is_ok() {
let file_path = get_tailwind_classes_file_path()?;
let input_str = input_lit.value();
collect_tailwind_classes(&input_str, false)?;
// Use output in source code
return Ok(quote! { #input_lit });
}
Ok(quote! { #input })
}

// Open the file in append mode
let mut file = std::fs::OpenOptions::new()
.append(true)
.create(true) // Create the file if it doesn't exist
.open(&file_path)?;
pub(crate) fn collect_tailwind_classes(input_str: &str, is_raw_css: bool) -> Result<(), Box<dyn Error>> {
// Only collect tailwind classes during build
if std::env::var("VERTIGO_BUNDLE").is_ok() {
let file_path = get_tailwind_classes_file_path()?;

// Open the file in append mode
let mut file = std::fs::OpenOptions::new()
.append(true)
.create(true) // Create the file if it doesn't exist
.open(&file_path)?;

if is_raw_css {
// For raw CSS, we only care about variables
// Extract var usage and write it in a way that validate.rs can identify as non-class
let re = regex::Regex::new(r"var\((--[a-zA-Z0-9_-]+)\)").unwrap();

Check failure on line 49 in crates/vertigo-macro/src/trace_tailwind/macro_impl.rs

View workflow job for this annotation

GitHub Actions / Nightly Vertigo Clippy Output

used `unwrap()` on a `Result` value

error: used `unwrap()` on a `Result` value --> crates/vertigo-macro/src/trace_tailwind/macro_impl.rs:49:22 | 49 | let re = regex::Regex::new(r"var\((--[a-zA-Z0-9_-]+)\)").unwrap(); | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | = note: if this value is an `Err`, it will panic = help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#unwrap_used = note: requested on the command line with `-D clippy::unwrap-used`
for cap in re.captures_iter(input_str) {
writeln!(file, "var({})", &cap[1])?;
}
} else {
// Write the input string to the file
writeln!(file, "{input_str}")?;
}
// Use output in source code
return Ok(quote! { #input_lit });
}
Ok(quote! { #input })
Ok(())
}
2 changes: 1 addition & 1 deletion crates/vertigo-macro/src/trace_tailwind/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ pub(crate) mod paths;
pub(crate) mod validate;

pub(crate) use bundle::bundle_tailwind;
pub(crate) use macro_impl::{add_to_tailwind, trace_tailwind};
pub(crate) use macro_impl::{add_to_tailwind, collect_tailwind_classes, trace_tailwind};
136 changes: 126 additions & 10 deletions crates/vertigo-macro/src/trace_tailwind/validate.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
use crate::trace_tailwind::paths::get_tailwind_classes_file_path;
use proc_macro_error::emit_error;
use regex::Regex;
use std::collections::BTreeSet;
use std::error::Error;

pub(crate) fn validate_tailwind_classes(bundle: &str) -> Result<(), Box<dyn Error>> {
Expand All @@ -9,9 +11,22 @@
return Ok(()); // If the file does not exist, there are no classes to validate
}

let classes_content = std::fs::read_to_string(&classes_path)?;
let defined_vars = extract_defined_variables(bundle);
let used_classes = get_all_used_classes(&classes_path)?;

let mut missing = std::collections::BTreeSet::new();
let (missing_classes, missing_vars) =
find_missing_elements(bundle, &defined_vars, used_classes);

emit_validation_errors(missing_classes, missing_vars);

Ok(())
}

fn get_all_used_classes(
classes_path: &std::path::Path,
) -> Result<BTreeSet<String>, Box<dyn Error>> {
let classes_content = std::fs::read_to_string(classes_path)?;
let mut used_classes = BTreeSet::new();

for line in classes_content.lines() {
let line = line.trim();
Expand All @@ -20,26 +35,78 @@
}
for class in line.split_whitespace() {
let class = class.trim();
if class.is_empty() {
continue;
if !class.is_empty() {
used_classes.insert(class.to_string());
}
if !contains_class(bundle, class) {
missing.insert(class.to_string());
}
}

Ok(used_classes)
}

fn find_missing_elements(
bundle: &str,
defined_vars: &BTreeSet<String>,
used_classes: BTreeSet<String>,
) -> (BTreeSet<String>, BTreeSet<String>) {
let mut missing_classes = BTreeSet::new();
let mut missing_vars = BTreeSet::new();

for class in used_classes {
// Validate class existence - only if it's not a raw var usage
if !class.starts_with("var(") && !contains_class(bundle, &class) {
missing_classes.insert(class.to_string());
}

// Validate CSS variables
for var in extract_used_variables(&class) {
if !defined_vars.contains(&var) {
missing_vars.insert(var);
}
}
}

if !missing.is_empty() {
let missing_list: Vec<&str> = missing.iter().map(|s| s.as_str()).collect();
// Warn the developer that these classes were used but not resolved by Tailwind
(missing_classes, missing_vars)
}

fn emit_validation_errors(missing_classes: BTreeSet<String>, missing_vars: BTreeSet<String>) {
if !missing_classes.is_empty() {
let missing_list: Vec<&str> = missing_classes.iter().map(|s| s.as_str()).collect();
emit_error!(
proc_macro::Span::call_site(),
"The following Tailwind classes were used but not found in the generated CSS: {}",
missing_list.join(", ")
);
}

Ok(())
if !missing_vars.is_empty() {
let missing_list: Vec<&str> = missing_vars.iter().map(|s| s.as_str()).collect();
emit_error!(
proc_macro::Span::call_site(),
"The following CSS variables were used but are not defined in the CSS bundle: {}",
missing_list.join(", ")
);
}
}

fn extract_defined_variables(bundle: &str) -> BTreeSet<String> {
let mut vars = BTreeSet::new();
// Matches --var-name:
let re = Regex::new(r"(?m)^\s+(--[a-zA-Z0-9_-]+):").unwrap();

Check failure on line 95 in crates/vertigo-macro/src/trace_tailwind/validate.rs

View workflow job for this annotation

GitHub Actions / Nightly Vertigo Clippy Output

used `unwrap()` on a `Result` value

error: used `unwrap()` on a `Result` value --> crates/vertigo-macro/src/trace_tailwind/validate.rs:95:14 | 95 | let re = Regex::new(r"(?m)^\s+(--[a-zA-Z0-9_-]+):").unwrap(); | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | = note: if this value is an `Err`, it will panic = help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#unwrap_used
for cap in re.captures_iter(bundle) {
vars.insert(cap[1].to_string());
}
vars
}

pub(crate) fn extract_used_variables(class: &str) -> Vec<String> {
let mut vars = Vec::new();
// Matches var(--var-name)
let re = Regex::new(r"var\((--[a-zA-Z0-9_-]+)\)").unwrap();

Check failure on line 105 in crates/vertigo-macro/src/trace_tailwind/validate.rs

View workflow job for this annotation

GitHub Actions / Nightly Vertigo Clippy Output

used `unwrap()` on a `Result` value

error: used `unwrap()` on a `Result` value --> crates/vertigo-macro/src/trace_tailwind/validate.rs:105:14 | 105 | let re = Regex::new(r"var\((--[a-zA-Z0-9_-]+)\)").unwrap(); | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | = note: if this value is an `Err`, it will panic = help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#unwrap_used
for cap in re.captures_iter(class) {
vars.push(cap[1].to_string());
}
vars
}

fn escape_css_name(name: &str) -> String {
Expand Down Expand Up @@ -79,3 +146,52 @@
// Also check for attribute selectors logic or complex variants if needed, but above covers 99%
false
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_extract_defined_variables() {
let bundle = r#"
:root {
--token-text-text_dark: #333;
--bg-primary: #fff;
}
.some-class {
color: var(--token-text-text_dark);
}
"#;
let vars = extract_defined_variables(bundle);
assert!(vars.contains("--token-text-text_dark"));
assert!(vars.contains("--bg-primary"));
assert_eq!(vars.len(), 2);
}

#[test]
fn test_extract_used_variables() {
let class = "text-[var(--token-text-text_dark)]";
let vars = extract_used_variables(class);
assert_eq!(vars, vec!["--token-text-text_dark"]);

let class_multiple = "bg-[var(--bg)] shadow-[0_0_10px_var(--shadow)]";
let vars_multiple = extract_used_variables(class_multiple);
assert_eq!(vars_multiple, vec!["--bg", "--shadow"]);
}

#[test]
fn test_validation_logic() {
let bundle = r#"
:root {
--menu-active: #000;
--menu-inactivef: #fff;
}
"#;
let defined_vars = extract_defined_variables(bundle);
let mut used_classes = BTreeSet::new();
used_classes.insert("bg-[var(--menu-inactive)]".to_string());

let (_, missing_vars) = find_missing_elements(bundle, &defined_vars, used_classes);
assert!(missing_vars.contains("--menu-inactive"));
}
}
2 changes: 1 addition & 1 deletion demo/app/src/app/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use super::{
};

fn css_menu_item(active: bool) -> Css {
let bg_color = if active { "lightblue" } else { "lightgreen" };
let bg_color = if active { "var(--menu-active)" } else { "var(--menu-inactive)" };
css! {"
display: inline-block;
padding: 5px 10px;
Expand Down
6 changes: 3 additions & 3 deletions demo/app/src/app/styling/tailwind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ pub fn Tailwind() {
my_class += tw!("flex");

if toogle_bg.get(context) {
my_class + tw!("bg-green-500 text-red-800")
my_class + tw!("bg-[var(--theme-secondary)] text-[var(--theme-accent)]")
} else {
my_class + tw!("bg-green-900 text-[white]")
}
Expand All @@ -20,7 +20,7 @@ pub fn Tailwind() {
<div tw="m-10 flex flex-col gap-[10px]">
<button
type="button"
tw="bg-[orange]/25 cursor-pointer p-[10px]"
tw="bg-[var(--theme-accent)]/25 cursor-pointer p-[10px]"
value={toogle_bg.clone()}
on_click={bind!(toogle_bg, |_| {
toogle_bg.change(|inner| {
Expand All @@ -35,7 +35,7 @@ pub fn Tailwind() {
<div class="some-external-class" tw={my_class}>
"Some tailwind-styled elements"
</div>
<div tw="bg-blue-400 w-full md:w-30 sm:w-20 p-[10px]">"Tailwind CSS 4 test"</div>
<div tw="bg-[var(--theme-primary)] w-full md:w-30 sm:w-20 p-[10px]">"Tailwind CSS 4 test"</div>
</div>
}
}
11 changes: 11 additions & 0 deletions demo/app/src/tailwind.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
@import "tailwindcss";
@source "./**/*.rs";

:root {
--theme-primary: #3b82f6;
--theme-secondary: #22c55e;
--theme-accent: #f97316;
--theme-background: #111827;
--menu-active: lightblue;
--menu-inactive: lightgreen;
}
Loading