Last updated

🔗Overview

Bowdy compiles BASS files (.bass) to standard CSS. Plain CSS is valid BASS, so you can adopt it incrementally.

var primary = #0d6efd
var radius = 4px

.card
  color: $primary
  border-radius: $radius
.card{color:#0d6efd;border-radius:4px}

Output is minified by default. Pass --pretty for formatted output.

Two block styles, freely mixed — even inside the same file:

.card {
  color: red
}

.card
  color: red
  &:hover
    color: black

Rules:

  • Indentation defines nesting for the indent style. A selector with no indented body and no {...} is a parse error (never a silent empty rule).
  • { ... } works everywhere a block is expected.
  • You can nest an indent block inside braces and vice versa.

🔗Comments

// single line, stripped

/* block, stripped */

/** docblock, preserved verbatim */

/*! banner, preserved verbatim */
  • // runs to end of line.
  • /* ... */ is stripped.
  • /** ... */ and /*! ... */ are preserved in output in source order, including banners inside rule bodies (emitted before the rule) and between rules.
  • # is not a comment. It starts an ID selector or hex color.

🔗Selectors

Supported: class (.foo), ID (#header), element (h1), universal (*), pseudo-class/element (:hover, ::before, :root), functional pseudo (:is(), :not(), :nth-child(2n+1)), attributes (see below), comma lists, combinators.

.foo { color: red; }
#header { width: 100px; }
h1 { color: red; }
* { margin: 0; }
.btn:hover { color: blue; }
.foo::before { display: block; }
.foo{color:#ff0000}
#header{width:100px}
h1{color:#ff0000}
*{margin:0}
.btn:hover{color:#0000ff}
.foo::before{display:block}

Comma-separated and combinators (spacing-insensitive > / + / ~):

h1, btn { color: red; }
a, btn:hover { padding-top: 10px; }
.offcanvas.offcanvas-start { color: red; }
.parent>.child { color: red; }
.btn-check:checked+.btn { color: red; }
ol ol, ul ul, ol ul, ul ol { margin-bottom: 0; }
.table :is(thead,tbody,tfoot)>tr>th,td { padding: .5rem; }

🔗Attribute selectors

[data-bs-theme=dark] { color: red; }
[data-bs-theme="dark"] { color: red; }
.dropdown[data-bs-popper] { padding: 0; }
a[href^="http"] { color: blue; }
[data-x~=foo] { display: block; }
[data-x$="suffix"] { display: none; }
[data-x|=en] { width: 10px; }
input[type="checkbox"]:checked { color: green; }
[data-x=foo i] { display: block; }

Operators: = ~= ^= $= *= |= ^=. Case flags i / s are preserved ([type="text" i]).

🔗Properties and values

.card
  color: red
  margin: 10px 20px
  box-shadow: 0 1px 2px rgba(0,0,0,.3), inset 0 0 0 1px red
  color: red !important
  • name: value, one per line (indent) or ;-separated (braces). Trailing ; is optional.
  • !important suffix is supported.
  • Space-separated parts form one value; commas separate segments.
  • Duplicate keys are allowed (vendor fallbacks):
th { text-align: inherit; text-align: -webkit-match-parent; }

🔗Numbers and units

  • Ints (10, 1e3 -> 1000), floats (1.5, 1.0 -> 1), scientific (1e3px, 1.5e-2px, 1E1, -1e2px, 5e-1s).
  • Leading-dot (.125rem -> 0.125rem) and leading-plus (+5px -> 5px, +.5 -> 0.5).
  • Negative numbers are single tokens (-0.375rem -0.75rem are two values).
  • Glued units: 10px, 2em, 50%, 45deg, 0.3s. Unknown suffixes (e.g. 1x) flow through as text; typos surface as validation errors instead of vanishing.

Known suffixes:

px em rem % vh vw vmin vmax ch ex cm mm in pt pc
s ms
deg rad grad turn
dpi dpcm dppx
Hz kHz fr

🔗Strings, keywords, colors

.a { font-family: "Arial"; }
.a { inherits: true; }
.a { content: null; }
.a { color: #0d6efd; }
.a { color: red; } /* -> #ff0000 */
  • "..." / '...' strings. CSS escapes round-trip ("\201E", "a\\b", "a\"b").
  • true / false / null in value position render as text.
  • Hex keeps spelling (lowercased): #0d6efd, #0e3f, #1e1e1e.
  • Named colors normalize to lowercase hex (red -> #ff0000), including inside gradients. transparent, currentcolor and CSS-wide keywords (inherit initial unset revert revert-layer) stay keywords.

🔗Variables

var primary = red
var radius = 4px
const gap = 8px

.card
  color: $primary
  border-radius: $radius
  • var / const. Use sites always use $name.
  • Definitions are always bare (var accent = red registers $accent); $accent or _accent at definition is a parse error. * marks export for imports (var accent* = ...).
  • Optional type: var c: color = red, var w: length = 4px.
  • Comma form: var a = 10, b = 20 (same-line idents only).
  • Arithmetic in scope: $base + 5, $radius - 3px, 4px + 3px. Same-unit rule applies to typed lengths/angles/times (mismatched units are hard errors). 1px * $i / $i * 1px work for loop unrolling.
  • Reassignment: $i = $i + 1 (used with while).
  • Named colors in initializers hexify (var primary = red stores #ff0000).

🔗Interpolation

${expr} inside values and selectors:

.a
  width: ${3}px        /* -> 3px */
  order: ${size + 1}

for i in range(1, 3):
  .p-${$i}
    z-index: $i
    padding: ${$i}px
    margin: ${$i}px auto
.p-1{z-index:1;padding:1px;margin:1px auto}.p-2{z-index:2;padding:2px;margin:2px auto}.p-3{z-index:3;padding:3px;margin:3px auto}
  • ${$i}px / ${3}em fold into a unit. Loop vars resolve at parse time when unrolled; other dynamic bases error clearly in codegen.

🔗Nesting

Descendant nesting is automatic; & splices the parent (Sass-style):

.card
  color: gray
  &:hover
    color: black
  &.active
    color: red
  .title
    font-weight: bold
.card{color:gray}.card:hover{color:black}.card.active{color:red}.card .title{font-weight:bold}

Combinators and comma handling:

.parent
  & > .item
    margin: 0
  & + .item
    margin: 0
  & ~ .item
    margin: 0
.a, .b
  .child
    color: red
/* -> .a .child, .b .child{color:red} */

.card
  &:hover, &:focus
    outline: 2px
/* -> .card:hover, .card:focus{outline:2px} */

.a, .b
  &:hover
    color: red
/* -> .a:hover, .b:hover{color:red} */
  • Parent may carry properties; they emit first, then children flatten.
  • Deep nesting works (.x .y .z).
  • & inside mixins resolves against the call-site parent.
  • At-rules may interleave with nesting and stay in source order.

🔗At-rules

Both styles work; at-rules nest inside selectors and selectors inside at-rules:

@media (max-width: 768px)
  .foo
    color: red

@supports (display: grid)
  .foo { color: red; }

@layer
  .foo
    color: red

@layer base, theme;

@font-face {
  font-family: "Custom";
  src: url("custom.woff2");
}

@keyframes slide
  from
    opacity: 0
  to
    opacity: 1

@keyframes move { 0% { left: 0; } 25%, 75% { left: 50%; } 100% { left: 100%; } }

@charset "utf-8";
@import url("style.css");
@namespace url("http://www.w3.org/1999/xhtml");

Nested at-rule inside a selector stays inside the emitted rule block:

.parent
  @media (max-width: 768px)
    .child
      color: blue
/* -> .parent{@media (max-width: 768px){.child{color:blue}}} */

🔗Mixins

Reusable declaration blocks spliced into rule bodies. No CSS is emitted for the definition alone.

mixin btn(color: color) =
  color: $color
  border-radius: 4px

.a
  @btn(red)
.a{color:#ff0000;border-radius:4px}

Signatures:

mixin reset() =
  margin: 0
  padding: 0

mixin box(w: length, h: length) =
  width: $w
  height: $h

mixin btn(color: color) {
  color: $color
}
  • Indent body requires = (mixin m(...) =); brace body does not (mixin m(...) { ... }). Missing = before an indent body is a parse error.
  • Calls: @name(args) inside a rule body. @reset() with parens works for zero-arg mixins.
  • Positional + named args; named accepts both $h = 5px and h = 5px:
.a
  @box($h = 5px, $w = 10px)
  • Typed params are checked; missing / too many args and unknown mixins are errors (unknown mixins warn at codegen when not statically known).
  • Bodies see outer vars; args accept outer vars and calls (@m(darken(red, 10))).
  • Nested selectors splice with the caller as parent; & works in both indent and brace mixin bodies.
  • Property order is preserved (parent props, then spliced props, then trailing parent props).

🔗Functions

fn / func are aliases. Functions evaluate in expression position and support overloading and forward declarations.

fn dbl(n: int): int =
  return $n * 2

var p = dbl(21)
.a { z-index: $p }
.a{z-index:42}
  • Indent body requires = (func dbl(n: int): int =); brace body does not (func dbl(n: int): int { ... }). = { ... } is a parse error, as is an indented body without =.
  • return expr, plus echo, break, continue statements exist.
  • Typed params (n: int, used as $n) and return type (: int).

🔗Control flow

Conditions take : or {. All forms work top-level and inside rule bodies (inside rules they emit declarations in source order).

var debug = true
.a
  if $debug:
    outline: 1px
  else:
    outline: none
var m = false
.a
  if $m:
    color: red
  elif $m == false:
    color: blue
  else:
    color: green

Loops:

for i in range(1, 3):
  .p-${$i}
    z-index: $i
/* -> .p-1{z-index:1}.p-2{z-index:2}.p-3{z-index:3} */

/* arrays of objects */
var s = [{k: 0, v: 0}, {k: 1, v: 0.25rem}]
for item in $s:
  .p-${$item.k}
    padding: $item.v

for s in [{k: 0, v: 0}, {k: 1, v: 1rem}]:
  .m-${$s.k}
    margin: $s.v
  • range(lo, hi) is inclusive. Constant range loops with ${...} selectors unroll at parse time.
  • Inside rules, for repeats declarations: .a { for i ...: z-index: $i } -> z-index:1;z-index:2;....
var i = 0
.a
  while $i < 2
    z-index: $i
    $i = $i + 1
/* -> .a{z-index:0;z-index:1} */

Case:

case $x:
  of 1:
    /* ... */
  of 2:
    /* ... */
  else:
    /* ... */

case X: with optional colon, of value: branches, optional else:. Brace form case X { of ... } is also accepted.

🔗Modules and imports

// _vars.bass
var accent* = #0d6efd
var radius* = 4px
.base
  color: gray

// main.bass
import "./_vars.bass"
.a
  color: $accent
  border-radius: $radius
.base{color:#808080}.a{color:#0d6efd;border-radius:4px}
  • Only *-exported vars/functions are visible to importers; rules splice unconditionally.
  • Relative imports resolve against the importing file. Missing files are errors.
  • Package imports: import "pkg/name" / import "pkg/name/sub" resolve under ~/.tim/packages/<name>/<latest-version>/src/ (main.bass, index.bass, <name>.bass, plus .timl/.css fallbacks).
  • One import statement per line: import "./_vars.bass".

🔗Custom properties and var()

Custom properties are first-class typed values, not strings:

:root { --bs-blue: #0d6efd; --bs-breakpoint-md: 768px; }
.btn { color: var(--bs-btn-hover-color); }
.a { color: var(--x, red); }
  • --name lexes as one token; --color-gray-100 is never re-split.
  • var(--name[, fallback]) parses as a real call returning cssvar. Bare keywords in fallbacks stay verbatim (var(--x, none)); dynamic fallbacks evaluate.
  • var($n) allows a computed name (var n = "--brand").
  • Registry is file-global across entry + imports, order-independent, last-wins. Selector/media scoping is ignored by design.
  • Declarations infer types: lengths, colors, darken(red, 10) -> color, $c aliases, chained var(--other) aliases. Keywords (transparent, auto) stay unchecked.
  • Under --strict: mistyped uses are hard errors (color: var(--fs-medium) where --fs-medium: 1rem); undeclared names only warn (they may come from plain-CSS imports or JS); fallbacks validate too. env() is untouched.

🔗Modern CSS functions

Two worlds:

  1. Bowdy value functions parse as real calls and evaluate to strictly typed values.
  2. Everything else (calc, url, env, min/max/clamp, attr, counter, repeat/minmax, ...) stays opaque raw text with spacing preserved.
.a { width: calc(100% - 2rem); }
.a { width: clamp(1rem, 2.5vw, 2rem); }
.a { background: url("img.png"); }
.a { padding-top: env(safe-area-inset-top); }
.a { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); }
.a { transform: translate(-50%, -50%) rotate(45deg); }
.a { filter: drop-shadow(0 0 5px rgba(0,0,0,.5)); }
.a { background: color-mix(in oklab, red, blue); }

Typed families: transforms (translate translateX translateY translateZ translate3d scale scaleX scaleY scaleZ scale3d rotate rotateX rotateY rotateZ rotate3d skew skewX skewY matrix matrix3d perspective), filters (blur brightness contrast grayscale invert opacity saturate sepia hue-rotate drop-shadow), gradients (linear-gradient repeating-linear-gradient radial-gradient repeating-radial-gradient conic-gradient repeating-conic-gradient), shapes (circle ellipse inset polygon path xywh ray), easing (cubic-bezier steps linear), colors (rgb rgba hsl hsla hwb lab lch oklab oklch color light-dark color-mix image image-set), plus var.

Notes:

  • Gradient direction keywords (to right, circle at center) stringify so they never resolve as vars.
  • color-mix(in <space>, a, b) leads with the in keyword; percentages space-join onto their color.
  • Fully static rgb()/hsl()-family calls keep source spelling (rgb(13 110 253), rgba(0,0,0,.3)); dynamic forms (any $var/var()/nested call) evaluate and render canonically. Slash alpha (rgb(255 0 0 / 50%)) splices into components + alpha.
  • A real CSS call nesting a permanently opaque fn (calc, url, env, ...) falls back to verbatim text.
  • Dynamic multi-values (margin: $a $b, border: 1px solid $c) evaluate each part at runtime; static multi-values render directly.

🔗Type system: lenient vs strict

Default bowdy c is lenient: only VM types apply (variable types, function signatures, stdlib constructors). A mistyped width: $colorVar still compiles.

--strict enables the static CSS type system: property values check against their CSS syntax, invalid colors are hard errors, and undeclared var(--x) names warn. Output is identical either way; only diagnostics change.

var primary = red
.a { width: $primary; } /* lenient: compiles; strict: error */

Strict also validates literals (color: 123, width: 100 200, display: yesplease, z-index: 10px, color: #zzzzzz all error) while box-shadow, grid-template-columns, content, margin, inherits stay warn-only for exotic forms.

🔗Errors

Parse and codegen errors carry source context (snippet + caret + line:col):

.a { color: red;
                 ^
(1:16) expected closing '}' ...

Common failures: missing = after mixin signature, missing :/{ after if/for/of, unclosed paren/brace, missing mixin arg, type mismatch under --strict, missing import file.