LibWeb: Parse scoped CSS import rules

Parse and store the optional `scope` clause in `@import` preludes.

WPT doesn't directly test the serialization behaviour for this, so add a
custom test for it.
This commit is contained in:
Sam Atkins 2026-05-29 17:16:12 +01:00
parent bec9b35e56
commit 538dca4448
10 changed files with 325 additions and 55 deletions

View file

@ -28,16 +28,17 @@ namespace Web::CSS {
GC_DEFINE_ALLOCATOR(CSSImportRule);
GC::Ref<CSSImportRule> CSSImportRule::create(JS::Realm& realm, URL url, GC::Ptr<DOM::Document> document, Optional<FlyString> layer, RefPtr<Supports> supports, GC::Ref<MediaList> media)
GC::Ref<CSSImportRule> CSSImportRule::create(JS::Realm& realm, URL url, GC::Ptr<DOM::Document> document, Optional<FlyString> layer, Optional<ImportScope>&& scope, RefPtr<Supports> supports, GC::Ref<MediaList> media)
{
return realm.create<CSSImportRule>(realm, move(url), document, move(layer), move(supports), move(media));
return realm.create<CSSImportRule>(realm, move(url), document, move(layer), move(scope), move(supports), move(media));
}
CSSImportRule::CSSImportRule(JS::Realm& realm, URL url, GC::Ptr<DOM::Document> document, Optional<FlyString> layer, RefPtr<Supports> supports, GC::Ref<MediaList> media)
CSSImportRule::CSSImportRule(JS::Realm& realm, URL url, GC::Ptr<DOM::Document> document, Optional<FlyString> layer, Optional<ImportScope>&& scope, RefPtr<Supports> supports, GC::Ref<MediaList> media)
: CSSRule(realm, Type::Import)
, m_url(move(url))
, m_document(document)
, m_layer(move(layer))
, m_scope(move(scope))
, m_supports(move(supports))
, m_media(move(media))
{
@ -108,6 +109,26 @@ String CSSImportRule::serialized() const
}
}
// AD-HOC: Serialize the rule's import scope if it exists.
if (m_scope.has_value()) {
builder.append(" scope"sv);
if (m_scope->start_selectors.has_value() || m_scope->end_selectors.has_value()) {
builder.append('(');
if (m_scope->start_selectors.has_value()) {
if (m_scope->end_selectors.has_value())
builder.appendff("({})", serialize_a_group_of_selectors(*m_scope->start_selectors));
else
builder.append(serialize_a_group_of_selectors(*m_scope->start_selectors));
}
if (m_scope->end_selectors.has_value()) {
if (m_scope->start_selectors.has_value())
builder.append(' ');
builder.appendff("to ({})", serialize_a_group_of_selectors(*m_scope->end_selectors));
}
builder.append(')');
}
}
// AD-HOC: Serialize the rule's supports condition if it exists.
// This isn't currently specified, but major browsers include this in their serialization of import rules
if (m_supports)
@ -262,6 +283,18 @@ Optional<String> CSSImportRule::supports_text() const
return m_supports->to_string();
}
Optional<SelectorList> const& CSSImportRule::scope_start_selectors() const
{
VERIFY(m_scope.has_value());
return m_scope->start_selectors;
}
Optional<SelectorList> const& CSSImportRule::scope_end_selectors() const
{
VERIFY(m_scope.has_value());
return m_scope->end_selectors;
}
Optional<FlyString> CSSImportRule::internal_qualified_layer_name(Badge<StyleScope>) const
{
if (!m_layer.has_value())
@ -301,6 +334,23 @@ void CSSImportRule::dump(StringBuilder& builder, int indent_levels) const
if (m_supports)
m_supports->dump(builder, indent_levels + 1);
if (m_scope.has_value()) {
dump_indent(builder, indent_levels + 1);
builder.append("Scope:\n"sv);
dump_indent(builder, indent_levels + 2);
if (m_scope->start_selectors.has_value())
builder.appendff("Start selectors: {}\n", serialize_a_group_of_selectors(*m_scope->start_selectors));
else
builder.append("Start selectors: <none>\n"sv);
dump_indent(builder, indent_levels + 2);
if (m_scope->end_selectors.has_value())
builder.appendff("End selectors: {}\n", serialize_a_group_of_selectors(*m_scope->end_selectors));
else
builder.append("End selectors: <none>\n"sv);
}
if (m_style_sheet) {
dump_sheet(builder, *m_style_sheet, indent_levels + 1);
} else {

View file

@ -10,6 +10,7 @@
#include <LibWeb/CSS/CSSRule.h>
#include <LibWeb/CSS/CSSStyleSheet.h>
#include <LibWeb/CSS/Selector.h>
#include <LibWeb/CSS/URL.h>
#include <LibWeb/Export.h>
#include <LibWeb/Forward.h>
@ -23,7 +24,12 @@ class WEB_API CSSImportRule final
GC_DECLARE_ALLOCATOR(CSSImportRule);
public:
[[nodiscard]] static GC::Ref<CSSImportRule> create(JS::Realm&, URL, GC::Ptr<DOM::Document>, Optional<FlyString> layer, RefPtr<Supports>, GC::Ref<MediaList>);
struct ImportScope {
Optional<SelectorList> start_selectors;
Optional<SelectorList> end_selectors;
};
[[nodiscard]] static GC::Ref<CSSImportRule> create(JS::Realm&, URL, GC::Ptr<DOM::Document>, Optional<FlyString> layer, Optional<ImportScope>&& scope, RefPtr<Supports>, GC::Ref<MediaList>);
virtual ~CSSImportRule() override;
@ -39,12 +45,15 @@ public:
Optional<String> supports_text() const;
bool matches() const;
bool has_scope() const { return m_scope.has_value(); }
Optional<SelectorList> const& scope_start_selectors() const;
Optional<SelectorList> const& scope_end_selectors() const;
Optional<FlyString> internal_layer_name() const { return m_layer_internal; }
Optional<FlyString> internal_qualified_layer_name(Badge<StyleScope>) const;
private:
CSSImportRule(JS::Realm&, URL, GC::Ptr<DOM::Document>, Optional<FlyString>, RefPtr<Supports>, GC::Ref<MediaList>);
CSSImportRule(JS::Realm&, URL, GC::Ptr<DOM::Document>, Optional<FlyString>, Optional<ImportScope>&&, RefPtr<Supports>, GC::Ref<MediaList>);
virtual void initialize(JS::Realm&) override;
virtual void visit_edges(Cell::Visitor&) override;
@ -63,6 +72,7 @@ private:
GC::Ptr<DOM::Document> m_document;
Optional<FlyString> m_layer;
Optional<FlyString> m_layer_internal;
Optional<ImportScope> m_scope;
RefPtr<Supports> m_supports;
GC::Ref<MediaList> m_media;
GC::Ptr<CSSStyleSheet> m_style_sheet;

View file

@ -237,15 +237,23 @@ GC::Ptr<CSSStyleRule> Parser::convert_to_style_rule(QualifiedRule const& qualifi
return CSSStyleRule::create(realm(), move(selectors), *declaration, *nested_rules);
}
static bool selector_list_contains_pseudo_element(SelectorList const& selectors)
{
for (auto const& selector : selectors) {
if (selector->target_pseudo_element().has_value())
return true;
}
return false;
}
GC::Ptr<CSSImportRule> Parser::convert_to_import_rule(AtRule const& rule)
{
// https://drafts.csswg.org/css-cascade-5/#at-import
// https://drafts.csswg.org/css-cascade-6/#at-import
// @import [ <url> | <string> ]
// [ layer | layer(<layer-name>) ]?
// <import-conditions> ;
//
// <import-conditions> = [ supports( [ <supports-condition> | <declaration> ] ) ]?
// <media-query-list>?
// [[ layer | layer(<layer-name>) ]
// || [ scope | scope(<scope-start> | <scope-boundaries>) ]
// || supports( [ <supports-condition> | <declaration> ] ) ]?
// <media-import-condition> ;
TokenStream tokens { rule.prelude };
if (rule.is_block_rule) {
@ -283,43 +291,167 @@ GC::Ptr<CSSImportRule> Parser::convert_to_import_rule(AtRule const& rule)
tokens.discard_whitespace();
Optional<FlyString> layer;
// [ layer | layer(<layer-name>) ]?
if (tokens.next_token().is_ident("layer"sv)) {
tokens.discard_a_token(); // layer
layer = FlyString {};
} else if (tokens.next_token().is_function("layer"sv)) {
Optional<CSSImportRule::ImportScope> scope;
RefPtr<Supports> supports {};
auto parse_scope_selector_list = [&](TokenStream<ComponentValue>& selector_tokens, SelectorType selector_type) -> Optional<SelectorList> {
auto maybe_selectors = parse_a_selector_list(selector_tokens, selector_type);
selector_tokens.discard_whitespace();
if (maybe_selectors.is_error() || maybe_selectors.value().is_empty() || selector_tokens.has_next_token())
return {};
auto selectors = maybe_selectors.release_value();
if (selector_list_contains_pseudo_element(selectors))
return {};
return selectors;
};
auto parse_parenthesized_scope_selector_list = [&](TokenStream<ComponentValue>& selector_tokens, SelectorType selector_type) -> Optional<SelectorList> {
if (!(selector_tokens.next_token().is_block() && selector_tokens.next_token().block().is_paren()))
return {};
auto const& selector_block = selector_tokens.consume_a_token().block();
TokenStream block_tokens { selector_block.value };
return parse_scope_selector_list(block_tokens, selector_type);
};
auto contains_unparenthesized_scope_boundary_keyword = [](Vector<ComponentValue> const& component_values) {
ComponentValue const* previous_non_whitespace_token = nullptr;
for (auto const& component_value : component_values) {
if (component_value.is(Token::Type::Whitespace))
continue;
if (component_value.is_ident("to"sv)) {
if (!previous_non_whitespace_token)
return true;
if (!previous_non_whitespace_token->is_delim('.') && !previous_non_whitespace_token->is(Token::Type::Colon))
return true;
}
previous_non_whitespace_token = &component_value;
}
return false;
};
auto parse_layer = [&]() -> bool {
if (layer.has_value())
return false;
if (tokens.next_token().is_ident("layer"sv)) {
tokens.discard_a_token(); // layer
layer = FlyString {};
return true;
}
if (!tokens.next_token().is_function("layer"sv))
return false;
auto layer_transaction = tokens.begin_transaction();
auto& layer_function = tokens.consume_a_token().function();
TokenStream layer_tokens { layer_function.value };
auto name = parse_layer_name(layer_tokens, AllowBlankLayerName::No);
layer_tokens.discard_whitespace();
if (!name.has_value() || layer_tokens.has_next_token()) {
ErrorReporter::the().report(CSS::Parser::InvalidRuleError {
.rule_name = "@import"_fly_string,
.prelude = tokens.dump_string(),
.description = MUST(String::formatted("Unable to parse `{}` as a valid layer.", layer_function.original_source_text())),
});
} else {
layer_transaction.commit();
layer = name.release_value();
}
}
if (!name.has_value() || layer_tokens.has_next_token())
return false;
// <import-conditions> = [ supports( [ <supports-condition> | <declaration> ] ) ]?
// <media-query-list>?
tokens.discard_whitespace();
RefPtr<Supports> supports {};
if (tokens.next_token().is_function("supports"sv)) {
layer_transaction.commit();
layer = name.release_value();
return true;
};
auto parse_scope = [&]() -> bool {
if (scope.has_value())
return false;
if (tokens.next_token().is_ident("scope"sv)) {
tokens.discard_a_token(); // scope
scope = CSSImportRule::ImportScope {};
return true;
}
if (!tokens.next_token().is_function("scope"sv))
return false;
auto scope_transaction = tokens.begin_transaction();
auto& scope_function = tokens.consume_a_token().function();
TokenStream scope_tokens { scope_function.value };
CSSImportRule::ImportScope parsed_scope;
scope_tokens.discard_whitespace();
if (scope_tokens.is_empty()) {
scope_transaction.commit();
scope = move(parsed_scope);
return true;
}
if (scope_tokens.next_token().is_block() && scope_tokens.next_token().block().is_paren()) {
auto start_selectors = parse_parenthesized_scope_selector_list(scope_tokens, SelectorType::Standalone);
if (!start_selectors.has_value())
return false;
parsed_scope.start_selectors = start_selectors.release_value();
scope_tokens.discard_whitespace();
}
if (scope_tokens.next_token().is_ident("to"sv)) {
scope_tokens.discard_a_token(); // to
scope_tokens.discard_whitespace();
auto end_selectors = parse_parenthesized_scope_selector_list(scope_tokens, SelectorType::Relative);
if (!end_selectors.has_value())
return false;
parsed_scope.end_selectors = end_selectors.release_value();
scope_tokens.discard_whitespace();
}
if (!parsed_scope.start_selectors.has_value() && !parsed_scope.end_selectors.has_value()) {
if (contains_unparenthesized_scope_boundary_keyword(scope_function.value))
return false;
auto start_selectors = parse_scope_selector_list(scope_tokens, SelectorType::Standalone);
if (!start_selectors.has_value())
return false;
parsed_scope.start_selectors = start_selectors.release_value();
}
if (scope_tokens.has_next_token())
return false;
scope_transaction.commit();
scope = move(parsed_scope);
return true;
};
auto parse_supports = [&]() -> bool {
if (supports)
return false;
if (!tokens.next_token().is_function("supports"sv))
return false;
auto supports_transaction = tokens.begin_transaction();
auto component_value = tokens.consume_a_token();
TokenStream supports_tokens { component_value.function().value };
supports = parse_a_supports(supports_tokens);
if (!supports) {
auto parsed_supports = parse_a_supports(supports_tokens);
if (!parsed_supports) {
m_rule_context.append(RuleContext::SupportsCondition);
auto supports_declaration = parse_supports_declaration(supports_tokens);
m_rule_context.take_last();
if (supports_declaration)
supports = Supports::create(supports_declaration.release_nonnull<BooleanExpression>());
parsed_supports = Supports::create(supports_declaration.release_nonnull<BooleanExpression>());
}
if (!parsed_supports)
return false;
supports_transaction.commit();
supports = move(parsed_supports);
return true;
};
while (true) {
tokens.discard_whitespace();
if (parse_layer() || parse_scope() || parse_supports())
continue;
break;
}
auto media_query_list = parse_a_media_query_list(tokens);
@ -333,7 +465,7 @@ GC::Ptr<CSSImportRule> Parser::convert_to_import_rule(AtRule const& rule)
return {};
}
return CSSImportRule::create(realm(), url.release_value(), const_cast<DOM::Document*>(m_document.ptr()), move(layer), move(supports), MediaList::create(realm(), move(media_query_list)));
return CSSImportRule::create(realm(), url.release_value(), const_cast<DOM::Document*>(m_document.ptr()), move(layer), move(scope), move(supports), MediaList::create(realm(), move(media_query_list)));
}
Optional<FlyString> Parser::parse_layer_name(TokenStream<ComponentValue>& tokens, AllowBlankLayerName allow_blank_layer_name)
@ -885,15 +1017,6 @@ GC::Ptr<CSSPropertyRule> Parser::convert_to_property_rule(AtRule const& rule)
return CSSPropertyRule::create(realm(), name, syntax_maybe.value(), inherits_maybe.value(), move(initial_value_maybe));
}
static bool selector_list_contains_pseudo_element(SelectorList const& selectors)
{
for (auto const& selector : selectors) {
if (selector->target_pseudo_element().has_value())
return true;
}
return false;
}
// https://drafts.csswg.org/css-cascade-6/#scope-atrule
template<typename NestedDeclarationsRule>
GC::Ptr<CSSScopeRule> Parser::convert_to_scope_rule(AtRule const& rule, Nested nested)

View file

@ -0,0 +1,23 @@
PASS all scoped imports parsed
PASS bare scope import rule parsed
PASS bare scope import rule serialized as scoped
PASS empty scope() import rule parsed
PASS empty scope() import rule serialized as scoped
PASS start selector import rule parsed
PASS start selector import rule serialized as scoped
PASS start selector list import rule parsed
PASS start selector list import rule serialized as scoped
PASS end selector import rule parsed
PASS end selector import rule serialized as scoped
PASS start and end selector import rule parsed
PASS start and end selector import rule serialized as scoped
PASS combined modifier import rule parsed
PASS combined modifier import rule has layer
PASS combined modifier import rule has supports
PASS combined modifier import rule has media
PASS combined modifier import rule serialized as scoped
PASS unordered modifier import rule parsed
PASS unordered modifier import rule has layer
PASS unordered modifier import rule has supports
PASS unordered modifier import rule has media
PASS unordered modifier import rule serialized as scoped

View file

@ -4,5 +4,5 @@ Found 2 tests
1 Pass
1 Fail
Fail Scope-imported rule applies within implicit scope
Pass Scope-imported rule does not apply outside of implicit scope
Pass Scope-imported rule applies within implicit scope
Fail Scope-imported rule does not apply outside of implicit scope

View file

@ -4,5 +4,5 @@ Found 2 tests
1 Pass
1 Fail
Fail @scope within scope-imported stylesheet matches
Pass @scope within scope-imported does not ignore import scope
Pass @scope within scope-imported stylesheet matches
Fail @scope within scope-imported does not ignore import scope

View file

@ -4,5 +4,5 @@ Found 2 tests
1 Pass
1 Fail
Fail A stylesheet may be imported multiple times, and scoped differently
Pass Scope-imported rule does not apply outside of scope
Pass A stylesheet may be imported multiple times, and scoped differently
Fail Scope-imported rule does not apply outside of scope

View file

@ -4,5 +4,5 @@ Found 2 tests
1 Pass
1 Fail
Fail Scope-imported rule applies within scope, above limit
Pass Scope-imported rule does not apply below limit
Pass Scope-imported rule applies within scope, above limit
Fail Scope-imported rule does not apply below limit

View file

@ -4,5 +4,5 @@ Found 2 tests
1 Pass
1 Fail
Fail Scope-imported rule applies within scope
Pass Scope-imported rule does not apply outside of scope
Pass Scope-imported rule applies within scope
Fail Scope-imported rule does not apply outside of scope

View file

@ -0,0 +1,64 @@
<!DOCTYPE html>
<style>
@import url("data:text/css,") scope;
@import url("data:text/css,") scope();
@import url("data:text/css,") scope(.scope);
@import url("data:text/css,") scope(.scope, .other);
@import url("data:text/css,") scope(to (.limit));
@import url("data:text/css,") scope((.scope) to (.limit));
@import url("data:text/css,") layer(foo) supports(display: block) scope(.scope) screen;
@import url("data:text/css,") supports(display: block) scope(.scope) layer(bar) screen;
</style>
<script src="../include.js"></script>
<script>
function expect(condition, description) {
println(`${condition ? "PASS" : "FAIL"} ${description}`);
}
function expectCSSImportRule(rule, description) {
expect(rule instanceof CSSImportRule, description);
}
function expectScopedImport(rule, scopeText, description) {
expect(rule.cssText.includes(scopeText), description);
}
function expectImplicitScopedImport(rule, description) {
expect(rule.cssText.includes(" scope"), description);
}
test(() => {
let rules = document.styleSheets[0].cssRules;
expect(rules.length === 8, "all scoped imports parsed");
expectCSSImportRule(rules[0], "bare scope import rule parsed");
expectImplicitScopedImport(rules[0], "bare scope import rule serialized as scoped");
expectCSSImportRule(rules[1], "empty scope() import rule parsed");
expectImplicitScopedImport(rules[1], "empty scope() import rule serialized as scoped");
expectCSSImportRule(rules[2], "start selector import rule parsed");
expectScopedImport(rules[2], "scope(.scope)", "start selector import rule serialized as scoped");
expectCSSImportRule(rules[3], "start selector list import rule parsed");
expectScopedImport(rules[3], "scope(.scope, .other)", "start selector list import rule serialized as scoped");
expectCSSImportRule(rules[4], "end selector import rule parsed");
expectScopedImport(rules[4], "scope(to (.limit))", "end selector import rule serialized as scoped");
expectCSSImportRule(rules[5], "start and end selector import rule parsed");
expectScopedImport(rules[5], "scope((.scope) to (.limit))", "start and end selector import rule serialized as scoped");
expectCSSImportRule(rules[6], "combined modifier import rule parsed");
expect(rules[6].layerName === "foo", "combined modifier import rule has layer");
expect(rules[6].supportsText === "display: block", "combined modifier import rule has supports");
expect(rules[6].media.mediaText === "screen", "combined modifier import rule has media");
expectScopedImport(rules[6], "scope(.scope)", "combined modifier import rule serialized as scoped");
expectCSSImportRule(rules[7], "unordered modifier import rule parsed");
expect(rules[7].layerName === "bar", "unordered modifier import rule has layer");
expect(rules[7].supportsText === "display: block", "unordered modifier import rule has supports");
expect(rules[7].media.mediaText === "screen", "unordered modifier import rule has media");
expectScopedImport(rules[7], "scope(.scope)", "unordered modifier import rule serialized as scoped");
});
</script>