Skip to content

feat: Add keep_at_rules option#485

Merged
Stranger6667 merged 12 commits intoStranger6667:masterfrom
kamilzych:feat/keep-at-rules
Jul 26, 2025
Merged

feat: Add keep_at_rules option#485
Stranger6667 merged 12 commits intoStranger6667:masterfrom
kamilzych:feat/keep-at-rules

Conversation

@kamilzych
Copy link
Contributor

@kamilzych kamilzych commented Jun 27, 2025

@kamilzych kamilzych force-pushed the feat/keep-at-rules branch from 155e7b6 to 64161f7 Compare June 27, 2025 11:47
@codspeed-hq
Copy link

codspeed-hq bot commented Jun 27, 2025

CodSpeed Performance Report

Merging #485 will not alter performance

Comparing kamilzych:feat/keep-at-rules (bdd9bfe) with master (96a47bc)

Summary

✅ 6 untouched benchmarks

@codecov
Copy link

codecov bot commented Jun 27, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 89.11%. Comparing base (bd04285) to head (bdd9bfe).
⚠️ Report is 4 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master     #485      +/-   ##
==========================================
+ Coverage   88.43%   89.11%   +0.68%     
==========================================
  Files          18       18              
  Lines        1919     2040     +121     
==========================================
+ Hits         1697     1818     +121     
  Misses        222      222              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Owner

@Stranger6667 Stranger6667 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great start! Definitely on the right track :) I left a few comments


serializer.start_elem(&element.name, &element.attributes, style_node_id)?;

// TODO this part is the one that I don't like the most
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not 100% sure about this one, even though it is a sound approach.

The first thought that I have is that the least surprising thing would be to keep @ rules in their original places, instead of pushing everything into a single node. This won't work for external stylesheets, so we need to store them somewhere anyway.

On the other hand, this approach is better for performance, and probably it would not be super surprising for the user anyway if we put everything under one <style> tag.

So, in the end, I am leaning towards this approach.

A few other notes:

  1. We can reduce the performance hit of checking each node name by having two versions of serialize - the first one will work as before this PR. The new one will check for head and once it is found, it will switch to the other version of serialize that won't check the tag name. At the top level we check if at_rules are present and then call one of these two versions - I think it should minimize the impact as head is usually in the very beginning of the nodes vector.
  2. Are there cases when head is not present? Maybe in HTML fragments? see inline_fragment

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

btw, if the user chose to keep style tags - I think we can only extract @ rule from link, as the old style tags will be around anyway

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's something I had no time to look closer into yet

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given the current benchmark results, the performance hit is negligible, so we can skip this. For fragments, likely we can't really do it, as there is no regular HTML structure generally. Maybe we can return an error when the user sets an option to keep at rules, but uses inline_fragment?

@Stranger6667
Copy link
Owner

Hi @kamilzych

Let me know if you need any assistance or if you plan to continue working on this PR. I'd be happy to help or take over in case if you don't plan to continue this PR.

@kamilzych kamilzych force-pushed the feat/keep-at-rules branch from ffe46ee to c4adfc7 Compare July 19, 2025 17:27
@kamilzych
Copy link
Contributor Author

kamilzych commented Jul 19, 2025

@Stranger6667 Hi! Apologies for lack of activity, I simply had no time, life happened ¯\_(ツ)_/¯

I've just pushed a commit which addresses some of your comments. I'd love to finish the whole PR but I'm not sure when I have enough time to move this forward.
That said, if you have time to take it over, I'm absolutely fine with it. If you don't, I'll do my best to finish it ASAP and in such case I'd appreciate a review of the mentioned commit and perhaps some guidance about the next steps 🙏

@Stranger6667
Copy link
Owner

Awesome! Thank you so much! :) I’ll be on vacation till the end of next week, but I’ll review the changes in any event :) Let’s see how it goes

@Stranger6667
Copy link
Owner

Amazing to see that we already have a working version! There are a few minor things left; there is no time pressure, so I'm just noting what is left to do, and maybe I'll push some changes to it in the next few evenings:

  • Changelog entries for the main crate + bindings
  • Update all readmes with the new option + usage example
  • JS bindings
  • Java bindings
  • Python bindings
  • C bindings
  • Ruby bindings
  • Error for inline_fragment + keep_at_rules?

@kamilzych kamilzych force-pushed the feat/keep-at-rules branch from c4adfc7 to fdfd7c8 Compare July 19, 2025 22:14
@Stranger6667
Copy link
Owner

As for the next steps, I think most of them are about adding new config option to bindings, so they compile. This flag is the same as others in terms of behavior, so it should be more or less straightforward.

about the error, we can add an extra check to inline_fragment


--keep-link-tags
Keep "link" tags after inlining.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a small place above where this flag should be added too. The handle_boolean_flag function

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 960715e

@kamilzych
Copy link
Contributor Author

@Stranger6667 Hi again! I should have some time tomorrow to work on this again. Updating CHANGELOG, README and other docs should be easy. I can also take a look into

