Make new CSS preprocessor #59

Open
opened 2022-06-24 22:07:25 +00:00 by bvisness · 3 comments
Owner

We want to get rid of SCSS (#8), but rather than just throwing out the features of SCSS entirely, I want us to have a more minimal CSS preprocessor.

Declaration nesting is the most obvious feature, and worth doing on its own. Turns out there is a draft spec for this now, but it makes a lot of stupid decisions and we can do better: https://drafts.csswg.org/css-nesting/

Arithmetic expressions are convenient too.

Nesting

There's no point in requiring & at the beginning of every nested rule. The official CSS draft indicates that parsing ambiguities sometimes require large amounts of lookahead in some cases, making it unsuitable for runtime. We are not doing this at runtime, and furthermore, we can just avoid stupid ambiguous CSS.

Here's how ours should work:

Parsing rules vs. declarations

Broadly speaking, I think it should be pretty easy to tell the difference between rules and declarations by just looking ahead to a ; or a {. This is somewhat naive, but should be good enough.

Nesting syntax

  1. In a nested declaration, any occurrences of & will be replaced with :is(<parent selector>).
  2. Any nested declaration that does not contain & will be implicitly prefixed by & (with a space!) before processing step 1.
  3. Recursion do its thing

Example:

.foo {
  .bar, .baz & {
    color: red;
    
    :hover {
      color: blue;
    }
  }
}
:is(.foo) .bar, .baz :is(.foo) {
  color: red;
}

:is(:is(.foo) .bar, .baz :is(.foo)) :hover {
  color: blue;
}

Notice that the :hover rule is like & :hover instead of &:hover.

Some expressions could and should be simplified, such as any case where an :is contains only a single selector and occurs at the beginning of an expression. For example:

// This:
:is(.foo .bar) .baz { ... }

// Could be this:
.foo .bar .baz { ... }

// But this:
.baz :is(.foo .bar) { ... }
// could not, because the semantics are different.

(Unless this messes with selector precedence in some way? But I hope not.)

There are probably many other little optimizations to discover as we go.

Media queries

SCSS allows you to define media queries inside declarations. This is quite convenient, and shouldn't be hard to deal with as long as we can parse the @media stuff.

Variables & expressions

Preprocessor variables and expressions are occasionally quite useful. For example, Tachyons (a CSS utility library we use) defines several SCSS variables for all the preset spacing it uses. We can (and often do) reuse those variables in our own utility style declarations:

// In Tachyons
$spacing-medium: 1rem;

// In our SCSS
.cg3 { column-gap: $spacing-medium; }

We should support variables with exactly the same syntax. Variables should be scoped to the block they are declared in, and are allowed at the top level.

We should also support compile-time arithmetic expressions like SCSS, but without the very stupid decisions that the SCSS people are making.

In our preprocessor, any compile-time arithmetic should be delimited by #calc(), e.g. #calc($spacing-medium / 2). The idea is to have a compile-time version of CSS's calc.

The behavior of #calc should closely mimic CSS calc, e.g. supporting +, -, *, and /, and requiring space around operators. It should support values of all CSS unit types. It should support compile-time variables ($foo) but should not support CSS runtime variables (var()).

Custom functions

We should be able to create custom functions in our Go code that we can call from our CSS. For example, we currently have a SCSS function px2rem that converts a pixel value to rem (assuming 1rem is 16px). That would be nice to preserve in the form #px2rem(60px).

Other syntax

Line comments are allowed and converted to block comments.

We want to get rid of SCSS (#8), but rather than just throwing out the features of SCSS entirely, I want us to have a more minimal CSS preprocessor. Declaration nesting is the most obvious feature, and worth doing on its own. Turns out there is a draft spec for this now, but it makes a lot of stupid decisions and we can do better: https://drafts.csswg.org/css-nesting/ Arithmetic expressions are convenient too. ## Nesting There's no point in requiring `&` at the beginning of every nested rule. The official CSS draft indicates that parsing ambiguities sometimes require large amounts of lookahead in some cases, making it unsuitable for runtime. We are not doing this at runtime, and furthermore, we can just avoid stupid ambiguous CSS. Here's how ours should work: ### Parsing rules vs. declarations Broadly speaking, I think it should be pretty easy to tell the difference between rules and declarations by just looking ahead to a `;` or a `{`. This is somewhat naive, but should be good enough. ### Nesting syntax 1. In a nested declaration, any occurrences of `&` will be replaced with [`:is(<parent selector>)`](https://developer.mozilla.org/en-US/docs/Web/CSS/:is). 2. Any nested declaration that does not contain `&` will be implicitly prefixed by `& ` (with a space!) before processing step 1. 3. Recursion do its thing Example: ``` .foo { .bar, .baz & { color: red; :hover { color: blue; } } } ``` ``` :is(.foo) .bar, .baz :is(.foo) { color: red; } :is(:is(.foo) .bar, .baz :is(.foo)) :hover { color: blue; } ``` Notice that the `:hover` rule is like `& :hover` instead of `&:hover`. Some expressions could and should be simplified, such as any case where an `:is` contains only a single selector and occurs at the beginning of an expression. For example: ``` // This: :is(.foo .bar) .baz { ... } // Could be this: .foo .bar .baz { ... } // But this: .baz :is(.foo .bar) { ... } // could not, because the semantics are different. ``` (Unless this messes with selector precedence in some way? But I hope not.) There are probably many other little optimizations to discover as we go. ### Media queries SCSS allows you to define media queries inside declarations. This is quite convenient, and shouldn't be hard to deal with as long as we can parse the `@media` stuff. ## Variables & expressions Preprocessor variables and expressions are occasionally quite useful. For example, [Tachyons](https://tachyons.io/) (a CSS utility library we use) defines several SCSS variables for all the preset spacing it uses. We can (and often do) reuse those variables in our own utility style declarations: ```scss // In Tachyons $spacing-medium: 1rem; // In our SCSS .cg3 { column-gap: $spacing-medium; } ``` We should support variables with exactly the same syntax. Variables should be scoped to the block they are declared in, and are allowed at the top level. We should also support compile-time arithmetic expressions like SCSS, but without the [very stupid decisions](https://sass-lang.com/documentation/breaking-changes/slash-div) that the SCSS people are making. In our preprocessor, any compile-time arithmetic should be delimited by `#calc()`, e.g. `#calc($spacing-medium / 2)`. The idea is to have a compile-time version of CSS's [`calc`](https://developer.mozilla.org/en-US/docs/Web/CSS/calc). The behavior of `#calc` should closely mimic CSS `calc`, e.g. supporting `+`, `-`, `*`, and `/`, and requiring space around operators. It should support values of all CSS unit types. It should support compile-time variables (`$foo`) but should not support CSS runtime variables (`var()`). ### Custom functions We should be able to create custom functions in our Go code that we can call from our CSS. For example, we currently have a SCSS function `px2rem` that converts a pixel value to `rem` (assuming `1rem` is `16px`). That would be nice to preserve in the form `#px2rem(60px)`. ## Other syntax Line comments are allowed and converted to block comments.
Author
Owner

Here's a test corpus...this isn't necessarily 100% authoritative but I think it shows what we'd like to do, roughly. (It would be fine if it doesn't get exactly this output, since some of the handling of :is is pretty subtle.)

// Input:
.project {
  .pair {
    display: flex;
    align-items: flex-start;

    .key {
      font-weight: bold;
      flex-shrink: 0;
    }

    .value {
      text-align: right;
      flex-grow: 1;
    }
  }
}

// Output:
.project .pair {
  display: flex;
  align-items: flex-start;
}

.project .pair .key {
  font-weight: bold;
  flex-shrink: 0;
}

.project .pair .value {
  text-align: right;
  flex-grow: 1;
}
// Input:
.projectlist {
  .sidebar & {
    padding:0px;
    width:340px;

    // background-image:none;
    // background-color:transparent;
    // box-shadow:none;

    .project-card.more {
      height:40px;
      width:326px;
      padding-top:5px;
    }
  }
}

// Output:
.sidebar .projectlist {
  padding: 0px;
  width: 340px;

  /* background-image: none; */
  /* background-color:transparent; */
  /* box-shadow:none; */
}

.sidebar .projectlist .project-card.more {
  height: 40px;
  width: 326px;
  padding-top: 5px;
}
// Input:
.slideshow {
  /* Background color and color given by theme */
  position:relative;
  background-image:none;
  overflow:hidden;

  &.cards {
    #slide-deck {
      justify-content: flex-start;
    }

    .slide {
      flex: 0 1 auto;
    }
  }
}

// Output:
.slideshow {
  /* Background color and color given by theme */
  position: relative;
  background-image: none;
  overflow: hidden;
}

.slideshow.cards #slide-deck {
  justify-content: flex-start;
}

