Generating CSS from scratch with PostCSS

Me 2020 square

Pascal Widdershoven - 6 January 2020
602 words in about 3 minutes

For a soon-to-be open-sourced project I had to generate a CSS stylesheet based on user input.

Initially I started out with good ‘ol string templating and interpolation but it soon became pretty complex, as I needed to conditionally add certain properties and declarations.

It occurred to me that what I wanted was a representation of the CSS structure in code, an Abstract Syntax Tree (AST). That would allow me to build up the CSS tree structure in code and turn it into a string later on.

I decided to see if I could use PostCSS, since I figured it must be turning CSS into an AST already.

PostCSS

PostCSS is “A tool for transforming CSS with JavaScript”. It’s widely used in the industry for things like auto-prefixing CSS.

As the tag line says, PostCSS main business is transforming CSS, not generating it from scratch as I wanted to do. Looking at the PostCSS codebase I noticed a test named it allows to build own CSS so I figured it should be possible!

Fast forward a couple of hours diving through the PostCSS codebase, reading the API docs and several PostCSS plugins, I had a working solution.

Generating CSS from scratch

To generate CSS with PostCSS you first need to build up an AST:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const postcss = require("postcss");

const fontFamily = "My Family";
const root = postcss.root();

const bodyRule = postcss.rule({ selector: "body" }).append(
  postcss.decl({
    prop: "font-family",
    value: `"${fontFamily}"`
  })
);

const fontFace = postcss.atRule({ name: "font-face" }).append([
   postcss.decl({ prop: "font-family", value: `"${fontFamily}"` }),
   postcss.decl({ prop: "src", value: "url(./fonts/myfont.woff2)" })
]);

root.append([
  fontFace,
  bodyRule
]);

The CSS code can then be generated from the CSS with root.toString(), resulting in this:

1
2
3
4
5
6
7
@font-face {
    font-family: "My Family";
    src: url(./fonts/myfont.woff2)
}
body {
    font-family: "My Family"
}

A couple of things to note:

  • PostCSS does not provide much help in generating proper declaration values out of the box. For example quoting a font-family value in case it contains multiple words is not handled by PostCSS automatically.
  • It’s easy to conditionally generate properties, rules, declarations etc. The API follows a typical builder pattern, which makes it easy to conditionally call append, or not.
  • The CSS output is opinionated. There are no semicolons after the last declaration and no newlines between rules.

Fortunately, PostCSS is architected quite well and allows you to provide your own “Stringifier”. I didn’t find much documentation or guidance on this though, but after a bit of code diving I settled on this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const Stringifier = require("postcss/lib/stringifier");

class PrettyStringifier extends Stringifier {
  static new() {
    return (node, builder) => {
      let str = new this(builder);
      str.stringify(node);
    };
  }

  constructor(builder) {
    super(builder);
  }

  rule(node) {
    if (node.prev()) {
      // Add a newline after a rule, if it's preceded by another rule.
      this.builder("\n", node);
    }

    return super.rule(node);
  }

  decl(node) {
    return super.decl(node, true /* force semicolon */);
  }
}

As you can see, this is inheriting most of the default behaviour except around rules and declarations.

The custom Stringifier can be used like this:

1
root.toString(PrettyStringifier.new());

Conclusion

All in all I achieved what I had to do, but it didn’t feel like PostCSS was particularly well suited for this use-case as everything in PostCSS is geared towards transforming CSS.

Nevertheless it was a nice experiment. Having used PostCSS a lot via Autoprefixer it was interesting to dig into the internals of PostCSS to see how everything works!

If you have any tips on using PostCSS for this purpose, or perhaps other tools that might be better suited for this, please let me know!

Me 2020 square

Pascal Widdershoven

Full Stack Developer • Github: pascalw • Twitter: @_pascalw

At Kabisa, privacy is of the greatest importance. We think it is important that the data our visitors leave behind is handled with care. For example, you will not find tracking cookies from third parties such as Facebook, Hotjar or Hubspot on our website. Only cookies from Google and Vimeo are used in order to improve the user experience of our visitors. These cookies also ensure that relevant advertisements are displayed. Read more about the use of cookies in our privacy statement.