diff --git a/CHANGES.md b/CHANGES.md index 528d44e2..9ffbede5 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -182,12 +182,12 @@ declarations the browser discards rather than being refused outright, which dropped the selector and left an element carrying the class with nothing at all. A bare number after the hint is pixels, the spelling Tailwind's minifier - ships. Eight families do the same for a bracket no reader took at all, - writing it into their colour: `text-[notacolour]`, `stroke-[1zz]`, - `border-[abc]` and a malformed hex in any of `text-`, `outline-`, `border-`, - `accent-`, `caret-`, `placeholder-`, `fill-` and `stroke-` reach the sheet - where they used to be refused. `bg-` still refuses them (#761, #762, #763, - #764). + ships. Every colour family does the same for a bracket no reader took at all, + writing it into the longhand the hint named or into the colour when there is + no hint: `text-[notacolour]`, `stroke-[1zz]`, `bg-[image:nope]` and a + malformed hex in any of them reach the sheet where they used to be refused. + `bg-[size:...]` no longer substitutes `background-size: auto` for a value it + cannot read (#761, #762, #763, #764, #765). - A negated arbitrary length is `calc( * -1)` on every family and in every unit, the spelling Tailwind writes. The sign was folded into the number from a unit table that each of margin, the inset sides and `text-indent` kept diff --git a/docs/token-stream-contract.md b/docs/token-stream-contract.md index 0f8cda55..391c0277 100644 --- a/docs/token-stream-contract.md +++ b/docs/token-stream-contract.md @@ -313,10 +313,13 @@ does not. `text-[…]` and `outline-[…]` now write a bracket no reader took into their colour, which is each family's last resort, so `text-[foo:1.25rem]`, `text-[foo:red]`, `text-[notacolour]`, `outline-[foo:red]` and a malformed hex all reach the sheet. `border-`, `fill-`, `stroke-`, `accent-`, -`caret-` and `placeholder-` followed. `bg-` is the one still refusing: its -bracket runs through a size, a position, an image and a url before any colour -reader sees it, so there is no single point where a refusal falls through, and -the per-side border colours have no last resort either. An opacity modifier over a value that is not a colour +`caret-`, `placeholder-` and `bg-` followed. `bg-` took the most work, for the +reason a family with several readers always will: its bracket runs through a +size, a position, an image and a url, so each reader forwards its own value and +only the unhinted fall-through reaches the colour. The fall-through is gated on +the text being one bracket value, or `bg-[10px][20px]` would be accepted as a +single declaration. What is left is the per-side border colours, a separate +constructor per side. An opacity modifier over a value that is not a colour stays refused everywhere: the CLI writes a `color-mix()` around the raw token, and cascade's colour types hold colours, not token streams. The width hints in `lib/typography.ml`, `lib/borders.ml` and `lib/svg.ml` no longer refuse a value their reader diff --git a/lib/backgrounds.ml b/lib/backgrounds.ml index d4e5ad56..878cc4ce 100644 --- a/lib/backgrounds.ml +++ b/lib/backgrounds.ml @@ -200,6 +200,14 @@ module Handler = struct | Bg_opacity of Color.color * int * Color.opacity_modifier (* bg-[length:...] - explicit length prefix for bracket size *) | Bg_bracket_length of string + | (* A bracket no reader took. The hint chooses the longhand and the value + is forwarded verbatim, which is Tailwind's token-stream contract; with + no hint, or one this family does not know, the colour is the last + resort. The browser discards the declaration; the selector is what the + class was for. *) + Bg_bracket_raw_color of string * string + | Bg_bracket_raw_image of string * string + | Bg_bracket_raw_position of string * string (* bg-position-[...] bracket notation *) | Bg_position_bracket of string * Css.position_value (* bg-size-[...] bracket notation *) @@ -305,6 +313,10 @@ module Handler = struct | Bg_bracket_cover -> "bg-[cover]" | Bg_bracket_size v -> "bg-[size:" ^ v ^ "]" | Bg_bracket_length v -> "bg-[length:" ^ v ^ "]" + | Bg_bracket_raw_color (v, _) + | Bg_bracket_raw_image (v, _) + | Bg_bracket_raw_position (v, _) -> + "bg-[" ^ v ^ "]" | Bg_bracket_position (v, _) -> "bg-[" ^ v ^ "]" | Bg_bracket_typed_position (inner, _) -> "bg-[" ^ inner ^ "]" | Bg_bracket_color_var v -> "bg-[color:" ^ v ^ "]" @@ -419,7 +431,7 @@ module Handler = struct | Bg _ | Bg_opacity _ | Bg_inherit | Bg_current | Bg_current_opacity _ | Bg_transparent | Bg_bracket_color _ | Bg_bracket_color_opacity _ | Bg_bracket_color_var _ | Bg_bracket_color_var_opacity _ | Bg_bracket_var _ - | Bg_bracket_var_opacity _ -> + | Bg_bracket_var_opacity _ | Bg_bracket_raw_color _ -> 198 | Bg_gradient_to _ | Bg_linear_to _ | Bg_linear_to_interp _ | Bg_linear_angle _ | Bg_linear_angle_neg _ | Bg_linear_angle_interp _ @@ -429,7 +441,8 @@ module Handler = struct | Bg_conic_angle_neg_interp _ | Bg_radial | Bg_radial_interp _ | Bg_conic_bracket _ | Bg_radial_bracket _ | Bg_bracket_image_var _ | Bg_bracket_image _ | Bg_bracket_linear_gradient _ | Bg_bracket_url _ - | Bg_bracket_image_url _ | Bg_bracket_url_var _ | Bg_none -> + | Bg_bracket_image_url _ | Bg_bracket_url_var _ | Bg_bracket_raw_image _ + | Bg_none -> 199 | Via_none -> 202 | Gradient_color (From, _) -> 203 @@ -447,7 +460,7 @@ module Handler = struct | Bg_fixed | Bg_local | Bg_scroll -> 252 | Bg_clip_border | Bg_clip_padding | Bg_clip_content | Bg_clip_text -> 253 | Bg_position _ | Bg_bracket_position _ | Bg_bracket_typed_position _ - | Bg_position_bracket _ -> + | Bg_position_bracket _ | Bg_bracket_raw_position _ -> 254 | Bg_repeat | Bg_no_repeat | Bg_repeat_x | Bg_repeat_y | Bg_repeat_round | Bg_repeat_space -> @@ -1516,9 +1529,15 @@ module Handler = struct | Bg_bracket_contain -> style [ Css.background_size Contain ] | Bg_bracket_cover -> style [ Css.background_size Cover ] | Bg_bracket_size inner -> ( + (* A value the size grammar declines is forwarded verbatim, not replaced + by [auto]: the hint chose the longhand, and substituting a value the + class never asked for paints something rather than nothing. *) match parse_bracket_size inner with | Some decl -> style [ decl ] - | None -> style [ Css.background_size Auto ]) + | None -> + style + (Option.to_list + (Parse.opaque_declaration "background-size" inner))) | Bg_bracket_position (_, pos) -> style [ Css.background_position [ pos ] ] | Bg_bracket_typed_position (_, pos) -> style [ Css.background_position [ pos ] ] @@ -1590,10 +1609,23 @@ module Handler = struct | Bg_current_opacity opacity -> Color.bg_current_with_opacity ~theme opacity | Bg_transparent -> style [ Css.background_color (Css.hex "#0000") ] | Bg_opacity (color, shade, opacity) -> bg_with_opacity color shade opacity + | Bg_bracket_raw_color (_, value) -> + style + (Option.to_list (Parse.opaque_declaration "background-color" value)) + | Bg_bracket_raw_image (_, value) -> + style + (Option.to_list (Parse.opaque_declaration "background-image" value)) + | Bg_bracket_raw_position (_, value) -> + style + (Option.to_list + (Parse.opaque_declaration "background-position" value)) | Bg_bracket_length inner -> ( match parse_bracket_size inner with | Some decl -> style [ decl ] - | None -> style [ Css.background_size Auto ]) + | None -> + style + (Option.to_list + (Parse.opaque_declaration "background-size" inner))) | Bg_position_bracket (_, pos) -> style [ Css.background_position [ pos ] ] | Bg_size_bracket inner -> ( (* [of_class] refused an empty hint, so the peel succeeds here. *) @@ -1929,6 +1961,15 @@ module Handler = struct if !depth = 0 then close := i) done; let parse_opacity s = Color.opacity_of_string ~theme s in + (* The last resort forwards a value verbatim, so it has to be handed one + value: [bg-[10px][20px]] is two brackets glued together, which is no + utility to Tailwind either. Every reader above answers for a shape it + recognises; only the fall-through needs this. *) + let last_resort inner = + if Parse.is_bracket_value bracket_stuff then + Parse.arbitrary_declaration_value inner + else None + in if !close >= 0 && !close + 1 < len && bracket_stuff.[!close + 1] = '/' then (* Bracket with opacity: [color:var(--x)]/50 *) @@ -1986,7 +2027,12 @@ module Handler = struct in match bracket_position_value v with | Some pos -> Ok (Bg_bracket_typed_position (inner, pos)) - | None -> Error (`Msg ("Unknown bg bracket position: " ^ v))) + | None -> ( + (* The hint chose the longhand, so the value goes there + whatever the position grammar makes of it. *) + match last_resort inner with + | Some raw -> Ok (Bg_bracket_raw_position (inner, raw)) + | None -> Error (`Msg ("Unknown bg bracket position: " ^ v)))) | _ when has_bracket_color_hint inner -> ( (* The hint says how to read the value written after it; only a var() reference there names a custom property. *) @@ -1995,23 +2041,31 @@ module Handler = struct else match Color.parse_bracket_color v with | Some css_color -> Ok (Bg_bracket_color (inner, css_color)) - | None -> Error (`Msg ("Unknown bg bracket color: " ^ v))) + | None -> ( + match last_resort inner with + | Some raw -> Ok (Bg_bracket_raw_color (inner, raw)) + | None -> Error (`Msg ("Unknown bg bracket color: " ^ v)))) | _ when String.length inner > 6 && String.sub inner 0 6 = "image:" -> ( (* The [image:] data-type hint forces a background-image. The value is a [url(...)] literal, a [var(...)] reference, or a literal image (e.g. a gradient). *) let v = String.sub inner 6 (String.length inner - 6) in + let raw_image () = + match last_resort inner with + | Some raw -> Ok (Bg_bracket_raw_image (inner, raw)) + | None -> Error (`Msg ("Unknown bg bracket image: " ^ v)) + in if String.starts_with ~prefix:"url(" v then match Parse.url_token (Parse.decode_arbitrary_value v) with | Some _ -> Ok (Bg_bracket_image_url v) - | None -> Error (`Msg ("Unknown bg bracket image: " ^ v)) + | None -> raw_image () else if Parse.is_var v then Ok (Bg_bracket_image_var v) else match parse_bracket_image v with | Some img -> Ok (Bg_bracket_image (v, img)) - | None -> Error (`Msg ("Unknown bg bracket image: " ^ v))) - | _ when String.length inner > 4 && String.sub inner 0 4 = "url:" -> + | None -> raw_image ()) + | _ when String.length inner > 4 && String.sub inner 0 4 = "url:" -> ( (* The [url:] data-type hint forces a background-image the way [image:] does. Only a var() reference names a custom property; any other spelling is the image itself. *) @@ -2021,11 +2075,19 @@ module Handler = struct Parse.url_token (Parse.decode_arbitrary_value v) <> None || parse_bracket_image v <> None then Ok (Bg_bracket_url_var v) - else Error (`Msg ("Unknown bg bracket url: " ^ v)) + else + match last_resort inner with + | Some raw -> Ok (Bg_bracket_raw_image (inner, raw)) + | None -> Error (`Msg ("Unknown bg bracket url: " ^ v))) | _ when String.starts_with ~prefix:"url(" inner -> ( match Parse.url_token (Parse.decode_arbitrary_value inner) with | Some _ -> Ok (Bg_bracket_url inner) - | None -> Error (`Msg ("Unknown bg bracket url: " ^ inner))) + | None -> ( + (* No hint, so the colour is the last resort even though the + text opens with [url(]. *) + match last_resort inner with + | Some raw -> Ok (Bg_bracket_raw_color (inner, raw)) + | None -> Error (`Msg ("Unknown bg bracket url: " ^ inner)))) | _ when Parse.is_var inner -> Ok (Bg_bracket_var inner) | _ -> ( (* Try parsing as background-image (gradients, urls, @@ -2038,9 +2100,15 @@ module Handler = struct | None -> ( match parse_bracket_position inner with | Some pos -> Ok (Bg_bracket_position (inner, pos)) - | None -> - Error (`Msg ("Unknown bg bracket value: " ^ inner))))) - ) + | None -> ( + (* The colour is this family's last resort, which an + unknown hint reaches as well as no hint at all. *) + match last_resort inner with + | Some raw -> Ok (Bg_bracket_raw_color (inner, raw)) + | None -> + Error + (`Msg ("Unknown bg bracket value: " ^ inner))))) + )) | "bg" :: rest when List.exists has_opacity rest -> ( match Color.shade_and_opacity_of_strings ~theme rest with | Ok (color, shade, opacity) -> Ok (Bg_opacity (color, shade, opacity)) diff --git a/lib/parse.ml b/lib/parse.ml index 28a952d7..75f8eb9e 100644 --- a/lib/parse.ml +++ b/lib/parse.ml @@ -556,8 +556,36 @@ let declaration_value_of s = (* Every family reaches its last resort with the bracket as the author wrote it, so refusing an empty hint here refuses it everywhere. *) +(* A [theme()] or [--theme()] call is resolved before a family's reader sees + the bracket, so one still standing in the text is a lookup that declined. + Tailwind emits nothing for such a class, so it is not a token stream to + forward: [p-[--theme(spacing.4)]] is the v4 spelling over a v3 dot path, + which resolves to nothing and names no utility. *) +let holds_unresolved_theme_call s = + (* [theme(] preceded by anything that cannot continue an identifier, so a + function whose name merely ends in "theme" does not match. *) + let needle = "theme(" in + let n = String.length needle and len = String.length s in + let rec scan i = + match Strings.index ~sub:needle (String.sub s i (len - i)) with + | None -> false + | Some off -> + let at = i + off in + let before = if at = 0 then ' ' else s.[at - 1] in + let opens_a_name = + match before with + | 'a' .. 'z' | 'A' .. 'Z' | '0' .. '9' | '_' -> true + | '-' -> at >= 2 && s.[at - 2] <> '-' + | _ -> false + in + if opens_a_name then scan (at + n) else true + in + scan 0 + let arbitrary_declaration_value s = - Option.bind (value_after_hint s) declaration_value_of + match Option.bind (value_after_hint s) declaration_value_of with + | Some value when holds_unresolved_theme_call value -> None + | answer -> answer let wrap_declaration_value ~before ~after value = if value = "" || not (is_declaration_value value) then None diff --git a/lib/parse.mli b/lib/parse.mli index bb98e2f0..7f9a1fa8 100644 --- a/lib/parse.mli +++ b/lib/parse.mli @@ -195,7 +195,12 @@ val arbitrary_declaration_value : string -> string option returns its CSS declaration value: the text after any {!data_type_hint}, which has to hold something other than blank space. Values that can terminate or swallow the declaration are [None], as is a bracket whose hint - is empty. *) + is empty. + + A [theme()] or [--theme()] call still standing in the text is also [None]: + such a call is resolved before a family's reader sees the bracket, so one + that survived is a lookup that declined, and Tailwind names no utility for + it. *) val wrap_declaration_value : before:string -> after:string -> string -> string option diff --git a/test/test_backgrounds.ml b/test/test_backgrounds.ml index bca964df..c6a9cf23 100644 --- a/test/test_backgrounds.ml +++ b/test/test_backgrounds.ml @@ -189,22 +189,19 @@ let test_bracket_position_grammar () = writes "bg-[position:120px_120px]" "120px 120px"; writes "bg-position-[center_-100px]" "50% -100px" -(* A bracket value the property cannot take is not a utility. [bg-[image:...]] - used to emit an empty rule and [bg-[position:...]] a plausible-looking - [center]: no CSS the class asked for, and no diagnostic. *) +(* A bracket value the property cannot take goes to that property anyway: the + hint chose the longhand, and the browser discards what it cannot read. What + is ruled out is the two answers that are neither - [bg-[image:...]] used to + emit an empty rule and [bg-[position:...]] a plausible-looking [center], so + the class silently painted something it never asked for. *) let test_invalid_bracket_value () = - let rejected cls = - match Tw.of_string cls with - | Ok _ -> Alcotest.failf "expected %s to be rejected" cls - | Error _ -> () - in let accepted cls = match Tw.of_string cls with | Ok _ -> () | Error (`Msg m) -> Alcotest.failf "%s: %s" cls m in - rejected "bg-[image:nope]"; - rejected "bg-[position:nope]"; + check_declarations "bg-[image:nope]" [ "background-image:nope" ]; + check_declarations "bg-[position:nope]" [ "background-position:nope" ]; accepted "bg-[image:radial-gradient(white,black)]"; accepted "bg-[image:var(--x)]"; accepted "bg-[image:url(/a.png)]"; @@ -621,12 +618,15 @@ let test_bracket_data_type_hint_reads_the_value () = Alcotest.(check string) "bg-[color:red] round-trips" "bg-[color:red]" (Tw.pp (Result.get_ok (Tw.of_string "bg-[color:red]"))); - (* A value no colour reader takes is held open, not settled: Tailwind writes - the bracket out whatever it says, so refusing is an intermediate. *) - check_invalid_input - ~why:(Diverges "emitted verbatim; tw needs an opaque declaration to match") - (module Tw.Backgrounds.Handler) - "bg-[color:notacolour]" + (* The colour is this family's last resort, so a value no colour reader takes + still reaches [background-color], forwarded verbatim. *) + check_declarations "bg-[color:notacolour]" [ "background-color:notacolour" ]; + check_declarations "bg-[notacolour]" [ "background-color:notacolour" ]; + (* An unknown hint lands there too, and so does a bracket that opens with + [url(] but holds no url. *) + check_declarations "bg-[foo:50%]" [ "background-color:50%" ]; + check_declarations "bg-[url(x.png)_center]" + [ "background-color:url(x.png) center" ] (* Tailwind knows two spellings for the hint that names a background-position, and [bg-[…]] routes on the one the author wrote while the class name keeps @@ -635,10 +635,12 @@ let test_percentage_hint_names_a_position () = check_declarations "bg-[percentage:50%]" [ "background-position:50%" ]; check_declarations "bg-[position:50%]" [ "background-position:50%" ]; List.iter check [ "bg-[percentage:50%]"; "bg-[position:50%]" ]; - check_invalid_input - ~why:(Diverges "emitted verbatim; tw needs an opaque declaration to match") - (module Tw.Backgrounds.Handler) - "bg-[percentage:notaposition]" + (* Either spelling of the hint names the longhand, so a value the position + grammar declines still lands on [background-position]. *) + check_declarations "bg-[percentage:notaposition]" + [ "background-position:notaposition" ]; + check_declarations "bg-[position:notaposition]" + [ "background-position:notaposition" ] let tests = [ diff --git a/test/test_color.ml b/test/test_color.ml index 834536f1..139dfa9c 100644 --- a/test/test_color.ml +++ b/test/test_color.ml @@ -935,17 +935,8 @@ let test_invalid_bracket_hex () = ("accent", "accent-color:"); ("caret", "caret-color:"); ("placeholder", "color:"); + ("bg", "background-color:"); ]; - (* [bg-] reads its bracket through many longhands - a size, a position, an - image, a url - and has no single point where a refusal falls to the colour, - so it is the one family still refusing what the CLI writes. *) - List.iter - (fun prefix -> - rejected (prefix ^ "-[#zz]"); - rejected (prefix ^ "-[#]"); - rejected (prefix ^ "-[#12345]"); - rejected (prefix ^ "-[#zz]/50")) - [ "bg" ]; emits "text-[#abc]" "color:#abc"; emits "bg-[#00ff0080]" "background-color:#00ff0080"; emits "border-[#123456]" "border-color:#123456";