Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## 0.62.0

- [FIX] Accept CSS Nesting's relative-selector shorthand in a nested rule's prelude: `.parent { > .child {} }`, `+ .sib {}` and `~ .sib {}` resolve exactly like `& > .child`. The root of `[%css]`, `[%styled.global]` and a raw stylesheet still rejects a leading combinator, and `[%styled.global]` now also rejects one inside an at-rule block with no enclosing style rule (`@media print { > .a {} }`), the same way it rejects a parentless `&`, instead of shipping unresolved `&` (@davesnx)
- [FEATURE] Register the css-fonts-5 metric-override descriptors `ascent-override`, `descent-override`, `line-gap-override` (each `normal | <percentage>`), and `size-adjust` (`<percentage>`), so a valid `@font-face` block declaring them compiles instead of failing with an unknown-descriptor error (#580) (@davesnx)
- [FIX] Reject the `@font-face`-only descriptors (`src`, `unicode-range`, `font-display`, `ascent-override`, `descent-override`, `line-gap-override`, `size-adjust`) in any declaration outside an `@font-face` block. `.title { ascent-override: 90%; }` previously compiled and emitted a descriptor browsers ignore; it now fails with `Descriptor 'ascent-override' is only valid inside @font-face`. The reverse holds too: an `@font-face` body accepts descriptors only, so `@font-face { color: red; }` fails with `Property 'color' is not a @font-face descriptor` (@davesnx)
- [FIX] Lex an escaped identifier into its canonical CSS spelling instead of the decoded text. The lexer decoded `\31 a` to `1a` and dropped the escape, so `.\31 a { color: red; }` (class `1a`) rendered as the invalid `.1a{...}`, and `.a\.b { color: red; }` (one class named `a.b`) rendered as `.a.b{...}` — two classes, changing what the selector matches with no error at all. Every identifier token (class, id, type selector, attribute, pseudo-class, custom property name, custom ident, dimension unit) now re-escapes per CSSOM `serialize an identifier` as it is lexed; a hash that does not start an identifier (`#123456`) is a color and keeps its decoded text (@davesnx)
Expand Down
45 changes: 31 additions & 14 deletions packages/parser/lib/Parser.ml
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,6 @@ let next_significant_index tokens start =
in
loop start

(* -- Token set predicates (Design 3) -- *)

let is_selector_start = function
| Tokens.DELIM ("." | "&" | "*")
| Tokens.HASH _ | Tokens.LEFT_BRACKET | Tokens.INTERPOLATION _ ->
Expand All @@ -147,13 +145,11 @@ let is_at_rule_start = function
| Tokens.AT_RULE _ | Tokens.AT_KEYFRAMES _ -> true
| _ -> false

(* Tokens that start a selector (ident or any selector-start or pseudo) *)
let token_starts_selector tok =
match tok with
| Tokens.IDENT _ -> true
| tok -> is_selector_start tok || is_pseudo_start tok

(* Tokens that start a selector prelude (ident or selector-start, no pseudo) *)
let token_starts_selector_prelude tok =
match tok with Tokens.IDENT _ -> true | tok -> is_selector_start tok

Expand Down Expand Up @@ -353,8 +349,6 @@ let parse_wq_name stream =
let name, _ = expect_ident stream in
name

(* -- An+B microsyntax (nth payloads) -- *)

type nth_suffix =
| Nth_suffix_n
| Nth_suffix_n_dash (* requires a following integer *)
Expand All @@ -364,7 +358,6 @@ let raise_invalid_nth function_name (token : token_with_location) =
let message = Printf.sprintf "Invalid an+b value in :%s()" function_name in
raise (Parse_error (token.start_pos, token.end_pos, message))

(* Reject fractional or inexact float coefficients before converting to int. *)
let nth_int_of_number function_name (token : token_with_location) value =
let max_exact_float_int =
9007199254740992.
Expand All @@ -376,15 +369,13 @@ let nth_int_of_number function_name (token : token_with_location) value =

let is_ascii_digit = function '0' .. '9' -> true | _ -> false

(* Accept only non-empty ASCII digits; [int_of_string_opt] handles overflow. *)
let nth_int_of_digits function_name (token : token_with_location) digits =
if String.length digits > 0 && String.for_all is_ascii_digit digits then (
match int_of_string_opt digits with
| Some value -> value
| None -> raise_invalid_nth function_name token)
else raise_invalid_nth function_name token

(* Accept "n", "n-", or "n-<digits>", with ASCII-case-insensitive "n". *)
let classify_nth_suffix function_name (token : token_with_location) suffix =
let length = String.length suffix in
if length = 0 || (suffix.[0] <> 'n' && suffix.[0] <> 'N') then
Expand Down Expand Up @@ -790,6 +781,21 @@ and parse_relative_selector stream =
and parse_relative_selector_list stream =
parse_selector_list_with stream parse_relative_selector

(* A nested rule's prelude (i.e. one inside another rule's block) may start
with a bare combinator per CSS Nesting ("> .child" means "& > .child").
Only the item that actually starts with one is parsed as a
[RelativeSelector]; every other item keeps producing a plain
[ComplexSelector], so a selector list that doesn't use this shorthand
(the common case) has the exact same AST as before this existed. *)
and parse_nested_selector stream =
skip_whitespace stream;
match current_tok stream with
| Tokens.DELIM ("+" | "~" | ">") -> parse_relative_selector stream
| _ -> parse_selector stream

