diff --git a/Cargo.toml b/Cargo.toml index 21e65da..ffb6fed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,4 +11,4 @@ crate-type = ["cdylib"] [dependencies] pyo3 = { version = "0.28.2", features = ["extension-module"] } browserslist-rs = "0.19.0" -lightningcss = { version = "1.0.0-alpha.71", features = ["browserslist"] } +lightningcss = { version = "1.0.0-alpha.71", features = ["browserslist", "visitor"] } diff --git a/lightningcss.pyi b/lightningcss.pyi index 3d9b2bf..95c263b 100644 --- a/lightningcss.pyi +++ b/lightningcss.pyi @@ -2,19 +2,185 @@ A python wrapper for the core CSS transformation functionality of lightningcss. """ +from __future__ import annotations + from typing import NewType ParserFlags = NewType('ParserFlags', int) + +class Angle: + value: float + unit: str + def __init__(self, value: float, unit: str) -> None: ... + + +class Ratio: + numerator: float + denominator: float + def __init__(self, numerator: float, denominator: float) -> None: ... + + +class Resolution: + value: float + unit: str + def __init__(self, value: float, unit: str) -> None: ... + + +class Time: + value: float + unit: str + def __init__(self, value: float, unit: str) -> None: ... + + +class LengthValue: + value: float + unit: str + def __init__(self, value: float, unit: str) -> None: ... + + +class CssColor: + def __init__(self, css: str) -> None: ... + def css(self) -> str: ... + + +class Url: + url: str + def __init__(self, url: str) -> None: ... + + +class CustomIdent: + ident: str + def __init__(self, ident: str) -> None: ... + + +class DashedIdent: + ident: str + def __init__(self, ident: str) -> None: ... + + +class Function: + name: str + arguments: str + def __init__(self, name: str, arguments: str) -> None: ... + + +class Variable: + name: str + fallback: str | None + def __init__(self, name: str, fallback: str | None = None) -> None: ... + + +class EnvironmentVariable: + name: str + indices: list[int] + fallback: str | None + def __init__( + self, + name: str, + indices: list[int] = [], + fallback: str | None = None, + ) -> None: ... + + +class Image: + css: str + def __init__(self, css: str) -> None: ... + + +class Selector: + css: str + def __init__(self, css: str) -> None: ... + + +class MediaQuery: + css: str + def __init__(self, css: str) -> None: ... + + +class SupportsCondition: + css: str + def __init__(self, css: str) -> None: ... + + +class CssRule: + css: str + def __init__(self, css: str) -> None: ... + + +class Property: + css: str + def __init__(self, css: str) -> None: ... + + +class TokenOrValue: + css: str + def __init__(self, css: str) -> None: ... + + +class Visitor: + """ + Base class for visitors. To create a visitor, subclass this and override + the visit methods for the node types you want to observe or mutate. Then, + pass an instance of your visitor to `process_stylesheet()` or + `bundle_css()`. The visitor will be called for each visited node in the + stylesheet, allowing for observation or mutation of the node. + """ + def __init__(self, visit_types: int = 0) -> None: ... + + def visit_url(self, value: Url) -> Url | None: ... + def visit_color(self, value: CssColor) -> CssColor | None: ... + def visit_length(self, value: LengthValue) -> LengthValue | None: ... + def visit_angle(self, value: Angle) -> Angle | None: ... + def visit_ratio(self, value: Ratio) -> Ratio | None: ... + def visit_resolution(self, value: Resolution) -> Resolution | None: ... + def visit_time(self, value: Time) -> Time | None: ... + def visit_custom_ident(self, value: CustomIdent) -> CustomIdent | None: ... + def visit_dashed_ident(self, value: DashedIdent) -> DashedIdent | None: ... + + def visit_selector(self, value: Selector) -> object | None: ... + def visit_rule(self, value: CssRule) -> object | None: ... + def visit_property(self, value: Property) -> object | None: ... + def visit_image(self, value: Image) -> object | None: ... + def visit_variable(self, value: Variable) -> object | None: ... + def visit_environment_variable(self, value: EnvironmentVariable) -> object | None: ... + def visit_media_query(self, value: MediaQuery) -> object | None: ... + def visit_supports_condition(self, value: SupportsCondition) -> object | None: ... + def visit_function(self, value: Function) -> object | None: ... + def visit_token(self, value: TokenOrValue) -> object | None: ... + + +VISIT_RULES: int +VISIT_PROPERTIES: int +VISIT_URLS: int +VISIT_COLORS: int +VISIT_IMAGES: int +VISIT_LENGTHS: int +VISIT_ANGLES: int +VISIT_RATIOS: int +VISIT_RESOLUTIONS: int +VISIT_TIMES: int +VISIT_CUSTOM_IDENTS: int +VISIT_DASHED_IDENTS: int +VISIT_VARIABLES: int +VISIT_ENVIRONMENT_VARIABLES: int +VISIT_MEDIA_QUERIES: int +VISIT_SUPPORTS_CONDITIONS: int +VISIT_SELECTORS: int +VISIT_FUNCTIONS: int +VISIT_TOKENS: int + + def calc_parser_flags( nesting: bool = False, custom_media: bool = False, - deep_selector_combinator: bool = False + deep_selector_combinator: bool = False, ) -> ParserFlags: """ Calculates the `parser_flags` argument of `process_stylesheet()` and `bundle_css()`. """ + def process_stylesheet( code: str, /, @@ -23,7 +189,8 @@ def process_stylesheet( parser_flags: ParserFlags = ParserFlags(0), unused_symbols: set[str] | None = None, browsers_list: list[str] | None = None, - minify: bool = True + minify: bool = True, + visitor: Visitor | None = None, ) -> str: """ Processes the supplied CSS stylesheet and returns as a string. @@ -44,9 +211,14 @@ def process_stylesheet( specified, no prefixing/transpilation will occur. :param minify: If True, the final output will be minified. Otherwise, it will be pretty-printed. + :param visitor: An optional Visitor instance. If provided, the visitor will + be called for each visited node in the stylesheet, allowing for + observation or mutation of the node. See the Visitor class for more + details. :return: A string containing a processed CSS stylesheet. """ + def bundle_css( path: str, /, @@ -54,7 +226,8 @@ def bundle_css( parser_flags: ParserFlags = ParserFlags(0), unused_symbols: set[str] | None = None, browsers_list: list[str] | None = None, - minify: bool = True + minify: bool = True, + visitor: Visitor | None = None, ) -> str: """ Processes the supplied CSS stylesheet file and returns the bundle as a string. @@ -77,5 +250,9 @@ def bundle_css( specified, no prefixing/transpilation will occur. :param minify: If True, the final output will be minified. Otherwise, it will be pretty-printed. + :param visitor: An optional Visitor instance. If provided, the visitor will + be called for each visited node in the stylesheet, allowing for + observation or mutation of the node. See the Visitor class for more + details. :return: A string containing the CSS bundle. """ diff --git a/src/lib.rs b/src/lib.rs index aad94cc..2fbf4b4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,9 @@ use lightningcss::stylesheet::{ MinifyOptions, ParserFlags, ParserOptions, PrinterOptions, StyleSheet, }; use lightningcss::targets::{Browsers, Targets}; +use lightningcss::visitor::{Visit, VisitTypes}; + +mod visitor; /// Processes provided CSS and returns as a string. /// @@ -38,6 +41,7 @@ use lightningcss::targets::{Browsers, Targets}; unused_symbols=None, browsers_list=None, minify=true, + visitor=None, ))] fn process_stylesheet(code: &str, filename: &str, @@ -45,7 +49,8 @@ fn process_stylesheet(code: &str, parser_flags: u8, unused_symbols: Option>, browsers_list: Option>, - minify: bool) -> PyResult { + minify: bool, + visitor: Option<&mut visitor::PyVisitor>) -> PyResult { let targets = match mk_targets(browsers_list) { Ok(val) => val, @@ -55,6 +60,12 @@ fn process_stylesheet(code: &str, Ok(val) => val, Err(e) => {return Err(PyValueError::new_err(format!("Parsing stylesheet failed: {}", e.to_string())))} }; + if let Some(visitor) = visitor { + match stylesheet.visit(visitor) { + Ok(_) => {} + Err(e) => {return Err(PyValueError::new_err(format!("Visiting stylesheet failed: {}", e.to_string())))} + } + }; match stylesheet.minify(mk_minify_options(unused_symbols, &targets)) { Ok(_) => {} Err(e) => {return Err(PyValueError::new_err(format!("Minifying stylesheet failed: {}", e.to_string())));} @@ -123,6 +134,7 @@ fn mk_printer_options<'a>(targets: &Targets, unused_symbols=None, browsers_list=None, minify=true, + visitor=None, ))] fn bundle_css( path: &str, @@ -131,6 +143,7 @@ fn bundle_css( unused_symbols: Option>, browsers_list: Option>, minify: bool, + visitor: Option<&mut visitor::PyVisitor> ) -> PyResult { let fs = FileProvider::new(); let mut bundler = Bundler::new( @@ -160,6 +173,13 @@ fn bundle_css( } }; + if let Some(visitor) = visitor { + match stylesheet.visit(visitor) { + Ok(_) => {} + Err(e) => {return Err(PyValueError::new_err(format!("Visiting stylesheet failed: {}", e.to_string())))} + } + }; + let targets = match mk_targets(browsers_list) { Ok(val) => val, Err(e) => { @@ -197,4 +217,87 @@ mod py_lightningcss { use super::process_stylesheet; #[pymodule_export] use super::bundle_css; + + #[pymodule_export(name = "Visitor")] + use super::visitor::PyVisitor; + + // Export CSS value type classes + #[pymodule_export(name = "Url")] + use super::visitor::PyUrl; + #[pymodule_export(name = "CssColor")] + use super::visitor::PyCssColor; + #[pymodule_export(name = "LengthValue")] + use super::visitor::PyLengthValue; + #[pymodule_export(name = "Angle")] + use super::visitor::PyAngle; + #[pymodule_export(name = "Ratio")] + use super::visitor::PyRatio; + #[pymodule_export(name = "Resolution")] + use super::visitor::PyResolution; + #[pymodule_export(name = "Time")] + use super::visitor::PyTime; + #[pymodule_export(name = "CustomIdent")] + use super::visitor::PyCustomIdent; + #[pymodule_export(name = "DashedIdent")] + use super::visitor::PyDashedIdent; + #[pymodule_export(name = "Selector")] + use super::visitor::PySelector; + #[pymodule_export(name = "CssRule")] + use super::visitor::PyCssRule; + #[pymodule_export(name = "Property")] + use super::visitor::PyProperty; + #[pymodule_export(name = "Image")] + use super::visitor::PyImage; + #[pymodule_export(name = "Variable")] + use super::visitor::PyVariable; + #[pymodule_export(name = "EnvironmentVariable")] + use super::visitor::PyEnvironmentVariable; + #[pymodule_export(name = "MediaQuery")] + use super::visitor::PyMediaQuery; + #[pymodule_export(name = "SupportsCondition")] + use super::visitor::PySupportsCondition; + #[pymodule_export(name = "Function")] + use super::visitor::PyFunction; + #[pymodule_export(name = "TokenOrValue")] + use super::visitor::PyTokenOrValue; + + // Export all VisitTypes flag constants so they're accessible from Python + #[pymodule_export] + const VISIT_RULES: u32 = super::VisitTypes::RULES.bits(); + #[pymodule_export] + const VISIT_PROPERTIES: u32 = super::VisitTypes::PROPERTIES.bits(); + #[pymodule_export] + const VISIT_URLS: u32 = super::VisitTypes::URLS.bits(); + #[pymodule_export] + const VISIT_COLORS: u32 = super::VisitTypes::COLORS.bits(); + #[pymodule_export] + const VISIT_IMAGES: u32 = super::VisitTypes::IMAGES.bits(); + #[pymodule_export] + const VISIT_LENGTHS: u32 = super::VisitTypes::LENGTHS.bits(); + #[pymodule_export] + const VISIT_ANGLES: u32 = super::VisitTypes::ANGLES.bits(); + #[pymodule_export] + const VISIT_RATIOS: u32 = super::VisitTypes::RATIOS.bits(); + #[pymodule_export] + const VISIT_RESOLUTIONS: u32 = super::VisitTypes::RESOLUTIONS.bits(); + #[pymodule_export] + const VISIT_TIMES: u32 = super::VisitTypes::TIMES.bits(); + #[pymodule_export] + const VISIT_CUSTOM_IDENTS: u32 = super::VisitTypes::CUSTOM_IDENTS.bits(); + #[pymodule_export] + const VISIT_DASHED_IDENTS: u32 = super::VisitTypes::DASHED_IDENTS.bits(); + #[pymodule_export] + const VISIT_VARIABLES: u32 = super::VisitTypes::VARIABLES.bits(); + #[pymodule_export] + const VISIT_ENVIRONMENT_VARIABLES: u32 = super::VisitTypes::ENVIRONMENT_VARIABLES.bits(); + #[pymodule_export] + const VISIT_MEDIA_QUERIES: u32 = super::VisitTypes::MEDIA_QUERIES.bits(); + #[pymodule_export] + const VISIT_SUPPORTS_CONDITIONS: u32 = super::VisitTypes::SUPPORTS_CONDITIONS.bits(); + #[pymodule_export] + const VISIT_SELECTORS: u32 = super::VisitTypes::SELECTORS.bits(); + #[pymodule_export] + const VISIT_FUNCTIONS: u32 = super::VisitTypes::FUNCTIONS.bits(); + #[pymodule_export] + const VISIT_TOKENS: u32 = super::VisitTypes::TOKENS.bits(); } diff --git a/src/visitor.rs b/src/visitor.rs new file mode 100644 index 0000000..e3d7d2c --- /dev/null +++ b/src/visitor.rs @@ -0,0 +1,912 @@ +use lightningcss::dependencies::Location as UrlLocation; +use lightningcss::media_query::MediaQuery; +use lightningcss::printer::PrinterOptions; +use lightningcss::properties::custom::{EnvironmentVariable, Function, TokenOrValue, Variable}; +use lightningcss::properties::Property; +use lightningcss::rules::supports::SupportsCondition; +use lightningcss::rules::CssRule; +use lightningcss::selector::Selector; +use lightningcss::stylesheet::ParserOptions; +use lightningcss::traits::{Parse, ParseWithOptions, ToCss}; +use lightningcss::values::angle::Angle; +use lightningcss::values::color::CssColor; +use lightningcss::values::ident::{CustomIdent, DashedIdent}; +use lightningcss::values::image::Image; +use lightningcss::values::length::LengthValue; +use lightningcss::values::ratio::Ratio; +use lightningcss::values::resolution::Resolution; +use lightningcss::values::time::Time; +use lightningcss::values::url::Url; +use lightningcss::visitor::{Visit, VisitTypes, Visitor}; + +use pyo3::exceptions::PyAttributeError; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +// ── Helper ────────────────────────────────────────────────────────────────── + +/// Serialize any lightningcss ToCss value to a CSS string using default options. +fn lc_to_css_string(value: &T) -> String { + value + .to_css_string(PrinterOptions::default()) + .unwrap_or_default() +} + +// ── PyAngle ────────────────────────────────────────────────────────────────── + +/// Python wrapper for `lightningcss::values::angle::Angle`. +#[pyclass(module = "lightningcss", name = "Angle", from_py_object)] +#[derive(Clone, Debug)] +pub struct PyAngle { + #[pyo3(get, set)] + pub value: f32, + #[pyo3(get, set)] + pub unit: String, +} + +#[pymethods] +impl PyAngle { + #[new] + pub fn new(value: f32, unit: String) -> Self { + Self { value, unit } + } + fn __repr__(&self) -> String { + format!("Angle(value={}, unit={:?})", self.value, self.unit) + } + fn __str__(&self) -> String { + format!("{}{}", self.value, self.unit) + } +} + +impl From for PyAngle { + fn from(a: Angle) -> Self { + let (value, unit) = match a { + Angle::Deg(v) => (v, "deg"), + Angle::Rad(v) => (v, "rad"), + Angle::Grad(v) => (v, "grad"), + Angle::Turn(v) => (v, "turn"), + }; + Self { value, unit: unit.to_string() } + } +} + +impl TryFrom<&PyAngle> for Angle { + type Error = PyErr; + fn try_from(v: &PyAngle) -> Result { + let css = format!("{}{}", v.value, v.unit); + Angle::parse_string(css.as_str()).map_err(|e| { + PyValueError::new_err(format!("Failed to parse Angle from '{}': {}", css, e)) + }) + } +} + +// ── PyRatio ────────────────────────────────────────────────────────────────── + +/// Python wrapper for `lightningcss::values::ratio::Ratio`. +#[pyclass(module = "lightningcss", name = "Ratio", from_py_object)] +#[derive(Clone, Debug)] +pub struct PyRatio { + #[pyo3(get, set)] + pub numerator: f32, + #[pyo3(get, set)] + pub denominator: f32, +} + +#[pymethods] +impl PyRatio { + #[new] + pub fn new(numerator: f32, denominator: f32) -> Self { + Self { numerator, denominator } + } + fn __repr__(&self) -> String { + format!("Ratio(numerator={}, denominator={})", self.numerator, self.denominator) + } + fn __str__(&self) -> String { + format!("{}/{}", self.numerator, self.denominator) + } +} + +impl From for PyRatio { + fn from(r: Ratio) -> Self { + Self { numerator: r.0, denominator: r.1 } + } +} + +impl TryFrom<&PyRatio> for Ratio { + type Error = PyErr; + fn try_from(v: &PyRatio) -> Result { + let css = format!("{}/{}", v.numerator, v.denominator); + Ratio::parse_string(css.as_str()).map_err(|e| { + PyValueError::new_err(format!("Failed to parse Ratio from '{}': {}", css, e)) + }) + } +} + +// ── PyResolution ───────────────────────────────────────────────────────────── + +/// Python wrapper for `lightningcss::values::resolution::Resolution`. +#[pyclass(module = "lightningcss", name = "Resolution", from_py_object)] +#[derive(Clone, Debug)] +pub struct PyResolution { + #[pyo3(get, set)] + pub value: f32, + #[pyo3(get, set)] + pub unit: String, +} + +#[pymethods] +impl PyResolution { + #[new] + pub fn new(value: f32, unit: String) -> Self { + Self { value, unit } + } + fn __repr__(&self) -> String { + format!("Resolution(value={}, unit={:?})", self.value, self.unit) + } + fn __str__(&self) -> String { + format!("{}{}", self.value, self.unit) + } +} + +impl From for PyResolution { + fn from(r: Resolution) -> Self { + let (value, unit) = match r { + Resolution::Dpi(v) => (v, "dpi"), + Resolution::Dpcm(v) => (v, "dpcm"), + Resolution::Dppx(v) => (v, "dppx"), + }; + Self { value, unit: unit.to_string() } + } +} + +impl TryFrom<&PyResolution> for Resolution { + type Error = PyErr; + fn try_from(v: &PyResolution) -> Result { + let css = format!("{}{}", v.value, v.unit); + Resolution::parse_string(css.as_str()).map_err(|e| { + PyValueError::new_err(format!("Failed to parse Resolution from '{}': {}", css, e)) + }) + } +} + +// ── PyTime ──────────────────────────────────────────────────────────────────── + +/// Python wrapper for `lightningcss::values::time::Time`. +#[pyclass(module = "lightningcss", name = "Time", from_py_object)] +#[derive(Clone, Debug)] +pub struct PyTime { + #[pyo3(get, set)] + pub value: f32, + #[pyo3(get, set)] + pub unit: String, +} + +#[pymethods] +impl PyTime { + #[new] + pub fn new(value: f32, unit: String) -> Self { + Self { value, unit } + } + fn __repr__(&self) -> String { + format!("Time(value={}, unit={:?})", self.value, self.unit) + } + fn __str__(&self) -> String { + format!("{}{}", self.value, self.unit) + } +} + +impl From