Skip to content

Latest commit

 

History

History
405 lines (270 loc) · 17 KB

README.md

File metadata and controls

405 lines (270 loc) · 17 KB

⚡ Lightning CSS for Ruby

An extremely fast CSS parser, transformer, bundler, and minifier.

Ruby bindings for Lightning CSS, the CSS toolchain written in Rust.

Gem Version Documentation License Issues


What is Lightning CSS for Ruby?

Ruby bindings for Lightning CSS, an extremely fast CSS parser, transformer, bundler, and minifier written in Rust.

Everything here is Lightning CSS doing the work. For what the options mean and what it can do, lightningcss.dev is the reference.

Installation

bundle add lightningcss

Precompiled gems are published for Linux (gnu and musl) and macOS, on x86_64, aarch64, and arm. On those platforms nothing is compiled at install time. Anywhere else the gem builds from source and needs the Rust toolchain.

Versioning

The gem carries the version of the Lightning CSS release it packages. Gem v1.33.0 bundles Lightning CSS v1.33.0.

Lightning CSS publishes its Rust crate under a different number than it releases under: v1.33.0 ships the crate as 1.0.0-alpha.72. The gem follows the release, since that is the version everything else calls it. LightningCSS.lightningcss_version reports the crate version a given build was compiled against.

So the version tracks Lightning CSS, not the Ruby API here. A minor bump is a minor Lightning CSS release, and says nothing about whether this gem's own API moved. Pin on the Lightning CSS version you want.

If the gem needs releasing again for the same Lightning CSS version, that release adds a fourth digit, as in v1.33.0.1.

Usage

Transforming

LightningCSS.transform(".a { color: #ff0000 }", minify: true).code
#=> ".a{color:red}"

Minifying

minify answers the code directly, for when the rest of the result is not interesting.

LightningCSS.minify(".a { color: #ff0000 }")
#=> ".a{color:red}"

Targeting browsers

Give the browsers you support, each with the oldest version you support. Anything they cannot read is lowered, with the original kept after it so that a browser which understands it still wins.

LightningCSS.transform(".a { color: lab(50% 40 59) }", targets: { chrome: 80 }, minify: true).code
#=> ".a{color:#bf5702;color:lab(50% 40 59)}"

A version is an Integer for a major version, or a Float or String for a minor or patch release. Several features first shipped in a minor Safari release, so the minor version can change the output.

LightningCSS.transform(".a { color: oklch(60% .2 30) }", targets: { safari: 15 }, minify: true).code
#=> ".a{color:lab(51.6908% 61.9679 48.0969)}"

LightningCSS.transform(".a { color: oklch(60% .2 30) }", targets: { safari: "15.4" }, minify: true).code
#=> ".a{color:oklch(60% .2 30)}"

Prefer a String for a minor version above 9, since 16.10 as a Float is 16.1.

The browsers it reads are android, chrome, edge, firefox, ie, ios_saf (or ios), opera, safari, and samsung. Any other name raises a LightningCSS::OptionError.

Bundling

bundle resolves the @import statements a stylesheet was written with by reading the files they name, so it takes a path on disk to the entry stylesheet.

LightningCSS.bundle("app/assets/stylesheets/application.css", minify: true).code

A bundle names every file it reads by the path it read it from, so it takes no filename and says so when given one.

It answers the same result a transform does. Every warning names the file it came from, and compiling as a CSS module renames the names in every file it read while exporting the ones the entry wrote. A file it imported is hashed on its own, so its names never collide with the entry's.

result = LightningCSS.bundle("app/assets/stylesheets/application.css", css_modules: true)

result.exports
#=> {"application" => "_8Z4fiW_application"}

result.warnings.first
#=> "'deep' is not recognized as a valid pseudo-class. ... at app/assets/stylesheets/layout.css:1:9"

CSS modules

Compiling as a CSS module renames every class, id, @keyframes, and custom identifier, and reports what each name became.

result = LightningCSS.transform(".card { color: red }", css_modules: true, minify: true)

result.code
#=> "._8Z4fiW_card{color:red}"

result.exports
#=> {"card" => "_8Z4fiW_card"}

Pass a hash instead of true to say how:

LightningCSS.transform(".card {}", css_modules: { pattern: "scoped-[local]" }).exports
#=> {"card" => "scoped-card"}

Scoping

scope narrows every rule by a selector fragment, so a stylesheet only applies where that fragment matches. The fragment lands on the last compound of each selector, which is where a scope belongs.

LightningCSS.transform(".card .title { color: red }", scope: "[data-scope-abc]", minify: true).code
#=> ".card .title[data-scope-abc]{color:red}"