Error for inline_fragment + keep_at_rules?

and perhaps add a test(s) for it and other cases.

The only question I have right now is about bindings. Do you have any docs, guidance, or previous PR that could show me the ropes?

@Stranger6667
Copy link
Owner

@kamilzych

Hi! I’ll have access to my laptop in a few hours and will provide you with more details :)

@Stranger6667
Copy link
Owner

For bindings, all the TODO entries are similar. The core of them is:

  • Fill the options structure with the missing keep_at_rules
  • Extract the keep_at_rules value the same way as it goes for other boolean flags like keep_style_tags
  • Update language-specific annotations / docstrings

Depending on the language, it requires a bit different annotations, but you can just copy them from keep_style_tags.

For Ruby, extend the get_kwargs call by adding the field name to the array of strings + adding Option<bool> to one of the generics so the name of the new kwarg corresponds to that new Option<bool> position.

For Python, you need to add keep_at_rules = False to the signature macro + update the docstring the same way, so the positions match.

For C, you need to extend CssInlinerOptions with one new boolean and that is it.

For Java, extract a new boolean from the environment:

    let keep_at_rules = env.get_bool_field(&cfg, "keepAtRules")?;

And add it to the options builder.

For JavaScript, extend options::Options with this new boolean field, then the deserialization logic will be automatically derived. Also, you'll need to update the InlineOptions interface inside the INLINE constant. Then running npm run build:wasm && npm run build:wasm-web inside bindings/javascript should be enough to regenerate everything needed.

Let me know if I missed anything, will be happy to elaborate :)

@kamilzych kamilzych force-pushed the feat/keep-at-rules branch 2 times, most recently from 5ea0ba8 to 6156284 Compare July 25, 2025 07:51
@kamilzych kamilzych force-pushed the feat/keep-at-rules branch from 6156284 to 67574d3 Compare July 25, 2025 08:25
@Stranger6667
Copy link
Owner

One minor detail about Ruby: bindings use the latest release from crates.io to avoid packaging issues. It is suboptimal, but I am fine with having this PR point to the local crate, so we see everything passes. Then I'll update it before the release, and maybe add a better solution to avoid breaking releases for Ruby bindings.

</body>
</html>"#;
let inlined = inliner.inline(html).unwrap();
let expected = "<html><head><style>@media (max-width: 600px) { h1 { font-size: 18px; } }</style>\n\n</head>\n<body>\n<h1 style=\"color: blue;\">Hello</h1><p style=\"margin: 10px;\">World</p>\n\n</body></html>";
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see there is a double space - (max-width: 600px) {. I guess we can keep what is in the original string without adding extra space - it should be a bit cleaner + a bit faster

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unless it breaks some corner cases

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 430ca64

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Had to add it back at the end though be2c653 😅 Without it there's no space between multiple @media declarations, like this
@media (max-width: 600px) { ... }@media (max-width: 400px) { ... }

@kamilzych
Copy link
Contributor Author

kamilzych commented Jul 25, 2025

@Stranger6667 Regarding error when inlining fragments. I've just checked and it seems that keep_style_tags=true doesn't throw any error when inlining fragment. If I understand correctly, it is pretty much the same case, right?
If so, perhaps it's better to stick with this approach for unified experience and simply ignore keep_at_rules for now when inlining fragments? Changing this to throw errors for both flags could be a separate issue. What do you think?

here's the test I used for testing

#[test]
fn inline_fragment_keep_style_tags() {
    let inliner = CSSInliner::options().keep_style_tags(true).build();
    const HTML: &str = r#"<main>
<h1>Hello</h1>
<section>
<p>who am i</p>
</section>
</main>"#;

    const CSS: &str = r#"
p {
    color: red;
}

h1 {
    color: blue;
}
"#;
    let inlined = inliner.inline_fragment(HTML, CSS).unwrap();
    assert_eq!(inlined, "<main>\n<h1 style=\"color: blue;\">Hello</h1>\n<section>\n<p style=\"color: red;\">who am i</p>\n</section>\n</main>");
}

@Stranger6667
Copy link
Owner

@kamilzych I completely agree with you! lets handle that part separately

@kamilzych kamilzych force-pushed the feat/keep-at-rules branch 2 times, most recently from 733dc23 to 104de47 Compare July 25, 2025 12:21
@kamilzych kamilzych force-pushed the feat/keep-at-rules branch from 104de47 to 0a77f50 Compare July 25, 2025 12:26
@Stranger6667
Copy link
Owner

Awesome! The last failure with Ruby bindings could be resolved by running cargo generate-lockfile in bindings/ruby + copying the Cargo.lock file to ext/css_inline

@kamilzych
Copy link
Contributor Author

Awesome! The last failure with Ruby bindings could be resolved by running cargo generate-lockfile in bindings/ruby + copying the Cargo.lock file to ext/css_inline

Unfortunately, this is where I failed 🥲

After changing bindings/ruby/ext/css_inline/Cargo.toml to this

[dependencies.css-inline]
path = "../../../../css-inline"
version = "*"

running cargo generate-lockfile fails with package collision in the lockfile:

If I keep version 0.16 the command generates Lockfile, but then it will still use 0.16 from crates.io (at least that's what I think :))

@Stranger6667
Copy link
Owner

No worries! I will take a look :) I think at this point everything should be resolved and the feature is ready :) thank you so much for working on it! I plan to merge it if there are no unresolved issues and will sort out the ruby part later