.slideshow.cards .slide {
  flex: 0 1 auto;
}
// Input:
$spacing-medium: 1rem;
$breakpoint-large: "screen and (min-width: 60em)";

.landing-layout {
  display: grid;
  gap: $spacing-medium;

  > * {
    overflow: hidden;
  }
}

@media $breakpoint-large {
  .landing-layout {
    grid-template-columns: 1fr;
    grid-auto-columns: 1fr;

    > * {
      grid-column: 1 / 2;

      &.landing-right {
        grid-column: 2 / 3;
        grid-row: 1 / 20; // increase this number if somehow you ever add that much garbage to the home page :)
      }
    }
  }
}

// Output:
.landing-layout {
  display: grid;
  gap: 1rem;
}

.landing-layout > * {
  overflow: hidden;
}

@media screen and (min-width: 60em) {
  .landing-layout {
    grid-template-columns: 1fr;
    grid-auto-columns: 1fr;
  }

  .landing-layout > * {
    grid-column: 1 / 2;
  }

  .landing-layout > *.landing-right {
    grid-column: 2 / 3;
    grid-row: 1 / 20; /* increase this number if somehow you ever add that much garbage to the home page :) */
  }
}
// Input:
header {
  &:not(.clicked) .root-item:not(:hover),
  &.clicked .root-item:not(.clicked) {
    cursor: pointer;

    > .submenu {
      display: none;
    }
  }
}