Any single selector works as a fragment, so one carrying its own alternatives is fine:

LightningCSS.transform(".title { color: red }", scope: ":where([s], [s] *)", minify: true).code
#=> ".title:where([s],[s] *){color:red}"

Keyframe selectors are left alone, and so is the inside of a functional pseudo class:

LightningCSS.transform(".x:not(.y) { color: red }", scope: "[s]", minify: true).code
#=> ".x:not(.y)[s]{color:red}"

Style attributes

A style attribute is the list of declarations an element carries inline. It has no selectors and no at-rules around it, so it is its own grammar.

LightningCSS.transform_style_attribute("color: #ff0000; border: none", minify: true).code
#=> "color:red;border:none"

Having no selectors and no names, it takes neither scope nor css_modules, and says so when given one.

Parsing

parse answers what Lightning CSS read a stylesheet as, without printing anything. It takes filename and error_recovery, the two options that change how a stylesheet is read.

parsed = LightningCSS.parse(".card .title:hover { color: #f00 }")

parsed.stylesheet   #=> the AST exactly as Lightning CSS serializes it
parsed.warnings     #=> []
parsed.source       #=> ".card .title:hover { color: #f00 }"

The AST is Lightning CSS's own, so its shapes are the ones its JavaScript visitors see and lightningcss/node/ast.d.ts describes. It is what Lightning CSS understood, not what was written: #f00 reads as an rgb with r of 255.0, and a 0 margin reads as 0px.

Walking the AST

root answers the stylesheet as a LightningCSS::Node, which walks, reads its fields by name, and knows what it sits inside.

root = LightningCSS.parse(source).root

root.type                   #=> "stylesheet"
root.keys                   #=> the fields this node carries
root.fields                 #=> those fields and their values, without its type and location
root.child_nodes            #=> the nodes directly under it
root.every("declaration")   #=> every declaration in the stylesheet
root.each                   #=> an Enumerator over every node

Every object in the tree is a node, so reads chain all the way down.

rule = root.rules.first

rule.type                                  #=> "style"
rule.value.selectors.first.map(&:type)     #=> ["class", "combinator", "class", "pseudo-class"]
rule.value.declarations.declarations.first.value.r
#=> 255.0

Most objects say what they are with their own type. The stylesheet, a declaration block, and a declaration do not, so a node names those "stylesheet", "declaration-block", and "declaration" from where it sits. Anything else without a type, like the value a rule wraps, answers nil.

Inspecting a node shows every field it carries.

#<LightningCSS::Node style value=#<LightningCSS::Node>>
#<LightningCSS::Node location=0:1 selectors=[... 1 item] declarations=#<LightningCSS::Node declaration-block> rules=[]>
#<LightningCSS::Node declaration property="color" value=#<LightningCSS::Node rgb>>

Lightning CSS names its fields in camelCase, and a field answers to its snake_case name too.

block.important_declarations   # the same field as block.importantDeclarations
query.media_queries            # mediaQueries

Lightning CSS records where each rule starts and nothing else, so location is a rule's line, counted from 0, and its column, counted from 1 in UTF-16 code units. There are no end positions, so a node has no slice and there is no at(offset).

rule.value.location
#=> {"source_index" => 0, "line" => 0, "column" => 1}

ancestors answers what a node sits inside, and rule? says whether it is a rule.

declaration = root.every("declaration").first
declaration.ancestors.find(&:rule?).type
#=> "style"

Nodes pattern match, and nest, since a field holding an object comes back as a node. Patterns take snake_case names, and a node's type matches even when Lightning CSS gave it none.

declaration => { type: "declaration", property:, value: { type: "rgb", r: } }
property   #=> "color"
r          #=> 255.0

to_h and to_json answer the AST the node wraps.

declaration.value.to_h
#=> {"type" => "rgb", "r" => 255.0, "g" => 0.0, "b" => 0.0, "alpha" => 1.0}

LightningCSS::Visitor answers a node with the method named after its type, and walks through anything nothing answers.

class Colors < LightningCSS::Visitor
  def visit_style_rule(node)
    puts node.value.location["line"]

    visit_children(node)
  end

  def visit_declaration(node)
    puts node.property
  end
end

Colors.new.visit(LightningCSS.parse(source))

A rule is answered as one, as in visit_style_rule, visit_media_rule, or visit_namespace_rule. Lightning CSS calls the rule style, but namespace and nesting are also parts of a selector, so visit_namespace answers svg|rect and visit_namespace_rule answers @namespace. Hyphens become underscores, so a pseudo-class is answered by visit_pseudo_class.

Reusing options

