use lightningcss::{
rules::CssRule,
selector::{Component, Selector},
stylesheet::{MinifyOptions, ParserFlags, ParserOptions, PrinterOptions, StyleSheet},
targets::{Browsers, Features, Targets},
traits::IntoOwned,
values::ident::Ident,
};
use std::{
borrow::Cow,
collections::{hash_map::DefaultHasher, HashSet, VecDeque},
hash::{Hash, Hasher},
};
use pochoir::{
common::Spanned,
parser::{Attrs, Node, Tree, TreeRefId},
template_engine::{Escaping, TemplateBlock},
transformers::Transformer,
TransformerElementContext, TransformerResult, TransformerTreeContext,
};
fn add_component_to_selector<'a>(
component: &Component<'a>,
selector: &Selector<'a>,
) -> Selector<'a> {
let mut components = vec![VecDeque::new()];
let mut iter = selector.iter();
loop {
for c in &mut iter {
components.last_mut().unwrap().push_back(c.clone());
}
let is_nested = components
.last()
.unwrap()
.back()
.is_none_or(|c| *c == Component::Nesting);
if !is_nested {
components.last_mut().unwrap().push_back(component.clone());
}
if let Some(combinator) = iter.next_sequence() {
components
.last_mut()
.unwrap()
.push_front(Component::Combinator(combinator));
components.push(VecDeque::new());
} else {
break;
}
}
let components = components
.into_iter()
.rev()
.flatten()
.collect::<Vec<Component>>();
Selector::from(components)
}
fn do_scoped_css(
component_name: &str,
enhance_slots: bool,
tree: &mut Tree,
stylesheet: &mut StyleSheet,
) {
fn do_scoped_css_recursive<'a>(rule: &mut CssRule<'a>, data_component: &Component<'a>) {
if let CssRule::Style(ref mut style_rule) = rule {
for selector in &mut style_rule.selectors.0 {
*selector = add_component_to_selector(data_component, selector);
}
for rule in &mut style_rule.rules.0 {
do_scoped_css_recursive(rule, data_component);
}
}
}
const MAX_DIGITS: usize = 8;
let mut hasher = DefaultHasher::new();
component_name.hash(&mut hasher);
let unique_id = format!("{:x}", hasher.finish());
let unique_id_attr = format!("data-p-{}", &unique_id[..MAX_DIGITS]);
for id in tree.all_nodes() {
let node = tree.get(id);
if enhance_slots && node.name().is_ok_and(|el_name| el_name == "slot") {
tree.get_mut(id)
.set_attr("'__p_attr'", unique_id_attr.clone(), Escaping::None);
} else if node.name().is_ok_and(|el_name| {
!["html", "body", "style", "script"].contains(&&*el_name)
}) && node.closest("head").is_none()
{
tree.get_mut(id)
.set_attr(unique_id_attr.clone(), "", Escaping::None);
}
}
let local_name = Ident::from(unique_id_attr);
let data_component = Component::Where(Box::new([Selector::from(
Component::AttributeInNoNamespaceExists {
local_name: local_name.clone(),
local_name_lower: local_name.clone(),
},
)]));
for rule in &mut stylesheet.rules.0 {
do_scoped_css_recursive(rule, &data_component);
}
}
#[derive(Debug)]
pub struct EnhancedCss {
enhance_slots: bool,
style_source: String,
include_css_features: Features,
exclude_css_features: Features,
}
impl EnhancedCss {
pub fn new() -> Self {
Self {
enhance_slots: false,
style_source: String::new(),
include_css_features: Features::default(),
exclude_css_features: Features::default(),
}
}
#[must_use]
pub fn include_css_features(mut self, features: Features) -> Self {
self.include_css_features = features;
self
}
#[must_use]
pub fn exclude_css_features(mut self, features: Features) -> Self {
self.exclude_css_features = features;
self
}
}
impl Transformer for EnhancedCss {
fn on_after_element(&mut self, ctx: &mut TransformerElementContext) -> TransformerResult {
let el = ctx.tree.get(ctx.element_id);
if el.name().unwrap() == "style"
&& el
.attr("enhanced")
.expect("attr is called on an element")
.is_some()
{
if el
.attr("enhanced-slots")
.expect("attr is called on an element")
.is_some()
{
self.enhance_slots = true;
}
self.style_source
.push_str(&el.children().map(|c| c.text()).collect::<String>());
ctx.tree.get_mut(ctx.element_id).remove();
}
Ok(())
}
fn on_tree_parsed(&mut self, ctx: &mut TransformerTreeContext) -> TransformerResult {
let targets = Targets {
browsers: Browsers::load_browserslist()?,
include: self.include_css_features,
exclude: self.exclude_css_features,
};
let mut stylesheet = StyleSheet::parse(
&self.style_source,
ParserOptions {
filename: ctx.file_path.to_string_lossy().into_owned(),
flags: ParserFlags::NESTING | ParserFlags::CUSTOM_MEDIA,
..Default::default()
},
)
.map_err(|e| e.to_string())?;
do_scoped_css(
ctx.component_name,
self.enhance_slots,
ctx.tree,
&mut stylesheet,
);
stylesheet.minify(MinifyOptions {
targets,
..Default::default()
})?;
let enhanced_css = stylesheet
.to_css(PrinterOptions {
minify: true,
targets,
..Default::default()
})?
.code;
let parent = ctx.tree.select("head").unwrap().unwrap_or(TreeRefId::Root);
let style_id = ctx.tree.insert(
parent,
Spanned::new(Node::Element(Cow::Borrowed("style"), Attrs::new())),
);
let _text_id = ctx.tree.insert(
style_id,
Spanned::new(Node::TemplateBlock(TemplateBlock::RawText(Cow::Owned(
enhanced_css,
)))),
);
drop(stylesheet);
self.style_source = String::new();
Ok(())
}
}
impl Default for EnhancedCss {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug)]
pub struct EnhancedCssBundler<'a, 'source, 'options> {
enhance_slots: bool,
current_component_css_start: usize,
css_bundle: &'a mut CssBundle<'source, 'options>,
}
impl<'a, 'source, 'options> EnhancedCssBundler<'a, 'source, 'options> {
pub fn new(css_bundle: &'a mut CssBundle<'source, 'options>) -> Self {
Self::try_new(css_bundle).expect("an error happened when loading browserslist")
}
pub fn try_new(
css_bundle: &'a mut CssBundle<'source, 'options>,
) -> Result<Self, Box<dyn std::error::Error>> {
Ok(Self {
enhance_slots: false,
current_component_css_start: 0,
css_bundle,
})
}
#[must_use]
pub fn include_css_features(self, features: Features) -> Self {
self.css_bundle.targets.include = features;
self
}
#[must_use]
pub fn exclude_css_features(self, features: Features) -> Self {
self.css_bundle.targets.exclude = features;
self
}
}
impl Transformer for EnhancedCssBundler<'_, '_, '_> {
fn on_after_element(&mut self, ctx: &mut TransformerElementContext) -> TransformerResult {
let el = ctx.tree.get(ctx.element_id);
if el.name().unwrap() == "style"
&& el
.attr("enhanced")
.expect("attr is called on an element")
.is_some()
{
if el
.attr("enhanced-slots")
.expect("attr is called on an element")
.is_some()
{
self.enhance_slots = true;
}
self.css_bundle
.style_source
.push_str(&el.children().map(|c| c.text()).collect::<String>());
ctx.tree.get_mut(ctx.element_id).remove();
}
Ok(())
}
fn on_tree_parsed(&mut self, ctx: &mut TransformerTreeContext) -> TransformerResult {
let mut stylesheet = StyleSheet::parse(
&self.css_bundle.style_source[self.current_component_css_start..],
ParserOptions {
filename: ctx.file_path.to_string_lossy().into_owned(),
flags: ParserFlags::NESTING | ParserFlags::CUSTOM_MEDIA,
..Default::default()
},
)
.map_err(|e| e.to_string())?;
do_scoped_css(
ctx.component_name,
self.enhance_slots,
ctx.tree,
&mut stylesheet,
);
if self
.css_bundle
.processed_components
.contains(ctx.component_name)
{
return Ok(());
}
if let Some(bundle_stylesheet) = self.css_bundle.stylesheet.as_mut() {
bundle_stylesheet.sources.extend(stylesheet.sources);
bundle_stylesheet
.rules
.0
.extend(stylesheet.rules.0.into_owned());
bundle_stylesheet
.license_comments
.extend(stylesheet.license_comments.into_owned());
} else {
self.css_bundle.stylesheet = Some(StyleSheet::new(
stylesheet.sources,
stylesheet.rules.into_owned(),
ParserOptions {
filename: ctx.file_path.to_string_lossy().into_owned(),
flags: ParserFlags::NESTING | ParserFlags::CUSTOM_MEDIA,
..Default::default()
},
));
}
self.css_bundle
.processed_components
.insert(ctx.component_name.to_string());
self.current_component_css_start = self.css_bundle.style_source.len();
Ok(())
}
}
#[derive(Debug)]
pub struct CssBundle<'source, 'options> {
style_source: String,
stylesheet: Option<StyleSheet<'source, 'options>>,
targets: Targets,
processed_components: HashSet<String>,
}
impl CssBundle<'_, '_> {
pub fn new() -> Self {
Self::default()
}
pub fn finish(self) -> Result<String, Box<dyn std::error::Error>> {
if let Some(mut stylesheet) = self.stylesheet {
stylesheet.minify(MinifyOptions {
targets: self.targets,
..Default::default()
})?;
Ok(stylesheet
.to_css(PrinterOptions {
minify: true,
targets: self.targets,
..Default::default()
})
.map(|c| c.code)?)
} else {
Ok(String::new())
}
}
}
impl Default for CssBundle<'_, '_> {
fn default() -> Self {
Self {
style_source: String::new(),
stylesheet: None,
targets: Targets {
browsers: Browsers::load_browserslist().unwrap_or(None),
..Default::default()
},
processed_components: HashSet::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use pochoir::{lang::Context, transformers::Transformers, ComponentFile, Result};
fn compile_bundle<'a>(
index_source: &'a str,
child_source: &'a str,
) -> (CssBundle<'a, 'a>, Result<String>) {
let index_file_path = std::path::Path::new("index.html");
let child_file_path = std::path::Path::new("child.html");
let mut css_bundle = CssBundle::new();
let mut transformers =
Transformers::new().with_transformer(EnhancedCssBundler::new(&mut css_bundle));
let compiled = pochoir::transform_and_compile(
"index",
&mut Context::new(),
|name| {
let (file_path, source) = match name {
"index" => (index_file_path, index_source),
"child-component" => (child_file_path, child_source),
_ => unreachable!(),
};
Ok(ComponentFile::new(file_path, source))
},
&mut transformers,
);
drop(transformers);
(css_bundle, compiled)
}
#[test]
fn avoid_duplication() {
let index_source = "<h1>Index page</h1><child-component /><child-component />
<style enhanced>
h1 {
font-weight: extrabold;
}
</style>";
let my_button_source = "<button>Click me!</button>
<style enhanced>
button:hover {
background-color: antiquewhite;
color: antiquewhite;
}
</style>";
let (css_bundle, compiled) = compile_bundle(index_source, my_button_source);
assert_eq!(compiled.unwrap(), "<h1 data-p-fd1ea890>Index page</h1><button data-p-7bcfbb98>Click me!</button>\n<button data-p-7bcfbb98>Click me!</button>\n\n");
assert_eq!(css_bundle.finish().unwrap(), "h1:where([data-p-fd1ea890]){font-weight:extrabold}button:hover:where([data-p-7bcfbb98]){color:#faebd7;background-color:#faebd7}");
let my_button_source = "<button>Click me!</button>
<style enhanced>
button:hover {
background-color: antiquewhite;
> * {
color: antiquewhite;
}
}
</style>";
let (css_bundle, compiled) = compile_bundle(index_source, my_button_source);
assert_eq!(compiled.unwrap(), "<h1 data-p-fd1ea890>Index page</h1><button data-p-7bcfbb98>Click me!</button>\n<button data-p-7bcfbb98>Click me!</button>\n\n");
assert_eq!(css_bundle.finish().unwrap(), "h1:where([data-p-fd1ea890]){font-weight:extrabold}button:hover:where([data-p-7bcfbb98]){background-color:#faebd7}button:hover:where([data-p-7bcfbb98])>:where([data-p-7bcfbb98]){color:#faebd7}");
}
#[test]
fn ensure_the_whole_selector_is_modified() {
let index_source = "<h1>Index page</h1><child-component />
<style enhanced>
h1 {
font-weight: extrabold;
}
</style>";
let my_hero_source = r#"<div class="hero">
<div class="inner-box">
<p>Some text</p>
</div>
<img src="/404.png">
</div>
<style enhanced>
.hero {
padding: 2rem;
}
.hero > * {
border: solid 1px red;
}
</style>"#;
let (css_bundle, compiled) = compile_bundle(index_source, my_hero_source);
assert_eq!(
compiled.unwrap(),
r#"<h1 data-p-fd1ea890>Index page</h1><div class="hero" data-p-7bcfbb98>
<div class="inner-box" data-p-7bcfbb98>
<p data-p-7bcfbb98>Some text</p>
</div>
<img src="/404.png" data-p-7bcfbb98>
</div>
"#
);
assert_eq!(css_bundle.finish().unwrap(), "h1:where([data-p-fd1ea890]){font-weight:extrabold}.hero:where([data-p-7bcfbb98]){padding:2rem}.hero:where([data-p-7bcfbb98])>:where([data-p-7bcfbb98]){border:1px solid red}");
}
#[test]
fn slots_can_be_enhanced() {
let index_source = r#"<h1>Index page</h1><child-component>
<div class="inner-box">
<p>Some text</p>
</div>
<img src="/404.png">
</child-component>
<style enhanced>
h1 {
font-weight: extrabold;
}
</style>"#;
let my_hero_source = r#"<div class="hero">
<slot></slot>
</div>
<style enhanced enhanced-slots>
.hero {
padding: 2rem;
}
.hero > * {
border: solid 1px red;
}
</style>"#;
let (css_bundle, compiled) = compile_bundle(index_source, my_hero_source);
assert_eq!(
compiled.unwrap(),
r#"<h1 data-p-fd1ea890>Index page</h1><div class="hero" data-p-7bcfbb98>
<div class="inner-box" data-p-fd1ea890 data-p-7bcfbb98>
<p data-p-fd1ea890 data-p-7bcfbb98>Some text</p>
</div>
<img src="/404.png" data-p-fd1ea890 data-p-7bcfbb98>
</div>
"#
);
assert_eq!(css_bundle.finish().unwrap(), "h1:where([data-p-fd1ea890]){font-weight:extrabold}.hero:where([data-p-7bcfbb98]){padding:2rem}.hero:where([data-p-7bcfbb98])>:where([data-p-7bcfbb98]){border:1px solid red}");
}
}