UnunuraCSS - How I Built "my own TailwindCSS"
Tags: webdev, css, tailwindcss, typescript
Published: 2023-05-17 19:23:42

If you don't know, [TailwindCSS](https://tailwindcss.com/) is a *CSS framework* with the aim of providing classes for primary use.

[UnunuraCSS](https://ununura.com/#/), means "unique" in Esperanto (individual or unmarried, depending on the context, but that's beside the point), got its name because every class generated by *framework* is totally unique and not supported by other classes. Its main objective is to offer an alternative with more *build-in* functionalities, a shortened syntax and more practical configurations:

```ts
// Example in Tailwind
class="text-2xl text-white font-roboto font-bold flex flex-col flex-wrap align-center justify-between"

// Example in Ununura
class="text[3rem white roboto 700] f[col wrap ai-center jc-between]"
```

But, how did I build?

### On-Demand

The main inspiration for this *framework* is [UnoCSS](https://github.com/unocss/unocss) and his article [Reimagine Atomic CSS](https://antfu.me/posts/reimagine-atomic-css). Before reading the article, I recommend knowing more about UnoCSS and its philosophy.

Its main idea is to generate CSS on demand, which is basically to inspect all possible classes of an ecosystem as a whole, create the scope of the given classes according to the necessary specifications, and insert them into a single global .css file or the *SFC (Single File Component)* of the read file depending on the *framework*.

For example, we have the following case:

```html
// index.html
<aside class="text-bold-and-large other-class-here">
// ...
</aside>
```

`text-bold-and-large` can be a manually declared class in any CSS file, like:

```ts
.text-bold-and-large {
  font-weight: 700;
  text-size: 1.5rem;
}
```

But the point of UnunuraCSS is to build a specification IF it exists, that is, it is not a common class, and that it is valid, respecting the following sequence:

- Read all files that may or may not have CSS classes;
- If any, transform the appropriate sequence to a new specification;
- Transform the title to a nomenclature supported by CSS 3 and convert your template in the pre-processing of each file;
- Generate a global CSS with the generated classes that cannot be inserted in the same file.

This sequence is executed thanks to [Vite](https://vitejs.dev/), which by following the [Rollup](https://rollupjs.org/) specification, it is possible to do these tasks in a simple plugin:

```ts
return {
  name: 'ununuracss:core',
  enforce: 'pre',
  async transform(code, id) {
    if (!filter(id)) return

    if (isVueFile(id)) {
      return { code: await UnunuraScopedSFCFile(code, 'vue') }
    }

    if (isSvelteFile(id)) {
      return { code: await UnunuraScopedSFCFile(code, 'svelte') }
    }

    // ...
  },
  resolveId(id) {
    return id === VIRTUAL_CSS_INJECT_FILENAME ? RESOLVED_VIRTUAL_CSS_INJECT_FILENAME : null
  },
  async load(id) {
    if (id === RESOLVED_VIRTUAL_CSS_INJECT_FILENAME) {
      const code = await UnunuraGlobalGenerate(ununura)

      return { code }
    }
  },
  async handleHotUpdate({ server, file }) {
    if (filter(file)) {
      await reloadServer(server, ununura)
    }
  },
}
```

> enforce: It will be executed before all other rollup plugins and in the pre-processing stage, that is, before any transformation performed by internal instances or other rollup plugins.

> transform: Receives the file content (code) and the file path (id). Our objective in this step is to execute what is necessary for each type of file and its support (.vue, .svelte, .jsx...), ignoring files that cannot have classes (or Ununura does not offer explicit support) and converting to a new template. As Ununura does not use the default `className` titles (explained in Key-Resources), here is also the implementation of the new titles.

> resolveId: Resolve `\0`, recommended by Vite, for creating a virtual CSS file (ununura.css). All other id's are ignored.

> load: The creation of ununura.css file and the classes that will be globally in the application.

> handleHotUpdate: Every change in a valid file, that is, which may have had a CSS change, is triggered and the virtual CSS is rewritten:

```ts
export const reloadServer = async (server: ViteDevServer, options: UnunuraCoreOptions) => {
  const virtualModule = server.moduleGraph.getModuleById(RESOLVED_VIRTUAL_CSS_INJECT_FILENAME)

  if (virtualModule) {
    await server.reloadModule(virtualModule)
  }
}
```

> Because everything runs in pre-processing time, it is not necessary to deal with each library (such as using @vue/compiler-sfc), it is only necessary to respect the specification of each *SFC*.

### AST

To search for classes, a [deep-first search](https://en.wikipedia.org/wiki/Depth-first_search) is done in the [syntax abstraction tree](https://en.wikipedia.org/wiki/Abstract_syntax_tree) from all valid files, fetching the `className` and `class` attribute by default from embedded HTML's:

```ts
export const classesFromRawHtml = (html: string, adapters?: string[]): UnunuraASTNode[] => {
  // ast from rehype package
  const tree = fromHtml(html, { fragment: true })
  const classes: UnunuraASTNode[] = []

  const insert = (node: any, cl: string, flag?: string): UnunuraASTNode => ({
    tag: node.tagName,
    class: cl,
    position: node.position,
    flag,
  })

  const ast = (children: Content[]): void => {
    children.forEach((node) => {
      if (node.type === 'element') {
        if (node.tagName === 'template') ast((node.content as Root).children) // .vue sfc

        adapters?.forEach((adapter) => {
          if (node.properties && node.properties[adapter]) {
            classes.push(insert(node, node.properties[adapter]))
          }
        })

        const target = node.properties?.className?.join(' ')

        if (target) {
          classes.push(insert(node, target))
        }

        if (node.children) ast(node.children)
      }
    })
  }

  ast(tree.children)

  return classes
}
```

> Certain files have adapters to run the classes without the need for a *runtime*. For example, Ununura supports the `:class=[...]` of `.vue` in both ternary and object format. If needed, a separate *runtime* is also offered.

> *DFS* is not one of the best alternatives, but if you're not the type of programmer who writes a .vue over 3000 (three thousand) lines of code, it won't be a problem in development mode (much less in development mode). production, as the build time is practically instantaneous).

> Certain specifications use means other than AST, such as `.[jt]sx` using `@babel/parser`.

> A general *buffer* is used for the registration of all classes and a temporary *buffer* for the registration of the classes of each attribute, where this temporary buffer is used for the verification of unique keys by each attribute and the creation of rules , such as responsiveness and custom themes.

#### Key-Resources

Unlike standard CSS classes, Ununura transforms the following two definitions:

- Key-Unique Resource: `class="flex:col text:white"`

- Key-Multiple Feature: `class="flex[col wrap] text[white 1.1rem]"`

As you can see, these two shapes are not supported by default by CSS. So, in addition to the tool having to create the CSS given its key, which indicates the group it belongs to, and given its resources, which say what the key actually is, it is necessary to convert each key-resources to a title " minified" (*slug*):

```css
flex:col -> .flex-col 
text:white -> .text-white 

flex[col wrap] -> .flex-col-wrap 
text[white 1.1rem] -> .text-white-11rem 
```

Internally, all classes are generated to follow the concept of *Only Scoped*, where every class generated in the same application (disregarding *micro-frontend* federations), being totally unique in its selectors, such as `@media `, will not be affected by other contexts, where in each title the current line of `className` and the name of the referenced file are also inserted. For example:

```css
flex:col -> .flex-col -> .flex-col-1-app
text:white -> .text-white -> .text-white-1-app

flex[col wrap] -> .flex-col-wrap -> .flex-col-wrap-2-app 
text[white 1.1rem] -> .text-white-11rem -> .text-white-11rem-2-app 
```

> Because it is not valid at runtime, it was necessary to create a simple *lexer* to differentiate the spacing of each class with the spacing of each resource.

> Not all supporters have the same idea, where some keys in particular can take advantage of the resource space to use as a sequence, and even incorporate other supporters in themselves, as is the case with the gradient:

```ts
class="gr[90deg rgba-200-200-200-0.5 0% rgba-100-200-0-0.35 30% rgba-255-100-50-0.8 100%]"
```

> Each key can have more than one variation, like text-font, flex-f, rounded-r, ...

> As it is possible to have a virtual .css file, it is possible to insert classes and pre-definitions without the need to be treated in the common flow, such as having the functionality to "reset the css" in the class:

```ts
class="reset:meyer ..."
```

```css
/* ununura.css */
/* http://meyerweb.com/eric/tools/css/reset/ 
   v2.0 | 20110126
   License: none (public domain)
*/

html, body, div, span, ... {
	margin: 0;
	padding: 0;
	border: 0;
	font-size: 100%;
	font: inherit;
	vertical-align: baseline;
}

/* ... */
```

#### Supporters

To validate each resource and its proper context, a system of supporters is used, where each resource is classified in different ways by each key:

```ts
export const getResourceText = (
  identifier: UnunuraIdentifier,
  ctx: UnunuraGenerateContext
): string => {
  const color = getSupportedColor(ctx)
  const fontSize = getSupportedFontSize(ctx)
  const fontFamily = getSupportedFont(ctx)
  const fontWeight = getSupportedFontWeight(ctx)

  let setter = setterHead(ctx)
  setter += setterRow(color, `color: ${color}`, ctx.contents)
  setter += setterRow(fontSize, `font-size: ${fontSize}`, ctx.contents)
  setter += setterRow(fontWeight, `font-weight: ${fontWeight}`, ctx.contents)
  setter += setterRow(
    fontFamily,
    `font-family: ${getFontFamilyCallback(`\'${fontFamily}\'`, ctx?.ununura?.defaults?.values?.fontStack || DEFAULT_FONT_STACK)}`,
    ctx.contents
  )

  return resolveCssClass(identifier, setter, ctx)
}
```

> Supporters can infer rules by default, like the case of the unit supporter which, in the absence of a unit, it suffixes `px` and multiplies the numerical value by `1.0` (this can be overridden in the settings):

```ts
class="w:100% md(w:768)" -> class="w:100% md(w:768px)"
```

> Each supporter can accept different resource bases, like *color* which accepts several ways to enter a color, or `fontFamily` which accepts default browser fonts and fonts extended by configuration file:

```ts
class="bg:rgb-255-200-0 text:red"
class="font:arial"

// with extended configuration
class="bg:primary text[arial primary 1.2rem]"
class="font:roboto"
```

> Even though every resource search is O(n), a key will hardly have more than 10 (ten) resources.

#### Context

In addition to the default context, it is possible to infer specific rules using `()` as the attached context:

```ts
class="text:white md(text:black) dark(text:black md(text:white))"
```

> Because of validation by the temporary buffer, all keys in default context have to be inserted at the beginning.

> `class="md(hover(dark(xl(active(light(xl(focus(sepia(bg:rgba-0-0-0-0.1))))))))"` is valid, but each context group is resolved only once, that is, in this case only md, hover and dark will be considered rules. It would be nice, but it is not practical to have more than one specific group for identical context rules.

#### Globals

Globals are special resources that every key has access to, serving as a helper when generating your class, such as:

```css
text[! red] -> color: red !important;

flex[? flex-1] -> Removes display: flex;

before(grid[content-a_some_test col-3 ...]) -> content: "a some test"
```

#### Runtime

As mentioned in the AST section, the *framework* provides a *runtime*, which is basically a *MutationObserver* that recompiles the classes if necessary:

```ts
const MO = new MutationObserver((list, _observer) => {
  const targets: Element[] = []

  list.forEach((item) => {
    if (item.type === 'childList' && item.target !== injectStyle) {
      item.addedNodes.forEach((node) => {
        if (!node) return

        const _node = node as Element

        if (!_node?.className) return

        targets.push(_node)
      })
    } else if (item.type === 'attributes') {
      if (!item.target) return

      const _node = item.target as Element

      if (!_node?.className) return

      targets.push(_node)
    }
  })

  generate(targets)
})

MO.observe(defDocument.documentElement || window.document.body, {
  childList: true,
  subtree: true,
  attributes: true,
})
```

> The *runtime* is not coupled to avoid compatibility problems with the post-processing of each project, having to be inserted individually by the `ununura-runtime` package.

#### Extra

The engine offers other features, such as integration with [PostCSS](https://postcss.org/), *CLS Fallback* with [FontaineCSS](https://github.com/danielroe/fontaine), use of [Modern Font Stack](https://github.com/system-fonts/modern-font-stacks), styles for syntax customization, malleable configuration, support for other frameworks like [Nuxt](https://nuxtjs.org/) and [Astro](https://astro.build/), among other features.

The project is [fully open source](https://github.com/Novout/ununuracss), and if in doubt, [see the documentation](https://ununura.com/#/docs/install).

##### Did you like the article? Consider using the tool in some project, I guarantee you will like it :)