shrubbery-css

    CSS in shrubbery notation: lower shrubbery to a CSS tree, and print a CSS tree back as shrubbery

    css
    shrubbery
    rhombus
    syntax
    transpiler
    Download zip
    Version
    0.1.0
    License
    Apache-2.0
    Last updated
    3 hours ago
    Downloads
    1

    #marianoguerra/shrubbery-css

    CSS written in shrubbery notation, lowered to a CSS syntax tree.

    This is the only module that names both sides. marianoguerra/css knows nothing about shrubbery; marianoguerra/shrubbery knows nothing about CSS. Keeping the join here is what lets a project take either half without paying for the other.

    #The syntax, in one rule

    Write the thing's name, not a symbol for it. A thing is a call; a relation is a keyword.

    ///|
    test "a rule" {
    let src =
    #|class(card):
    #| display: grid
    #| gap: rem(1)
    inspect(
    @shrubbery_css.to_css(src, style=Minified),
    content=".card{display:grid;gap:1rem}",
    )
    }

    CSSwhat it isshrubbery
    .carda classclass(card)
    divan elementdiv, or tag("…") when the name needs quoting
    #sidebaran idid(sidebar)
    :hovera pseudo-classhover()
    ::beforea pseudo-elementelement(before)
    [type="text"]an attribute matchhas_attr(type = "text")
    *any elementany()
    &the parent selectorparent()
    #f7f7f7a hex colourhex(f7f7f7)
    10pxten pixelspx(10)
    50%fifty percentpercent(50)
    !importantthe important flagimportant()
    @media (…)the media at-rulemedia(…)
    a bb is inside aa ~in b
    a > bb is a child of aa ~child b
    a + bb is the next siblinga ~next b
    a ~ bb is a later siblinga ~sibling b

    A declaration needs no translation at all: color: red is already a shrubbery group with a block.

    #Whitespace is not a relation

    Shrubbery's tree does not record whitespace, so a space cannot mean anything. Juxtaposition is one compound selector, and every relation is written out. That is the cost of the design, and it buys a distinction CSS's own spelling cannot survive here:

    ///|
    test "compound and descendant are different, and both are expressible" {
    inspect(
    @shrubbery_css.to_css("a class(b):\n x: y\n", style=Minified),
    content="a.b{x:y}",
    )
    inspect(
    @shrubbery_css.to_css("a ~in class(b):\n x: y\n", style=Minified),
    content="a .b{x:y}",
    )
    }

    #Hyphens

    A hyphen is an operator in shrubbery, so identifiers use _:

    ///|
    test "underscores become hyphens" {
    inspect(
    @shrubbery_css.to_css("a:\n font_family: system_ui\n", style=Minified),
    content="a{font-family:system-ui}",
    )
    }

    A name that genuinely contains _ has no bare spelling, so it is written literally — and which call does it depends on where the name sits, because a class, an element and a value are three different things:

    ///|
    test "the literal escape, in each position it is needed" {
    let src =
    #|class("btn__primary"):
    #| color: hex(fff)
    #|
    #|tag("side_bar"):
    #| font_family: ident("side_bar")
    inspect(
    @shrubbery_css.to_css(src, style=Minified),
    content=".btn__primary{color:#fff}side_bar{font-family:side_bar}",
    )
    }

    Custom properties keep their dashes, since --brand is already a prefix operator and a name.

    #Both directions

    CSS in, shrubbery out, and the loop closes: what comes back lowers to the same tree it came from.

    ///|
    test "CSS becomes shrubbery, and back again" {
    let shrub = @shrubbery_css.to_shrubbery(".card { display: grid }")
    inspect(
    shrub,
    content=(
    #|class(card):
    #| display: grid
    #|
    ),
    )
    inspect(
    @shrubbery_css.to_css(shrub, style=Minified),
    content=".card{display:grid}",
    )
    }

    It is a normalisation rather than an inverse, and two things do not survive the detour. A number loses its source spelling, because shrubbery's literal holds a value and not a spelling -- 1.50 comes back as 1.5, +1 as 1. And a CSS comment becomes a line comment. Recovering either would mean reading the shrubbery node's raw metadata, which the lowering deliberately never does; that purity is what makes it total over hand-built trees.

    #Diagnostics name the fix

    A syntax nobody has seen before is worth diagnosing precisely, so where the mistake is recognisable the message says what to type instead.

    ///|
    test "a CSS habit is met with its replacement" {
    let l = @shrubbery_css.lower(".card:\n a: b\n")
    inspect(l.diagnostics()[0].kind.code(), content="shrubcss::sigil_selector")
    }

    The one construct that parses cleanly and means the wrong thing is a: hover, which is structurally a declaration of a property called a. It gets its own diagnostic, guarded so that cursor: default stays quiet.

    #Layout

    packagewhat
    namesthe tables: units, pseudo-classes, at-rules, relations, _-
    kind, errordiagnostics, and the one bridge to error-report
    lowershrubbery → a CSS tree
    emita CSS tree → shrubbery

    #Licence

    Apache-2.0.

    emit

    A CSS syntax tree as shrubbery CSS.

    lower

    Shrubbery CSS source, as a CSS syntax tree, with everything noticed on the way.

    to_css

    Shrubbery CSS source, as CSS.

    The headline function, and the one most callers want: notation in, CSS out. Tolerant -- a source with one mistake still produces a stylesheet with everything else intact -- so a caller who needs to know asks lower, which hands back the diagnostics alongside the tree.

    to_shrubbery

    fn to_shrubbery(css : String) -> String raise
    CssError

    CSS source, as shrubbery CSS.

    The other headline function, and the one that closes the loop: hand it CSS and it hands back notation that lowers to the same tree.

    It is a normalisation, not an inverse. Two things do not survive the detour, and neither can be helped: a number's source spelling, because shrubbery's literal holds a value rather than a spelling (1.50 comes back 1.5, +1 comes back 1), and a comment's placement, since CSS comments become line comments. Everything else round-trips.

    Source Files