@kamilzych
Copy link
Contributor Author

Thank you too for all the patience, guidance and help 🙏 I had a lot of fun when working on it!

In the meantime, I created #514

I will also take a look at unit tests, might add more.

@kamilzych
Copy link
Contributor Author

@Stranger6667 I can already see some issues. Some are simply incorrect behaviour which I will try to fix, but for one I have a question. Let's consider such test:

    let html = html!("@media (max-width: 767px) { padding: 0;} h1 {background-color: blue;}", "<h1>Hello world!</h1>");
    let options = InlineOptions {
        inline_style_tags: false,
        keep_style_tags: false,
        keep_at_rules: true,
        ..Default::default()
    };
    let inliner = CSSInliner::new(options);
    let result = inliner.inline(&html).unwrap();
    assert_eq!(
        result,
        "???"
    )

What should we expect for such options setup? Right now, the result is <html><head></head><body><h1>Hello world!</h1></body></html> so keep_at_rules is ignored.

@Stranger6667
Copy link
Owner

Thank you too for all the patience, guidance and help 🙏 I had a lot of fun when working on it!

I am glad to hear it! :)

Re: config options combo. From a use case point of view, I think the user might want to remove everything except at-rules, so I'd expect a style element with only the @media part.

As keep_style_tags defaults to false, it is equivalent to options().inline_style_tags(false).keep_at_rules(true). In this scenario, I read it like "don't inline anything, but keep @-rules" + the default behavior is to remove styles, so it kinda checks out. I see where the contradiction is, but I am not sure whether it would be surprising for the user if such a combo will remove all except @-rules. What do you think?

P.S., adding an unused private field could be a good idea to avoid a public API to construct this without InlineOptions

@kamilzych
Copy link
Contributor Author

Yeah, with keep_style_tags: false && keep_at_rules: true it does work as you described and I think that setting inline_style_tags to false shouldn't change anything. I'll fix it.

adding an unused private field could be a good idea to avoid a public API to construct this without InlineOptions

Not sure if I follow, where this private field should be added? 🤔

@Stranger6667
Copy link
Owner

Thanks! :)

I think adding _private: () to InlineOptions should be the way to go, but it could be done separately, as such a change would only affect what the user could do and is not directly related to the new config option.

The idea is that having this private field allows for fewer breaking changes. Right now, dependencies could build this struct directly with or without using Default::default() - a private field will make it impossible to build this struct directly, and the only possible way will be using a builder or ::default(). For this reason, adding new config options will not break the dependencies

@kamilzych kamilzych force-pushed the feat/keep-at-rules branch from be2c653 to cc509a2 Compare July 25, 2025 17:15
@kamilzych
Copy link
Contributor Author

Ah, I see. Makes sense, although I'm not sure if it should be a part of this PR 🤔

FYI, I've just committed a failing test, fixing it seems to be a bit harder than I thought and I have to run. I hope to find some time next week to work on it again. Thanks again! 🙏

@Stranger6667
Copy link
Owner

Sure thing! The private field thing is definitely for later!

Thank you again for working on this :)
I'll take a deeper look over the weekend and hope that I will be able to finish things that are in the TODO list here :) Have a great weekend

Signed-off-by: Dmitry Dygalo <dmitry@dygalo.dev>
Signed-off-by: Dmitry Dygalo <dmitry@dygalo.dev>
Signed-off-by: Dmitry Dygalo <dmitry@dygalo.dev>
@Stranger6667 Stranger6667 marked this pull request as ready for review July 26, 2025 11:04
@Stranger6667
Copy link
Owner

I think we are ready to go! :) I am so happy to see this feature implemented! :) Great work!

I will deal with Ruby separately

@Stranger6667 Stranger6667 merged commit 9d76579 into Stranger6667:master Jul 26, 2025
57 of 76 checks passed
@kamilzych
Copy link
Contributor Author

Awesome, glad to hear it! Perhaps I'll add more unit tests for corner cases (e.g. at-rules in external CSS, various flags' configs etc) in the future. Besides, once you release it, I'll test this feature performance-wise with real-world case (quite complex HTML with some at-rules I need to keep in output HTML).
Great collabo! 🎉

@Stranger6667
Copy link
Owner

Great! Also, feel free to add that HTML as a benchmark here (if it could be shared publicly). The structure there could be extended so we can benchmark the new config option too (e.g. add optional "keep_at_rules": true to the new benchmark)

@mrdziuban mrdziuban mentioned this pull request Oct 17, 2025
4 tasks
@kamilzych kamilzych deleted the feat/keep-at-rules branch January 4, 2026 18:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants