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]"
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>
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;
}
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)
}
},
}
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)
}
}
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
}
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
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
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%]"
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 ..."
/* 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:
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 by1.0
(this can be overridden in the settings):
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:
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:
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:
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:
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, 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.
Top comments (2)
With respect for your work, it is a duplicate of UnoCSS ?
I'm lost any things ? What are the differences ?
Overall, yes, the idea is directly inspired, with the syntax change and some internal constructs like the supporters.