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)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)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 callerApply.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)Reader.peek_utf8_at returns None for negative and out-of-range offsets instead of indexing outside the input (#308)-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.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)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)scroll-margin properties take a negative length, as CSS Scroll Snap 1 allows; only scroll-padding is non-negative (#280)} 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)background-position and mask-position print one position per layer, comma-separated (#209)scale(1.2345678.9012e+19) (#265)--minify; @foo bar {x:1} printed @foobar{x:1}, a different at-rule (#272).p { a, b { ... } } is .p a, .p b (#205)& wraps a complex parent in :is(): .a .b { .dark & { ... } } flattens to .dark :is(.a .b) (#194)--lossless preserves logical sizing and corner aliases in source order.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)!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::-webkit-search-cancel-button keeps the other selectors' declarations (#203)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)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)@layerCss.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)@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)Css.optimize takes ?regroup to turn them off (#215, #224)!important alone), and pairs exactly matching rules before falling back to the property signature (#206)!important, its layer and its metadata through the projection, so the differ no longer calls two sheets that disagree about the flag identical (#271)<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)--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)@layer a; pin, which makes the other layer the weaker one, read as no difference at all@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@media with the rule below it - which changes which declaration wins above the breakpoint - printed CSS files are identical and exited 0a{color:red;color:blue} against a{color:red;color:green} report color: blue -> red, a value neither side holds (#285)(modified, no details); it claimed a position change, which only a Reordered entry establishes (#285)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)Canonical forms differ: above a string diff of the two forms (#290)cascade diff heads it with the two file names instead of Expected and Actual (#290)@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)@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)@container blocks against two report the removed block rather than two changed containers; the same pairing decides media, layer and supports (#258)@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)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)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)Css.Values.oklch_none_hue for an achromatic colour with a missing hue, printed as oklch(55.6% 0 none) (#190)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)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.
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.Css.to_string ?minify), with several typed printers exposed (pp_color, pp_length, ...).fold, map, sort, flatten_nesting, inline_imports) and structural CSS diff utilities via the cascade.diff sub-library.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.~objective:\Raw(CLI--objective=raw`) to optimise raw bytes instead, for output that ships uncompressed.Spec coverage:
:has(), :is(), :where(), :not(), nesting &, and full attribute syntax.calc(), clamp(), min(), max(), minmax(), the modern length units, durations, angles.oklch(), oklab(), lch(), hwb(), color-mix(), plus the 148 named colours.@media, @supports, @container (including typed style()/scroll-state() queries with range operators), @when / @else.@layer declarations and blocks, CSS-wide keywords, and all reset semantics in the optimizer.var() parsing/printing, typed fallbacks, theme/default substitution, @property registration.@font-face descriptors), Animations Level 1 (@keyframes, @starting-style).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.<syntax> parsing for @property, registered-property substitution against an explicit Css.Context.t, and round-trip-stable var() serialisation with literal fallbacks.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.samoht/tap/cascade, with opam installation still available for OCaml users.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.@import rules are preserved by default. Use --inline-imports for explicit closed-world filesystem inlining.