and parse_nested_selector_list stream =
parse_selector_list_with stream parse_nested_selector

let update_declaration_value_state state (value, _) =
match value with
| Whitespace -> state
Expand Down Expand Up @@ -1024,7 +1030,7 @@ and parse_at_rule stream =
parse_braced_rules stream left_brace (fun stream ->
parse_rule_list stream
~stop:(fun stream -> current_is stream Tokens.RIGHT_BRACE)
~parse_one:parse_block_rule ~allow_empty:true)
~parse_one:parse_nested_block_rule ~allow_empty:true)
in
{
name = name, component_loc at_token;
Expand All @@ -1036,24 +1042,30 @@ and parse_at_rule stream =
end
| _ -> raise_parse_error (current_token stream)

and parse_style_rule stream =
and parse_style_rule_with stream ~parse_prelude =
skip_whitespace stream;
let start_pos = (current_token stream).start_pos in
let prelude = parse_selector_list stream in
let prelude = parse_prelude stream in
skip_whitespace stream;
let left_brace = expect_token stream Tokens.LEFT_BRACE in
let block =
parse_braced_rules stream left_brace (fun stream ->
let rules, rules_loc =
parse_rule_list stream
~stop:(fun stream -> current_is stream Tokens.RIGHT_BRACE)
~parse_one:parse_block_rule ~allow_empty:false
~parse_one:parse_nested_block_rule ~allow_empty:false
in
rules, rules_loc)
in
{ prelude; block; loc = loc_from_start stream start_pos }

and parse_block_rule stream =
and parse_style_rule stream =
parse_style_rule_with stream ~parse_prelude:parse_selector_list

and parse_nested_style_rule stream =
parse_style_rule_with stream ~parse_prelude:parse_nested_selector_list

and parse_block_rule_with stream ~parse_style_rule =
skip_whitespace stream;
match current_tok stream with
| Tokens.AT_KEYFRAMES _ | Tokens.AT_RULE _ -> At_rule (parse_at_rule stream)
Expand All @@ -1072,6 +1084,11 @@ and parse_block_rule stream =
end
| _ -> Style_rule (parse_style_rule stream)

and parse_block_rule stream = parse_block_rule_with stream ~parse_style_rule

and parse_nested_block_rule stream =
parse_block_rule_with stream ~parse_style_rule:parse_nested_style_rule

and parse_stylesheet_rule stream =
skip_whitespace stream;
match current_tok stream with
Expand Down
163 changes: 87 additions & 76 deletions packages/parser/lib/Selector_nesting.re
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,25 @@ let subject_escapes_ampersand_subtree = (sel: selector): bool => {
&& !subject_inside_ampersand(flatten_selector_chain(sel));
};

/* A top-level prelude selector needs an ambient parent to resolve against
when it contains a literal `&`, or when it starts with a bare combinator
(`> .a` means `& > .a`, same implicit `&` as writing it out). Only the
outermost shape is checked for the latter — a leading combinator nested
inside a `:has()`/`:is()` payload (`:has(> img)`) is self-contained and
never needs one, which is why this doesn't just teach `contains_ampersand`
itself: that function also runs on payloads. Used to reject a
[%styled.global] rule the same way regardless of which of the two
spellings it used. */
let needs_parent_selector = (selector: selector): bool => {
contains_ampersand(selector)
|| (
switch (selector) {
| RelativeSelector({ combinator: Some(_), _ }) => true
| _ => false
}
);
};

/* Flatten nested combinator trees into head + flat segment steps.
Purely structural (no `&` synthesis, unlike `flatten_selector_chain`);
nested trees arise from joins/substitutions of complex selectors. */
Expand All @@ -201,24 +220,23 @@ let rec flatten_combinator_tree =
};