// Output:
header:not(.clicked) .root-item:not(:hover), header.clicked .root-item:not(.clicked) {
  cursor: pointer;
}

:is(header:not(.clicked) .root-item:not(:hover), header.clicked .root-item:not(.clicked)) > .submenu {
  display: none;
}
// Input:
$breakpoint-not-small: "screen and (min-width: 30em)";

header {
  $logo-height: #px2rem(60px);

  .root-item {
    @media $breakpoint-not-small {
      & {
        position: relative; // makes submenus align to this item instead of the screen
        height: $logo-height;
      }
    }
  }
}

// Output:
@media screen and (min-width: 30em) {
  header .root-item {
    position: relative; /* makes submenus align to this item instead of the screen */
    height: 3.75rem;
  }
}
Here's a test corpus...this isn't necessarily 100% authoritative but I think it shows what we'd like to do, roughly. (It would be fine if it doesn't get exactly this output, since some of the handling of `:is` is pretty subtle.) ``` // Input: .project { .pair { display: flex; align-items: flex-start; .key { font-weight: bold; flex-shrink: 0; } .value { text-align: right; flex-grow: 1; } } } // Output: .project .pair { display: flex; align-items: flex-start; } .project .pair .key { font-weight: bold; flex-shrink: 0; } .project .pair .value { text-align: right; flex-grow: 1; } ``` ``` // Input: .projectlist { .sidebar & { padding:0px; width:340px; // background-image:none; // background-color:transparent; // box-shadow:none; .project-card.more { height:40px; width:326px; padding-top:5px; } } } // Output: .sidebar .projectlist { padding: 0px; width: 340px; /* background-image: none; */ /* background-color:transparent; */ /* box-shadow:none; */ } .sidebar .projectlist .project-card.more { height: 40px; width: 326px; padding-top: 5px; } ``` ``` // Input: .slideshow { /* Background color and color given by theme */ position:relative; background-image:none; overflow:hidden; &.cards { #slide-deck { justify-content: flex-start; } .slide { flex: 0 1 auto; } } } // Output: .slideshow { /* Background color and color given by theme */ position: relative; background-image: none; overflow: hidden; } .slideshow.cards #slide-deck { justify-content: flex-start; } .slideshow.cards .slide { flex: 0 1 auto; } ``` ``` // Input: $spacing-medium: 1rem; $breakpoint-large: "screen and (min-width: 60em)"; .landing-layout { display: grid; gap: $spacing-medium; > * { overflow: hidden; } } @media $breakpoint-large { .landing-layout { grid-template-columns: 1fr; grid-auto-columns: 1fr; > * { grid-column: 1 / 2; &.landing-right { grid-column: 2 / 3; grid-row: 1 / 20; // increase this number if somehow you ever add that much garbage to the home page :) } } } } // Output: .landing-layout { display: grid; gap: 1rem; } .landing-layout > * { overflow: hidden; } @media screen and (min-width: 60em) { .landing-layout { grid-template-columns: 1fr; grid-auto-columns: 1fr; } .landing-layout > * { grid-column: 1 / 2; } .landing-layout > *.landing-right { grid-column: 2 / 3; grid-row: 1 / 20; /* increase this number if somehow you ever add that much garbage to the home page :) */ } } ``` ``` // Input: header { &:not(.clicked) .root-item:not(:hover), &.clicked .root-item:not(.clicked) { cursor: pointer; > .submenu { display: none; } } } // Output: header:not(.clicked) .root-item:not(:hover), header.clicked .root-item:not(.clicked) { cursor: pointer; } :is(header:not(.clicked) .root-item:not(:hover), header.clicked .root-item:not(.clicked)) > .submenu { display: none; } ``` ``` // Input: $breakpoint-not-small: "screen and (min-width: 30em)"; header { $logo-height: #px2rem(60px); .root-item { @media $breakpoint-not-small { & { position: relative; // makes submenus align to this item instead of the screen height: $logo-height; } } } } // Output: @media screen and (min-width: 30em) { header .root-item { position: relative; /* makes submenus align to this item instead of the screen */ height: 3.75rem; } } ```
Author
Owner

