frutjam
Version:
A utility-first CSS UI Library for Tailwind CSS
333 lines (284 loc) • 12 kB
JavaScript
import { readFileSync, readdirSync, statSync, existsSync } from "fs"
import { join, dirname, basename, extname } from "path"
import { fileURLToPath } from "url"
import postcss from "postcss"
const __filename = fileURLToPath(import.meta.url)
const __dir = dirname(__filename)
const srcDir = join(__dir, "src")
const VERSION = JSON.parse(readFileSync(join(__dir, "package.json"), "utf8")).version
// ── Keys that must be injected after Tailwind processes layers ───────────────
// These files conflict with @layer utilities from Tailwind plugins (e.g. typography),
// so they are injected via OnceExit into @layer utilities (or root if not found).
const LATE_INJECT = new Set(['typography'])
// ── Registry (built once at startup, not per build) ──────────────────────────
function buildRegistry() {
const registry = {}
// Components: src/components/*/base.css — keyed by folder name
const componentsDir = join(srcDir, "components")
for (const name of readdirSync(componentsDir)) {
const entry = join(componentsDir, name, "base.css")
if (statSync(join(componentsDir, name)).isDirectory() && existsSync(entry)) {
registry[name] = entry
}
}
// Utilities: src/utilities/*.css — keyed by filename without extension
const utilitiesDir = join(srcDir, "utilities")
for (const file of readdirSync(utilitiesDir)) {
if (extname(file) === ".css") {
registry[basename(file, ".css")] = join(utilitiesDir, file)
}
}
return registry
}
const REGISTRY = buildRegistry()
// ── Helpers ─────────────────────────────────────────────────────────────────
function readFile(p) { return readFileSync(p, "utf8") }
function parseList(val) {
if (!val) return []
if (Array.isArray(val)) return val.map(s => s.trim()).filter(Boolean)
return String(val).split(",").map(s => s.trim()).filter(Boolean)
}
function applyPrefix(css, prefix) {
if (!prefix) return css
css = css.replace(
/@utility\s+([\w-]+\*?)\s*\{/g,
(_, name) => `@utility ${prefix}-${name} {`
)
css = css.replace(
/@apply\s+([^;{}\n]+)/g,
(_, classes) => `@apply ${classes.trim().split(/\s+/).map(c => `${prefix}-${c}`).join(' ')}`
)
css = css.replace(
/\/\*[\s\S]*?\*\/|"[^"]*"|'[^']*'|\.(([a-z][a-z0-9]*-?)+)/g,
(match, name) => name ? `.${prefix}-${name}` : match
)
return css
}
function buildUtilityMap(css) {
const map = new Map()
const pattern = /@utility\s+([\w-]+(?:\s*\*)?)\s*\{/g
let match
while ((match = pattern.exec(css)) !== null) {
const name = match[1].trim()
if (map.has(name)) continue
let depth = 0, bodyStart = -1
for (let i = match.index + match[0].length - 1; i < css.length; i++) {
if (css[i] === '{') {
if (depth === 0) bodyStart = i + 1
depth++
} else if (css[i] === '}') {
if (--depth === 0) { map.set(name, css.slice(bodyStart, i)); break }
}
}
}
return map
}
function resolveCopy(css, source = css) {
const map = source instanceof Map ? source : buildUtilityMap(source)
return css.replace(/@copy\s+([\w-]+)\s*;/g, (original, name) => map.get(name) ?? original)
}
function resolveImports(css, baseDir) {
return css.replace(
/@import\s+["']([^"']+)["'];?\s*/g,
(_, importPath) => {
const fullPath = join(baseDir, importPath)
const importedDir = dirname(fullPath)
const importedCSS = readFile(fullPath)
return resolveImports(importedCSS, importedDir)
}
)
}
function buildCustomThemes(themes) {
if (!themes || Object.keys(themes).length === 0) return ""
return Object.entries(themes)
.map(([name, vars]) => {
const varString = Object.entries(vars)
.map(([k, v]) => ` ${k}: ${v};`)
.join("\n")
return `[data-theme="${name}"] {\n${varString}\n}`
})
.join("\n\n")
}
// ── Base layers (inlined — no entry CSS files needed) ───────────────────────
const BASE_FULL = [
"base/reboot.css",
"theme/base.css",
]
const BASE_CORE = [
"base/variants.css",
"base/tokens.css",
"theme/base.css",
]
// ── Build ────────────────────────────────────────────────────────────────────
// Scope one selector part (already split from a comma list) to rootSelector.
// Returns an array of replacement selectors.
// [data-theme] selectors become compound with rootSelector so theme variables
// only apply when data-theme is set directly on the root element — no ancestor
// inheritance, which would break the isolation guarantee of the root option.
function scopeThemeSelector(sel, rootSelector) {
const t = sel.trimStart()
const lead = sel.slice(0, sel.length - t.length)
// Already remapped from :root (e.g. rootSelector:not([data-theme])) — keep as-is
if (t.startsWith(rootSelector)) return [sel]
// :is([data-theme="x"], [data-theme="y"]) — flatten into individual compound selectors
if (t.startsWith(":is([data-theme")) {
const values = []
const re = /\[data-theme=["']([^"']+)["']\]/g
let m
while ((m = re.exec(t)) !== null) values.push(m[1])
return values.map(v => `${lead}${rootSelector}[data-theme="${v}"]`)
}
// [data-theme="x"] — compound with rootSelector
if (/^\[data-theme=/.test(t)) {
const m = t.match(/^\[data-theme=["']([^"']+)["']\]/)
if (m) return [`${lead}${rootSelector}[data-theme="${m[1]}"]`]
}
// [data-theme] without value (base tokens) — compound with rootSelector
if (t.startsWith("[data-theme]")) {
return [`${lead}${rootSelector}[data-theme]`]
}
return [sel]
}
function buildCSS(prefix, themes, reset, rootSelector, include, exclude) {
// Resolve which components+utilities to include
let keys = Object.keys(REGISTRY)
if (include.length > 0) {
keys = include.filter(k => REGISTRY[k])
} else if (exclude.length > 0) {
const excludeSet = new Set(exclude)
keys = keys.filter(k => !excludeSet.has(k))
}
// LATE_INJECT keys are handled separately via OnceExit — skip them here
keys = keys.filter(k => !LATE_INJECT.has(k))
// Base layer (full with reboot, or core without)
const baseFiles = reset ? BASE_FULL : BASE_CORE
const baseCss = baseFiles
.map(f => resolveImports(readFile(join(srcDir, f)), join(srcDir, dirname(f))))
.join("\n")
// Components + utilities
const modulesCss = keys
.map(k => resolveImports(readFile(REGISTRY[k]), dirname(REGISTRY[k])))
.join("\n")
const combined = baseCss + "\n" + modulesCss
const utilMap = buildUtilityMap(combined)
// Base CSS (theme variables, token definitions, text-color overrides) must NOT be prefixed —
// the @utility text-* declarations target the same class names as Tailwind's theme-generated
// utilities and must stay unprefixed (e.g. text-primary, not fj-text-primary).
let resolvedBase = resolveCopy(baseCss, utilMap)
let resolvedModules = resolveCopy(modulesCss, utilMap)
if (rootSelector !== ":root") {
resolvedBase = resolvedBase.replace(/:root\b/g, rootSelector)
resolvedModules = resolvedModules.replace(/:root\b/g, rootSelector)
}
let css = resolvedBase + "\n" + applyPrefix(resolvedModules, prefix)
const customThemes = buildCustomThemes(themes)
if (customThemes) css += "\n\n" + customThemes
if (rootSelector !== ":root") {
const ast = postcss.parse(css)
ast.walkRules(rule => {
rule.selectors = rule.selectors.flatMap(sel => scopeThemeSelector(sel, rootSelector))
})
css = ast.toString()
}
// Extract @utility text-* rules from base CSS and convert to plain CSS for late injection.
// In the PostCSS path, Tailwind's theme-generated text-{color} utilities take precedence over
// same-named @utility definitions. Injecting them as plain CSS at the end of @layer utilities
// via OnceExit ensures they appear after the theme-generated rules and therefore win.
const baseUtilMap = buildUtilityMap(resolvedBase)
const textColorOverrides = [...baseUtilMap.entries()]
.filter(([name]) => name.startsWith('text-'))
.map(([name, body]) => `.${name} {${body}}`)
.join('\n')
return { css, utilMap, textColorOverrides }
}
function buildLateCSS(prefix, include, exclude, utilMap = new Map()) {
let keys = [...LATE_INJECT].filter(k => REGISTRY[k])
if (include.length > 0) {
keys = keys.filter(k => include.includes(k))
} else if (exclude.length > 0) {
const excludeSet = new Set(exclude)
keys = keys.filter(k => !excludeSet.has(k))
}
if (keys.length === 0) return ""
const raw = keys
.map(k => resolveImports(readFile(REGISTRY[k]), dirname(REGISTRY[k])))
.join("\n")
const css = resolveCopy(raw, utilMap)
return applyPrefix(css, prefix)
}
// ── Plugin ───────────────────────────────────────────────────────────────────
function parseAtPluginOptions(atRule) {
const opts = {}
atRule.each(node => {
if (node.type !== "decl") return
let val = node.value.trim()
if (val === "true") val = true
else if (val === "false") val = false
else if (
(val.startsWith('"') && val.endsWith('"')) ||
(val.startsWith("'") && val.endsWith("'"))
) val = val.slice(1, -1)
opts[node.prop.trim()] = val
})
return opts
}
export { buildUtilityMap, resolveCopy }
export default function frutjam(options = {}) {
let _lateCss = ""
return {
postcssPlugin: "frutjam",
Once(root) {
// Collect @plugin "frutjam" { ... } blocks from the CSS
const atPluginNodes = []
root.each(node => {
if (
node.type === "atrule" &&
node.name === "plugin" &&
/^["']frutjam["']/.test(node.params.trim()) &&
node.nodes
) atPluginNodes.push(node)
})
// CSS-level options override factory options
let cssOptions = {}
for (const node of atPluginNodes) {
Object.assign(cssOptions, parseAtPluginOptions(node))
node.remove()
}
const merged = { ...options, ...cssOptions }
const prefix = typeof merged.prefix === "string" ? merged.prefix.trim() : ""
const themes = typeof merged.themes === "object" ? merged.themes : {}
const reset = merged.reset !== false
const rootSelector = typeof merged.root === "string" ? merged.root : ":root"
const logs = merged.logs !== false
const include = parseList(merged.include)
const exclude = parseList(merged.exclude)
const { css, utilMap, textColorOverrides } = buildCSS(prefix, themes, reset, rootSelector, include, exclude)
root.append(postcss.parse(css).nodes)
_lateCss = buildLateCSS(prefix, include, exclude, utilMap)
if (textColorOverrides) _lateCss += '\n' + textColorOverrides
if (logs) {
const d = "\x1b[2m", r = "\x1b[0m"
const badge = "\x1b[46m\x1b[30m frutjam \x1b[0m"
console.log("\n" + badge + " " + d + "v" + VERSION + r + "\n")
}
},
OnceExit(root) {
if (!_lateCss) return
// Inject into @layer utilities if it exists (correct cascade layer),
// otherwise append to root (unlayered CSS beats all @layer rules).
let utilLayer = null
root.each(node => {
if (node.type === "atrule" && node.name === "layer" && node.params === "utilities" && node.nodes) {
utilLayer = node
}
})
const nodes = postcss.parse(_lateCss).nodes
if (utilLayer) {
utilLayer.append(nodes)
} else {
root.append(nodes)
}
}
}
}
frutjam.postcss = true