/* Pop the rightmost compound off a complex selector (flattens first so
the popped segment is never a whole subtree). Returns
`(last, combinator_before_last, rest)`. */
the popped segment is never a whole subtree). */
let pop_last_selector = (selector: selector) => {
let (head, segments) = flatten_combinator_tree(selector);
switch (List.rev(segments)) {
| [] => (head, None, None)
| [(ctor, last)] => (last, Some(ctor), Some(head))
| [] => (head, None)
| [(ctor, last)] => (last, Some((ctor, head)))
| [(ctor, last), ...rest_rev] => (
last,
Some(ctor),
Some(
Some((
ctor,
ComplexSelector(
Combinator({
left: head,
right: List.rev(rest_rev),
}),
),
),
)),
)
};
};
Expand Down Expand Up @@ -276,77 +294,40 @@ let merge_compound_selectors =
raise. */
let join_compound_selector =
(selector, { subclass_selectors, pseudo_selectors, _ } as compound) => {
let new_compound =
CompoundSelector({
type_selector: None,
subclass_selectors,
pseudo_selectors,
});
switch (pop_last_selector(selector)) {
| (SimpleSelector(simple), None, None) =>
CompoundSelector({
type_selector: Some(simple),
subclass_selectors,
pseudo_selectors,
})
| (SimpleSelector(simple), Some(ctor), Some(rest)) =>
join_selector_with_combinator(
~combinator=ctor,
rest,
let (last, before_last) = pop_last_selector(selector);
let extended =
switch (last) {
| SimpleSelector(simple) =>
CompoundSelector({
type_selector: Some(simple),
subclass_selectors,
pseudo_selectors,
}),
)
| (
CompoundSelector({
type_selector: last_type_selector,
subclass_selectors: last_subclass_selectors,
pseudo_selectors: last_pseudo_selectors,
}),
None,
None,
) =>
merge_compound_selectors(
~last_type_selector,
~last_subclass_selectors,
~last_pseudo_selectors,
compound,
)
| (
CompoundSelector({
})
| CompoundSelector({
type_selector: last_type_selector,
subclass_selectors: last_subclass_selectors,
pseudo_selectors: last_pseudo_selectors,
}),
Some(ctor),
Some(rest),
) =>
join_selector_with_combinator(
~combinator=ctor,
rest,
}) =>
merge_compound_selectors(
~last_type_selector,
~last_subclass_selectors,
~last_pseudo_selectors,
compound,
),
)
/* Defensive fallback — see comment above. */
| (other, None, None) => join_selector_with_combinator(other, new_compound)
| (other, Some(ctor), Some(rest)) =>
join_selector_with_combinator(
~combinator=ctor,
rest,
join_selector_with_combinator(other, new_compound),
)
/* The other inconsistent (Some/None) mixes are unreachable by
construction in `pop_last_selector`, but matching them keeps the
compiler's exhaustiveness check happy without a wildcard. */
| (_, Some(_), None)
| (_, None, Some(_)) =>
join_selector_with_combinator(selector, new_compound)
)
| other =>
join_selector_with_combinator(
other,
CompoundSelector({
type_selector: None,
subclass_selectors,
pseudo_selectors,
}),
)
};
switch (before_last) {
| None => extended
| Some((ctor, rest)) =>
join_selector_with_combinator(~combinator=ctor, rest, extended)
};
};

Expand Down Expand Up @@ -565,22 +546,52 @@ let split_by_kind = (rules: list(rule)) => {
);
};

/* A nested rule's prelude item that starts with a combinator (`> .child`)
means exactly `& combinator .child`: rebuild it as the equivalent
explicit-`&` complex selector so it goes through the same
`contains_ampersand`/`replace_ampersand` path below as if the user had
written `&` themselves. Flattening the combinator onto the front of
`complex_selector`'s own segment list (rather than nesting it as a new
`ComplexSelector` on the right of a fresh `Combinator`) matches exactly
what parsing `& combinator ...` would have produced, so `> .child` and
`& > .child` resolve to the identical AST. A `RelativeSelector` with no
combinator (only reachable today from a `:has()`-style payload, never
from a style-rule prelude) is just its bare `complex_selector`. */
let relative_selector_to_complex_selector =
({ combinator, complex_selector }: relative_selector): selector => {
switch (combinator) {
| None => ComplexSelector(complex_selector)
| Some(combinator) =>
let (first_selector, rest) =
switch (complex_selector) {
| Selector(selector) => (selector, [])
| Combinator({ left, right }) => (left, right)
};
ComplexSelector(
Combinator({
left: SimpleSelector(Ampersand),
right: [(combinator, first_selector), ...rest],
}),
);
};
};

/** Compute the merged prefix when nesting a selector under a parent.

Per CSS Nesting Level 1 §3.1, a nested selector that does not
contain the nesting selector (`&`) and does not start with a
combinator desugars by descendant-combinator-joining with the
parent. Selectors that do contain `&` resolve via literal
substitution. The two arms below implement exactly those rules.

Selectors that start with a combinator are accepted as relative
(e.g. `> .child` desugars to `& > .child`) when the parser supports
them in nested position. The current parser only accepts leading
combinators inside pseudo-class payloads (`:has(> img)`); a leading
`>` after `{` is rejected at parse time, so this function never
sees that shape. Users must write `& > .child` until the parser
grows nested-relative-selector support. */
parent. Selectors that do contain `&` (including one synthesised
from a leading combinator, see `relative_selector_to_complex_selector`
above) resolve via literal substitution. The two arms below implement
exactly those rules. */
let compute_new_prefix = (~prefix, current_selector) => {
let current_selector =
switch (current_selector) {
| RelativeSelector(relative) =>
relative_selector_to_complex_selector(relative)
| other => other
};
switch (prefix) {
| None => current_selector
| Some(prefix) =>
Expand Down
Loading