Not only is our current SCSS compiler extremely slow, but it also no longer works with new versions of Go: https://github.com/wellington/go-libsass/issues/84

This is probably a higher priority now.

Not only is our current SCSS compiler extremely slow, but it also no longer works with new versions of Go: https://github.com/wellington/go-libsass/issues/84 This is probably a higher priority now.
Author
Owner

Interestingly, it seems that the official declaration nesting syntax is improving. They seem to have walked back @nest, which was the most obviously terrible part of the proposed spec. Furthermore, there is continuing discussion on relaxing the syntax further: https://github.com/w3c/csswg-drafts/issues/7961

It now might actually be worth supporting the official declaration nesting syntax, so that we get the benefits of compatibility with other tools (e.g. disabling the unfolding of nested declarations in dev so browser dev tools can give a better experience).

Interestingly, it seems that the official declaration nesting syntax is improving. They seem to have walked back `@nest`, which was the most obviously terrible part of the proposed spec. Furthermore, there is continuing discussion on relaxing the syntax further: https://github.com/w3c/csswg-drafts/issues/7961 It now might actually be worth supporting the official declaration nesting syntax, so that we get the benefits of compatibility with other tools (e.g. disabling the unfolding of nested declarations in dev so browser dev tools can give a better experience).
Sign in to join this conversation.
No Milestone
No Assignees
1 Participants
Notifications
Due Date
The due date is invalid or out of range. Please use the format 'yyyy-mm-dd'.

No due date set.

Dependencies

No dependencies set.

Reference: hmn/hmn#59
No description provided.