package tw
sectionYPositions = computeSectionYPositions($el), 10)"
x-init="setTimeout(() => sectionYPositions = computeSectionYPositions($el), 10)"
>
Type-safe Tailwind CSS v4 in OCaml
Install
dune-project
Dependency
Authors
Maintainers
Sources
tw-1.1.0.tbz
sha256=48754ab34d0a97c37f5f2dbf50ce46747ec0ca6d483f5adbb7305fc247fb5315
sha512=f43621b49e77adc23fab3c968e5041188e428228d1930b89c307fc8916c428f1943a5d74c21467219077247021f0ba83fda9234b0dd119dfd14b7f9746332bf7
doc/src/tw/tw.ml.html
Source file tw.ml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582(** A type-safe, ergonomic DSL for Tailwind CSS using nominal types. This library takes inspiration from Tailwind CSS v3's utility-first approach while leveraging OCaml's type system for compile-time safety. We cherry-pick concepts that work well with OCaml and add our own innovations where appropriate. Key design decisions: - Pure OCaml implementation without external CSS dependencies - Type-safe API that prevents invalid CSS at compile time - Simplified spacing functions that accept integers directly - Support for modern CSS features like container queries and 3D transforms - Minimal bundle size for js_of_ocaml by avoiding Format module. *) type t = Utility.t include Color include Backgrounds include Margin include Gap include Padding include Sizing include Typography include Layout include Overflow include Overscroll include Overflow_wrap include Box_sizing include Tab include Scrollbar include Zoom include Field_sizing include Grid include Grid_item include Grid_template include Flex include Flex_props include Flex_layout include Alignment include Borders include Effects include Text_shadow include Transforms include Cursor include Touch include Divide include Interactivity include Containers include Filters include Masks include Mask_gradient include Clipping include Position include Animations include Transitions include Forms include Tables include Svg include Accessibility include Modifiers include Prose include Columns include Contain include Scroll include Arbitrary let theme_token_rename = Build.theme_token_rename let property_fallbacks = Build.author_property_fallbacks let to_css ?theme ?(base = Build.default_config.base) ?forms ?(layers = Build.default_config.layers) ?extra utilities = Build.to_css ?theme ~config:{ base; forms; layers } ?extra utilities let to_inline_style ?theme utilities = Build.to_inline_style ?theme utilities let preflight = Preflight.stylesheet (* Class generation functions *) let pp utility = Utility.to_class utility let to_classes styles = styles |> List.map Utility.to_class |> String.concat " " let modifiers_of_string = Modifiers.of_string let is_whitespace = function | ' ' | '\t' | '\n' | '\r' | '\012' -> true | _ -> false let split_whitespace s = let current = Buffer.create 16 in let tokens = ref [] in let flush () = if Buffer.length current > 0 then ( tokens := Buffer.contents current :: !tokens; Buffer.clear current) in String.iter (fun c -> if is_whitespace c then flush () else Buffer.add_char current c) s; flush (); List.rev !tokens (* The v4 [prop-(--x)] shorthand is [prop-[var(--x)]] in value but keeps its own class name. Rewrite the trailing [(...)] to its bracket form for parsing; the original spelling is restored via [Utility.alias]. Handles the bare var [(--x)] / [(--x,fallback)] -> [[var(--x...)]] and the typed [(family-name:--x)] -> [[family-name:var(--x)]] forms. Returns [None] when there is no paren shorthand. *) let rewrite_paren_var base_class = let n = String.length base_class in if n > 4 && base_class.[n - 1] = ')' then match String.rindex_opt base_class '(' with | Some lp when lp > 0 && base_class.[lp - 1] = '-' -> ( let prefix = String.sub base_class 0 lp in let inner = String.sub base_class (lp + 1) (n - lp - 2) in let ilen = String.length inner in if ilen > 1 && inner.[0] = '-' && inner.[1] = '-' then (* bare var (with optional ,fallback) *) Some (prefix ^ "[var(" ^ inner ^ ")]") else (* typed hint: <type>:--name[,fallback] *) match String.index_opt inner ':' with | Some ci when ci + 2 < ilen && inner.[ci + 1] = '-' && inner.[ci + 2] = '-' -> let typ = String.sub inner 0 ci in let v = String.sub inner (ci + 1) (ilen - ci - 1) in Some (prefix ^ "[" ^ typ ^ ":var(" ^ v ^ ")]") | _ -> None) | _ -> None else None (* The [/modifier] a class ends with, outside any brackets or parentheses, apart from what it modifies. *) let split_trailing_modifier s = let n = String.length s in let rec last_slash i depth found = if i >= n then found else match s.[i] with | '(' | '[' -> last_slash (i + 1) (depth + 1) found | ')' | ']' -> last_slash (i + 1) (depth - 1) found | '/' when depth = 0 -> last_slash (i + 1) depth (Some i) | _ -> last_slash (i + 1) depth found in match last_slash 0 0 None with | Some i when i > 0 -> (String.sub s 0 i, String.sub s i (n - i)) | _ -> (s, "") (* A colour utility takes its opacity after the shorthand, [bg-(--c)/50], so the modifier is carried across the rewrite: the class then ends at its [/50] rather than at the [)] the rewrite looks for. *) let normalize_paren_var base_class = let base, modifier = split_trailing_modifier base_class in Option.map (fun rewritten -> rewritten ^ modifier) (rewrite_paren_var base) (* Split the [!] important marker off the base class: the v3 prefix ([!flex], [md:!flex]) or the v4 trailing form ([flex!]). Each keeps its form in the generated selector so it matches the source class. *) let split_importance base_class = let n = String.length base_class in if n > 1 && base_class.[0] = '!' then (`Prefix, String.sub base_class 1 (n - 1)) else if n > 1 && base_class.[n - 1] = '!' then (`Suffix, String.sub base_class 0 (n - 1)) else (`None, base_class) (* The alpha a [theme()] path carries, as the percentage to mix at: a percentage as written, a bare number as the fraction Tailwind reads it for. *) let theme_alpha_percent a = let n = String.length a in if n > 1 && a.[n - 1] = '%' then float_of_string_opt (String.sub a 0 (n - 1)) else Option.map (fun f -> f *. 100.) (float_of_string_opt a) (* Tailwind applies a [theme()] alpha by mixing the colour with transparent, which reads whatever the [@theme] bound the palette entry to. *) let theme_color_with_alpha base a = match (Cascade.Css.parse_color base, theme_alpha_percent a) with | Some color, Some percent1 -> Some (Cascade.Css.Pp.to_string Cascade.Css.pp_color (Cascade.Css.color_mix ~in_space:Oklab ~percent1 color Cascade.Css.Transparent)) | _ -> None (* The value the theme binds a [theme()] dot path to, before any alpha: [colors.<name>.<shade>] is the palette entry, [spacing.<n>] the spacing product. [None] is a path this does not resolve, which is not the same question as whether the theme carries the key. *) let theme_key_value ~theme path = match String.split_on_char '.' path with | [ "colors"; name; shade ] -> ( (* A project that renames or adds a palette entry in its [@theme] gets its own value back here, the way [bg-<name>-<shade>] already does. *) let token = String.concat "-" [ "color"; name; shade ] in match Scheme.theme_value (Some theme) token with | Some _ as v -> v | None -> ( match (Color.of_string name, int_of_string_opt shade) with | Ok c, Some sh when Color.is_valid_shade c sh -> Some (Color.to_oklch_css c sh) | _ -> None)) | [ "spacing"; n ] -> (* The step here is Tailwind's own 0.25rem and not whatever [--spacing] the project set: checked against the CLI with [@theme { --spacing: 0.5rem }], both sheets still resolve [theme(spacing.4)] to [1rem], so reading the theme would lose parity rather than gain it. [Theme.spacing_times] is that same fixed product, and going through it is what keeps the two spellings of it in step. *) Option.bind (float_of_string_opt n) Theme.spacing_times | _ -> None (* The value the resolved theme binds [--<name>] to, with the [\@layer theme] declaration that keeps the binding in the sheet. A registered default or a project override answers for itself; the palette answers through the same catalogue an arbitrary [var(--color-red-500)] reads, so a shaded entry and a shadeless one both resolve. [None] is a token the resolved theme does not carry. *) let theme_token_binding ~theme name = match Scheme.token theme name with | Some css -> Some (css, Cascade.Css.custom_property ~layer:"theme" ("--" ^ name) css) | None -> Option.map (fun decl -> (Cascade.Css.declaration_value decl, decl)) (Color.Handler.theme_color_decl ~theme name) (* The token a [theme(--x)] names, resolved. This is the v4 spelling of the lookup, and it reads the token table itself rather than one of the v3 namespaces the dot paths name. *) let theme_token_value ~theme path = Option.bind (Parse.bare_name path) (theme_token_binding ~theme) (* Resolve one theme() argument. [`Value] is the key resolved, mixed with the [/<alpha>] it carries when it has one. [`Token] is the same for the [--<name>] spelling, and carries the binding that keeps the token in the theme layer: unlike a dot path, that spelling does not consume it. [`Missing] is a key the theme provably does not carry, which Tailwind answers with no rule at all: a bare segment names no namespace, the palette namespace is one tw resolves in full, and the [--<name>] spelling reads the token table itself, so an absence there is an absence. [`Verbatim] is everything else - an alpha naming no number, and a namespace tw does not resolve - where there is no table saying the key is absent and Tailwind emits a rule for the ones it knows. *) let theme_resolve_key ~theme key = let path, alpha = match String.index_opt key '/' with | Some k -> ( String.sub key 0 k, Some (String.sub key (k + 1) (String.length key - k - 1)) ) | None -> (key, None) in let with_alpha base = match alpha with | None -> Some base | Some a -> theme_color_with_alpha base a in match theme_key_value ~theme path with | Some base -> ( match with_alpha base with Some v -> `Value v | None -> `Verbatim) | None -> ( match theme_token_value ~theme path with | Some (base, decl) -> ( match with_alpha base with | Some v -> `Token (decl, v) | None -> `Verbatim) | None -> ( match String.split_on_char '.' path with | "colors" :: _ | [ _ ] -> `Missing | _ -> `Verbatim)) (* Split a theme() argument into its key and the fallback after the first top-level comma, which stands in whenever the key resolves to nothing. *) let theme_call_fallback inner = let n = String.length inner in let rec go i depth = if i >= n then (inner, None) else match inner.[i] with | '(' -> go (i + 1) (depth + 1) | ')' -> go (i + 1) (depth - 1) | ',' when depth = 0 -> (String.sub inner 0 i, Some (String.sub inner (i + 1) (n - i - 1))) | _ -> go (i + 1) depth in go 0 0 (* Replace each theme() call in a class string with what it resolves to, and report the theme tokens the calls read. [theme(<key>)] writes the resolved value back in the spelling an arbitrary value is read in: a space becomes [_] and an underscore [\_], so a project that binds a token to [var(--brand_red)] keeps the variable it named. [--theme(--<name>)] is the same lookup written as a reference, and writes [var(--<name>)] instead of the value; it admits neither a v3 dot path nor an alpha, both of which Tailwind reads some other way, so those are left for the class to fail on. [None] is a call naming a key the theme does not carry and offering no fallback: Tailwind emits no rule for such a class, so it is not a utility. *) let resolve_theme_functions ~theme s = let buf = Buffer.create (String.length s) in let n = String.length s in let missing = ref false in let bindings = ref [] in let i = ref 0 in let resolved v = Buffer.add_string buf (Parse.encode_underscores v) in let opens j lit = j + String.length lit <= n && String.sub s j (String.length lit) = lit in while !i < n do let reference = opens !i "--theme(" in if reference || opens !i "theme(" then begin let open_len = if reference then 8 else 6 in let j = ref (!i + open_len) and depth = ref 1 in while !j < n && !depth > 0 do if s.[!j] = '(' then incr depth else if s.[!j] = ')' then decr depth; if !depth > 0 then incr j done; (* [!j] is the closing paren, or [n] when the call never closed - a truncated attribute or template artefact in scanned markup. An unterminated call has no key to read, so the rest of the string is copied through and the class stays unresolved. *) let closed = !depth = 0 in let inner = String.sub s (!i + open_len) (!j - (!i + open_len)) in let verbatim () = let stop = if closed then !j + 1 else n in Buffer.add_string buf (String.sub s !i (stop - !i)) in (if not closed then verbatim () else let key, fallback = theme_call_fallback inner in if reference && String.contains key '/' then verbatim () else match theme_resolve_key ~theme key with | `Value v -> if reference then verbatim () else resolved v | `Token (decl, v) -> bindings := decl :: !bindings; (* [key] is the [--<name>] the call spelled: the reference form bailed above on anything carrying an alpha, so the whole key is the property name. *) if reference then Buffer.add_string buf ("var(" ^ key ^ ")") else resolved v | `Verbatim -> verbatim () | `Missing -> ( match fallback with | Some f -> Buffer.add_string buf f | None -> missing := true)); i := if closed then !j + 1 else n end else begin Buffer.add_char buf s.[!i]; incr i end done; if !missing then None else Some (Buffer.contents buf, List.rev !bindings) (* Tailwind v4 removed the v3 opacity utilities: the opacity is written on the colour as a [/modifier]. The verdict does not change - the pinned CLI emits nothing for these either - but "unknown" reads as a typo, and a v3 class is not one. Only these six families are worth naming: every other v3 spelling that v4 renamed ([flex-grow], [overflow-ellipsis], [decoration-slice]) is still emitted by 4.3.3, so tw compiles it and must keep doing so. *) let v3_opacity_replacement base_class = match String.split_on_char '-' base_class with | [ family; "opacity"; amount ] when amount <> "" && List.mem family [ "bg"; "text"; "border"; "divide"; "ring"; "placeholder" ] -> Some (String.concat "" [ family; "-<color>/"; amount ]) | _ -> None (* The rejection a class gets once no handler has claimed it. A bracket class that looks like an arbitrary property but that nothing accepted is malformed rather than unsupported: the bracket has no property name or does not end the class, or the modifier after it is not an opacity on a colour. *) let unknown_class_error ~base_class class_str = if String.length base_class > 2 && base_class.[0] = '[' && String.contains base_class ':' then Error (`Msg ("Invalid arbitrary property '" ^ class_str ^ "': expected [property:value], optionally followed by an /opacity \ modifier on a colour value (e.g. [color:var(--x)]/50)")) else match v3_opacity_replacement base_class with | Some replacement -> Error (`Msg ("Tailwind v4 removed '" ^ class_str ^ "': write the opacity on the colour, as " ^ replacement)) | None -> Error (`Msg ("Unknown class: " ^ class_str)) (* Whether the candidate spells [name] itself. A [var(--x)] the author wrote into an arbitrary value is their reference, and Tailwind writes it through whatever the theme holds. *) let spells class_str name = let n = String.length name and len = String.length class_str in let rec at i j = j = n || (class_str.[i + j] = name.[j] && at i (j + 1)) in let rec from i = i + n <= len && (at i 0 || from (i + 1)) in from 0 (* Tailwind compiles nothing for a candidate whose value the [@theme] block took away: [--text-*: initial] leaves [text-lg] no size to write. The palette and the breakpoints refuse such a candidate as they read it; every other family is held here, against the tokens its rules read. *) let removed_token_read ~theme class_str u = if Scheme.removes_tokens theme then Build.removed_token_read ~theme ~authored:(spells class_str) (Rule.outputs ~theme u) else None (* Parse a single class string into a Tw.t *) let of_candidate ~theme class_str = let modifiers, base_class = modifiers_of_string class_str in let importance, base_class = split_importance base_class in (* Wrap [important] around the base before applying modifiers, so a responsive/state prefix stays outermost: md:!flex -> md:(!flex). An optional [alias] sits inside importance so [w-(--w)!] keeps both forms. *) let finish ?alias ?(bindings = []) base_utility = let base_util = Utility.base base_utility in let base_util = match alias with | Some cls -> Utility.alias cls base_util | None -> base_util in let base_util = match importance with | `Prefix -> Utility.important base_util | `Suffix -> Utility.important ~suffix:true base_util | `None -> base_util in (* Outside [important]: the binding is a theme declaration the utility drags in, not one of the declarations the [!] marks. *) let base_util = Utility.theme_bound bindings base_util in match Modifiers.apply ~theme modifiers base_util with | None -> Error (`Msg ("Unknown modifier in: " ^ class_str)) | Some u -> ( match removed_token_read ~theme class_str u with | None -> Ok u | Some token -> Error (`Msg (Pp.str [ class_str; " reads --"; token; ", which the theme removed"; ]))) in (* Resolve theme() calls for dispatch, keeping the original spelling as the class-name alias so the utility still round-trips. A call naming no key leaves nothing to dispatch on. *) match resolve_theme_functions ~theme base_class with | None -> unknown_class_error ~base_class class_str | Some (resolved_base, bindings) -> ( let theme_alias = if resolved_base = base_class then None else Some base_class in match Utility.base_of_class theme resolved_base with | Ok base_utility -> finish ?alias:theme_alias ~bindings base_utility | Error _ -> ( (* Fallback: the v4 [prop-(--x)] shorthand for handlers that accept the [prop-[var(--x)]] form but not the paren spelling directly. Handlers that support [(--x)] natively (e.g. rotate) already matched above, so this never overrides them. The original spelling is kept via the alias. *) match normalize_paren_var base_class with | Some normalized -> ( match Utility.base_of_class theme normalized with | Ok base_utility -> finish ~alias:base_class base_utility | Error _ -> Error (`Msg ("Unknown class: " ^ class_str))) | None -> unknown_class_error ~base_class class_str)) (* [prefix(tw)] puts [tw:] in front of every candidate. It is not a variant - nothing reads it as one - so it comes off here, before anything parses, and goes back on in [Rule.outputs], once the modifiers have composed the name underneath it. A candidate that does not carry the prefix names no utility, the way an unknown class names none: Tailwind compiles nothing for a bare [p-4] once the import asks for a prefix. *) let strip_prefix theme class_str = match theme.Scheme.prefix with | None -> Some class_str | Some prefix -> let head = prefix ^ ":" in let n = String.length head in if String.length class_str > n && String.sub class_str 0 n = head then Some (String.sub class_str n (String.length class_str - n)) else None let of_string ?(theme = Scheme.default) class_str = match strip_prefix theme class_str with | None -> Error (`Msg ("Unknown class: " ^ class_str)) | Some candidate -> of_candidate ~theme candidate (* A name the parser rejects may be a typo or a deliberate non-tw class - a framework hook, a JS selector - and nothing here can tell the two apart. So parsing a class string never fails: it hands back what it recognised and what it did not, and the caller judges. *) let of_classes ?theme s = let styles, unknown = List.fold_left (fun (styles, unknown) cls -> match of_string ?theme cls with | Ok t -> (t :: styles, unknown) | Error _ -> (styles, cls :: unknown)) ([], []) (split_whitespace s) in (List.rev styles, List.rev unknown) let str s = fst (of_classes s) (** {1 Module Exports} *) module Style = Style module Margin = Margin module Padding = Padding module Gap = Gap module Spacing = Spacing module Flex = Flex module Flex_props = Flex_props module Flex_layout = Flex_layout module Alignment = Alignment module Cursor = Cursor module Borders = Borders module Backgrounds = Backgrounds module Sizing = Sizing module Layout = Layout module Overflow = Overflow module Overscroll = Overscroll module Overflow_wrap = Overflow_wrap module Box_sizing = Box_sizing module Tab = Tab module Scrollbar = Scrollbar module Zoom = Zoom module Field_sizing = Field_sizing module Grid = Grid module Grid_item = Grid_item module Grid_template = Grid_template module Typography = Typography module Divide = Divide module Effects = Effects module Text_shadow = Text_shadow module Transforms = Transforms module Interactivity = Interactivity module Containers = Containers module Filters = Filters module Masks = Masks module Position = Position module Animations = Animations module Transitions = Transitions module Forms = Forms module Tables = Tables module Svg = Svg module Accessibility = Accessibility module Output = Output module Rule = Rule module Build = Build module Prose = Prose module Css = Cascade.Css module Color = Color module Modifiers = Modifiers module Var = Var module Theme = Theme module Scheme = Scheme module Utility = Utility module Columns = Columns module Contain = Contain module Scroll = Scroll module Arbitrary = Arbitrary module Touch = Touch module Parse = Parse module Mask_gradient = Mask_gradient module Property = Property (* Include flex utilities *) include Flex (* Include grid utilities *) include Grid (* Include cursor utilities *) include Cursor
sectionYPositions = computeSectionYPositions($el), 10)"
x-init="setTimeout(() => sectionYPositions = computeSectionYPositions($el), 10)"
>