twcss
Version:
Fast minimalist utility-first CSS runtime inspired by Tailwind and Twind
231 lines (195 loc) • 6.83 kB
JavaScript
import { COLOR_PROPS } from './colors.js'
import { HIGH_PRIORITY_RULES, OPACITIES, STATES, PSEUDO, STRING_SIZES } from './constants.js'
import { KEYFRAMES } from './keyframes.js'
import { QUERIES } from './queries.js'
import { RESET } from './reset.js'
import { UTILS } from './utils.js'
// Global object.
export const tw = { instances: new Map(), extend, parser: getParser() }
function getParser() {
// biome-ignore format: It is easier to understand with extra indentation.
return new RegExp([
'(?<negative>-?)?', // Minus sign.
`((?<mq>${[...QUERIES.keys()].map(mq => mq.replace(/(\.|\\|\+|\*|\?|\^|\$|\(|\)|\[|\]|\{|\})/g, '\\$1')).join('|')}):)?`, // Media or container query.
`((?<state>${STATES.join('|')}):)?`, // State.
`((?<pseudo>${PSEUDO.join('|')}):)?`, // Pseudo element.
'(?<util>',
// Values
'((?<base>[a-z-]+)-(',
'(?<number>[0-9.]+)|',
'(?<fraction>[0-9]+/[0-9]+)|',
'(?<string>[a-z]+)|',
'(\\[(?<raw>[^\\[]+)\\])|',
'(\\((?<custom>--[a-z-]+)\\))',
')$)|',
// Default
'(.+)',
')'
].join(''))
}
function createSheet() {
const sheet = new CSSStyleSheet()
for (const css of RESET) {
sheet.insertRule(css, sheet.cssRules.length)
}
for (const [name, keyframes] of KEYFRAMES.entries()) {
sheet.insertRule(`@keyframes ${name} { ${keyframes} }`, sheet.cssRules.length)
}
return sheet
}
function addRule(instance, cls) {
// Skip if empty or already present.
if (!cls || instance.usedRules.has(cls)) {
return
}
const { negative, mq, state, pseudo, util, base, number, fraction, string, raw, custom } = cls.match(tw.parser).groups
let css = UTILS.get(util) // Basic class.
if (!css) {
css = UTILS.get(base) // Dynamic class.
if (!css || !css.includes('$')) {
throw new Error(`[TWCSS] Unknown utility class: ${cls}`)
}
const minus = negative ? '-' : ''
if (number) {
css = css.replace('$', `calc(${minus}${number} * 4px)`)
} else if (fraction) {
css = css.replace('$', `calc(${minus}${fraction} * 100%)`)
} else if (raw) {
css = css.replace('$', raw.replace(/_/g, ' '))
} else if (custom) {
css = css.replace('$', `var(${custom})`)
} else if (string && STRING_SIZES[string]) {
css = css.replace('$', STRING_SIZES[string])
} else {
throw new Error(`Unknown utility class: [${cls}]`)
}
}
// Rules are added in the following order.
// 1. Standard rules.
// 2. High priority standard rules.
// 3. Media query rules.
// 4. High priority media query rules.
const isHighPriority = Boolean(HIGH_PRIORITY_RULES.find(r => util.includes(r)))
const escapedUtil = CSS.escape(util)
const [statePrefix, stateSuffix] = state ? [`${state}\\:`, `:${state}`] : ['', '']
const [pseudoPrefix, pseudoSuffix] = pseudo ? [`${pseudo}\\:`, `::${pseudo}`] : ['', '']
const fullUtil = `${statePrefix}${pseudoPrefix}${escapedUtil}${pseudoSuffix}${stateSuffix}`
let rule
let index
if (mq) {
// Wrap in media query.
index = isHighPriority ? instance.sheet.cssRules.length : instance.mqRulesStartIndex
rule = `${QUERIES.get(mq)} { .${CSS.escape(mq)}\\:${fullUtil} ${css} }`
} else {
rule = `.${fullUtil} ${css}`
index = isHighPriority ? instance.mqRulesStartIndex : 0
instance.mqRulesStartIndex += 1
}
instance.usedRules.add(cls)
instance.sheet.insertRule(rule, index)
}
function processElement(instance, el) {
const timestamp = Date.now()
const className = el.getAttribute('tw')
const classes = (className || '').split(/ +/)
for (const cls of classes) {
try {
addRule(instance, cls)
} catch (err) {
console.warn(err.message)
}
}
el.className = className
instance.lastGenerationTime = Date.now() - timestamp
}
export function extend({ classes = {}, colors = {}, keyframes = {}, queries = {} }) {
// Inject keyframes.
Object.entries(keyframes).forEach(([name, keyframes]) => {
tw.instances.values().forEach(instance => {
instance.sheet.insertRule(`@keyframes ${name} { ${keyframes} }`, instance.sheet.cssRules.length)
})
})
// Generate classes for new colors.
COLOR_PROPS.entries().forEach(([colorClass, colorProp]) => {
Object.entries(colors).forEach(([colorSuffix, lch]) => {
classes[`${colorClass}-${colorSuffix}`] = `{ ${colorProp}: oklch(${lch}) }`
OPACITIES.forEach(opacity => {
classes[`${colorClass}-${colorSuffix}/${opacity}`] = `{ ${colorProp}: oklch(${lch} / ${opacity / 100}) }`
})
})
})
Object.entries(queries).forEach(([name, query]) => QUERIES.set(name, query))
Object.entries(classes).forEach(([name, css]) => UTILS.set(name, css))
// Update parser to account for new media queries.
tw.parser = getParser()
}
export function init(root) {
if (!self.tw) {
self.tw = tw
}
if (tw.instances.has(root)) {
return
}
const timestamp = Date.now()
const sheet = createSheet()
const instance = {
root,
usedRules: new Set(),
sheet,
mqRulesStartIndex: sheet.cssRules.length,
observer: new MutationObserver(async mutations => {
let clean = false
for (const m of mutations) {
// Attribute change.
if (m.type === 'attributes' && m.attributeName === 'tw') {
processElement(instance, m.target)
continue
}
for (const node of m.addedNodes) {
if (node.nodeType === 1) {
// Process current node.
node.hasAttribute('tw') && processElement(instance, node)
// Process all children.
node.querySelectorAll('[tw]').forEach(el => {
processElement(instance, el)
})
// Initialize new shadow root.
if (node.shadowRoot) {
init(node.shadowRoot)
}
}
}
clean ||= m.removedNodes.length
}
// If any nodes were removed, check if any shadow roots were disconnected.
if (clean) {
for (const inst of tw.instances.values()) {
if (!inst.root.isConnected) {
inst.observer.disconnect()
tw.instances.delete(inst.root)
}
}
}
}),
}
// Add instance to the map.
tw.instances.set(root, instance)
// Inject style sheet to the root.
root.adoptedStyleSheets = [instance.sheet]
// Update existing classes.
for (const el of root.querySelectorAll('*')) {
if (el.shadowRoot) {
init(el.shadowRoot)
}
el.hasAttribute('tw') && processElement(instance, el)
}
// Start observing the root.
instance.observer.observe(root, {
attributes: true,
childList: true,
subtree: true,
attributeFilter: ['tw'],
})
// Performance metrics.
instance.initTime = Date.now() - timestamp
}