#[derive(Debug, Clone, serde::Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct CssAnalyticsReport {
pub files: Vec<CssFileAnalytics>,
pub summary: CssAnalyticsSummary,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub scoped_unused: Vec<ScopedUnusedClasses>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub unreferenced_keyframes: Vec<UnreferencedKeyframes>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub undefined_keyframes: Vec<UndefinedKeyframes>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub duplicate_declaration_blocks: Vec<CssDuplicateBlock>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tailwind_arbitrary_values: Vec<TailwindArbitraryValue>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub unused_at_rules: Vec<UnusedAtRule>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub unresolved_class_references: Vec<UnresolvedClassReference>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub unreferenced_css_classes: Vec<UnreferencedCssClass>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub unused_font_faces: Vec<UnusedFontFace>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub unused_theme_tokens: Vec<UnusedThemeToken>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub font_size_unit_mix: Option<CssNotationConsistency>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct CssNotationConsistency {
pub axis: String,
pub notations: Vec<CssNotationCount>,
pub actions: Vec<CssCandidateAction>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct CssNotationCount {
pub notation: String,
pub count: u32,
}
#[derive(Debug, Clone, serde::Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnusedAtRule {
#[serde(rename = "type")]
pub kind: UnusedAtRuleKind,
pub name: String,
pub path: String,
pub actions: Vec<CssCandidateAction>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
#[repr(u8)]
pub enum UnusedAtRuleKind {
PropertyRegistration,
Layer,
}
#[derive(Debug, Clone, serde::Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct TailwindArbitraryValue {
pub value: String,
pub count: u32,
pub path: String,
pub line: u32,
pub actions: Vec<CssCandidateAction>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct CssDuplicateBlock {
pub declaration_count: u16,
pub occurrence_count: u32,
pub estimated_savings: u32,
pub occurrences: Vec<CssBlockOccurrence>,
pub actions: Vec<CssCandidateAction>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct CssBlockOccurrence {
pub path: String,
pub line: u32,
}
#[derive(Debug, Clone, serde::Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnreferencedKeyframes {
pub name: String,
pub path: String,
pub actions: Vec<CssCandidateAction>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnusedFontFace {
pub family: String,
pub path: String,
pub actions: Vec<CssCandidateAction>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnusedThemeToken {
pub token: String,
pub namespace: String,
pub path: String,
pub line: u32,
pub actions: Vec<CssCandidateAction>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnreferencedCssClass {
pub class: String,
pub path: String,
pub line: u32,
pub actions: Vec<CssCandidateAction>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UndefinedKeyframes {
pub name: String,
pub path: String,
pub actions: Vec<CssCandidateAction>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct UnresolvedClassReference {
pub class: String,
pub suggestion: String,
pub path: String,
pub line: u32,
pub actions: Vec<CssCandidateAction>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct ScopedUnusedClasses {
pub path: String,
pub classes: Vec<String>,
pub actions: Vec<CssCandidateAction>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct CssCandidateAction {
#[serde(rename = "type")]
pub kind: CssCandidateActionType,
pub auto_fixable: bool,
pub description: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum CssCandidateActionType {
VerifyUnused,
VerifyUndefined,
Consolidate,
ReplaceWithToken,
Standardize,
}
impl CssCandidateAction {
#[must_use]
pub fn verify_unused_font_face(family: &str) -> Self {
Self {
kind: CssCandidateActionType::VerifyUnused,
auto_fixable: false,
description: format!(
"Confirm the \"{family}\" font family is not applied from an inline style or JavaScript before removing the @font-face and its font files."
),
command: safe_token_search(family),
}
}
#[must_use]
pub fn verify_unused_theme_token(token: &str, namespace: &str, name: &str) -> Self {
Self {
kind: CssCandidateActionType::VerifyUnused,
auto_fixable: false,
description: format!(
"Confirm the {token} @theme token is used by nothing, no `*-{name}` utility (e.g. `bg-{name}` / `text-{name}` / `{namespace}-{name}`) in markup or @apply, no `var({token})` read in any stylesheet or JS, and no arbitrary `[{token}]` value, before removing it from the @theme block."
),
command: theme_token_search(namespace, name),
}
}
#[must_use]
pub fn verify_unreferenced_class(name: &str) -> Self {
Self {
kind: CssCandidateActionType::VerifyUnused,
auto_fixable: false,
description: format!(
"Confirm no HTML email, server-rendered template, CMS content, or Markdown applies the \"{name}\" class before removing it (fallow scanned only in-project JS/TS/HTML/Vue/Svelte/Astro markup)."
),
command: safe_token_search(name),
}
}
#[must_use]
pub fn verify_keyframe(name: &str) -> Self {
Self {
kind: CssCandidateActionType::VerifyUnused,
auto_fixable: false,
description: format!(
"Confirm no JavaScript or template applies the \"{name}\" animation before removing the @keyframes."
),
command: safe_token_search(name),
}
}
#[must_use]
pub fn verify_undefined_keyframe(name: &str) -> Self {
Self {
kind: CssCandidateActionType::VerifyUndefined,
auto_fixable: false,
description: format!(
"Confirm \"{name}\" is not a @keyframes defined in CSS-in-JS (styled-components, Emotion, vanilla-extract) before treating the animation reference as a typo."
),
command: safe_token_search(name),
}
}
#[must_use]
pub fn standardize_notation(axis: &str, dominant: &str) -> Self {
Self {
kind: CssCandidateActionType::Standardize,
auto_fixable: false,
description: format!(
"{axis} are authored in several notations; standardize on one ({dominant} is the most common) so the scale is a single source of truth, unless this is an intentional migration in progress."
),
command: None,
}
}
#[must_use]
pub fn consolidate_block(occurrence_count: u32) -> Self {
Self {
kind: CssCandidateActionType::Consolidate,
auto_fixable: false,
description: format!(
"Extract this declaration block into one rule and reference it from all {occurrence_count} occurrences, unless they are intentionally separate overrides."
),
command: None,
}
}
#[must_use]
pub fn replace_arbitrary_value(value: &str) -> Self {
let command = (!value.contains('\'')).then(|| {
format!(
"grep -rnF '{value}' --include='*.jsx' --include='*.tsx' --include='*.html' --include='*.vue' --include='*.svelte' --include='*.astro' ."
)
});
Self {
kind: CssCandidateActionType::ReplaceWithToken,
auto_fixable: false,
description:
"Replace this one-off arbitrary value with a scale token from your Tailwind theme, or confirm it is intentional."
.to_string(),
command,
}
}
#[must_use]
pub fn verify_unused_at_rule(kind: UnusedAtRuleKind, name: &str) -> Self {
let description = match kind {
UnusedAtRuleKind::PropertyRegistration => format!(
"Confirm \"{name}\" is not read or set from JavaScript before removing the @property registration."
),
UnusedAtRuleKind::Layer => format!(
"Confirm the @layer \"{name}\" is not populated via @import layer() before removing the declaration."
),
};
Self {
kind: CssCandidateActionType::VerifyUnused,
auto_fixable: false,
description,
command: safe_token_search(name),
}
}
#[must_use]
pub fn verify_unresolved_class(class: &str, suggestion: &str) -> Self {
Self {
kind: CssCandidateActionType::VerifyUndefined,
auto_fixable: false,
description: format!(
"\"{class}\" matches no CSS class; did you mean \"{suggestion}\"? Confirm \"{class}\" is not defined in CSS-in-JS or an external stylesheet before fixing the reference."
),
command: safe_token_search(class),
}
}
#[must_use]
pub fn verify_scoped_classes() -> Self {
Self {
kind: CssCandidateActionType::VerifyUnused,
auto_fixable: false,
description:
"Confirm none of these scoped classes is assembled from a dynamic string (e.g. `:class=\"prefix + name\"`) before removing them."
.to_string(),
command: None,
}
}
}
fn theme_token_search(namespace: &str, name: &str) -> Option<String> {
let is_plain = |s: &str| {
!s.is_empty()
&& s.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
};
(is_plain(namespace) && is_plain(name)).then(|| {
format!(
"grep -rnE -- '-{name}\\b|--{namespace}-{name}' --include='*.css' --include='*.html' --include='*.js' --include='*.jsx' --include='*.ts' --include='*.tsx' --include='*.vue' --include='*.svelte' --include='*.astro' ."
)
})
}
fn safe_token_search(name: &str) -> Option<String> {
let is_plain = !name.is_empty()
&& name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_');
is_plain.then(|| {
format!(
"grep -rnw '{name}' --include='*.js' --include='*.jsx' --include='*.ts' --include='*.tsx' --include='*.vue' --include='*.svelte' --include='*.html' ."
)
})
}
#[derive(Debug, Clone, serde::Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct CssFileAnalytics {
pub path: String,
pub analytics: fallow_types::extract::CssAnalytics,
}
#[derive(Debug, Clone, Default, serde::Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct CssAnalyticsSummary {
pub files_analyzed: u32,
pub total_rules: u32,
pub total_declarations: u32,
pub important_declarations: u32,
pub empty_rules: u32,
pub max_nesting_depth: u8,
pub unique_colors: u32,
pub unique_font_sizes: u32,
pub unique_z_indexes: u32,
pub unique_box_shadows: u32,
pub unique_border_radii: u32,
pub unique_line_heights: u32,
pub custom_properties_defined: u32,
pub custom_properties_unreferenced: u32,
pub custom_properties_undefined: u32,
pub keyframes_defined: u32,
pub keyframes_unreferenced: u32,
pub keyframes_undefined: u32,
pub scoped_unused_classes: u32,
pub duplicate_declaration_blocks: u32,
pub duplicate_declarations_total: u32,
pub tailwind_arbitrary_values: u32,
pub tailwind_arbitrary_value_uses: u32,
pub unused_property_registrations: u32,
pub unused_layers: u32,
pub unresolved_class_references: u32,
pub unreferenced_css_classes: u32,
pub unused_font_faces: u32,
pub unused_theme_tokens: u32,
pub font_size_units_used: u32,
pub notable_truncated_files: u32,
}