package cascade

  1. Overview
  2. Docs
CSS generation and manipulation library for OCaml

Install

dune-project
 Dependency

Authors

Maintainers

Sources

cascade-1.1.0.tbz
sha256=c303651e043ed8d807d3d6f2c3745233e792ce7c30d562c9717d284e1bf5ac6a
sha512=681847993deabdc3899f62268b3cbc931623abca0413b0e8873e8568ec25a86c4d3db7f7f0e6a6a6bf24dbfdc7df314dae07ae66d49f76f3dd6953a4381bbf54

doc/CHANGES.html

1.1.0

Breaking

  • Css.hex raises Invalid_argument on a malformed hex string instead of returning opaque black; Css.hex_opt returns an option. Parsing is unaffected (#232)
  • cascade fmt and cascade diff drop --memtrace, and the library drops the memtrace dependency (#237)
  • A parse failure that drops every rule makes cascade fmt exit 1 instead of writing an empty stylesheet with a green status (#273)
  • cascade diff --diff=canonical exits 1 whenever the two canonical forms differ, and prints the difference. It called such a pair equivalent and exited 0 when the structural walk reached no difference, which left a missing normalisation key and a blind spot in the walk both reading as success. Css_compare.equal ~mode:`Canonical answers on the same bytes, and Css_compare.No_diff no longer carries the two canonical forms (#290)
  • Tree_diff.t gains layer_order; record expressions must set it and record patterns must bind it or use ; _ (#295)
  • Optimizer profiling is per run: Css.optimize and Optimize.stylesheet take ?stats:Stats.t and Stats.snapshot reads back an immutable record. Optimize.counters, Optimize.pass_times, Optimize.iteration_stats and Optimize.set_profile are gone, along with the per-pass table and the marginal-stop counter, which no pass had written since the DAG scheduler replaced the multi-pass fixpoint (#288)
  • Resolve.NODE gains text_children, the data of a node's direct child text nodes, which :empty needs (#286)
  • Apply.Make(...).compute takes ~sheet:Stylesheet.t instead of ~css:string. It parsed the text itself and answered an empty result when the parse failed, so a caller could not tell invalid CSS from empty CSS; the parse, and the warnings Css.of_string collects with it, now stay with the caller
  • Apply.result.kept counts the rules it says it counts. A block at-rule counted once for its wrapper, so a @media holding three rules reported one (#287)
  • cascade apply exits 1 when a <style> block or the supplementary stylesheet parses to nothing, and leaves such a block in the page instead of deleting it. It used to delete the block and exit 0, shipping an unstyled page under a green status. A supplementary stylesheet that cannot be read is an error rather than no stylesheet at all (#287)

Parsing

  • Reader.peek_utf8_at returns None for negative and out-of-range offsets instead of indexing outside the input (#308)
  • URL references use RFC 3986 resolution, preserving data, scheme-relative, query-only and fragment-only URLs (#304)
  • Container conditions parse component values directly, so strings and escaped or case-insensitive boolean keywords keep their CSS meaning (#305)
  • Media queries parse component values directly, so escaped identifiers and balanced general-enclosed values keep their CSS meaning (#306)
  • Typed -webkit- and -moz- aliases use their standard value grammar.
  • shape-outside reads its whole grammar. Only none, circle(), a non-empty inset() and the CSS-wide keywords were accepted, so margin-box, circle(50%) content-box, url(shape.png) and every other basic shape were rejected and the declaration dropped
  • An identifier takes any code point at or above U+0080, so a selector such as .text-↗ parses. Css.of_string takes ?enforce_spec and cascade takes --enforce-spec to restrict identifiers to the CSS Syntax 3 range list; output is the same either way (#254)
  • Parsed rather than dropped with a warning: perspective: none, text-underline-offset: auto and a negative text-underline-offset (#212); a nested rule whose selector starts with an identifier, such as h2:where(...) (#193); and an unrecognised media query such as theme(static), kept as never-matching instead of discarding the @media block or the @import (#192)
  • The eleven scroll-margin properties take a negative length, as CSS Scroll Snap 1 allows; only scroll-padding is non-negative (#280)
  • A } ends a declaration value in Parser.block_contents instead of being swallowed along with the rest of the block, and a bad string serialises back to source that reads as one instead of vanishing from an at-rule prelude (#284)

Printing and nesting

  • background-position and mask-position print one position per layer, comma-separated (#209)
  • A non-integer above roughly 4.6e10 prints as a number again, not as scale(1.2345678.9012e+19) (#265)
  • An unknown at-rule keeps the space before its prelude under --minify; @foo bar {x:1} printed @foobar{x:1}, a different at-rule (#272)
  • Flattening distributes the parent over every branch of a nested selector list: .p { a, b { ... } } is .p a, .p b (#205)
  • Substituting & wraps a complex parent in :is(): .a .b { .dark & { ... } } flattens to .dark :is(.a .b) (#194)

Minification

  • --lossless preserves logical sizing and corner aliases in source order.
  • A hex colour folds to its name whenever the name is no longer, which the hand-copied inversion table missed for bisque, indigo, orchid, salmon, sienna, tomato and violet: #ffe4c4 stayed hex where #f0ffff already became azure (#289)
  • Css.Values.read_color_name reads every name pp_color_name prints, not 21 of the 148, and grey reads as Grey rather than the Gray that prints gray (#289)
  • --lossless keeps a longhand in source order against the shorthand that resets it, whether the property is typed or not; moving the pair changed what the rule rendered (#267, #270)
  • --lossless keeps a colour's alpha exact, like its other channels; oklch(... / .74567) printed /.746 (#278)
  • --lossless keeps a flow-relative property in source order against a physical one of the same family, since the writing mode decides which physical side it resolves to (#277)
  • A vendor-prefixed value repeated with !important collapses to the later declaration; only a genuine value difference is kept as a legacy fallback. display:-webkit-box;display:-webkit-box!important survived as both
  • A selector list mixing a vendor pseudo-element with ordinary selectors is split, so a browser that ignores ::-webkit-search-cancel-button keeps the other selectors' declarations (#203)
  • A rule with nested children absorbs a later rule with the same selector when the two are disjoint (#203)
  • SVG presentation properties are typed and minify like any other value: stop-color:#fff, fill-opacity:.1, stroke-dashoffset:0, stroke-dasharray:4 2, paint-order: stroke fill markers to stroke, a redundant vector-effect keyword dropped, and fill-rule, clip-rule, stroke-linecap and stroke-linejoin read as their own grammars, including miter-clip and arcs (#214, #228, #234, #235, #236, #240, #241, #242)
  • More values fold to their exact equivalent: grid-auto-flow: row dense to dense (#230); a zero angle in radians to 0deg, so hue-rotate(0rad) folds like the other units (#229); hue-rotate() with a zero argument inside a custom property (#257); a font-stretch keyword to its percentage, except in the font shorthand (#206); and adjacent gradient stops of one colour to a double-position stop, with a 0deg linear-gradient angle dropped and the stops reversed, never for the legacy prefixed gradients (#214)
  • --log=cascade.factor:debug reports the optimizer's factoring decisions: each fixpoint iteration and every segment reverted or skipped (#239)
  • --minify is faster on a large stylesheet, for the same output (#221)

Custom properties and @layer

  • Css.inline_vars resolves var() across @layer boundaries and folds a custom property redefined across layers to its cascade winner, so a layered stylesheet inlines like its unlayered form (#187, #189)
  • cascade apply projects rules inside @layer onto elements; a fully layered stylesheet, such as Tailwind v4 output, inlined nothing (#188)
  • cascade apply weighs cascade layers instead of ignoring them: an unlayered declaration beats a layered one, !important reverses that, and a rule with no inline form stays inside its layer (#283)
  • cascade apply leaves a declaration in the stylesheet when a rule inside @scope, @starting-style, @when, @else or @-moz-document sets the same property; it moved inline, above the rule that was kept (#286)
  • cascade apply only inlines a rule whose selector its matcher can represent, so [data-k="X" i], a namespaced selector and the >>> and || combinators stay in the stylesheet rather than being inlined onto nobody and dropped, and :empty counts an element's text: <p>text</p> is not empty (#286)
  • Css.vars_of_declarations reports the var() references of 39 properties it answered with none, so Css.resolve_theme emits the theme binding for inline-size: var(--w) as it does for width: var(--w) (#266)

Canonical diff

  • The projection expands selector-list rules onto their branches (#204), folds two conditional blocks sharing a condition (#211), keys @media not all and (X) as the @media not (X) it equals while still emitting what it read (#231), and keys a run of @property rules by name, keeping the last registration (#227)
  • It skips the rule-regrouping passes, which depend on input order; Css.optimize takes ?regroup to turn them off (#215, #224)
  • It normalises the space after a top-level comma in a custom-property value, drops a declaration a later rule with the identical selector also writes (leaving !important alone), and pairs exactly matching rules before falling back to the property signature (#206)
  • A custom property keeps its !important, its layer and its metadata through the projection, so the differ no longer calls two sheets that disagree about the flag identical (#271)
  • It rewrites a quoted multi-word font name in a custom property as the <ident> sequence it unquotes to, the same family name under CSS Fonts 4 sec. 15.3: --font-sans: ui-sans-serif, "Noto Color Emoji" and --font-sans: ui-sans-serif, Noto Color Emoji reach one form. The structural comparator already folded the two together; the projection did not (#290)
  • Under --lossless it keys a color(srgb ...) whose channels land on whole bytes as the rgb() spelling of the same colour, so color(srgb 1 0 0) and rgb(255 0 0) stop reading as a difference. Exact conversions only: color(display-p3 1 0 0) and an off-grid channel stay distinct, and the printer still emits the function that was written (#289)

Diff report

  • Grouping repeated selectors and conditions is linear in the number of rules while preserving source order (#307)
  • The cascade layer order the two sheets declare is compared, and the report names the layer pairs that swapped. Dropping an @layer a; pin, which makes the other layer the weaker one, read as no difference at all
  • Every entry the report prints names what it is about. A @property or @keyframes surplus reached the rule level, which has no rule to name and printed a bare tree connector while still counting the entry towards the summary; @charset, @namespace and @layer a, b; had no name at all
  • A container that changed places is reported whether or not its body changed, on the same order keys the rule level uses. Three absolute-index distances gated it before, so swapping an @media with the rule below it - which changes which declaration wins above the breakpoint - printed CSS files are identical and exited 0
  • A rule that writes one property more than once, as a fallback chain does, is compared occurrence by occurrence; matching by name alone made a{color:red;color:blue} against a{color:red;color:green} report color: blue -> red, a value neither side holds (#285)
  • Containers are compared however deep they nest. The walk stopped at three levels, so a leaf difference under five at-rules was reported as no difference at all, exit code included (#285)
  • A container entry with nothing to show under it reads as (modified, no details); it claimed a position change, which only a Reordered entry establishes (#285)
  • A comparison that classified nothing says so: Changes: none classified structurally (see report below). It read No structural differences, which claimed equivalence over a comparison that fell through to a string diff, and over a side whose content the parser discarded (#285)
  • A canonical-form difference the structural walk did not reach is printed as Canonical forms differ: above a string diff of the two forms (#290)
  • A string diff names the two sides with the labels it was given, so cascade diff heads it with the two file names instead of Expected and Actual (#290)
  • A declaration reorder that decides the cascade is reported inside @media, @layer and @supports (#268), and the at-rules that carry no selector - @page, @starting-style, @counter-style, @scope, a second @font-face - are compared on their bodies (#269)
  • An @property is compared on its whole body and the entry names the descriptors that differ (#264); a rule is reported as reordered only when it moved against another rule (#263); and the size summary lists the two files in the order of the --- and +++ headers, so an addition no longer reads as a shrink (#261)
  • A selector written by more than one rule is reported once, at the top level and inside a container; when its declarations survive on both sides, spread differently, the entry names the move and counts as a rearranged rule (#259, #260)
  • Blocks sharing a condition are reconciled one for one, so three @container blocks against two report the removed block rather than two changed containers; the same pairing decides media, layer and supports (#258)
  • Every statement of an added or removed container is reported, including a nested @media, and a container is no longer counted again as a rule difference with an empty selector (#253)
  • cascade diff bounds its report to the deepest level that fits, with --depth to pin a level or print the tree in full; parse warnings print above the report, capped per side; and blocks that only moved are reported as a shift run (#210)

New properties and values

  • Complete the logical border properties: Css.border_block_color and its start/end siblings, the Css.border_inline_width and Css.border_block_width shorthands (type logical_border_width), and the start/end style longhands such as Css.border_inline_start_style (#197, #198, #199, #200)
  • Add Css.parse_font_family, Css.parse_list_style_type and Css.parse_list_style_image, the single-value readers behind the font and list-style shorthands (#201, #202)
  • Add Css.Values.oklch_none_hue for an achromatic colour with a missing hue, printed as oklch(55.6% 0 none) (#190)

Testing

  • CI rejects merlint findings and incomplete library record patterns (#310)
  • dune test renders a stylesheet and its optimized forms in a headless browser and compares the computed style of every element, on a document derived from the stylesheet's own selectors; it skips where no browser is installed (#275)

1.0.0

First public release. Cascade was extracted from the tw (Tailwind CSS v4 in OCaml) project as a standalone CSS command-line tool and library, then stabilised over several internal milestones.

Library

  • Typed CSS AST: selectors, declarations, values, statements, and stylesheets are sealed ADTs. Invalid constructions are caught at compile time.
  • Single warning-aware parse entry point:

    • Css.of_string runs CSS Syntax Level 3 recovery and returns (parse, Error.t) result, where parse = { stylesheet; warnings }.
    • ~strict:true promotes the first warning to Error _.
    • Css.of_string_exn returns the recovered stylesheet directly and raises Error.Parse_error on Error.
  • Pretty-printer with separate pretty and minified contexts (Css.to_string ?minify), with several typed printers exposed (pp_color, pp_length, ...).
  • Structural transforms (fold, map, sort, flatten_nesting, inline_imports) and structural CSS diff utilities via the cascade.diff sub-library.
  • Optimizer with deduplication, rule merging, selector combining, and shorthand/longhand coverage including all reset folding. Rule merging is order-independent: rules are scheduled through a conflict DAG so cascade-safe reorderings converge on the same output regardless of source order.
  • Minification optimises estimated compressed (gzip) transfer size by default: a global factoring that shrinks raw bytes but would grow the compressed output is not applied. Pass ~objective:\Raw(CLI--objective=raw`) to optimise raw bytes instead, for output that ships uncompressed.
  • Spec coverage:

    • Selectors Level 4 -- including :has(), :is(), :where(), :not(), nesting &, and full attribute syntax.
    • Values & Units Level 4 -- calc(), clamp(), min(), max(), minmax(), the modern length units, durations, angles.
    • Color Level 4 -- 15 colour spaces including oklch(), oklab(), lch(), hwb(), color-mix(), plus the 148 named colours.
    • Conditional Rules Level 3-5 -- @media, @supports, @container (including typed style()/scroll-state() queries with range operators), @when / @else.
    • Cascade Level 5 -- @layer declarations and blocks, CSS-wide keywords, and all reset semantics in the optimizer.
    • Custom Properties Level 1 -- var() parsing/printing, typed fallbacks, theme/default substitution, @property registration.
    • Fonts Level 4 (@font-face descriptors), Animations Level 1 (@keyframes, @starting-style).
  • Over 400 typed properties cover box model, flexbox, grid (including grid-template-areas validation), logical properties, typography (font-variant-*, text-emphasis-*, text-decoration-skip-*, initial-letter*), borders and border-image, backgrounds and gradients, transforms (translate, scale, rotate, transform), transitions, animations (animation-range*, scroll-driven timelines), filters, masks, scroll snap, anchor positioning (position-anchor, position-area, position-try-fallbacks), view transitions, and the common vendor-prefixed properties.
  • Custom-property workflows: typed <syntax> parsing for @property, registered-property substitution against an explicit Css.Context.t, and round-trip-stable var() serialisation with literal fallbacks.

CLI tools

  • CLI commands share one binary file reader that closes its descriptor when a read fails (#309)
  • cascade -- pretty-print and minify CSS files. It accepts stdin via - or a missing file argument, and writes output to stdout.
  • cascade --minify applies the standard safe transforms, including deduplication, rule merging, selector grouping, empty-rule elimination, and nested-rule flattening, optimising estimated gzip transfer size by default (--objective=raw optimises raw bytes instead).
  • cascade --inline-imports resolves local @import rules relative to the input file, and cascade --inline-vars substitutes static custom-property references. --keep-vars=NAMES preserves selected custom properties.
  • cascade diff provides structural CSS diffing between two files with auto, tree, string, and canonical modes; respects NO_COLOR, CASCADE_COLOR, and --color, and colours only when stdout is a tty. Identical files exit 0 and differing files exit 1, so the command slots into CI checks and git hooks. The canonical mode projects both sheets to a normal form first, so equivalent factorings -- different rule grouping, cascade-safe rule and declaration order -- compare identical rather than as spurious changes.
  • The CLI is installable as a binary through the Homebrew tap samoht/tap/cascade, with opam installation still available for OCaml users.

Notes

  • cascade parses already-decoded UTF-8 strings. The CSS Syntax Level 3 byte-stream decoding step (BOM handling, @charset byte sniffing, HTTP/environment charset fallback) is the caller's responsibility.
  • CSS nesting round-trips through the parser and printer, and the minifier flattens nested rules when safe.
  • @import rules are preserved by default. Use --inline-imports for explicit closed-world filesystem inlining.
  • No source-map support.