UNPKG

frutjam

Version:

A utility-first CSS UI Library for Tailwind CSS

333 lines (284 loc) 12 kB
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