DEV Community

Cover image for UnunuraCSS - How I Built "my own TailwindCSS"
Giovane Cardoso
Giovane Cardoso

Posted on

UnunuraCSS - How I Built "my own TailwindCSS"

If you don't know, TailwindCSS is a CSS framework with the aim of providing classes for primary use.

UnunuraCSS, 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:

// 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]"
Enter fullscreen mode Exit fullscreen mode

But, how did I build?

On-Demand

The main inspiration for this framework is UnoCSS and his article 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:

// index.html
<aside class="text-bold-and-large other-class-here">
// ...
</aside>
Enter fullscreen mode Exit fullscreen mode

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

.text-bold-and-large {
  font-weight: 700;
  text-size: 1.5rem;
}
Enter fullscreen mode Exit fullscreen mode

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, which by following the Rollup specification, it is possible to do these tasks in a simple plugin:

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)
    }
  },
}
Enter fullscreen mode Exit fullscreen mode

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:

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

  if (virtualModule) {
    await server.reloadModule(virtualModule)
  }
}
Enter fullscreen mode Exit fullscreen mode

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 is done in the syntax abstraction tree from all valid files, fetching the className and class attribute by default from embedded HTML's:

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
}
Enter fullscreen mode Exit fullscreen mode

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):

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

flex[col wrap] -> .flex-col-wrap 
text[white 1.1rem] -> .text-white-11rem 
Enter fullscreen mode Exit fullscreen mode

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:

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 
Enter fullscreen mode Exit fullscreen mode

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:

class="gr[90deg rgba-200-200-200-0.5 0% rgba-100-200-0-0.35 30% rgba-255-100-50-0.8 100%]"
Enter fullscreen mode Exit fullscreen mode

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:

class="reset:meyer ..."
Enter fullscreen mode Exit fullscreen mode
/* 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;
}

/* ... */
Enter fullscreen mode Exit fullscreen mode

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:

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)
}
Enter fullscreen mode Exit fullscreen mode

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):

class="w:100% md(w:768)" -> class="w:100% md(w:768px)"
Enter fullscreen mode Exit fullscreen mode

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:

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"
Enter fullscreen mode Exit fullscreen mode

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:

class="text:white md(text:black) dark(text:black md(text:white))"
Enter fullscreen mode Exit fullscreen mode

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:

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

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

before(grid[content-a_some_test col-3 ...]) -> content: "a some test"
Enter fullscreen mode Exit fullscreen mode

Runtime

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

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,
})
Enter fullscreen mode Exit fullscreen mode

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, CLS Fallback with FontaineCSS, use of Modern Font Stack, styles for syntax customization, malleable configuration, support for other frameworks like Nuxt and Astro, among other features.

The project is fully open source, and if in doubt, see the documentation.

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

Top comments (2)

Collapse
 
clabnet profile image
Claudio Barca • Edited

With respect for your work, it is a duplicate of UnoCSS ?
I'm lost any things ? What are the differences ?

Collapse
 
gcnovout profile image
Giovane Cardoso

Overall, yes, the idea is directly inspired, with the syntax change and some internal constructs like the supporters.