LightningCSS::Transformer holds a set of options to use across many stylesheets. Options given to a call are merged over the ones it was built with.

transformer = LightningCSS::Transformer.new(minify: true, targets: { chrome: 100 })

transformer.transform(".a { color: red }").code
transformer.transform(css, scope: "[data-scope-abc]").code
transformer.with(scope: "[s]")

It answers call as well, so it can be handed to anything expecting something callable.

transformer.parse reads with the filename and error_recovery it holds and leaves the rest, which only matter to printing.

Options

Option Type Description
filename String The name to use in errors and warnings.
minify bool Whether to print the result as small as it goes.
error_recovery bool Whether to carry on past a rule it cannot read. Off by default, so such a rule raises.
targets Hash The browsers being compiled for, each with the oldest version supported.
css_modules bool, Hash Whether to compile as a CSS module, and how.
scope String A selector fragment to narrow every rule by.

An option nobody reads is refused:

LightningCSS.transform(".a {}", nonsense: true)
#=> LightningCSS::OptionError: Unknown option: nonsense

Results

transform, bundle, and transform_style_attribute all answer a LightningCSS::Result.

result = LightningCSS.transform(".a { color: #ff0000 }", minify: true)

result.code
#=> ".a{color:red}"

result.to_s
#=> ".a{color:red}"

result.exports
#=> nil

result.warnings
#=> []

result.warnings?
#=> false

exports is filled in when the stylesheet was compiled as a CSS module, and maps every name as it was written to the name it was compiled to:

result = LightningCSS.transform(".card { color: red }", css_modules: true, minify: true)

result.code
#=> "._8Z4fiW_card{color:red}"

result.exports
#=> {"card" => "_8Z4fiW_card"}

warnings holds what Lightning CSS understood well enough to keep but not well enough to act on. They are worth reading, because what they describe is kept as written and then does nothing:

result = LightningCSS.transform(".a:deep(.b) { color: red }", minify: true)

result.warnings?
#=> true

result.warnings.first.to_s
#=> "'deep' is not recognized as a valid pseudo-class. Did you mean '::deep' (pseudo-element) or is this a typo? at :1:9"

result.code
#=> ".a:deep(.b){color:red}"

Locations

A warning is a LightningCSS::Warning with a message, a type, and a location, the same as a diagnostic in Prism or Herb. A LightningCSS::Error carries the same type and location, and keeps the location in its message.

A LightningCSS::Location counts lines from 1 and columns from 0, the same as Prism, Herb, and Yerba, and prints the way an editor reads it:

css = ".a { color: red; }\n.b, { color: blue; }\n"

begin
  LightningCSS.transform(css, filename: "main.css")
rescue LightningCSS::ParseError => error
  error.message  #=> "Invalid empty selector at main.css:2:5"
  error.type     #=> "SelectorError"
  error.location #=> #<data LightningCSS::Location filename="main.css", line=2, column=4>
end

warning = LightningCSS.transform(css, filename: "main.css", error_recovery: true).warnings.first

warning.message       #=> "Invalid empty selector"
warning.type          #=> "SelectorError"
warning.location.line #=> 2
warning.to_s          #=> "Invalid empty selector at main.css:2:5"

A warning converts to the string it always was wherever a String is expected and is equal to it, so warnings.include?("...") still works. An error without a location, such as an OptionError, has nil for type and location.

Development

The gem is a C extension over a Rust crate. rust/ builds a static library and generates the C header with cbindgen, ext/lightningcss/ wraps it, and lib/ is the Ruby API over that.

bin/setup
bundle exec rake

sig/ is generated from the #: annotations next to the code. Regenerate it with rake rbs after changing a signature, and CI checks that it matches.

Acknowledgements

Lightning CSS is written by Devon Govett and maintained at parcel-bundler/lightningcss. This gem only calls into it. Every CSS feature, browser target, and optimization comes from there.

Its selector engine, parcel_selectors, is a fork of the selector matching from Servo, by the Servo Project Developers.

Thank you to all of them.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/marcoroth/lightningcss-ruby. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.

Issues with CSS parsing, transforming, or minifying itself belong upstream, since this gem does none of that. Issues with the Ruby API, the build, or the bindings belong here.

License

The Ruby, C, and Rust code in this gem is available under the terms of the MIT License.

It builds against Lightning CSS, which is licensed under the MPL-2.0, and the native extension a precompiled gem ships has that code compiled into it. MPL-2.0 is a file-level copyleft license, so the source of the MPL-covered files stays available under the MPL. The upstream LICENSE has the terms, and a copy travels with the gem in licenses/ so that whoever received it has them in hand.